mcp-metacritic 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/PRIVACY.md +160 -0
- package/README.md +398 -259
- package/dist/index.js +177 -79
- package/dist/index.js.map +1 -1
- package/dist/mc/client.d.ts +13 -3
- package/dist/mc/client.js +76 -26
- package/dist/mc/client.js.map +1 -1
- package/package.json +16 -11
- package/server.json +4 -4
package/dist/mc/client.d.ts
CHANGED
|
@@ -124,7 +124,8 @@ type Sentiment = "all" | "positive" | "neutral" | "negative";
|
|
|
124
124
|
interface ReviewPage<T> {
|
|
125
125
|
reviews: T[];
|
|
126
126
|
/** How many exist upstream, before limit and offset. */
|
|
127
|
-
|
|
127
|
+
/** What the site counted, or null where it published no count. */
|
|
128
|
+
totalResults: number | null;
|
|
128
129
|
/** How many entries the response carried, which paging must advance by. */
|
|
129
130
|
itemCount: number;
|
|
130
131
|
}
|
|
@@ -136,7 +137,8 @@ interface WatchOffer {
|
|
|
136
137
|
}
|
|
137
138
|
interface TitlePage {
|
|
138
139
|
titles: TitleSummary[];
|
|
139
|
-
|
|
140
|
+
/** What the site counted, or null where it published no count. */
|
|
141
|
+
totalResults: number | null;
|
|
140
142
|
itemCount: number;
|
|
141
143
|
}
|
|
142
144
|
|
|
@@ -166,6 +168,13 @@ interface Outcome<T> {
|
|
|
166
168
|
}
|
|
167
169
|
type Sort = keyof typeof SORT_BY;
|
|
168
170
|
declare class McClient {
|
|
171
|
+
/**
|
|
172
|
+
* The reads that have gone out and not come back, by URL.
|
|
173
|
+
*
|
|
174
|
+
* A route answering several variants is read once, so this is keyed on the
|
|
175
|
+
* URL rather than on the cache key a variant makes.
|
|
176
|
+
*/
|
|
177
|
+
private readonly reading;
|
|
169
178
|
private readonly config;
|
|
170
179
|
private readonly logger;
|
|
171
180
|
private readonly limiter;
|
|
@@ -218,4 +227,5 @@ declare class McClient {
|
|
|
218
227
|
private fetchParsed;
|
|
219
228
|
}
|
|
220
229
|
|
|
221
|
-
export { McClient
|
|
230
|
+
export { McClient };
|
|
231
|
+
export type { McClientOptions, Outcome, Sort };
|
package/dist/mc/client.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import process from "process";
|
|
3
3
|
|
|
4
4
|
// src/version.ts
|
|
5
|
-
var PKG_VERSION = "2.
|
|
5
|
+
var PKG_VERSION = "2.1.0";
|
|
6
6
|
var REPO_URL = "https://github.com/smeet666/mcp-metacritic";
|
|
7
7
|
|
|
8
8
|
// src/config.ts
|
|
@@ -296,10 +296,28 @@ function sleep(ms) {
|
|
|
296
296
|
var BACKOFF_BASE_MS = 2e3;
|
|
297
297
|
var BACKOFF_FACTOR = 2;
|
|
298
298
|
var BACKOFF_MAX_MS = 2e4;
|
|
299
|
+
var MAX_BODY_BYTES = 8e6;
|
|
299
300
|
function backoffDelay(attempt, random = Math.random) {
|
|
300
301
|
const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
|
|
301
302
|
return Math.round(capped * (0.5 + random() * 0.5));
|
|
302
303
|
}
|
|
304
|
+
async function readBounded(response, url) {
|
|
305
|
+
const declared = Number(response.headers.get("content-length"));
|
|
306
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
307
|
+
throw parseFailure(
|
|
308
|
+
url,
|
|
309
|
+
`the response declares ${declared} bytes, past the ${MAX_BODY_BYTES} this server reads`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
const body = await response.text();
|
|
313
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
314
|
+
throw parseFailure(
|
|
315
|
+
url,
|
|
316
|
+
`the response carries more than the ${MAX_BODY_BYTES} bytes this server reads`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return body;
|
|
320
|
+
}
|
|
303
321
|
function readAnswer(url, status, body, retryAfterMs, ownGuessMs) {
|
|
304
322
|
if (status === 429 || status === 503 || status === 403) {
|
|
305
323
|
return {
|
|
@@ -355,10 +373,10 @@ async function fetchText(url, deps) {
|
|
|
355
373
|
let askedWaitMs = null;
|
|
356
374
|
for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
|
|
357
375
|
if (attempt > 0) {
|
|
358
|
-
const delay = askedWaitMs ?? backoffDelay(attempt - 1);
|
|
376
|
+
const delay = askedWaitMs ?? Math.min(backoffDelay(attempt - 1), BACKOFF_MAX_MS);
|
|
359
377
|
askedWaitMs = null;
|
|
360
378
|
logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
|
|
361
|
-
await sleep(
|
|
379
|
+
await sleep(delay);
|
|
362
380
|
}
|
|
363
381
|
let status;
|
|
364
382
|
let body;
|
|
@@ -375,7 +393,7 @@ async function fetchText(url, deps) {
|
|
|
375
393
|
});
|
|
376
394
|
status = response.status;
|
|
377
395
|
retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
378
|
-
body = await response
|
|
396
|
+
body = await readBounded(response, url);
|
|
379
397
|
} catch (error) {
|
|
380
398
|
lastError = asTransportError(error, url);
|
|
381
399
|
logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
|
|
@@ -590,6 +608,14 @@ var str = (value) => {
|
|
|
590
608
|
const trimmed = value.trim();
|
|
591
609
|
return trimmed === "" ? null : trimmed;
|
|
592
610
|
};
|
|
611
|
+
var line = (value) => {
|
|
612
|
+
const text = str(value);
|
|
613
|
+
return text === null ? null : text.replace(/\s*[\r\n]+\s*/g, " ");
|
|
614
|
+
};
|
|
615
|
+
var link = (value) => {
|
|
616
|
+
const text = str(value);
|
|
617
|
+
return text !== null && ABSOLUTE_URL.test(text) ? text : null;
|
|
618
|
+
};
|
|
593
619
|
var num = (value) => {
|
|
594
620
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
595
621
|
return value;
|
|
@@ -628,8 +654,8 @@ function toSummary(node) {
|
|
|
628
654
|
return null;
|
|
629
655
|
}
|
|
630
656
|
const id = intOf(node.id);
|
|
631
|
-
const slug =
|
|
632
|
-
const title =
|
|
657
|
+
const slug = line(node.slug);
|
|
658
|
+
const title = line(node.title);
|
|
633
659
|
const kind = kindOf(node.type);
|
|
634
660
|
if (id === null || !slug || !title || !kind) {
|
|
635
661
|
return null;
|
|
@@ -669,7 +695,7 @@ function parseTitlePage(raw, url, what) {
|
|
|
669
695
|
}
|
|
670
696
|
return {
|
|
671
697
|
titles,
|
|
672
|
-
totalResults: intOf(data[FIELD.totalResults])
|
|
698
|
+
totalResults: intOf(data[FIELD.totalResults]),
|
|
673
699
|
itemCount: items.length
|
|
674
700
|
};
|
|
675
701
|
}
|
|
@@ -735,7 +761,7 @@ function parseDetail(raw, url, kind, slug) {
|
|
|
735
761
|
throw parseFailure(url, "the response carries no item object");
|
|
736
762
|
}
|
|
737
763
|
const id = intOf(item.id);
|
|
738
|
-
const title =
|
|
764
|
+
const title = line(item.title);
|
|
739
765
|
if (id === null || !title) {
|
|
740
766
|
throw parseFailure(url, "the entry has no id or no title");
|
|
741
767
|
}
|
|
@@ -748,7 +774,7 @@ function parseDetail(raw, url, kind, slug) {
|
|
|
748
774
|
year: intOf(item.premiereYear),
|
|
749
775
|
releaseDate: str(item.releaseDate),
|
|
750
776
|
rating: str(item.rating),
|
|
751
|
-
metascore:
|
|
777
|
+
metascore: aggregate(critic.score),
|
|
752
778
|
userScore: null,
|
|
753
779
|
sourceUrl: titlePageUrl(kind, slug),
|
|
754
780
|
description: str(item.description),
|
|
@@ -773,10 +799,14 @@ function parseScore(raw, url, what) {
|
|
|
773
799
|
if (max === null) {
|
|
774
800
|
throw parseFailure(url, "the score carries no scale");
|
|
775
801
|
}
|
|
802
|
+
const reviewCount = intOf(item.reviewCount);
|
|
776
803
|
return {
|
|
777
|
-
|
|
804
|
+
// A zero here is read against what stands behind it. An entry nobody has
|
|
805
|
+
// rated is published as 0 out of 10 from 0 ratings, which is an absence; a
|
|
806
|
+
// zero with reviews counted behind it is a verdict those reviewers gave.
|
|
807
|
+
score: reviewCount ? num(item.score) : aggregate(item.score),
|
|
778
808
|
max,
|
|
779
|
-
reviewCount
|
|
809
|
+
reviewCount,
|
|
780
810
|
positiveCount: intOf(item.positiveCount),
|
|
781
811
|
neutralCount: intOf(item.neutralCount),
|
|
782
812
|
negativeCount: intOf(item.negativeCount),
|
|
@@ -803,7 +833,7 @@ function parseCriticReviews(raw, url, what, sentiment) {
|
|
|
803
833
|
continue;
|
|
804
834
|
}
|
|
805
835
|
const quote = str(node.quote);
|
|
806
|
-
const publication =
|
|
836
|
+
const publication = line(node.publicationName);
|
|
807
837
|
if (!(quote && publication)) {
|
|
808
838
|
continue;
|
|
809
839
|
}
|
|
@@ -811,8 +841,8 @@ function parseCriticReviews(raw, url, what, sentiment) {
|
|
|
811
841
|
quote,
|
|
812
842
|
score: num(node.score),
|
|
813
843
|
publication,
|
|
814
|
-
author:
|
|
815
|
-
url:
|
|
844
|
+
author: line(node.author),
|
|
845
|
+
url: link(node.url),
|
|
816
846
|
date: str(node.date)
|
|
817
847
|
});
|
|
818
848
|
}
|
|
@@ -821,7 +851,7 @@ function parseCriticReviews(raw, url, what, sentiment) {
|
|
|
821
851
|
}
|
|
822
852
|
return {
|
|
823
853
|
reviews,
|
|
824
|
-
totalResults: intOf(data[FIELD.totalResults])
|
|
854
|
+
totalResults: intOf(data[FIELD.totalResults]),
|
|
825
855
|
itemCount: bucket.length
|
|
826
856
|
};
|
|
827
857
|
}
|
|
@@ -844,22 +874,22 @@ function parseUserReviews(raw, url, what, sentiment) {
|
|
|
844
874
|
}
|
|
845
875
|
return {
|
|
846
876
|
reviews,
|
|
847
|
-
totalResults: intOf(data[FIELD.totalResults])
|
|
877
|
+
totalResults: intOf(data[FIELD.totalResults]),
|
|
848
878
|
itemCount: bucket.length
|
|
849
879
|
};
|
|
850
880
|
}
|
|
851
|
-
function unwrapLink(
|
|
852
|
-
if (!
|
|
881
|
+
function unwrapLink(wrapper) {
|
|
882
|
+
if (!wrapper) {
|
|
853
883
|
return null;
|
|
854
884
|
}
|
|
855
885
|
try {
|
|
856
|
-
const target = new URL(
|
|
886
|
+
const target = new URL(wrapper).searchParams.get("r");
|
|
857
887
|
if (target && ABSOLUTE_URL.test(target)) {
|
|
858
888
|
return target;
|
|
859
889
|
}
|
|
860
890
|
} catch {
|
|
861
891
|
}
|
|
862
|
-
return
|
|
892
|
+
return ABSOLUTE_URL.test(wrapper) ? wrapper : null;
|
|
863
893
|
}
|
|
864
894
|
function parseOffers(raw, url, what) {
|
|
865
895
|
const data = envelope(raw, url, what);
|
|
@@ -876,7 +906,7 @@ function parseOffers(raw, url, what) {
|
|
|
876
906
|
if (!isObject(node)) {
|
|
877
907
|
continue;
|
|
878
908
|
}
|
|
879
|
-
const provider =
|
|
909
|
+
const provider = line(node.providerName);
|
|
880
910
|
if (!provider) {
|
|
881
911
|
continue;
|
|
882
912
|
}
|
|
@@ -887,16 +917,23 @@ function parseOffers(raw, url, what) {
|
|
|
887
917
|
}
|
|
888
918
|
|
|
889
919
|
// src/mc/client.ts
|
|
890
|
-
var BROWSER_IDENTITY = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i;
|
|
891
920
|
function withGuarantees(config) {
|
|
892
|
-
const userAgent =
|
|
921
|
+
const userAgent = config.userAgent.includes(DEFAULT_USER_AGENT) ? config.userAgent : `${config.userAgent} ${DEFAULT_USER_AGENT}`;
|
|
922
|
+
const asked = Number.isFinite(config.minIntervalMs) ? config.minIntervalMs : 0;
|
|
893
923
|
return {
|
|
894
924
|
...config,
|
|
895
925
|
userAgent,
|
|
896
|
-
minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS,
|
|
926
|
+
minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, asked)
|
|
897
927
|
};
|
|
898
928
|
}
|
|
899
929
|
var McClient = class {
|
|
930
|
+
/**
|
|
931
|
+
* The reads that have gone out and not come back, by URL.
|
|
932
|
+
*
|
|
933
|
+
* A route answering several variants is read once, so this is keyed on the
|
|
934
|
+
* URL rather than on the cache key a variant makes.
|
|
935
|
+
*/
|
|
936
|
+
reading = /* @__PURE__ */ new Map();
|
|
900
937
|
config;
|
|
901
938
|
logger;
|
|
902
939
|
limiter;
|
|
@@ -939,9 +976,10 @@ var McClient = class {
|
|
|
939
976
|
}
|
|
940
977
|
async browse(options) {
|
|
941
978
|
const url = browseUrl(options);
|
|
979
|
+
const cache = options.sort === "score" ? this.catalogueCache : this.scoresCache;
|
|
942
980
|
return await this.fetchParsed(
|
|
943
981
|
url,
|
|
944
|
-
|
|
982
|
+
cache,
|
|
945
983
|
(body) => parseTitlePage(body, url, `that ${options.kind} listing`)
|
|
946
984
|
);
|
|
947
985
|
}
|
|
@@ -1010,12 +1048,24 @@ var McClient = class {
|
|
|
1010
1048
|
this.logger.debug(`cache hit ${key}`);
|
|
1011
1049
|
return { data: hit, cached: true };
|
|
1012
1050
|
}
|
|
1013
|
-
const
|
|
1051
|
+
const inFlight = this.reading.get(url);
|
|
1052
|
+
if (inFlight !== void 0) {
|
|
1053
|
+
const body2 = await inFlight;
|
|
1054
|
+
return { data: parse(body2), cached: true };
|
|
1055
|
+
}
|
|
1056
|
+
const reading = fetchText(url, {
|
|
1014
1057
|
config: this.config,
|
|
1015
1058
|
limiter: this.limiter,
|
|
1016
1059
|
logger: this.logger,
|
|
1017
1060
|
...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
|
|
1018
1061
|
});
|
|
1062
|
+
this.reading.set(url, reading);
|
|
1063
|
+
let body;
|
|
1064
|
+
try {
|
|
1065
|
+
body = await reading;
|
|
1066
|
+
} finally {
|
|
1067
|
+
this.reading.delete(url);
|
|
1068
|
+
}
|
|
1019
1069
|
const data = parse(body);
|
|
1020
1070
|
cache.set(key, data);
|
|
1021
1071
|
return { data, cached: false };
|