mcp-metacritic 2.0.1 → 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 +9 -0
- package/PRIVACY.md +25 -19
- package/README.md +19 -13
- 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 +13 -12
- package/server.json +4 -4
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
8
8
|
import process from "process";
|
|
9
9
|
|
|
10
10
|
// src/version.ts
|
|
11
|
-
var PKG_VERSION = "2.0
|
|
11
|
+
var PKG_VERSION = "2.1.0";
|
|
12
12
|
var REPO_URL = "https://github.com/smeet666/mcp-metacritic";
|
|
13
13
|
|
|
14
14
|
// src/config.ts
|
|
@@ -306,10 +306,28 @@ function sleep(ms) {
|
|
|
306
306
|
var BACKOFF_BASE_MS = 2e3;
|
|
307
307
|
var BACKOFF_FACTOR = 2;
|
|
308
308
|
var BACKOFF_MAX_MS = 2e4;
|
|
309
|
+
var MAX_BODY_BYTES = 8e6;
|
|
309
310
|
function backoffDelay(attempt, random = Math.random) {
|
|
310
311
|
const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
|
|
311
312
|
return Math.round(capped * (0.5 + random() * 0.5));
|
|
312
313
|
}
|
|
314
|
+
async function readBounded(response, url) {
|
|
315
|
+
const declared = Number(response.headers.get("content-length"));
|
|
316
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
317
|
+
throw parseFailure(
|
|
318
|
+
url,
|
|
319
|
+
`the response declares ${declared} bytes, past the ${MAX_BODY_BYTES} this server reads`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const body = await response.text();
|
|
323
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
324
|
+
throw parseFailure(
|
|
325
|
+
url,
|
|
326
|
+
`the response carries more than the ${MAX_BODY_BYTES} bytes this server reads`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return body;
|
|
330
|
+
}
|
|
313
331
|
function readAnswer(url, status, body, retryAfterMs, ownGuessMs) {
|
|
314
332
|
if (status === 429 || status === 503 || status === 403) {
|
|
315
333
|
return {
|
|
@@ -365,10 +383,10 @@ async function fetchText(url, deps) {
|
|
|
365
383
|
let askedWaitMs = null;
|
|
366
384
|
for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
|
|
367
385
|
if (attempt > 0) {
|
|
368
|
-
const delay = askedWaitMs ?? backoffDelay(attempt - 1);
|
|
386
|
+
const delay = askedWaitMs ?? Math.min(backoffDelay(attempt - 1), BACKOFF_MAX_MS);
|
|
369
387
|
askedWaitMs = null;
|
|
370
388
|
logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
|
|
371
|
-
await sleep(
|
|
389
|
+
await sleep(delay);
|
|
372
390
|
}
|
|
373
391
|
let status;
|
|
374
392
|
let body;
|
|
@@ -385,7 +403,7 @@ async function fetchText(url, deps) {
|
|
|
385
403
|
});
|
|
386
404
|
status = response.status;
|
|
387
405
|
retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
388
|
-
body = await response
|
|
406
|
+
body = await readBounded(response, url);
|
|
389
407
|
} catch (error) {
|
|
390
408
|
lastError = asTransportError(error, url);
|
|
391
409
|
logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
|
|
@@ -600,6 +618,14 @@ var str = (value) => {
|
|
|
600
618
|
const trimmed = value.trim();
|
|
601
619
|
return trimmed === "" ? null : trimmed;
|
|
602
620
|
};
|
|
621
|
+
var line = (value) => {
|
|
622
|
+
const text = str(value);
|
|
623
|
+
return text === null ? null : text.replace(/\s*[\r\n]+\s*/g, " ");
|
|
624
|
+
};
|
|
625
|
+
var link = (value) => {
|
|
626
|
+
const text = str(value);
|
|
627
|
+
return text !== null && ABSOLUTE_URL.test(text) ? text : null;
|
|
628
|
+
};
|
|
603
629
|
var num = (value) => {
|
|
604
630
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
605
631
|
return value;
|
|
@@ -638,8 +664,8 @@ function toSummary(node) {
|
|
|
638
664
|
return null;
|
|
639
665
|
}
|
|
640
666
|
const id = intOf(node.id);
|
|
641
|
-
const slug =
|
|
642
|
-
const title =
|
|
667
|
+
const slug = line(node.slug);
|
|
668
|
+
const title = line(node.title);
|
|
643
669
|
const kind = kindOf(node.type);
|
|
644
670
|
if (id === null || !slug || !title || !kind) {
|
|
645
671
|
return null;
|
|
@@ -679,7 +705,7 @@ function parseTitlePage(raw, url, what) {
|
|
|
679
705
|
}
|
|
680
706
|
return {
|
|
681
707
|
titles,
|
|
682
|
-
totalResults: intOf(data[FIELD.totalResults])
|
|
708
|
+
totalResults: intOf(data[FIELD.totalResults]),
|
|
683
709
|
itemCount: items.length
|
|
684
710
|
};
|
|
685
711
|
}
|
|
@@ -745,7 +771,7 @@ function parseDetail(raw, url, kind, slug) {
|
|
|
745
771
|
throw parseFailure(url, "the response carries no item object");
|
|
746
772
|
}
|
|
747
773
|
const id = intOf(item.id);
|
|
748
|
-
const title =
|
|
774
|
+
const title = line(item.title);
|
|
749
775
|
if (id === null || !title) {
|
|
750
776
|
throw parseFailure(url, "the entry has no id or no title");
|
|
751
777
|
}
|
|
@@ -758,7 +784,7 @@ function parseDetail(raw, url, kind, slug) {
|
|
|
758
784
|
year: intOf(item.premiereYear),
|
|
759
785
|
releaseDate: str(item.releaseDate),
|
|
760
786
|
rating: str(item.rating),
|
|
761
|
-
metascore:
|
|
787
|
+
metascore: aggregate(critic.score),
|
|
762
788
|
userScore: null,
|
|
763
789
|
sourceUrl: titlePageUrl(kind, slug),
|
|
764
790
|
description: str(item.description),
|
|
@@ -783,10 +809,14 @@ function parseScore(raw, url, what) {
|
|
|
783
809
|
if (max === null) {
|
|
784
810
|
throw parseFailure(url, "the score carries no scale");
|
|
785
811
|
}
|
|
812
|
+
const reviewCount = intOf(item.reviewCount);
|
|
786
813
|
return {
|
|
787
|
-
|
|
814
|
+
// A zero here is read against what stands behind it. An entry nobody has
|
|
815
|
+
// rated is published as 0 out of 10 from 0 ratings, which is an absence; a
|
|
816
|
+
// zero with reviews counted behind it is a verdict those reviewers gave.
|
|
817
|
+
score: reviewCount ? num(item.score) : aggregate(item.score),
|
|
788
818
|
max,
|
|
789
|
-
reviewCount
|
|
819
|
+
reviewCount,
|
|
790
820
|
positiveCount: intOf(item.positiveCount),
|
|
791
821
|
neutralCount: intOf(item.neutralCount),
|
|
792
822
|
negativeCount: intOf(item.negativeCount),
|
|
@@ -813,7 +843,7 @@ function parseCriticReviews(raw, url, what, sentiment) {
|
|
|
813
843
|
continue;
|
|
814
844
|
}
|
|
815
845
|
const quote = str(node.quote);
|
|
816
|
-
const publication =
|
|
846
|
+
const publication = line(node.publicationName);
|
|
817
847
|
if (!(quote && publication)) {
|
|
818
848
|
continue;
|
|
819
849
|
}
|
|
@@ -821,8 +851,8 @@ function parseCriticReviews(raw, url, what, sentiment) {
|
|
|
821
851
|
quote,
|
|
822
852
|
score: num(node.score),
|
|
823
853
|
publication,
|
|
824
|
-
author:
|
|
825
|
-
url:
|
|
854
|
+
author: line(node.author),
|
|
855
|
+
url: link(node.url),
|
|
826
856
|
date: str(node.date)
|
|
827
857
|
});
|
|
828
858
|
}
|
|
@@ -831,7 +861,7 @@ function parseCriticReviews(raw, url, what, sentiment) {
|
|
|
831
861
|
}
|
|
832
862
|
return {
|
|
833
863
|
reviews,
|
|
834
|
-
totalResults: intOf(data[FIELD.totalResults])
|
|
864
|
+
totalResults: intOf(data[FIELD.totalResults]),
|
|
835
865
|
itemCount: bucket.length
|
|
836
866
|
};
|
|
837
867
|
}
|
|
@@ -854,22 +884,22 @@ function parseUserReviews(raw, url, what, sentiment) {
|
|
|
854
884
|
}
|
|
855
885
|
return {
|
|
856
886
|
reviews,
|
|
857
|
-
totalResults: intOf(data[FIELD.totalResults])
|
|
887
|
+
totalResults: intOf(data[FIELD.totalResults]),
|
|
858
888
|
itemCount: bucket.length
|
|
859
889
|
};
|
|
860
890
|
}
|
|
861
|
-
function unwrapLink(
|
|
862
|
-
if (!
|
|
891
|
+
function unwrapLink(wrapper) {
|
|
892
|
+
if (!wrapper) {
|
|
863
893
|
return null;
|
|
864
894
|
}
|
|
865
895
|
try {
|
|
866
|
-
const target = new URL(
|
|
896
|
+
const target = new URL(wrapper).searchParams.get("r");
|
|
867
897
|
if (target && ABSOLUTE_URL.test(target)) {
|
|
868
898
|
return target;
|
|
869
899
|
}
|
|
870
900
|
} catch {
|
|
871
901
|
}
|
|
872
|
-
return
|
|
902
|
+
return ABSOLUTE_URL.test(wrapper) ? wrapper : null;
|
|
873
903
|
}
|
|
874
904
|
function parseOffers(raw, url, what) {
|
|
875
905
|
const data = envelope(raw, url, what);
|
|
@@ -886,7 +916,7 @@ function parseOffers(raw, url, what) {
|
|
|
886
916
|
if (!isObject(node)) {
|
|
887
917
|
continue;
|
|
888
918
|
}
|
|
889
|
-
const provider =
|
|
919
|
+
const provider = line(node.providerName);
|
|
890
920
|
if (!provider) {
|
|
891
921
|
continue;
|
|
892
922
|
}
|
|
@@ -897,16 +927,23 @@ function parseOffers(raw, url, what) {
|
|
|
897
927
|
}
|
|
898
928
|
|
|
899
929
|
// src/mc/client.ts
|
|
900
|
-
var BROWSER_IDENTITY = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i;
|
|
901
930
|
function withGuarantees(config) {
|
|
902
|
-
const userAgent =
|
|
931
|
+
const userAgent = config.userAgent.includes(DEFAULT_USER_AGENT) ? config.userAgent : `${config.userAgent} ${DEFAULT_USER_AGENT}`;
|
|
932
|
+
const asked = Number.isFinite(config.minIntervalMs) ? config.minIntervalMs : 0;
|
|
903
933
|
return {
|
|
904
934
|
...config,
|
|
905
935
|
userAgent,
|
|
906
|
-
minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS,
|
|
936
|
+
minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, asked)
|
|
907
937
|
};
|
|
908
938
|
}
|
|
909
939
|
var McClient = class {
|
|
940
|
+
/**
|
|
941
|
+
* The reads that have gone out and not come back, by URL.
|
|
942
|
+
*
|
|
943
|
+
* A route answering several variants is read once, so this is keyed on the
|
|
944
|
+
* URL rather than on the cache key a variant makes.
|
|
945
|
+
*/
|
|
946
|
+
reading = /* @__PURE__ */ new Map();
|
|
910
947
|
config;
|
|
911
948
|
logger;
|
|
912
949
|
limiter;
|
|
@@ -949,9 +986,10 @@ var McClient = class {
|
|
|
949
986
|
}
|
|
950
987
|
async browse(options) {
|
|
951
988
|
const url = browseUrl(options);
|
|
989
|
+
const cache = options.sort === "score" ? this.catalogueCache : this.scoresCache;
|
|
952
990
|
return await this.fetchParsed(
|
|
953
991
|
url,
|
|
954
|
-
|
|
992
|
+
cache,
|
|
955
993
|
(body) => parseTitlePage(body, url, `that ${options.kind} listing`)
|
|
956
994
|
);
|
|
957
995
|
}
|
|
@@ -1020,12 +1058,24 @@ var McClient = class {
|
|
|
1020
1058
|
this.logger.debug(`cache hit ${key}`);
|
|
1021
1059
|
return { data: hit, cached: true };
|
|
1022
1060
|
}
|
|
1023
|
-
const
|
|
1061
|
+
const inFlight = this.reading.get(url);
|
|
1062
|
+
if (inFlight !== void 0) {
|
|
1063
|
+
const body2 = await inFlight;
|
|
1064
|
+
return { data: parse(body2), cached: true };
|
|
1065
|
+
}
|
|
1066
|
+
const reading = fetchText(url, {
|
|
1024
1067
|
config: this.config,
|
|
1025
1068
|
limiter: this.limiter,
|
|
1026
1069
|
logger: this.logger,
|
|
1027
1070
|
...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
|
|
1028
1071
|
});
|
|
1072
|
+
this.reading.set(url, reading);
|
|
1073
|
+
let body;
|
|
1074
|
+
try {
|
|
1075
|
+
body = await reading;
|
|
1076
|
+
} finally {
|
|
1077
|
+
this.reading.delete(url);
|
|
1078
|
+
}
|
|
1029
1079
|
const data = parse(body);
|
|
1030
1080
|
cache.set(key, data);
|
|
1031
1081
|
return { data, cached: false };
|
|
@@ -1171,7 +1221,7 @@ function indentMarkerLines(body) {
|
|
|
1171
1221
|
return body.replace(/^(Note:|Source:)/gm, " $1");
|
|
1172
1222
|
}
|
|
1173
1223
|
function ok(structured, body, options = {}) {
|
|
1174
|
-
const attribution = options.sourceUrl ? `${ATTRIBUTION}
|
|
1224
|
+
const attribution = options.sourceUrl ? `${ATTRIBUTION} (${options.sourceUrl})` : ATTRIBUTION;
|
|
1175
1225
|
const noteLines = (options.notes ?? []).map((note) => `Note: ${note}`);
|
|
1176
1226
|
while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_MIRROR_CHARS / 2) {
|
|
1177
1227
|
noteLines.pop();
|
|
@@ -1202,7 +1252,8 @@ function toToolError(error) {
|
|
|
1202
1252
|
if (known.details.hint) {
|
|
1203
1253
|
lines.push(`Hint: ${known.details.hint}`);
|
|
1204
1254
|
}
|
|
1205
|
-
|
|
1255
|
+
const safe = truncate(indentMarkerLines(lines.join("\n")), MAX_TEXT_MIRROR_CHARS);
|
|
1256
|
+
return { content: [{ type: "text", text: safe }], isError: true };
|
|
1206
1257
|
}
|
|
1207
1258
|
function truncate(text, maxChars) {
|
|
1208
1259
|
if (text.length <= maxChars) {
|
|
@@ -1290,7 +1341,7 @@ async function runBrowseTitles(client, args) {
|
|
|
1290
1341
|
limit: args.limit,
|
|
1291
1342
|
offset: args.offset
|
|
1292
1343
|
});
|
|
1293
|
-
const results = data.titles.map(toTitleSummaryOut);
|
|
1344
|
+
const results = data.titles.slice(0, args.limit).map(toTitleSummaryOut);
|
|
1294
1345
|
const notes = [];
|
|
1295
1346
|
if (cached) {
|
|
1296
1347
|
notes.push("Served from this server's short-lived in-memory cache.");
|
|
@@ -1305,13 +1356,14 @@ async function runBrowseTitles(client, args) {
|
|
|
1305
1356
|
`${data.itemCount - data.titles.length} entries on this page could not be read and were skipped.`
|
|
1306
1357
|
);
|
|
1307
1358
|
}
|
|
1308
|
-
const nextOffset = data.itemCount < args.limit ? null : args.offset +
|
|
1359
|
+
const nextOffset = data.itemCount < args.limit ? null : args.offset + args.limit;
|
|
1309
1360
|
const heading = [
|
|
1310
1361
|
args.genre ? `${args.genre} ` : "",
|
|
1311
1362
|
`${args.kind}s`,
|
|
1312
1363
|
orderedBy(args.sort)
|
|
1313
1364
|
].join("");
|
|
1314
|
-
const
|
|
1365
|
+
const outOf = data.totalResults === null ? "" : ` of ${data.totalResults}`;
|
|
1366
|
+
const summary = results.length === 0 ? `No rows for ${heading}.` : `${results.length}${outOf} ${heading}:
|
|
1315
1367
|
${renderTitleList(results)}`;
|
|
1316
1368
|
return ok(
|
|
1317
1369
|
{
|
|
@@ -1351,15 +1403,20 @@ var getReviewsInput = strictInput({
|
|
|
1351
1403
|
limit: z4.number().int().min(1).max(50).default(10).describe("How many reviews to return from the sample Metacritic publishes."),
|
|
1352
1404
|
offset: z4.number().int().min(0).default(0).describe("How many of the sampled reviews to skip. The sample itself cannot be paged past.")
|
|
1353
1405
|
});
|
|
1354
|
-
var
|
|
1406
|
+
var saidSchema = {
|
|
1355
1407
|
quote: z4.string().nullable().describe("Excerpt as published, truncated if long."),
|
|
1356
1408
|
score: z4.number().nullable(),
|
|
1357
1409
|
max: z4.number().describe("Scale for this review's score: 100 for critics, 10 for users."),
|
|
1410
|
+
date: z4.string().nullable()
|
|
1411
|
+
};
|
|
1412
|
+
var criticReviewSchema = z4.object({
|
|
1413
|
+
...saidSchema,
|
|
1358
1414
|
publication: z4.string().nullable().describe("Who ran it. Name it when quoting."),
|
|
1359
1415
|
author: z4.string().nullable(),
|
|
1360
|
-
url: z4.string().nullable().describe("Original article. Link it when quoting.")
|
|
1361
|
-
date: z4.string().nullable()
|
|
1416
|
+
url: z4.string().nullable().describe("Original article. Link it when quoting.")
|
|
1362
1417
|
});
|
|
1418
|
+
var userReviewSchema = z4.object(saidSchema);
|
|
1419
|
+
var reviewSchema = z4.union([criticReviewSchema, userReviewSchema]);
|
|
1363
1420
|
var getReviewsOutputShape = {
|
|
1364
1421
|
slug: z4.string(),
|
|
1365
1422
|
kind: z4.string(),
|
|
@@ -1385,15 +1442,22 @@ async function runGetReviews(client, args) {
|
|
|
1385
1442
|
const max = args.source === "critic" ? 100 : 10;
|
|
1386
1443
|
const { data, cached } = args.source === "critic" ? await client.getCriticReviews(request) : await client.getUserReviews(request);
|
|
1387
1444
|
const sampled = data.reviews.slice(args.offset, args.offset + args.limit);
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1445
|
+
const credits = (review) => "publication" in review ? review : null;
|
|
1446
|
+
const reviews = sampled.map((review) => {
|
|
1447
|
+
const said = {
|
|
1448
|
+
quote: review.quote === null ? null : truncate(review.quote, MAX_QUOTE_CHARS),
|
|
1449
|
+
score: review.score,
|
|
1450
|
+
max,
|
|
1451
|
+
date: review.date
|
|
1452
|
+
};
|
|
1453
|
+
const credited = credits(review);
|
|
1454
|
+
return credited === null ? said : {
|
|
1455
|
+
...said,
|
|
1456
|
+
publication: credited.publication,
|
|
1457
|
+
author: credited.author,
|
|
1458
|
+
url: credited.url
|
|
1459
|
+
};
|
|
1460
|
+
});
|
|
1397
1461
|
const notes = [];
|
|
1398
1462
|
if (cached) {
|
|
1399
1463
|
notes.push("Served from this server's short-lived in-memory cache.");
|
|
@@ -1416,7 +1480,11 @@ async function runGetReviews(client, args) {
|
|
|
1416
1480
|
`${data.itemCount - data.reviews.length} entries in the sample could not be attributed and were skipped.`
|
|
1417
1481
|
);
|
|
1418
1482
|
}
|
|
1419
|
-
if (data.totalResults
|
|
1483
|
+
if (data.totalResults === null) {
|
|
1484
|
+
notes.push(
|
|
1485
|
+
"Metacritic published no count of the reviews it holds for this entry, so the length of this sample is not evidence of how many exist."
|
|
1486
|
+
);
|
|
1487
|
+
} else if (data.totalResults > data.reviews.length) {
|
|
1420
1488
|
const slice = args.sentiment === "all" ? "" : ` in the ${args.sentiment} slice`;
|
|
1421
1489
|
notes.push(
|
|
1422
1490
|
`Metacritic counts ${data.totalResults} ${args.source} reviews for this entry and serves ${data.reviews.length}${slice} here. The rest are only on the website: ${sourceUrl}`
|
|
@@ -1425,12 +1493,13 @@ async function runGetReviews(client, args) {
|
|
|
1425
1493
|
const consumed = args.offset + reviews.length;
|
|
1426
1494
|
const nextOffset = consumed < data.reviews.length ? consumed : null;
|
|
1427
1495
|
const listing = reviews.map((review, index) => {
|
|
1428
|
-
const
|
|
1496
|
+
const credited = credits(sampled[index]);
|
|
1497
|
+
const who = [credited?.publication, credited?.author].filter(Boolean).join(", ");
|
|
1429
1498
|
const head = `${index + 1}. ${review.score === null ? "" : `${review.score}/${max} `}${who || "anonymous"}`;
|
|
1430
|
-
const quoted = (review.quote ?? "").split("\n").map((
|
|
1499
|
+
const quoted = (review.quote ?? "").split("\n").map((line2) => ` ${line2}`).join("\n");
|
|
1431
1500
|
return `${head}
|
|
1432
1501
|
${quoted}
|
|
1433
|
-
${
|
|
1502
|
+
${credited?.url ?? sourceUrl}`;
|
|
1434
1503
|
}).join("\n");
|
|
1435
1504
|
const summary = reviews.length === 0 ? `No ${args.source} review to show for "${args.slug}".` : `${reviews.length} ${args.source} review${reviews.length === 1 ? "" : "s"} of "${args.slug}":
|
|
1436
1505
|
${listing}`;
|
|
@@ -1515,16 +1584,16 @@ var getTitleInput = strictInput({
|
|
|
1515
1584
|
});
|
|
1516
1585
|
var getTitleOutputShape = {
|
|
1517
1586
|
title: titleSummarySchema,
|
|
1518
|
-
description: z5.string().nullable(),
|
|
1519
|
-
tagline: z5.string().nullable(),
|
|
1520
|
-
genres: z5.array(z5.string()),
|
|
1521
|
-
duration_minutes: z5.number().int().nullable().describe("Runtime in minutes for a film, or the length of a typical episode for a show."),
|
|
1522
|
-
imdb_id: z5.string().nullable().describe("IMDb identifier, usable to cross-reference other sources."),
|
|
1523
|
-
total_chars: z5.number().int().describe("Length of the full description."),
|
|
1524
|
-
returned_chars: z5.number().int(),
|
|
1587
|
+
description: z5.string().nullable().optional(),
|
|
1588
|
+
tagline: z5.string().nullable().optional(),
|
|
1589
|
+
genres: z5.array(z5.string()).optional(),
|
|
1590
|
+
duration_minutes: z5.number().int().nullable().optional().describe("Runtime in minutes for a film, or the length of a typical episode for a show."),
|
|
1591
|
+
imdb_id: z5.string().nullable().optional().describe("IMDb identifier, usable to cross-reference other sources."),
|
|
1592
|
+
total_chars: z5.number().int().optional().describe("Length of the full description."),
|
|
1593
|
+
returned_chars: z5.number().int().optional(),
|
|
1525
1594
|
offset: z5.number().int(),
|
|
1526
|
-
next_offset: z5.number().int().nullable().describe("Pass as 'offset' to read the rest."),
|
|
1527
|
-
truncated: z5.boolean(),
|
|
1595
|
+
next_offset: z5.number().int().nullable().optional().describe("Pass as 'offset' to read the rest."),
|
|
1596
|
+
truncated: z5.boolean().optional(),
|
|
1528
1597
|
critic_score: scoreSchema.nullable(),
|
|
1529
1598
|
user_score: scoreSchema.nullable(),
|
|
1530
1599
|
awards: z5.array(
|
|
@@ -1552,8 +1621,14 @@ async function runGetTitle(client, args) {
|
|
|
1552
1621
|
let userScore = null;
|
|
1553
1622
|
if (wanted.has("scores")) {
|
|
1554
1623
|
[criticScore, userScore] = await Promise.all([
|
|
1555
|
-
optionalScore(client, args.kind, args.slug, "critic",
|
|
1556
|
-
|
|
1624
|
+
optionalScore(client, args.kind, args.slug, "critic", {
|
|
1625
|
+
notes,
|
|
1626
|
+
onEntryPage: item.metascore
|
|
1627
|
+
}),
|
|
1628
|
+
optionalScore(client, args.kind, args.slug, "user", {
|
|
1629
|
+
notes,
|
|
1630
|
+
onEntryPage: item.userScore
|
|
1631
|
+
})
|
|
1557
1632
|
]);
|
|
1558
1633
|
}
|
|
1559
1634
|
const basic = wanted.has("basic");
|
|
@@ -1570,29 +1645,32 @@ async function runGetTitle(client, args) {
|
|
|
1570
1645
|
maxChars: args.max_chars
|
|
1571
1646
|
})
|
|
1572
1647
|
);
|
|
1648
|
+
const entry = basic ? {
|
|
1649
|
+
description: slice === "" ? null : slice,
|
|
1650
|
+
tagline: item.tagline,
|
|
1651
|
+
genres: item.genres,
|
|
1652
|
+
duration_minutes: item.duration,
|
|
1653
|
+
imdb_id: item.imdbId,
|
|
1654
|
+
total_chars: full.length,
|
|
1655
|
+
returned_chars: slice.length,
|
|
1656
|
+
next_offset: nextOffset,
|
|
1657
|
+
truncated: nextOffset !== null
|
|
1658
|
+
} : {};
|
|
1573
1659
|
const structured = {
|
|
1574
1660
|
title: {
|
|
1575
1661
|
...toTitleSummaryOut(item),
|
|
1576
1662
|
metascore: criticScore?.score ?? item.metascore,
|
|
1577
1663
|
user_score: userScore?.score ?? null
|
|
1578
1664
|
},
|
|
1579
|
-
|
|
1580
|
-
tagline: basic ? item.tagline : null,
|
|
1581
|
-
genres: basic ? item.genres : [],
|
|
1582
|
-
duration_minutes: basic ? item.duration : null,
|
|
1583
|
-
imdb_id: basic ? item.imdbId : null,
|
|
1584
|
-
total_chars: basic ? full.length : 0,
|
|
1585
|
-
returned_chars: basic ? slice.length : 0,
|
|
1665
|
+
...entry,
|
|
1586
1666
|
offset: args.offset,
|
|
1587
|
-
next_offset: basic ? nextOffset : null,
|
|
1588
|
-
truncated: basic && nextOffset !== null,
|
|
1589
1667
|
critic_score: criticScore ? toScoreOut(criticScore) : null,
|
|
1590
1668
|
user_score: userScore ? toScoreOut(userScore) : null,
|
|
1591
1669
|
notes
|
|
1592
1670
|
};
|
|
1593
1671
|
const production = attachAskedSections(structured, item, wanted, notes);
|
|
1594
|
-
const offers = wanted.has("where_to_watch") ? await watchOffers(client, item, args.kind, notes) :
|
|
1595
|
-
if (
|
|
1672
|
+
const offers = wanted.has("where_to_watch") ? await watchOffers(client, item, args.kind, notes) : null;
|
|
1673
|
+
if (offers !== null) {
|
|
1596
1674
|
structured.where_to_watch = offers;
|
|
1597
1675
|
}
|
|
1598
1676
|
return ok(
|
|
@@ -1600,7 +1678,8 @@ async function runGetTitle(client, args) {
|
|
|
1600
1678
|
render(item, slice, wanted, {
|
|
1601
1679
|
critic: criticScore,
|
|
1602
1680
|
user: userScore,
|
|
1603
|
-
offers,
|
|
1681
|
+
offers: offers ?? [],
|
|
1682
|
+
offersRead: offers !== null,
|
|
1604
1683
|
production
|
|
1605
1684
|
}),
|
|
1606
1685
|
{
|
|
@@ -1612,13 +1691,16 @@ async function runGetTitle(client, args) {
|
|
|
1612
1691
|
return toToolError(error);
|
|
1613
1692
|
}
|
|
1614
1693
|
}
|
|
1615
|
-
async function optionalScore(client, kind, slug, source,
|
|
1694
|
+
async function optionalScore(client, kind, slug, source, said) {
|
|
1695
|
+
const { notes, onEntryPage } = said;
|
|
1616
1696
|
try {
|
|
1617
1697
|
const { data } = await client.getScore(kind, slug, source);
|
|
1618
1698
|
return data;
|
|
1619
1699
|
} catch (error) {
|
|
1620
1700
|
if (error instanceof McError && error.code === "not_found") {
|
|
1621
|
-
notes.push(
|
|
1701
|
+
notes.push(
|
|
1702
|
+
onEntryPage === null ? `Metacritic publishes no ${source} score for this entry.` : `Metacritic publishes no ${source} score breakdown for this entry, so the number stands without the review counts behind it.`
|
|
1703
|
+
);
|
|
1622
1704
|
} else {
|
|
1623
1705
|
const reason = error instanceof McError ? error.code : "an unexpected error";
|
|
1624
1706
|
notes.push(
|
|
@@ -1641,8 +1723,10 @@ async function watchOffers(client, item, kind, notes) {
|
|
|
1641
1723
|
const { data } = await client.getWatchOffers(item.imdbId, kind);
|
|
1642
1724
|
return data;
|
|
1643
1725
|
} catch {
|
|
1644
|
-
notes.push(
|
|
1645
|
-
|
|
1726
|
+
notes.push(
|
|
1727
|
+
"Streaming offers could not be read for this entry, so they are missing here rather than absent from Metacritic. Call again to retry."
|
|
1728
|
+
);
|
|
1729
|
+
return null;
|
|
1646
1730
|
}
|
|
1647
1731
|
}
|
|
1648
1732
|
function renderAwards(item, wanted) {
|
|
@@ -1661,7 +1745,7 @@ function renderAwards(item, wanted) {
|
|
|
1661
1745
|
})
|
|
1662
1746
|
];
|
|
1663
1747
|
}
|
|
1664
|
-
function renderAskedSections(item, wanted, offers, production) {
|
|
1748
|
+
function renderAskedSections(item, wanted, offers, offersRead, production) {
|
|
1665
1749
|
const lines = [];
|
|
1666
1750
|
lines.push(...renderAwards(item, wanted));
|
|
1667
1751
|
if (wanted.has("networks") && item.networks.length > 0) {
|
|
@@ -1673,16 +1757,16 @@ function renderAskedSections(item, wanted, offers, production) {
|
|
|
1673
1757
|
production.length === 0 ? "Production: no company credited." : `Production: ${production.map((company) => company.name).join(", ")}`
|
|
1674
1758
|
);
|
|
1675
1759
|
}
|
|
1676
|
-
if (wanted.has("where_to_watch")) {
|
|
1760
|
+
if (wanted.has("where_to_watch") && offersRead) {
|
|
1677
1761
|
lines.push("", offers.length === 0 ? "Where to watch: nothing listed." : "Where to watch:");
|
|
1678
1762
|
for (const o of offers) {
|
|
1679
|
-
lines.push(` ${o.provider} (${o.kind})${o.url ?
|
|
1763
|
+
lines.push(` ${o.provider} (${o.kind})${o.url ? `: ${o.url}` : ""}`);
|
|
1680
1764
|
}
|
|
1681
1765
|
}
|
|
1682
1766
|
return lines;
|
|
1683
1767
|
}
|
|
1684
1768
|
function render(item, description, wanted, said) {
|
|
1685
|
-
const { critic, user, offers, production } = said;
|
|
1769
|
+
const { critic, user, offers, offersRead, production } = said;
|
|
1686
1770
|
const yearShown = item.year !== null && !item.title.includes(`(${item.year})`);
|
|
1687
1771
|
const header = [item.title, yearShown ? `(${item.year})` : "", `\xB7 ${item.kind}`].filter(Boolean).join(" ");
|
|
1688
1772
|
const lines = [header];
|
|
@@ -1707,7 +1791,7 @@ function render(item, description, wanted, said) {
|
|
|
1707
1791
|
if (description) {
|
|
1708
1792
|
lines.push("", description);
|
|
1709
1793
|
}
|
|
1710
|
-
lines.push(...renderAskedSections(item, wanted, offers, production));
|
|
1794
|
+
lines.push(...renderAskedSections(item, wanted, offers, offersRead, production));
|
|
1711
1795
|
return lines.join("\n");
|
|
1712
1796
|
}
|
|
1713
1797
|
|
|
@@ -1746,11 +1830,25 @@ async function runSearchTitles(client, args) {
|
|
|
1746
1830
|
if (cached) {
|
|
1747
1831
|
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1748
1832
|
}
|
|
1833
|
+
if (data.itemCount > data.titles.length) {
|
|
1834
|
+
notes.push(
|
|
1835
|
+
`${data.itemCount - data.titles.length} entries in this page could not be read and were skipped.`
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
if (args.kind !== "any" && data.totalResults !== null) {
|
|
1839
|
+
notes.push(
|
|
1840
|
+
`total_available counts every kind Metacritic matched for this query, and the rows here are the ${args.kind}s among them.`
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1749
1843
|
const looseCount = WHITESPACE.test(args.query.trim());
|
|
1750
1844
|
if (looseCount) {
|
|
1751
1845
|
notes.push(
|
|
1752
1846
|
"Metacritic counts a multi-word query loosely, so total_available is far larger than the number of real matches. The rows themselves are ordered by relevance."
|
|
1753
1847
|
);
|
|
1848
|
+
} else if (data.totalResults === null) {
|
|
1849
|
+
notes.push(
|
|
1850
|
+
"Metacritic published no count of what this search matched, so a short list here is not evidence that little exists."
|
|
1851
|
+
);
|
|
1754
1852
|
} else if (data.totalResults > results.length) {
|
|
1755
1853
|
notes.push(
|
|
1756
1854
|
`${data.totalResults} entries matched and ${results.length} are shown. Raise 'limit' for more, or narrow the query: this tool cannot page.`
|