mcp-metacritic 1.2.0 → 2.0.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 +126 -0
- package/LICENSE +1 -1
- package/README.md +40 -0
- package/dist/index.js +420 -172
- package/dist/index.js.map +1 -1
- package/dist/mc/client.js +225 -91
- package/dist/mc/client.js.map +1 -1
- package/package.json +15 -11
- package/server.json +6 -10
package/dist/index.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import process2 from "process";
|
|
4
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
6
|
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import process from "process";
|
|
9
|
+
|
|
6
10
|
// src/version.ts
|
|
7
|
-
var PKG_VERSION = "
|
|
11
|
+
var PKG_VERSION = "2.0.0";
|
|
8
12
|
var REPO_URL = "https://github.com/smeet666/mcp-metacritic";
|
|
9
13
|
|
|
10
14
|
// src/config.ts
|
|
@@ -28,7 +32,9 @@ var MAX_ALLOWED_INTERVAL_MS = 6e4;
|
|
|
28
32
|
var LOG_LEVELS = ["silent", "error", "info", "debug"];
|
|
29
33
|
function readNumber(name, env, range) {
|
|
30
34
|
const raw = env[name];
|
|
31
|
-
if (raw === void 0 || raw.trim() === "")
|
|
35
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
36
|
+
return range.fallback;
|
|
37
|
+
}
|
|
32
38
|
const parsed = Number(raw);
|
|
33
39
|
if (!Number.isFinite(parsed)) {
|
|
34
40
|
warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
|
|
@@ -49,7 +55,9 @@ function warn(message) {
|
|
|
49
55
|
}
|
|
50
56
|
function readInterval(env) {
|
|
51
57
|
const raw = env.MC_MIN_INTERVAL_MS;
|
|
52
|
-
if (raw === void 0 || raw.trim() === "")
|
|
58
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
59
|
+
return DEFAULTS.minIntervalMs;
|
|
60
|
+
}
|
|
53
61
|
const parsed = Number(raw);
|
|
54
62
|
if (!Number.isFinite(parsed)) {
|
|
55
63
|
warn(`MC_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
|
|
@@ -142,7 +150,9 @@ var TtlLruCache = class {
|
|
|
142
150
|
store = /* @__PURE__ */ new Map();
|
|
143
151
|
get(key) {
|
|
144
152
|
const entry = this.store.get(key);
|
|
145
|
-
if (!entry)
|
|
153
|
+
if (!entry) {
|
|
154
|
+
return void 0;
|
|
155
|
+
}
|
|
146
156
|
if (Date.now() >= entry.expiresAt) {
|
|
147
157
|
this.store.delete(key);
|
|
148
158
|
return void 0;
|
|
@@ -152,12 +162,16 @@ var TtlLruCache = class {
|
|
|
152
162
|
return entry.value;
|
|
153
163
|
}
|
|
154
164
|
set(key, value) {
|
|
155
|
-
if (this.maxEntries <= 0 || this.ttlMs <= 0)
|
|
165
|
+
if (this.maxEntries <= 0 || this.ttlMs <= 0) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
156
168
|
this.store.delete(key);
|
|
157
169
|
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
158
170
|
while (this.store.size > this.maxEntries) {
|
|
159
171
|
const oldest = this.store.keys().next();
|
|
160
|
-
if (oldest.done)
|
|
172
|
+
if (oldest.done) {
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
161
175
|
this.store.delete(oldest.value);
|
|
162
176
|
}
|
|
163
177
|
}
|
|
@@ -172,7 +186,7 @@ var TtlLruCache = class {
|
|
|
172
186
|
// src/errors.ts
|
|
173
187
|
var McError = class extends Error {
|
|
174
188
|
constructor(code, message, details = {}) {
|
|
175
|
-
super(message);
|
|
189
|
+
super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
|
|
176
190
|
this.code = code;
|
|
177
191
|
this.details = details;
|
|
178
192
|
this.name = "McError";
|
|
@@ -202,11 +216,11 @@ function rateLimited(url, retryAfterMs) {
|
|
|
202
216
|
}
|
|
203
217
|
);
|
|
204
218
|
}
|
|
205
|
-
function parseFailure(url, what) {
|
|
219
|
+
function parseFailure(url, what, cause) {
|
|
206
220
|
return new McError(
|
|
207
221
|
"parse_failure",
|
|
208
222
|
`Metacritic answered, but the response did not have the expected shape (${what}).`,
|
|
209
|
-
{ url, hint: `Please report this, with the request you made, at ${ISSUES_URL}
|
|
223
|
+
{ url, hint: `Please report this, with the request you made, at ${ISSUES_URL}`, cause }
|
|
210
224
|
);
|
|
211
225
|
}
|
|
212
226
|
function upstreamError(url, status) {
|
|
@@ -274,10 +288,14 @@ var RateLimiter = class {
|
|
|
274
288
|
this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
|
|
275
289
|
}
|
|
276
290
|
async waitForSlot() {
|
|
277
|
-
if (this.intervalMs === 0 || this.lastStart === 0)
|
|
291
|
+
if (this.intervalMs === 0 || this.lastStart === 0) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
278
294
|
const elapsed = Date.now() - this.lastStart;
|
|
279
295
|
const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
|
|
280
|
-
if (remaining > 0)
|
|
296
|
+
if (remaining > 0) {
|
|
297
|
+
await sleep(remaining);
|
|
298
|
+
}
|
|
281
299
|
}
|
|
282
300
|
};
|
|
283
301
|
function sleep(ms) {
|
|
@@ -292,10 +310,57 @@ function backoffDelay(attempt, random = Math.random) {
|
|
|
292
310
|
const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
|
|
293
311
|
return Math.round(capped * (0.5 + random() * 0.5));
|
|
294
312
|
}
|
|
313
|
+
function readAnswer(url, status, body, retryAfterMs, ownGuessMs) {
|
|
314
|
+
if (status === 429 || status === 503 || status === 403) {
|
|
315
|
+
return {
|
|
316
|
+
kind: "again",
|
|
317
|
+
error: rateLimited(url, retryAfterMs ?? ownGuessMs),
|
|
318
|
+
// A server that says when to come back knows better than our own guess.
|
|
319
|
+
waitMs: retryAfterMs,
|
|
320
|
+
penalise: true,
|
|
321
|
+
because: `refused with ${status}`
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (status >= 500) {
|
|
325
|
+
return {
|
|
326
|
+
kind: "again",
|
|
327
|
+
error: upstreamError(url, status),
|
|
328
|
+
waitMs: null,
|
|
329
|
+
penalise: false,
|
|
330
|
+
because: `status ${status}`
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (status === 404) {
|
|
334
|
+
return { kind: "refused", error: notFound(url, "that request") };
|
|
335
|
+
}
|
|
336
|
+
if (status >= 400) {
|
|
337
|
+
return { kind: "refused", error: upstreamError(url, status) };
|
|
338
|
+
}
|
|
339
|
+
const trimmed = body.trim();
|
|
340
|
+
if (trimmed === "") {
|
|
341
|
+
return {
|
|
342
|
+
kind: "again",
|
|
343
|
+
error: rateLimited(url, ownGuessMs),
|
|
344
|
+
waitMs: null,
|
|
345
|
+
penalise: true,
|
|
346
|
+
because: "empty body, treated as a refusal"
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) {
|
|
350
|
+
return {
|
|
351
|
+
kind: "again",
|
|
352
|
+
error: parseFailure(url, "the body is not JSON"),
|
|
353
|
+
waitMs: retryAfterMs,
|
|
354
|
+
penalise: true,
|
|
355
|
+
because: "non-JSON body"
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
return { kind: "usable" };
|
|
359
|
+
}
|
|
295
360
|
async function fetchText(url, deps) {
|
|
296
361
|
const { config, limiter, logger } = deps;
|
|
297
362
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
298
|
-
return limiter.schedule(async () => {
|
|
363
|
+
return await limiter.schedule(async () => {
|
|
299
364
|
let lastError;
|
|
300
365
|
let askedWaitMs = null;
|
|
301
366
|
for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
|
|
@@ -326,33 +391,17 @@ async function fetchText(url, deps) {
|
|
|
326
391
|
logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
|
|
327
392
|
continue;
|
|
328
393
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));
|
|
333
|
-
logger.info(
|
|
334
|
-
`refused on ${url} with ${status}, interval now ${limiter.currentIntervalMs}ms`
|
|
335
|
-
);
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
if (status >= 500) {
|
|
339
|
-
lastError = upstreamError(url, status);
|
|
340
|
-
continue;
|
|
394
|
+
const verdict = readAnswer(url, status, body, retryAfterMs, backoffDelay(attempt));
|
|
395
|
+
if (verdict.kind === "refused") {
|
|
396
|
+
throw verdict.error;
|
|
341
397
|
}
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
352
|
-
limiter.penalize();
|
|
353
|
-
lastError = parseFailure(url, "the body is not JSON");
|
|
354
|
-
askedWaitMs = retryAfterMs;
|
|
355
|
-
logger.info(`non-JSON body on ${url}, retrying`);
|
|
398
|
+
if (verdict.kind === "again") {
|
|
399
|
+
if (verdict.penalise) {
|
|
400
|
+
limiter.penalize();
|
|
401
|
+
logger.info(`${verdict.because} on ${url}, interval now ${limiter.currentIntervalMs}ms`);
|
|
402
|
+
}
|
|
403
|
+
askedWaitMs = verdict.waitMs;
|
|
404
|
+
lastError = verdict.error;
|
|
356
405
|
continue;
|
|
357
406
|
}
|
|
358
407
|
limiter.relax();
|
|
@@ -362,15 +411,23 @@ async function fetchText(url, deps) {
|
|
|
362
411
|
});
|
|
363
412
|
}
|
|
364
413
|
function parseRetryAfter(raw) {
|
|
365
|
-
if (!raw)
|
|
414
|
+
if (!raw) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
366
417
|
const seconds = Number(raw.trim());
|
|
367
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
418
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
419
|
+
return Math.round(seconds * 1e3);
|
|
420
|
+
}
|
|
368
421
|
const when = Date.parse(raw);
|
|
369
|
-
if (Number.isNaN(when))
|
|
422
|
+
if (Number.isNaN(when)) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
370
425
|
return Math.max(0, when - Date.now());
|
|
371
426
|
}
|
|
372
427
|
function asTransportError(error, url) {
|
|
373
|
-
if (error instanceof McError)
|
|
428
|
+
if (error instanceof McError) {
|
|
429
|
+
return error;
|
|
430
|
+
}
|
|
374
431
|
const name = error instanceof Error ? error.name : "";
|
|
375
432
|
if (name === "TimeoutError" || name === "AbortError") {
|
|
376
433
|
return new McError("timeout", "Metacritic did not answer in time.", {
|
|
@@ -428,10 +485,14 @@ var FIELD = {
|
|
|
428
485
|
};
|
|
429
486
|
|
|
430
487
|
// src/mc/urls.ts
|
|
488
|
+
var SLUG = /^[a-z0-9][a-z0-9-]*$/i;
|
|
489
|
+
var IMDB_ID = /^tt\d+$/;
|
|
431
490
|
function build(path, params) {
|
|
432
491
|
const query = new URLSearchParams();
|
|
433
492
|
for (const [key, value] of Object.entries(params)) {
|
|
434
|
-
if (value !== void 0 && value !== "")
|
|
493
|
+
if (value !== void 0 && value !== "") {
|
|
494
|
+
query.set(key, String(value));
|
|
495
|
+
}
|
|
435
496
|
}
|
|
436
497
|
const suffix = query.toString();
|
|
437
498
|
return suffix ? `${API_BASE}${path}?${suffix}` : `${API_BASE}${path}`;
|
|
@@ -441,7 +502,7 @@ function checkSlug(slug) {
|
|
|
441
502
|
if (trimmed === "") {
|
|
442
503
|
throw invalidInput("The slug is empty.", "Take it from a search_titles result.");
|
|
443
504
|
}
|
|
444
|
-
if (
|
|
505
|
+
if (!SLUG.test(trimmed)) {
|
|
445
506
|
throw invalidInput(
|
|
446
507
|
`"${slug}" does not look like a Metacritic slug.`,
|
|
447
508
|
"Slugs are lowercase words joined by hyphens, such as the-matrix. Use search_titles to get one."
|
|
@@ -484,7 +545,7 @@ function reviewsUrl(options) {
|
|
|
484
545
|
}
|
|
485
546
|
function watchUrl(imdbId, kind) {
|
|
486
547
|
const trimmed = imdbId.trim();
|
|
487
|
-
if (
|
|
548
|
+
if (!IMDB_ID.test(trimmed)) {
|
|
488
549
|
throw invalidInput(
|
|
489
550
|
`"${imdbId}" is not an IMDb id.`,
|
|
490
551
|
"IMDb ids look like tt0133093. get_title returns one as imdb_id."
|
|
@@ -503,38 +564,51 @@ function bucketFor(sentiment) {
|
|
|
503
564
|
}
|
|
504
565
|
|
|
505
566
|
// src/mc/parse.ts
|
|
567
|
+
var ABSOLUTE_URL = /^https?:\/\//i;
|
|
506
568
|
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
507
569
|
function envelope(raw, url, what) {
|
|
508
570
|
let body;
|
|
509
571
|
try {
|
|
510
572
|
body = JSON.parse(raw);
|
|
511
|
-
} catch {
|
|
512
|
-
throw parseFailure(url, "the body is not valid JSON");
|
|
573
|
+
} catch (cause) {
|
|
574
|
+
throw parseFailure(url, "the body is not valid JSON", cause);
|
|
575
|
+
}
|
|
576
|
+
if (!isObject(body)) {
|
|
577
|
+
throw parseFailure(url, "the body is not an object");
|
|
513
578
|
}
|
|
514
|
-
if (!isObject(body)) throw parseFailure(url, "the body is not an object");
|
|
515
579
|
const errors = body[FIELD.errors];
|
|
516
580
|
if (Array.isArray(errors) && errors.length > 0) {
|
|
517
581
|
const first = isObject(errors[0]) ? errors[0] : {};
|
|
518
|
-
if (first.code === 404)
|
|
582
|
+
if (first.code === 404) {
|
|
583
|
+
throw notFound(url, what);
|
|
584
|
+
}
|
|
519
585
|
throw parseFailure(
|
|
520
586
|
url,
|
|
521
587
|
`the site reported ${String(first.reason ?? first.code ?? "an error")}`
|
|
522
588
|
);
|
|
523
589
|
}
|
|
524
590
|
const data = body[FIELD.data];
|
|
525
|
-
if (!isObject(data))
|
|
591
|
+
if (!isObject(data)) {
|
|
592
|
+
throw parseFailure(url, "the response carries no data object");
|
|
593
|
+
}
|
|
526
594
|
return data;
|
|
527
595
|
}
|
|
528
596
|
var str = (value) => {
|
|
529
|
-
if (typeof value !== "string")
|
|
597
|
+
if (typeof value !== "string") {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
530
600
|
const trimmed = value.trim();
|
|
531
601
|
return trimmed === "" ? null : trimmed;
|
|
532
602
|
};
|
|
533
603
|
var num = (value) => {
|
|
534
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
604
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
605
|
+
return value;
|
|
606
|
+
}
|
|
535
607
|
if (typeof value === "string") {
|
|
536
608
|
const parsed = Number(value);
|
|
537
|
-
if (Number.isFinite(parsed))
|
|
609
|
+
if (Number.isFinite(parsed)) {
|
|
610
|
+
return parsed;
|
|
611
|
+
}
|
|
538
612
|
}
|
|
539
613
|
return null;
|
|
540
614
|
};
|
|
@@ -548,18 +622,28 @@ var aggregate = (value) => {
|
|
|
548
622
|
};
|
|
549
623
|
function kindOf(value) {
|
|
550
624
|
const type = str(value);
|
|
551
|
-
if (type === SEARCH_TYPE.movie)
|
|
552
|
-
|
|
553
|
-
|
|
625
|
+
if (type === SEARCH_TYPE.movie) {
|
|
626
|
+
return "movie";
|
|
627
|
+
}
|
|
628
|
+
if (type === SEARCH_TYPE.show) {
|
|
629
|
+
return "show";
|
|
630
|
+
}
|
|
631
|
+
if (type === SEARCH_TYPE.game) {
|
|
632
|
+
return "game";
|
|
633
|
+
}
|
|
554
634
|
return null;
|
|
555
635
|
}
|
|
556
636
|
function toSummary(node) {
|
|
557
|
-
if (!isObject(node))
|
|
637
|
+
if (!isObject(node)) {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
558
640
|
const id = intOf(node.id);
|
|
559
641
|
const slug = str(node.slug);
|
|
560
642
|
const title = str(node.title);
|
|
561
643
|
const kind = kindOf(node.type);
|
|
562
|
-
if (id === null || !slug || !title || !kind)
|
|
644
|
+
if (id === null || !slug || !title || !kind) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
563
647
|
const critic = isObject(node.criticScoreSummary) ? node.criticScoreSummary : {};
|
|
564
648
|
const userSummary = isObject(node.userScoreSummary) ? node.userScoreSummary : {};
|
|
565
649
|
const userObject = isObject(node.userScore) ? node.userScore : {};
|
|
@@ -580,11 +664,15 @@ function toSummary(node) {
|
|
|
580
664
|
function parseTitlePage(raw, url, what) {
|
|
581
665
|
const data = envelope(raw, url, what);
|
|
582
666
|
const items = data[FIELD.items];
|
|
583
|
-
if (!Array.isArray(items))
|
|
667
|
+
if (!Array.isArray(items)) {
|
|
668
|
+
throw parseFailure(url, "the response carries no items array");
|
|
669
|
+
}
|
|
584
670
|
const titles = [];
|
|
585
671
|
for (const node of items) {
|
|
586
672
|
const row = toSummary(node);
|
|
587
|
-
if (row)
|
|
673
|
+
if (row) {
|
|
674
|
+
titles.push(row);
|
|
675
|
+
}
|
|
588
676
|
}
|
|
589
677
|
if (items.length > 0 && titles.length === 0) {
|
|
590
678
|
throw parseFailure(url, `${items.length} rows but none could be read`);
|
|
@@ -597,35 +685,51 @@ function parseTitlePage(raw, url, what) {
|
|
|
597
685
|
}
|
|
598
686
|
function toCompanies(value) {
|
|
599
687
|
const source = isObject(value) ? value.companies : value;
|
|
600
|
-
if (!Array.isArray(source))
|
|
688
|
+
if (!Array.isArray(source)) {
|
|
689
|
+
return [];
|
|
690
|
+
}
|
|
601
691
|
const seen = /* @__PURE__ */ new Set();
|
|
602
692
|
const out = [];
|
|
603
693
|
for (const node of source) {
|
|
604
694
|
const name = isObject(node) ? str(node.name) : str(node);
|
|
605
|
-
if (!name)
|
|
695
|
+
if (!name) {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
606
698
|
const key = name.toLowerCase();
|
|
607
|
-
if (seen.has(key))
|
|
699
|
+
if (seen.has(key)) {
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
608
702
|
seen.add(key);
|
|
609
703
|
out.push({ name, id: isObject(node) ? intOf(node.id) : null });
|
|
610
704
|
}
|
|
611
705
|
return out;
|
|
612
706
|
}
|
|
613
707
|
function toStringList(value) {
|
|
614
|
-
if (!Array.isArray(value))
|
|
708
|
+
if (!Array.isArray(value)) {
|
|
709
|
+
return [];
|
|
710
|
+
}
|
|
615
711
|
const out = [];
|
|
616
712
|
for (const node of value) {
|
|
617
713
|
const name = isObject(node) ? str(node.name) : str(node);
|
|
618
|
-
if (name)
|
|
714
|
+
if (name) {
|
|
715
|
+
out.push(name);
|
|
716
|
+
}
|
|
619
717
|
}
|
|
620
718
|
return out;
|
|
621
719
|
}
|
|
622
720
|
function toAwards(value) {
|
|
623
|
-
if (!Array.isArray(value))
|
|
721
|
+
if (!Array.isArray(value)) {
|
|
722
|
+
return [];
|
|
723
|
+
}
|
|
624
724
|
const out = [];
|
|
625
725
|
for (const node of value) {
|
|
626
|
-
if (!isObject(node))
|
|
726
|
+
if (!isObject(node)) {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
627
729
|
const ceremony = str(node.awardEvent);
|
|
628
|
-
if (!ceremony)
|
|
730
|
+
if (!ceremony) {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
629
733
|
out.push({
|
|
630
734
|
ceremony,
|
|
631
735
|
wins: intOf(node.wins),
|
|
@@ -637,10 +741,14 @@ function toAwards(value) {
|
|
|
637
741
|
function parseDetail(raw, url, kind, slug) {
|
|
638
742
|
const data = envelope(raw, url, `${kind} "${slug}"`);
|
|
639
743
|
const item = data[FIELD.item];
|
|
640
|
-
if (!isObject(item))
|
|
744
|
+
if (!isObject(item)) {
|
|
745
|
+
throw parseFailure(url, "the response carries no item object");
|
|
746
|
+
}
|
|
641
747
|
const id = intOf(item.id);
|
|
642
748
|
const title = str(item.title);
|
|
643
|
-
if (id === null || !title)
|
|
749
|
+
if (id === null || !title) {
|
|
750
|
+
throw parseFailure(url, "the entry has no id or no title");
|
|
751
|
+
}
|
|
644
752
|
const critic = isObject(item.criticScoreSummary) ? item.criticScoreSummary : {};
|
|
645
753
|
return {
|
|
646
754
|
id,
|
|
@@ -668,9 +776,13 @@ function parseDetail(raw, url, kind, slug) {
|
|
|
668
776
|
function parseScore(raw, url, what) {
|
|
669
777
|
const data = envelope(raw, url, what);
|
|
670
778
|
const item = data[FIELD.item];
|
|
671
|
-
if (!isObject(item))
|
|
779
|
+
if (!isObject(item)) {
|
|
780
|
+
throw parseFailure(url, "the response carries no score object");
|
|
781
|
+
}
|
|
672
782
|
const max = num(item.max);
|
|
673
|
-
if (max === null)
|
|
783
|
+
if (max === null) {
|
|
784
|
+
throw parseFailure(url, "the score carries no scale");
|
|
785
|
+
}
|
|
674
786
|
return {
|
|
675
787
|
score: aggregate(item.score),
|
|
676
788
|
max,
|
|
@@ -683,7 +795,9 @@ function parseScore(raw, url, what) {
|
|
|
683
795
|
}
|
|
684
796
|
function reviewBucket(data, url, sentiment) {
|
|
685
797
|
const item = data[FIELD.item];
|
|
686
|
-
if (!isObject(item))
|
|
798
|
+
if (!isObject(item)) {
|
|
799
|
+
throw parseFailure(url, "the response carries no reviews object");
|
|
800
|
+
}
|
|
687
801
|
const bucket = item[bucketFor(sentiment)];
|
|
688
802
|
if (!Array.isArray(bucket)) {
|
|
689
803
|
throw parseFailure(url, `there is no "${bucketFor(sentiment)}" group of reviews`);
|
|
@@ -695,10 +809,14 @@ function parseCriticReviews(raw, url, what, sentiment) {
|
|
|
695
809
|
const bucket = reviewBucket(data, url, sentiment);
|
|
696
810
|
const reviews = [];
|
|
697
811
|
for (const node of bucket) {
|
|
698
|
-
if (!isObject(node))
|
|
812
|
+
if (!isObject(node)) {
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
699
815
|
const quote = str(node.quote);
|
|
700
816
|
const publication = str(node.publicationName);
|
|
701
|
-
if (!quote
|
|
817
|
+
if (!(quote && publication)) {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
702
820
|
reviews.push({
|
|
703
821
|
quote,
|
|
704
822
|
score: num(node.score),
|
|
@@ -722,9 +840,13 @@ function parseUserReviews(raw, url, what, sentiment) {
|
|
|
722
840
|
const bucket = reviewBucket(data, url, sentiment);
|
|
723
841
|
const reviews = [];
|
|
724
842
|
for (const node of bucket) {
|
|
725
|
-
if (!isObject(node))
|
|
843
|
+
if (!isObject(node)) {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
726
846
|
const quote = str(node.quote);
|
|
727
|
-
if (!quote)
|
|
847
|
+
if (!quote) {
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
728
850
|
reviews.push({ quote, score: num(node.score), date: str(node.date) });
|
|
729
851
|
}
|
|
730
852
|
if (bucket.length > 0 && reviews.length === 0) {
|
|
@@ -737,10 +859,14 @@ function parseUserReviews(raw, url, what, sentiment) {
|
|
|
737
859
|
};
|
|
738
860
|
}
|
|
739
861
|
function unwrapLink(link) {
|
|
740
|
-
if (!link)
|
|
862
|
+
if (!link) {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
741
865
|
try {
|
|
742
866
|
const target = new URL(link).searchParams.get("r");
|
|
743
|
-
if (target &&
|
|
867
|
+
if (target && ABSOLUTE_URL.test(target)) {
|
|
868
|
+
return target;
|
|
869
|
+
}
|
|
744
870
|
} catch {
|
|
745
871
|
}
|
|
746
872
|
return link;
|
|
@@ -748,14 +874,22 @@ function unwrapLink(link) {
|
|
|
748
874
|
function parseOffers(raw, url, what) {
|
|
749
875
|
const data = envelope(raw, url, what);
|
|
750
876
|
const item = data[FIELD.item];
|
|
751
|
-
if (!isObject(item))
|
|
877
|
+
if (!isObject(item)) {
|
|
878
|
+
throw parseFailure(url, "the response carries no offers object");
|
|
879
|
+
}
|
|
752
880
|
const offers = [];
|
|
753
881
|
for (const [group, value] of Object.entries(item)) {
|
|
754
|
-
if (!Array.isArray(value))
|
|
882
|
+
if (!Array.isArray(value)) {
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
755
885
|
for (const node of value) {
|
|
756
|
-
if (!isObject(node))
|
|
886
|
+
if (!isObject(node)) {
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
757
889
|
const provider = str(node.providerName);
|
|
758
|
-
if (!provider)
|
|
890
|
+
if (!provider) {
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
759
893
|
offers.push({ provider, kind: group, url: unwrapLink(str(node.link)) });
|
|
760
894
|
}
|
|
761
895
|
}
|
|
@@ -763,8 +897,9 @@ function parseOffers(raw, url, what) {
|
|
|
763
897
|
}
|
|
764
898
|
|
|
765
899
|
// src/mc/client.ts
|
|
900
|
+
var BROWSER_IDENTITY = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i;
|
|
766
901
|
function withGuarantees(config) {
|
|
767
|
-
const userAgent =
|
|
902
|
+
const userAgent = BROWSER_IDENTITY.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
|
|
768
903
|
return {
|
|
769
904
|
...config,
|
|
770
905
|
userAgent,
|
|
@@ -806,7 +941,7 @@ var McClient = class {
|
|
|
806
941
|
*/
|
|
807
942
|
async search(query, limit, offset) {
|
|
808
943
|
const url = searchUrl(query, limit, offset);
|
|
809
|
-
return this.fetchParsed(
|
|
944
|
+
return await this.fetchParsed(
|
|
810
945
|
url,
|
|
811
946
|
this.catalogueCache,
|
|
812
947
|
(body) => parseTitlePage(body, url, `the query "${query}"`)
|
|
@@ -814,7 +949,7 @@ var McClient = class {
|
|
|
814
949
|
}
|
|
815
950
|
async browse(options) {
|
|
816
951
|
const url = browseUrl(options);
|
|
817
|
-
return this.fetchParsed(
|
|
952
|
+
return await this.fetchParsed(
|
|
818
953
|
url,
|
|
819
954
|
this.catalogueCache,
|
|
820
955
|
(body) => parseTitlePage(body, url, `that ${options.kind} listing`)
|
|
@@ -823,7 +958,7 @@ var McClient = class {
|
|
|
823
958
|
async getDetail(kind, slug) {
|
|
824
959
|
const url = detailUrl(kind, slug);
|
|
825
960
|
const clean = slug.trim();
|
|
826
|
-
return this.fetchParsed(
|
|
961
|
+
return await this.fetchParsed(
|
|
827
962
|
url,
|
|
828
963
|
this.catalogueCache,
|
|
829
964
|
(body) => parseDetail(body, url, kind, clean)
|
|
@@ -831,7 +966,7 @@ var McClient = class {
|
|
|
831
966
|
}
|
|
832
967
|
async getScore(kind, slug, source) {
|
|
833
968
|
const url = scoreUrl(kind, slug, source);
|
|
834
|
-
return this.fetchParsed(
|
|
969
|
+
return await this.fetchParsed(
|
|
835
970
|
url,
|
|
836
971
|
this.scoresCache,
|
|
837
972
|
(body) => parseScore(body, url, `the ${source} score of ${kind} "${slug}"`)
|
|
@@ -839,7 +974,7 @@ var McClient = class {
|
|
|
839
974
|
}
|
|
840
975
|
async getCriticReviews(options) {
|
|
841
976
|
const url = reviewsUrl({ ...options, source: "critic" });
|
|
842
|
-
return this.fetchParsed(
|
|
977
|
+
return await this.fetchParsed(
|
|
843
978
|
url,
|
|
844
979
|
this.scoresCache,
|
|
845
980
|
(body) => parseCriticReviews(
|
|
@@ -853,7 +988,7 @@ var McClient = class {
|
|
|
853
988
|
}
|
|
854
989
|
async getUserReviews(options) {
|
|
855
990
|
const url = reviewsUrl({ ...options, source: "user" });
|
|
856
|
-
return this.fetchParsed(
|
|
991
|
+
return await this.fetchParsed(
|
|
857
992
|
url,
|
|
858
993
|
this.scoresCache,
|
|
859
994
|
(body) => parseUserReviews(
|
|
@@ -867,7 +1002,7 @@ var McClient = class {
|
|
|
867
1002
|
}
|
|
868
1003
|
async getWatchOffers(imdbId, kind) {
|
|
869
1004
|
const url = watchUrl(imdbId, kind);
|
|
870
|
-
return this.fetchParsed(
|
|
1005
|
+
return await this.fetchParsed(
|
|
871
1006
|
url,
|
|
872
1007
|
this.scoresCache,
|
|
873
1008
|
(body) => parseOffers(body, url, `streaming offers for ${imdbId}`)
|
|
@@ -903,10 +1038,24 @@ import { z as z3 } from "zod";
|
|
|
903
1038
|
// src/tools/arguments.ts
|
|
904
1039
|
import { z } from "zod";
|
|
905
1040
|
var INVALID_INPUT = "invalid_input";
|
|
1041
|
+
var defaultMessage = z.core.locales.en().localeError;
|
|
906
1042
|
function strictInput(shape) {
|
|
907
1043
|
const declared = Object.keys(shape);
|
|
908
|
-
const
|
|
909
|
-
|
|
1044
|
+
const refuse = (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : `[${INVALID_INPUT}] ${defaultMessage(issue)}`;
|
|
1045
|
+
for (const argument of Object.values(shape)) {
|
|
1046
|
+
carryTheCode(argument, refuse);
|
|
1047
|
+
}
|
|
1048
|
+
return z.strictObject(shape, { error: refuse });
|
|
1049
|
+
}
|
|
1050
|
+
function carryTheCode(schema, refuse) {
|
|
1051
|
+
const definition = schema._zod.def;
|
|
1052
|
+
definition.error = refuse;
|
|
1053
|
+
for (const check of definition.checks ?? []) {
|
|
1054
|
+
check._zod.def.error = refuse;
|
|
1055
|
+
}
|
|
1056
|
+
if (definition.innerType) {
|
|
1057
|
+
carryTheCode(definition.innerType, refuse);
|
|
1058
|
+
}
|
|
910
1059
|
}
|
|
911
1060
|
function unknownArgumentMessage(keys, declared) {
|
|
912
1061
|
const named = keys.map((key) => {
|
|
@@ -918,15 +1067,21 @@ function unknownArgumentMessage(keys, declared) {
|
|
|
918
1067
|
function nearestArgument(key, declared) {
|
|
919
1068
|
const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
920
1069
|
const flat = flatten(key);
|
|
921
|
-
if (flat.length === 0)
|
|
1070
|
+
if (flat.length === 0) {
|
|
1071
|
+
return void 0;
|
|
1072
|
+
}
|
|
922
1073
|
const sameName = declared.find((name) => flatten(name) === flat);
|
|
923
|
-
if (sameName)
|
|
1074
|
+
if (sameName) {
|
|
1075
|
+
return sameName;
|
|
1076
|
+
}
|
|
924
1077
|
const overlapping = declared.find((name) => {
|
|
925
1078
|
const other = flatten(name);
|
|
926
1079
|
const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
|
|
927
1080
|
return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
|
|
928
1081
|
});
|
|
929
|
-
if (overlapping)
|
|
1082
|
+
if (overlapping) {
|
|
1083
|
+
return overlapping;
|
|
1084
|
+
}
|
|
930
1085
|
let closest;
|
|
931
1086
|
let shortest = Number.POSITIVE_INFINITY;
|
|
932
1087
|
for (const name of declared) {
|
|
@@ -940,15 +1095,25 @@ function nearestArgument(key, declared) {
|
|
|
940
1095
|
}
|
|
941
1096
|
function editDistance(left, right) {
|
|
942
1097
|
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
1098
|
+
let distance = right.length;
|
|
943
1099
|
for (let row = 1; row <= left.length; row += 1) {
|
|
944
1100
|
const current = [row];
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1101
|
+
let diagonal = row - 1;
|
|
1102
|
+
let leftward = row;
|
|
1103
|
+
for (const [column, above] of previous.entries()) {
|
|
1104
|
+
if (column === 0) {
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
const substitution = diagonal + (left[row - 1] === right[column - 1] ? 0 : 1);
|
|
1108
|
+
const cell = Math.min(substitution, above + 1, leftward + 1);
|
|
1109
|
+
current.push(cell);
|
|
1110
|
+
diagonal = above;
|
|
1111
|
+
leftward = cell;
|
|
948
1112
|
}
|
|
949
1113
|
previous = current;
|
|
1114
|
+
distance = leftward;
|
|
950
1115
|
}
|
|
951
|
-
return
|
|
1116
|
+
return distance;
|
|
952
1117
|
}
|
|
953
1118
|
|
|
954
1119
|
// src/tools/shared.ts
|
|
@@ -1034,21 +1199,29 @@ ${trailer}`;
|
|
|
1034
1199
|
function toToolError(error) {
|
|
1035
1200
|
const known = error instanceof McError ? error : new McError("network_error", error instanceof Error ? error.message : String(error));
|
|
1036
1201
|
const lines = [`[${known.code}] ${known.message}`];
|
|
1037
|
-
if (known.details.hint)
|
|
1202
|
+
if (known.details.hint) {
|
|
1203
|
+
lines.push(`Hint: ${known.details.hint}`);
|
|
1204
|
+
}
|
|
1038
1205
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
|
|
1039
1206
|
}
|
|
1040
1207
|
function truncate(text, maxChars) {
|
|
1041
|
-
if (text.length <= maxChars)
|
|
1208
|
+
if (text.length <= maxChars) {
|
|
1209
|
+
return text;
|
|
1210
|
+
}
|
|
1042
1211
|
return `${text.slice(0, maxChars - 1).trimEnd()}\u2026`;
|
|
1043
1212
|
}
|
|
1044
1213
|
function sliceAtLineBoundary(text, offset, maxChars) {
|
|
1045
1214
|
const rest = text.slice(offset);
|
|
1046
|
-
if (rest.length <= maxChars)
|
|
1215
|
+
if (rest.length <= maxChars) {
|
|
1216
|
+
return { slice: rest, nextOffset: null };
|
|
1217
|
+
}
|
|
1047
1218
|
const window = rest.slice(0, maxChars);
|
|
1048
1219
|
const lastBreak = window.lastIndexOf("\n");
|
|
1049
1220
|
let cut = lastBreak > 0 ? lastBreak : maxChars;
|
|
1050
1221
|
const code = rest.charCodeAt(cut - 1);
|
|
1051
|
-
if (code >= 55296 && code <= 56319)
|
|
1222
|
+
if (code >= 55296 && code <= 56319) {
|
|
1223
|
+
cut -= 1;
|
|
1224
|
+
}
|
|
1052
1225
|
return { slice: rest.slice(0, cut), nextOffset: offset + cut };
|
|
1053
1226
|
}
|
|
1054
1227
|
function renderTitleList(titles) {
|
|
@@ -1070,6 +1243,15 @@ function renderTitleList(titles) {
|
|
|
1070
1243
|
}
|
|
1071
1244
|
|
|
1072
1245
|
// src/tools/browseTitles.ts
|
|
1246
|
+
function orderedBy(sort) {
|
|
1247
|
+
if (sort === "score") {
|
|
1248
|
+
return " by critic score";
|
|
1249
|
+
}
|
|
1250
|
+
if (sort === "recent") {
|
|
1251
|
+
return " by release date";
|
|
1252
|
+
}
|
|
1253
|
+
return " by current popularity";
|
|
1254
|
+
}
|
|
1073
1255
|
var browseTitlesDescription = [
|
|
1074
1256
|
"List films, shows or games by score, by release date or by current popularity.",
|
|
1075
1257
|
"Use this when there is no specific title to look up: best rated, newest, what people are looking at now.",
|
|
@@ -1110,7 +1292,9 @@ async function runBrowseTitles(client, args) {
|
|
|
1110
1292
|
});
|
|
1111
1293
|
const results = data.titles.map(toTitleSummaryOut);
|
|
1112
1294
|
const notes = [];
|
|
1113
|
-
if (cached)
|
|
1295
|
+
if (cached) {
|
|
1296
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1297
|
+
}
|
|
1114
1298
|
if (results.length === 0) {
|
|
1115
1299
|
notes.push(
|
|
1116
1300
|
args.genre ? `No ${args.kind} matched the genre "${args.genre}" at this offset. Check the spelling, or drop the genre.` : "The listing returned no rows at this offset."
|
|
@@ -1125,7 +1309,7 @@ async function runBrowseTitles(client, args) {
|
|
|
1125
1309
|
const heading = [
|
|
1126
1310
|
args.genre ? `${args.genre} ` : "",
|
|
1127
1311
|
`${args.kind}s`,
|
|
1128
|
-
args.sort
|
|
1312
|
+
orderedBy(args.sort)
|
|
1129
1313
|
].join("");
|
|
1130
1314
|
const summary = results.length === 0 ? `No rows for ${heading}.` : `${results.length} of ${data.totalResults} ${heading}:
|
|
1131
1315
|
${renderTitleList(results)}`;
|
|
@@ -1211,7 +1395,9 @@ async function runGetReviews(client, args) {
|
|
|
1211
1395
|
date: review.date
|
|
1212
1396
|
}));
|
|
1213
1397
|
const notes = [];
|
|
1214
|
-
if (cached)
|
|
1398
|
+
if (cached) {
|
|
1399
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1400
|
+
}
|
|
1215
1401
|
if (reviews.length === 0) {
|
|
1216
1402
|
if (args.offset > 0 && args.offset >= data.reviews.length) {
|
|
1217
1403
|
notes.push(
|
|
@@ -1271,6 +1457,46 @@ ${listing}`;
|
|
|
1271
1457
|
|
|
1272
1458
|
// src/tools/getTitle.ts
|
|
1273
1459
|
import { z as z5 } from "zod";
|
|
1460
|
+
function notesOnWhatWasReadAndAsked(read) {
|
|
1461
|
+
const { basic, wanted, slice, nextOffset, fullLength, offset, maxChars } = read;
|
|
1462
|
+
const notes = [];
|
|
1463
|
+
if (basic && nextOffset !== null) {
|
|
1464
|
+
notes.push(
|
|
1465
|
+
`The description is longer than ${maxChars} characters. Call again with offset=${nextOffset} for the rest.`
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
if (basic && slice === "" && offset > 0 && fullLength > 0) {
|
|
1469
|
+
notes.push(
|
|
1470
|
+
`offset=${offset} is past the end of a description of ${fullLength} characters. Call again with offset=0 to read it from the start.`
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
if (!wanted.has("scores")) {
|
|
1474
|
+
notes.push(
|
|
1475
|
+
"Scores were not requested, so critic_score and user_score are null here regardless of what Metacritic publishes. Add 'scores' to sections to read them."
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
return notes;
|
|
1479
|
+
}
|
|
1480
|
+
var PRODUCTION_CAP = 25;
|
|
1481
|
+
function attachAskedSections(structured, item, wanted, notes) {
|
|
1482
|
+
if (wanted.has("awards")) {
|
|
1483
|
+
structured.awards = item.awards;
|
|
1484
|
+
}
|
|
1485
|
+
if (wanted.has("networks")) {
|
|
1486
|
+
structured.networks = item.networks;
|
|
1487
|
+
}
|
|
1488
|
+
if (!wanted.has("production")) {
|
|
1489
|
+
return [];
|
|
1490
|
+
}
|
|
1491
|
+
const production = item.production.slice(0, PRODUCTION_CAP);
|
|
1492
|
+
structured.production = production;
|
|
1493
|
+
if (item.production.length > PRODUCTION_CAP) {
|
|
1494
|
+
notes.push(
|
|
1495
|
+
`${item.production.length} companies are credited and the first ${PRODUCTION_CAP} are shown. The list mixes producers with distributors and home-video labels across every territory.`
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
return production;
|
|
1499
|
+
}
|
|
1274
1500
|
var SECTIONS = ["basic", "scores", "awards", "production", "networks", "where_to_watch"];
|
|
1275
1501
|
var getTitleDescription = [
|
|
1276
1502
|
"Read one Metacritic entry by slug and kind, both from search_titles.",
|
|
@@ -1318,7 +1544,9 @@ async function runGetTitle(client, args) {
|
|
|
1318
1544
|
const wanted = new Set(args.sections);
|
|
1319
1545
|
const notes = [];
|
|
1320
1546
|
const detail = await client.getDetail(args.kind, args.slug);
|
|
1321
|
-
if (detail.cached)
|
|
1547
|
+
if (detail.cached) {
|
|
1548
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1549
|
+
}
|
|
1322
1550
|
const item = detail.data;
|
|
1323
1551
|
let criticScore = null;
|
|
1324
1552
|
let userScore = null;
|
|
@@ -1331,21 +1559,17 @@ async function runGetTitle(client, args) {
|
|
|
1331
1559
|
const basic = wanted.has("basic");
|
|
1332
1560
|
const full = basic ? item.description ?? "" : "";
|
|
1333
1561
|
const { slice, nextOffset } = sliceAtLineBoundary(full, args.offset, args.max_chars);
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
notes.push(
|
|
1346
|
-
"Scores were not requested, so critic_score and user_score are null here regardless of what Metacritic publishes. Add 'scores' to sections to read them."
|
|
1347
|
-
);
|
|
1348
|
-
}
|
|
1562
|
+
notes.push(
|
|
1563
|
+
...notesOnWhatWasReadAndAsked({
|
|
1564
|
+
basic,
|
|
1565
|
+
wanted,
|
|
1566
|
+
slice,
|
|
1567
|
+
nextOffset,
|
|
1568
|
+
fullLength: full.length,
|
|
1569
|
+
offset: args.offset,
|
|
1570
|
+
maxChars: args.max_chars
|
|
1571
|
+
})
|
|
1572
|
+
);
|
|
1349
1573
|
const structured = {
|
|
1350
1574
|
title: {
|
|
1351
1575
|
...toTitleSummaryOut(item),
|
|
@@ -1366,24 +1590,24 @@ async function runGetTitle(client, args) {
|
|
|
1366
1590
|
user_score: userScore ? toScoreOut(userScore) : null,
|
|
1367
1591
|
notes
|
|
1368
1592
|
};
|
|
1369
|
-
|
|
1370
|
-
const PRODUCTION_CAP = 25;
|
|
1371
|
-
const production = wanted.has("production") ? item.production.slice(0, PRODUCTION_CAP) : [];
|
|
1372
|
-
if (wanted.has("production")) {
|
|
1373
|
-
structured.production = production;
|
|
1374
|
-
if (item.production.length > PRODUCTION_CAP) {
|
|
1375
|
-
notes.push(
|
|
1376
|
-
`${item.production.length} companies are credited and the first ${PRODUCTION_CAP} are shown. The list mixes producers with distributors and home-video labels across every territory.`
|
|
1377
|
-
);
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
if (wanted.has("networks")) structured.networks = item.networks;
|
|
1593
|
+
const production = attachAskedSections(structured, item, wanted, notes);
|
|
1381
1594
|
const offers = wanted.has("where_to_watch") ? await watchOffers(client, item, args.kind, notes) : [];
|
|
1382
|
-
if (wanted.has("where_to_watch"))
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1595
|
+
if (wanted.has("where_to_watch")) {
|
|
1596
|
+
structured.where_to_watch = offers;
|
|
1597
|
+
}
|
|
1598
|
+
return ok(
|
|
1599
|
+
structured,
|
|
1600
|
+
render(item, slice, wanted, {
|
|
1601
|
+
critic: criticScore,
|
|
1602
|
+
user: userScore,
|
|
1603
|
+
offers,
|
|
1604
|
+
production
|
|
1605
|
+
}),
|
|
1606
|
+
{
|
|
1607
|
+
notes,
|
|
1608
|
+
sourceUrl: item.sourceUrl
|
|
1609
|
+
}
|
|
1610
|
+
);
|
|
1387
1611
|
} catch (error) {
|
|
1388
1612
|
return toToolError(error);
|
|
1389
1613
|
}
|
|
@@ -1421,35 +1645,25 @@ async function watchOffers(client, item, kind, notes) {
|
|
|
1421
1645
|
return [];
|
|
1422
1646
|
}
|
|
1423
1647
|
}
|
|
1424
|
-
function
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
const lines = [header];
|
|
1428
|
-
if (wanted.has("basic")) {
|
|
1429
|
-
if (item.genres.length > 0) lines.push(`Genres: ${item.genres.join(", ")}`);
|
|
1430
|
-
if (item.duration !== null) lines.push(`Runtime: ${item.duration} min`);
|
|
1431
|
-
}
|
|
1432
|
-
if (critic?.score !== null && critic !== null) {
|
|
1433
|
-
lines.push(
|
|
1434
|
-
`Critics: ${critic.score}/${critic.max} from ${critic.reviewCount ?? "?"} reviews${critic.sentiment ? ` (${critic.sentiment})` : ""}`
|
|
1435
|
-
);
|
|
1436
|
-
}
|
|
1437
|
-
if (user?.score !== null && user !== null) {
|
|
1438
|
-
lines.push(
|
|
1439
|
-
`Users: ${user.score}/${user.max} from ${user.reviewCount ?? "?"} ratings${user.sentiment ? ` (${user.sentiment})` : ""}`
|
|
1440
|
-
);
|
|
1648
|
+
function renderAwards(item, wanted) {
|
|
1649
|
+
if (!wanted.has("awards") || item.awards.length === 0) {
|
|
1650
|
+
return [];
|
|
1441
1651
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1652
|
+
return [
|
|
1653
|
+
"",
|
|
1654
|
+
"Awards:",
|
|
1655
|
+
...item.awards.map((a) => {
|
|
1446
1656
|
const tally = [
|
|
1447
1657
|
a.wins === null ? "" : `${a.wins} win${a.wins === 1 ? "" : "s"}`,
|
|
1448
1658
|
a.nominations === null ? "" : `${a.nominations} nomination${a.nominations === 1 ? "" : "s"}`
|
|
1449
1659
|
].filter(Boolean);
|
|
1450
|
-
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1660
|
+
return ` ${a.ceremony}${tally.length > 0 ? `: ${tally.join(", ")}` : ""}`;
|
|
1661
|
+
})
|
|
1662
|
+
];
|
|
1663
|
+
}
|
|
1664
|
+
function renderAskedSections(item, wanted, offers, production) {
|
|
1665
|
+
const lines = [];
|
|
1666
|
+
lines.push(...renderAwards(item, wanted));
|
|
1453
1667
|
if (wanted.has("networks") && item.networks.length > 0) {
|
|
1454
1668
|
lines.push("", `Networks: ${item.networks.join(", ")}`);
|
|
1455
1669
|
}
|
|
@@ -1461,13 +1675,45 @@ function render(item, description, critic, user, wanted, offers, production) {
|
|
|
1461
1675
|
}
|
|
1462
1676
|
if (wanted.has("where_to_watch")) {
|
|
1463
1677
|
lines.push("", offers.length === 0 ? "Where to watch: nothing listed." : "Where to watch:");
|
|
1464
|
-
for (const o of offers)
|
|
1678
|
+
for (const o of offers) {
|
|
1679
|
+
lines.push(` ${o.provider} (${o.kind})${o.url ? ` \u2014 ${o.url}` : ""}`);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return lines;
|
|
1683
|
+
}
|
|
1684
|
+
function render(item, description, wanted, said) {
|
|
1685
|
+
const { critic, user, offers, production } = said;
|
|
1686
|
+
const yearShown = item.year !== null && !item.title.includes(`(${item.year})`);
|
|
1687
|
+
const header = [item.title, yearShown ? `(${item.year})` : "", `\xB7 ${item.kind}`].filter(Boolean).join(" ");
|
|
1688
|
+
const lines = [header];
|
|
1689
|
+
if (wanted.has("basic")) {
|
|
1690
|
+
if (item.genres.length > 0) {
|
|
1691
|
+
lines.push(`Genres: ${item.genres.join(", ")}`);
|
|
1692
|
+
}
|
|
1693
|
+
if (item.duration !== null) {
|
|
1694
|
+
lines.push(`Runtime: ${item.duration} min`);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
if (critic?.score !== null && critic !== null) {
|
|
1698
|
+
lines.push(
|
|
1699
|
+
`Critics: ${critic.score}/${critic.max} from ${critic.reviewCount ?? "?"} reviews${critic.sentiment ? ` (${critic.sentiment})` : ""}`
|
|
1700
|
+
);
|
|
1465
1701
|
}
|
|
1702
|
+
if (user?.score !== null && user !== null) {
|
|
1703
|
+
lines.push(
|
|
1704
|
+
`Users: ${user.score}/${user.max} from ${user.reviewCount ?? "?"} ratings${user.sentiment ? ` (${user.sentiment})` : ""}`
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
if (description) {
|
|
1708
|
+
lines.push("", description);
|
|
1709
|
+
}
|
|
1710
|
+
lines.push(...renderAskedSections(item, wanted, offers, production));
|
|
1466
1711
|
return lines.join("\n");
|
|
1467
1712
|
}
|
|
1468
1713
|
|
|
1469
1714
|
// src/tools/searchTitles.ts
|
|
1470
1715
|
import { z as z6 } from "zod";
|
|
1716
|
+
var WHITESPACE = /\s/;
|
|
1471
1717
|
var searchTitlesDescription = [
|
|
1472
1718
|
"Search Metacritic for films, shows and games by title.",
|
|
1473
1719
|
"Returns one compact row per match, carrying the critic Metascore, so a question about the critical verdict needs no second call.",
|
|
@@ -1497,8 +1743,10 @@ async function runSearchTitles(client, args) {
|
|
|
1497
1743
|
const results = matching.slice(0, args.limit).map(toTitleSummaryOut);
|
|
1498
1744
|
const windowExhausted = args.kind !== "any" && matching.length === results.length;
|
|
1499
1745
|
const notes = [];
|
|
1500
|
-
if (cached)
|
|
1501
|
-
|
|
1746
|
+
if (cached) {
|
|
1747
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1748
|
+
}
|
|
1749
|
+
const looseCount = WHITESPACE.test(args.query.trim());
|
|
1502
1750
|
if (looseCount) {
|
|
1503
1751
|
notes.push(
|
|
1504
1752
|
"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."
|
|
@@ -1606,16 +1854,16 @@ async function main() {
|
|
|
1606
1854
|
const transport = new StdioServerTransport();
|
|
1607
1855
|
await server.connect(transport);
|
|
1608
1856
|
const shutdown = () => {
|
|
1609
|
-
|
|
1857
|
+
server.close().catch(() => void 0).finally(() => process2.exit(0));
|
|
1610
1858
|
};
|
|
1611
|
-
|
|
1612
|
-
|
|
1859
|
+
process2.on("SIGINT", shutdown);
|
|
1860
|
+
process2.on("SIGTERM", shutdown);
|
|
1613
1861
|
}
|
|
1614
1862
|
main().catch((error) => {
|
|
1615
|
-
|
|
1863
|
+
process2.stderr.write(
|
|
1616
1864
|
`[mcp-metacritic] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}
|
|
1617
1865
|
`
|
|
1618
1866
|
);
|
|
1619
|
-
|
|
1867
|
+
process2.exit(1);
|
|
1620
1868
|
});
|
|
1621
1869
|
//# sourceMappingURL=index.js.map
|