mcp-metacritic 1.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/LICENSE +21 -0
- package/README.md +345 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1526 -0
- package/dist/index.js.map +1 -0
- package/dist/mc/client.d.ts +221 -0
- package/dist/mc/client.js +889 -0
- package/dist/mc/client.js.map +1 -0
- package/package.json +81 -0
- package/server.json +35 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1526 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/version.ts
|
|
7
|
+
var PKG_VERSION = "1.0.0";
|
|
8
|
+
var REPO_URL = "https://github.com/smeet666/mcp-metacritic";
|
|
9
|
+
|
|
10
|
+
// src/config.ts
|
|
11
|
+
var DEFAULT_USER_AGENT = `mcp-metacritic v${PKG_VERSION} (${REPO_URL})`;
|
|
12
|
+
var DEFAULTS = {
|
|
13
|
+
minIntervalMs: 1e3,
|
|
14
|
+
timeoutMs: 15e3,
|
|
15
|
+
maxRetries: 3,
|
|
16
|
+
// A catalogue entry moves when a title is edited, which is rare, so a day is
|
|
17
|
+
// safe. The cache lives in memory and dies with the process, so this is a
|
|
18
|
+
// ceiling on one session rather than a stored copy of the site.
|
|
19
|
+
cacheTtlMs: 24 * 60 * 60 * 1e3,
|
|
20
|
+
// Scores and reviews move as reviews come in, especially around a release,
|
|
21
|
+
// so they get their own much shorter lifetime.
|
|
22
|
+
scoresCacheTtlMs: 60 * 60 * 1e3,
|
|
23
|
+
cacheMaxEntries: 200,
|
|
24
|
+
logLevel: "error"
|
|
25
|
+
};
|
|
26
|
+
var MIN_ALLOWED_INTERVAL_MS = 500;
|
|
27
|
+
var MAX_ALLOWED_INTERVAL_MS = 6e4;
|
|
28
|
+
var LOG_LEVELS = ["silent", "error", "info", "debug"];
|
|
29
|
+
function readNumber(name, env, range) {
|
|
30
|
+
const raw = env[name];
|
|
31
|
+
if (raw === void 0 || raw.trim() === "") return range.fallback;
|
|
32
|
+
const parsed = Number(raw);
|
|
33
|
+
if (!Number.isFinite(parsed)) {
|
|
34
|
+
warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
|
|
35
|
+
return range.fallback;
|
|
36
|
+
}
|
|
37
|
+
const rounded = Math.round(parsed);
|
|
38
|
+
if (rounded < range.min || rounded > range.max) {
|
|
39
|
+
warn(
|
|
40
|
+
`${name}=${raw} is outside the accepted range ${range.min}-${range.max} and was ignored; using ${range.fallback}`
|
|
41
|
+
);
|
|
42
|
+
return range.fallback;
|
|
43
|
+
}
|
|
44
|
+
return rounded;
|
|
45
|
+
}
|
|
46
|
+
function warn(message) {
|
|
47
|
+
process.stderr.write(`[mcp-metacritic] ${message}
|
|
48
|
+
`);
|
|
49
|
+
}
|
|
50
|
+
function readInterval(env) {
|
|
51
|
+
const raw = env.MC_MIN_INTERVAL_MS;
|
|
52
|
+
if (raw === void 0 || raw.trim() === "") return DEFAULTS.minIntervalMs;
|
|
53
|
+
const parsed = Number(raw);
|
|
54
|
+
if (!Number.isFinite(parsed)) {
|
|
55
|
+
warn(`MC_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
|
|
56
|
+
return DEFAULTS.minIntervalMs;
|
|
57
|
+
}
|
|
58
|
+
const rounded = Math.round(parsed);
|
|
59
|
+
if (rounded < MIN_ALLOWED_INTERVAL_MS) {
|
|
60
|
+
warn(
|
|
61
|
+
`MC_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; using ${DEFAULTS.minIntervalMs}ms. Metacritic publishes no rate limit, so this client sets its own.`
|
|
62
|
+
);
|
|
63
|
+
return DEFAULTS.minIntervalMs;
|
|
64
|
+
}
|
|
65
|
+
if (rounded > MAX_ALLOWED_INTERVAL_MS) {
|
|
66
|
+
warn(
|
|
67
|
+
`MC_MIN_INTERVAL_MS=${raw} exceeds the ${MAX_ALLOWED_INTERVAL_MS}ms ceiling; using ${MAX_ALLOWED_INTERVAL_MS}ms.`
|
|
68
|
+
);
|
|
69
|
+
return MAX_ALLOWED_INTERVAL_MS;
|
|
70
|
+
}
|
|
71
|
+
return rounded;
|
|
72
|
+
}
|
|
73
|
+
function loadConfig(env = process.env) {
|
|
74
|
+
const rawUserAgent = env.MC_USER_AGENT?.trim();
|
|
75
|
+
const rawLogLevel = env.MC_LOG_LEVEL?.trim().toLowerCase();
|
|
76
|
+
let logLevel = DEFAULTS.logLevel;
|
|
77
|
+
if (rawLogLevel) {
|
|
78
|
+
if (LOG_LEVELS.includes(rawLogLevel)) {
|
|
79
|
+
logLevel = rawLogLevel;
|
|
80
|
+
} else {
|
|
81
|
+
warn(`MC_LOG_LEVEL="${rawLogLevel}" is unknown, using "${DEFAULTS.logLevel}"`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
userAgent: rawUserAgent || DEFAULT_USER_AGENT,
|
|
86
|
+
minIntervalMs: readInterval(env),
|
|
87
|
+
timeoutMs: readNumber("MC_TIMEOUT_MS", env, {
|
|
88
|
+
min: 1e3,
|
|
89
|
+
max: 12e4,
|
|
90
|
+
fallback: DEFAULTS.timeoutMs
|
|
91
|
+
}),
|
|
92
|
+
maxRetries: readNumber("MC_MAX_RETRIES", env, {
|
|
93
|
+
min: 0,
|
|
94
|
+
max: 10,
|
|
95
|
+
fallback: DEFAULTS.maxRetries
|
|
96
|
+
}),
|
|
97
|
+
cacheTtlMs: readNumber("MC_CACHE_TTL_MS", env, {
|
|
98
|
+
min: 0,
|
|
99
|
+
max: 7 * 24 * 60 * 60 * 1e3,
|
|
100
|
+
fallback: DEFAULTS.cacheTtlMs
|
|
101
|
+
}),
|
|
102
|
+
scoresCacheTtlMs: readNumber("MC_SCORES_CACHE_TTL_MS", env, {
|
|
103
|
+
min: 0,
|
|
104
|
+
max: 24 * 60 * 60 * 1e3,
|
|
105
|
+
fallback: DEFAULTS.scoresCacheTtlMs
|
|
106
|
+
}),
|
|
107
|
+
cacheMaxEntries: readNumber("MC_CACHE_MAX_ENTRIES", env, {
|
|
108
|
+
min: 0,
|
|
109
|
+
max: 1e4,
|
|
110
|
+
fallback: DEFAULTS.cacheMaxEntries
|
|
111
|
+
}),
|
|
112
|
+
logLevel
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
var LEVEL_RANK = { silent: 0, error: 1, info: 2, debug: 3 };
|
|
116
|
+
function createLogger(level) {
|
|
117
|
+
const emit = (at, message) => {
|
|
118
|
+
if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {
|
|
119
|
+
process.stderr.write(`[mcp-metacritic] ${message}
|
|
120
|
+
`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
error: (message) => emit("error", message),
|
|
125
|
+
info: (message) => emit("info", message),
|
|
126
|
+
debug: (message) => emit("debug", message)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/server.ts
|
|
131
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
132
|
+
|
|
133
|
+
// src/mc/cache.ts
|
|
134
|
+
var TtlLruCache = class {
|
|
135
|
+
constructor(maxEntries, ttlMs) {
|
|
136
|
+
this.maxEntries = maxEntries;
|
|
137
|
+
this.ttlMs = ttlMs;
|
|
138
|
+
}
|
|
139
|
+
maxEntries;
|
|
140
|
+
ttlMs;
|
|
141
|
+
store = /* @__PURE__ */ new Map();
|
|
142
|
+
get(key) {
|
|
143
|
+
const entry = this.store.get(key);
|
|
144
|
+
if (!entry) return void 0;
|
|
145
|
+
if (Date.now() >= entry.expiresAt) {
|
|
146
|
+
this.store.delete(key);
|
|
147
|
+
return void 0;
|
|
148
|
+
}
|
|
149
|
+
this.store.delete(key);
|
|
150
|
+
this.store.set(key, entry);
|
|
151
|
+
return entry.value;
|
|
152
|
+
}
|
|
153
|
+
set(key, value) {
|
|
154
|
+
if (this.maxEntries <= 0 || this.ttlMs <= 0) return;
|
|
155
|
+
this.store.delete(key);
|
|
156
|
+
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
157
|
+
while (this.store.size > this.maxEntries) {
|
|
158
|
+
const oldest = this.store.keys().next();
|
|
159
|
+
if (oldest.done) break;
|
|
160
|
+
this.store.delete(oldest.value);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
get size() {
|
|
164
|
+
return this.store.size;
|
|
165
|
+
}
|
|
166
|
+
clear() {
|
|
167
|
+
this.store.clear();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// src/errors.ts
|
|
172
|
+
var McError = class extends Error {
|
|
173
|
+
constructor(code, message, details = {}) {
|
|
174
|
+
super(message);
|
|
175
|
+
this.code = code;
|
|
176
|
+
this.details = details;
|
|
177
|
+
this.name = "McError";
|
|
178
|
+
}
|
|
179
|
+
code;
|
|
180
|
+
details;
|
|
181
|
+
};
|
|
182
|
+
var ISSUES_URL = "https://github.com/smeet666/mcp-metacritic/issues";
|
|
183
|
+
function notFound(url, what) {
|
|
184
|
+
return new McError("not_found", `Metacritic has no entry for ${what}.`, {
|
|
185
|
+
url,
|
|
186
|
+
status: 404,
|
|
187
|
+
hint: "Use search_titles to find an entry, then pass its slug and kind to this tool."
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function invalidInput(message, hint) {
|
|
191
|
+
return new McError("invalid_input", message, hint ? { hint } : {});
|
|
192
|
+
}
|
|
193
|
+
function rateLimited(url, retryAfterMs) {
|
|
194
|
+
return new McError(
|
|
195
|
+
"rate_limited",
|
|
196
|
+
"Metacritic is refusing requests from this client for now. This does NOT mean the title is missing.",
|
|
197
|
+
{
|
|
198
|
+
url,
|
|
199
|
+
retryAfterMs,
|
|
200
|
+
hint: `Wait about ${Math.ceil(retryAfterMs / 1e3)} seconds, then call the same tool again with the same arguments. If it keeps happening, raise MC_MIN_INTERVAL_MS.`
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
function parseFailure(url, what) {
|
|
205
|
+
return new McError(
|
|
206
|
+
"parse_failure",
|
|
207
|
+
`Metacritic answered, but the response did not have the expected shape (${what}).`,
|
|
208
|
+
{ url, hint: `Please report this, with the request you made, at ${ISSUES_URL}` }
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
function upstreamError(url, status) {
|
|
212
|
+
return new McError("network_error", `Metacritic returned HTTP ${status}.`, {
|
|
213
|
+
url,
|
|
214
|
+
status,
|
|
215
|
+
...status >= 500 ? { hint: "This is a problem on Metacritic's side. Try again shortly." } : {}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/mc/rateLimiter.ts
|
|
220
|
+
var DEFAULT_MAX_INTERVAL_MS = 1e4;
|
|
221
|
+
var RateLimiter = class {
|
|
222
|
+
baseIntervalMs;
|
|
223
|
+
maxIntervalMs;
|
|
224
|
+
intervalMs;
|
|
225
|
+
/** Tail of the queue: each task chains onto the previous one. */
|
|
226
|
+
tail = Promise.resolve();
|
|
227
|
+
lastStart = 0;
|
|
228
|
+
constructor(options) {
|
|
229
|
+
this.baseIntervalMs = Math.max(0, options.minIntervalMs);
|
|
230
|
+
this.maxIntervalMs = Math.max(
|
|
231
|
+
this.baseIntervalMs,
|
|
232
|
+
options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS
|
|
233
|
+
);
|
|
234
|
+
this.intervalMs = this.baseIntervalMs;
|
|
235
|
+
}
|
|
236
|
+
get currentIntervalMs() {
|
|
237
|
+
return this.intervalMs;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Queue a task. Tasks run in call order, one at a time.
|
|
241
|
+
*
|
|
242
|
+
* A task may send several requests, since the retry loop lives inside its
|
|
243
|
+
* slot. It must call `beforeRequest` around each of them: the pacing is
|
|
244
|
+
* measured between requests, and stamping only the start of the task would
|
|
245
|
+
* let the next one follow a long retry chain's final request immediately.
|
|
246
|
+
*/
|
|
247
|
+
schedule(task) {
|
|
248
|
+
const run = this.tail.then(async () => task());
|
|
249
|
+
this.tail = run.then(
|
|
250
|
+
() => void 0,
|
|
251
|
+
() => void 0
|
|
252
|
+
);
|
|
253
|
+
return run;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Wait for this request's slot, then claim it.
|
|
257
|
+
*
|
|
258
|
+
* Called once per upstream request rather than once per task, so every
|
|
259
|
+
* request is paced from the previous one whether or not they belong to the
|
|
260
|
+
* same retry chain.
|
|
261
|
+
*/
|
|
262
|
+
async beforeRequest() {
|
|
263
|
+
await this.waitForSlot();
|
|
264
|
+
this.lastStart = Date.now();
|
|
265
|
+
}
|
|
266
|
+
/** Called after the site signals it is under load. */
|
|
267
|
+
penalize() {
|
|
268
|
+
const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;
|
|
269
|
+
this.intervalMs = Math.min(this.maxIntervalMs, next);
|
|
270
|
+
}
|
|
271
|
+
/** Called after a success, so a single hiccup does not slow things down forever. */
|
|
272
|
+
relax() {
|
|
273
|
+
this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
|
|
274
|
+
}
|
|
275
|
+
async waitForSlot() {
|
|
276
|
+
if (this.intervalMs === 0 || this.lastStart === 0) return;
|
|
277
|
+
const elapsed = Date.now() - this.lastStart;
|
|
278
|
+
const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
|
|
279
|
+
if (remaining > 0) await sleep(remaining);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
function sleep(ms) {
|
|
283
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/mc/http.ts
|
|
287
|
+
var BACKOFF_BASE_MS = 2e3;
|
|
288
|
+
var BACKOFF_FACTOR = 2;
|
|
289
|
+
var BACKOFF_MAX_MS = 2e4;
|
|
290
|
+
function backoffDelay(attempt, random = Math.random) {
|
|
291
|
+
const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
|
|
292
|
+
return Math.round(capped * (0.5 + random() * 0.5));
|
|
293
|
+
}
|
|
294
|
+
async function fetchText(url, deps) {
|
|
295
|
+
const { config, limiter, logger } = deps;
|
|
296
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
297
|
+
return limiter.schedule(async () => {
|
|
298
|
+
let lastError;
|
|
299
|
+
let askedWaitMs = null;
|
|
300
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
|
|
301
|
+
if (attempt > 0) {
|
|
302
|
+
const delay = askedWaitMs ?? backoffDelay(attempt - 1);
|
|
303
|
+
askedWaitMs = null;
|
|
304
|
+
logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
|
|
305
|
+
await sleep(Math.min(delay, BACKOFF_MAX_MS));
|
|
306
|
+
}
|
|
307
|
+
let status;
|
|
308
|
+
let body;
|
|
309
|
+
let retryAfterMs = null;
|
|
310
|
+
try {
|
|
311
|
+
await limiter.beforeRequest();
|
|
312
|
+
const response = await doFetch(url, {
|
|
313
|
+
headers: {
|
|
314
|
+
"User-Agent": config.userAgent,
|
|
315
|
+
Accept: "application/json,*/*;q=0.8"
|
|
316
|
+
},
|
|
317
|
+
redirect: "follow",
|
|
318
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
319
|
+
});
|
|
320
|
+
status = response.status;
|
|
321
|
+
retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
322
|
+
body = await response.text();
|
|
323
|
+
} catch (error) {
|
|
324
|
+
lastError = asTransportError(error, url);
|
|
325
|
+
logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (status === 429 || status === 503 || status === 403) {
|
|
329
|
+
limiter.penalize();
|
|
330
|
+
askedWaitMs = retryAfterMs;
|
|
331
|
+
lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));
|
|
332
|
+
logger.info(
|
|
333
|
+
`refused on ${url} with ${status}, interval now ${limiter.currentIntervalMs}ms`
|
|
334
|
+
);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (status >= 500) {
|
|
338
|
+
lastError = upstreamError(url, status);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (status === 404) throw notFound(url, "that request");
|
|
342
|
+
if (status >= 400) throw upstreamError(url, status);
|
|
343
|
+
const trimmed = body.trim();
|
|
344
|
+
if (trimmed === "") {
|
|
345
|
+
limiter.penalize();
|
|
346
|
+
lastError = rateLimited(url, backoffDelay(attempt));
|
|
347
|
+
logger.info(`empty body on ${url}, treating as a refusal`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
351
|
+
limiter.penalize();
|
|
352
|
+
lastError = parseFailure(url, "the body is not JSON");
|
|
353
|
+
askedWaitMs = retryAfterMs;
|
|
354
|
+
logger.info(`non-JSON body on ${url}, retrying`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
limiter.relax();
|
|
358
|
+
return body;
|
|
359
|
+
}
|
|
360
|
+
throw lastError ?? new McError("network_error", `Could not fetch ${url}.`, { url });
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function parseRetryAfter(raw) {
|
|
364
|
+
if (!raw) return null;
|
|
365
|
+
const seconds = Number(raw.trim());
|
|
366
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
|
|
367
|
+
const when = Date.parse(raw);
|
|
368
|
+
if (Number.isNaN(when)) return null;
|
|
369
|
+
return Math.max(0, when - Date.now());
|
|
370
|
+
}
|
|
371
|
+
function asTransportError(error, url) {
|
|
372
|
+
if (error instanceof McError) return error;
|
|
373
|
+
const name = error instanceof Error ? error.name : "";
|
|
374
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
375
|
+
return new McError("timeout", "Metacritic did not answer in time.", {
|
|
376
|
+
url,
|
|
377
|
+
hint: "Raise MC_TIMEOUT_MS if this happens often on a slow connection."
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
381
|
+
return new McError("network_error", `Could not reach Metacritic: ${message}`, { url });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/mc/paths.ts
|
|
385
|
+
var API_BASE = "https://backend.metacritic.com";
|
|
386
|
+
var SITE_BASE = "https://www.metacritic.com";
|
|
387
|
+
var API_SEGMENT = {
|
|
388
|
+
movie: "movies",
|
|
389
|
+
show: "shows",
|
|
390
|
+
game: "games"
|
|
391
|
+
};
|
|
392
|
+
var SITE_SEGMENT = {
|
|
393
|
+
movie: "movie",
|
|
394
|
+
show: "tv",
|
|
395
|
+
game: "game"
|
|
396
|
+
};
|
|
397
|
+
var MCO_TYPE_ID = {
|
|
398
|
+
show: 1,
|
|
399
|
+
movie: 2,
|
|
400
|
+
game: 13
|
|
401
|
+
};
|
|
402
|
+
var SEARCH_TYPE = {
|
|
403
|
+
movie: "movie",
|
|
404
|
+
show: "show",
|
|
405
|
+
game: "game-title"
|
|
406
|
+
};
|
|
407
|
+
var SORT_BY = {
|
|
408
|
+
score: "-metaScore",
|
|
409
|
+
recent: "-releaseDate",
|
|
410
|
+
popular: "-popularityCount"
|
|
411
|
+
};
|
|
412
|
+
var COMPONENT = {
|
|
413
|
+
product: { componentName: "product", componentType: "Product" },
|
|
414
|
+
criticStats: { componentName: "critic-score-summary", componentType: "MetaScoreSummary" },
|
|
415
|
+
userStats: { componentName: "user-score-summary", componentType: "MetaScoreSummary" },
|
|
416
|
+
criticReviews: { componentName: "critic-reviews", componentType: "ProductReviewsSummary" },
|
|
417
|
+
userReviews: { componentName: "user-reviews", componentType: "ProductReviewsSummary" }
|
|
418
|
+
};
|
|
419
|
+
var FIELD = {
|
|
420
|
+
data: "data",
|
|
421
|
+
errors: "errors",
|
|
422
|
+
item: "item",
|
|
423
|
+
items: "items",
|
|
424
|
+
totalResults: "totalResults",
|
|
425
|
+
/** Reviews arrive pre-sorted into these buckets, which is the sentiment filter. */
|
|
426
|
+
reviewBuckets: { all: "default", positive: "positive", neutral: "neutral", negative: "negative" }
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// src/mc/urls.ts
|
|
430
|
+
function build(path, params) {
|
|
431
|
+
const query = new URLSearchParams();
|
|
432
|
+
for (const [key, value] of Object.entries(params)) {
|
|
433
|
+
if (value !== void 0 && value !== "") query.set(key, String(value));
|
|
434
|
+
}
|
|
435
|
+
const suffix = query.toString();
|
|
436
|
+
return suffix ? `${API_BASE}${path}?${suffix}` : `${API_BASE}${path}`;
|
|
437
|
+
}
|
|
438
|
+
function checkSlug(slug) {
|
|
439
|
+
const trimmed = slug.trim();
|
|
440
|
+
if (trimmed === "") {
|
|
441
|
+
throw invalidInput("The slug is empty.", "Take it from a search_titles result.");
|
|
442
|
+
}
|
|
443
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) {
|
|
444
|
+
throw invalidInput(
|
|
445
|
+
`"${slug}" does not look like a Metacritic slug.`,
|
|
446
|
+
"Slugs are lowercase words joined by hyphens, such as the-matrix. Use search_titles to get one."
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
return trimmed;
|
|
450
|
+
}
|
|
451
|
+
function searchUrl(query, limit, offset) {
|
|
452
|
+
const trimmed = query.trim();
|
|
453
|
+
if (trimmed === "") {
|
|
454
|
+
throw invalidInput("The search query is empty.", "Pass a title or part of one.");
|
|
455
|
+
}
|
|
456
|
+
return build(`/finder/metacritic/search/${encodeURIComponent(trimmed)}/web`, { limit, offset });
|
|
457
|
+
}
|
|
458
|
+
function browseUrl(options) {
|
|
459
|
+
return build("/finder/metacritic/web", {
|
|
460
|
+
sortBy: SORT_BY[options.sort],
|
|
461
|
+
mcoTypeId: MCO_TYPE_ID[options.kind],
|
|
462
|
+
genres: options.genre,
|
|
463
|
+
limit: options.limit,
|
|
464
|
+
offset: options.offset
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
function detailUrl(kind, slug) {
|
|
468
|
+
return build(`/${API_SEGMENT[kind]}/metacritic/${checkSlug(slug)}/web`, COMPONENT.product);
|
|
469
|
+
}
|
|
470
|
+
function scoreUrl(kind, slug, source) {
|
|
471
|
+
const component = source === "critic" ? COMPONENT.criticStats : COMPONENT.userStats;
|
|
472
|
+
return build(
|
|
473
|
+
`/reviews/metacritic/${source}/${API_SEGMENT[kind]}/${checkSlug(slug)}/stats/web`,
|
|
474
|
+
component
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
function reviewsUrl(options) {
|
|
478
|
+
const component = options.source === "critic" ? COMPONENT.criticReviews : COMPONENT.userReviews;
|
|
479
|
+
return build(
|
|
480
|
+
`/reviews/metacritic/${options.source}/${API_SEGMENT[options.kind]}/${checkSlug(options.slug)}/summary/web`,
|
|
481
|
+
component
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
function watchUrl(imdbId, kind) {
|
|
485
|
+
const trimmed = imdbId.trim();
|
|
486
|
+
if (!/^tt\d+$/.test(trimmed)) {
|
|
487
|
+
throw invalidInput(
|
|
488
|
+
`"${imdbId}" is not an IMDb id.`,
|
|
489
|
+
"IMDb ids look like tt0133093. get_title returns one as imdb_id."
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return build("/justwatch/metacritic/offersByIMDbId", {
|
|
493
|
+
id: trimmed,
|
|
494
|
+
type: kind === "show" ? "show" : "movie"
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
function titlePageUrl(kind, slug) {
|
|
498
|
+
return `${SITE_BASE}/${SITE_SEGMENT[kind]}/${slug}/`;
|
|
499
|
+
}
|
|
500
|
+
function bucketFor(sentiment) {
|
|
501
|
+
return sentiment === "all" ? "default" : sentiment;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/mc/parse.ts
|
|
505
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
506
|
+
function envelope(raw, url, what) {
|
|
507
|
+
let body;
|
|
508
|
+
try {
|
|
509
|
+
body = JSON.parse(raw);
|
|
510
|
+
} catch {
|
|
511
|
+
throw parseFailure(url, "the body is not valid JSON");
|
|
512
|
+
}
|
|
513
|
+
if (!isObject(body)) throw parseFailure(url, "the body is not an object");
|
|
514
|
+
const errors = body[FIELD.errors];
|
|
515
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
516
|
+
const first = isObject(errors[0]) ? errors[0] : {};
|
|
517
|
+
if (first.code === 404) throw notFound(url, what);
|
|
518
|
+
throw parseFailure(
|
|
519
|
+
url,
|
|
520
|
+
`the site reported ${String(first.reason ?? first.code ?? "an error")}`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const data = body[FIELD.data];
|
|
524
|
+
if (!isObject(data)) throw parseFailure(url, "the response carries no data object");
|
|
525
|
+
return data;
|
|
526
|
+
}
|
|
527
|
+
var str = (value) => {
|
|
528
|
+
if (typeof value !== "string") return null;
|
|
529
|
+
const trimmed = value.trim();
|
|
530
|
+
return trimmed === "" ? null : trimmed;
|
|
531
|
+
};
|
|
532
|
+
var num = (value) => {
|
|
533
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
534
|
+
if (typeof value === "string") {
|
|
535
|
+
const parsed = Number(value);
|
|
536
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
539
|
+
};
|
|
540
|
+
var intOf = (value) => {
|
|
541
|
+
const parsed = num(value);
|
|
542
|
+
return parsed === null ? null : Math.trunc(parsed);
|
|
543
|
+
};
|
|
544
|
+
function kindOf(value) {
|
|
545
|
+
const type = str(value);
|
|
546
|
+
if (type === SEARCH_TYPE.movie) return "movie";
|
|
547
|
+
if (type === SEARCH_TYPE.show) return "show";
|
|
548
|
+
if (type === SEARCH_TYPE.game) return "game";
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
function toSummary(node) {
|
|
552
|
+
if (!isObject(node)) return null;
|
|
553
|
+
const id = intOf(node.id);
|
|
554
|
+
const slug = str(node.slug);
|
|
555
|
+
const title = str(node.title);
|
|
556
|
+
const kind = kindOf(node.type);
|
|
557
|
+
if (id === null || !slug || !title || !kind) return null;
|
|
558
|
+
const critic = isObject(node.criticScoreSummary) ? node.criticScoreSummary : {};
|
|
559
|
+
const userSummary = isObject(node.userScoreSummary) ? node.userScoreSummary : {};
|
|
560
|
+
const userObject = isObject(node.userScore) ? node.userScore : {};
|
|
561
|
+
const userScore = num(userSummary.score) ?? num(userObject.score) ?? num(node.userScore);
|
|
562
|
+
return {
|
|
563
|
+
id,
|
|
564
|
+
kind,
|
|
565
|
+
title,
|
|
566
|
+
slug,
|
|
567
|
+
year: intOf(node.premiereYear),
|
|
568
|
+
releaseDate: str(node.releaseDate),
|
|
569
|
+
rating: str(node.rating),
|
|
570
|
+
metascore: num(critic.score),
|
|
571
|
+
userScore,
|
|
572
|
+
sourceUrl: titlePageUrl(kind, slug)
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function parseTitlePage(raw, url, what) {
|
|
576
|
+
const data = envelope(raw, url, what);
|
|
577
|
+
const items = data[FIELD.items];
|
|
578
|
+
if (!Array.isArray(items)) throw parseFailure(url, "the response carries no items array");
|
|
579
|
+
const titles = [];
|
|
580
|
+
for (const node of items) {
|
|
581
|
+
const row = toSummary(node);
|
|
582
|
+
if (row) titles.push(row);
|
|
583
|
+
}
|
|
584
|
+
if (items.length > 0 && titles.length === 0) {
|
|
585
|
+
throw parseFailure(url, `${items.length} rows but none could be read`);
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
titles,
|
|
589
|
+
totalResults: intOf(data[FIELD.totalResults]) ?? titles.length,
|
|
590
|
+
itemCount: items.length
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function toCompanies(value) {
|
|
594
|
+
const source = isObject(value) ? value.companies : value;
|
|
595
|
+
if (!Array.isArray(source)) return [];
|
|
596
|
+
const seen = /* @__PURE__ */ new Set();
|
|
597
|
+
const out = [];
|
|
598
|
+
for (const node of source) {
|
|
599
|
+
const name = isObject(node) ? str(node.name) : str(node);
|
|
600
|
+
if (!name) continue;
|
|
601
|
+
const key = name.toLowerCase();
|
|
602
|
+
if (seen.has(key)) continue;
|
|
603
|
+
seen.add(key);
|
|
604
|
+
out.push({ name, id: isObject(node) ? intOf(node.id) : null });
|
|
605
|
+
}
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
function toStringList(value) {
|
|
609
|
+
if (!Array.isArray(value)) return [];
|
|
610
|
+
const out = [];
|
|
611
|
+
for (const node of value) {
|
|
612
|
+
const name = isObject(node) ? str(node.name) : str(node);
|
|
613
|
+
if (name) out.push(name);
|
|
614
|
+
}
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
617
|
+
function toAwards(value) {
|
|
618
|
+
if (!Array.isArray(value)) return [];
|
|
619
|
+
const out = [];
|
|
620
|
+
for (const node of value) {
|
|
621
|
+
if (!isObject(node)) continue;
|
|
622
|
+
const ceremony = str(node.awardEvent);
|
|
623
|
+
if (!ceremony) continue;
|
|
624
|
+
out.push({
|
|
625
|
+
ceremony,
|
|
626
|
+
wins: intOf(node.wins),
|
|
627
|
+
nominations: intOf(node.nominations)
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
function parseDetail(raw, url, kind, slug) {
|
|
633
|
+
const data = envelope(raw, url, `${kind} "${slug}"`);
|
|
634
|
+
const item = data[FIELD.item];
|
|
635
|
+
if (!isObject(item)) throw parseFailure(url, "the response carries no item object");
|
|
636
|
+
const id = intOf(item.id);
|
|
637
|
+
const title = str(item.title);
|
|
638
|
+
if (id === null || !title) throw parseFailure(url, "the entry has no id or no title");
|
|
639
|
+
const critic = isObject(item.criticScoreSummary) ? item.criticScoreSummary : {};
|
|
640
|
+
return {
|
|
641
|
+
id,
|
|
642
|
+
kind,
|
|
643
|
+
title,
|
|
644
|
+
slug,
|
|
645
|
+
year: intOf(item.premiereYear),
|
|
646
|
+
releaseDate: str(item.releaseDate),
|
|
647
|
+
rating: str(item.rating),
|
|
648
|
+
metascore: num(critic.score),
|
|
649
|
+
userScore: null,
|
|
650
|
+
sourceUrl: titlePageUrl(kind, slug),
|
|
651
|
+
description: str(item.description),
|
|
652
|
+
tagline: str(item.tagline),
|
|
653
|
+
genres: toStringList(item.genres),
|
|
654
|
+
duration: intOf(item.duration),
|
|
655
|
+
imdbId: str(item.imdbId),
|
|
656
|
+
networks: toStringList(item.networks),
|
|
657
|
+
production: toCompanies(item.production),
|
|
658
|
+
awards: toAwards(item.awards),
|
|
659
|
+
criticScoreDetail: null,
|
|
660
|
+
userScoreDetail: null
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function parseScore(raw, url, what) {
|
|
664
|
+
const data = envelope(raw, url, what);
|
|
665
|
+
const item = data[FIELD.item];
|
|
666
|
+
if (!isObject(item)) throw parseFailure(url, "the response carries no score object");
|
|
667
|
+
const max = num(item.max);
|
|
668
|
+
if (max === null) throw parseFailure(url, "the score carries no scale");
|
|
669
|
+
return {
|
|
670
|
+
score: num(item.score),
|
|
671
|
+
max,
|
|
672
|
+
reviewCount: intOf(item.reviewCount),
|
|
673
|
+
positiveCount: intOf(item.positiveCount),
|
|
674
|
+
neutralCount: intOf(item.neutralCount),
|
|
675
|
+
negativeCount: intOf(item.negativeCount),
|
|
676
|
+
sentiment: str(item.sentiment)
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
function reviewBucket(data, url, sentiment) {
|
|
680
|
+
const item = data[FIELD.item];
|
|
681
|
+
if (!isObject(item)) throw parseFailure(url, "the response carries no reviews object");
|
|
682
|
+
const bucket = item[bucketFor(sentiment)];
|
|
683
|
+
if (!Array.isArray(bucket)) {
|
|
684
|
+
throw parseFailure(url, `there is no "${bucketFor(sentiment)}" group of reviews`);
|
|
685
|
+
}
|
|
686
|
+
return bucket;
|
|
687
|
+
}
|
|
688
|
+
function parseCriticReviews(raw, url, what, sentiment) {
|
|
689
|
+
const data = envelope(raw, url, what);
|
|
690
|
+
const bucket = reviewBucket(data, url, sentiment);
|
|
691
|
+
const reviews = [];
|
|
692
|
+
for (const node of bucket) {
|
|
693
|
+
if (!isObject(node)) continue;
|
|
694
|
+
const quote = str(node.quote);
|
|
695
|
+
const publication = str(node.publicationName);
|
|
696
|
+
if (!quote || !publication) continue;
|
|
697
|
+
reviews.push({
|
|
698
|
+
quote,
|
|
699
|
+
score: num(node.score),
|
|
700
|
+
publication,
|
|
701
|
+
author: str(node.author),
|
|
702
|
+
url: str(node.url),
|
|
703
|
+
date: str(node.date)
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
if (bucket.length > 0 && reviews.length === 0) {
|
|
707
|
+
throw parseFailure(url, `${bucket.length} reviews but none could be read`);
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
reviews,
|
|
711
|
+
totalResults: intOf(data[FIELD.totalResults]) ?? reviews.length,
|
|
712
|
+
itemCount: bucket.length
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function parseUserReviews(raw, url, what, sentiment) {
|
|
716
|
+
const data = envelope(raw, url, what);
|
|
717
|
+
const bucket = reviewBucket(data, url, sentiment);
|
|
718
|
+
const reviews = [];
|
|
719
|
+
for (const node of bucket) {
|
|
720
|
+
if (!isObject(node)) continue;
|
|
721
|
+
const quote = str(node.quote);
|
|
722
|
+
if (!quote) continue;
|
|
723
|
+
reviews.push({ quote, score: num(node.score), date: str(node.date) });
|
|
724
|
+
}
|
|
725
|
+
if (bucket.length > 0 && reviews.length === 0) {
|
|
726
|
+
throw parseFailure(url, `${bucket.length} reviews but none could be read`);
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
reviews,
|
|
730
|
+
totalResults: intOf(data[FIELD.totalResults]) ?? reviews.length,
|
|
731
|
+
itemCount: bucket.length
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
function unwrapLink(link) {
|
|
735
|
+
if (!link) return null;
|
|
736
|
+
try {
|
|
737
|
+
const target = new URL(link).searchParams.get("r");
|
|
738
|
+
if (target && /^https?:\/\//i.test(target)) return target;
|
|
739
|
+
} catch {
|
|
740
|
+
}
|
|
741
|
+
return link;
|
|
742
|
+
}
|
|
743
|
+
function parseOffers(raw, url, what) {
|
|
744
|
+
const data = envelope(raw, url, what);
|
|
745
|
+
const item = data[FIELD.item];
|
|
746
|
+
if (!isObject(item)) throw parseFailure(url, "the response carries no offers object");
|
|
747
|
+
const offers = [];
|
|
748
|
+
for (const [group, value] of Object.entries(item)) {
|
|
749
|
+
if (!Array.isArray(value)) continue;
|
|
750
|
+
for (const node of value) {
|
|
751
|
+
if (!isObject(node)) continue;
|
|
752
|
+
const provider = str(node.providerName);
|
|
753
|
+
if (!provider) continue;
|
|
754
|
+
offers.push({ provider, kind: group, url: unwrapLink(str(node.link)) });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return offers;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/mc/client.ts
|
|
761
|
+
function withGuarantees(config) {
|
|
762
|
+
const userAgent = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
|
|
763
|
+
return {
|
|
764
|
+
...config,
|
|
765
|
+
userAgent,
|
|
766
|
+
minIntervalMs: Math.max(MIN_ALLOWED_INTERVAL_MS, config.minIntervalMs)
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
var McClient = class {
|
|
770
|
+
config;
|
|
771
|
+
logger;
|
|
772
|
+
limiter;
|
|
773
|
+
/**
|
|
774
|
+
* Two caches, because the two kinds of data age at different speeds. A
|
|
775
|
+
* catalogue entry changes when someone edits it, which is rare. A score moves
|
|
776
|
+
* every time a review lands, which around a release is constantly.
|
|
777
|
+
*/
|
|
778
|
+
catalogueCache;
|
|
779
|
+
scoresCache;
|
|
780
|
+
fetchImpl;
|
|
781
|
+
constructor(options = {}) {
|
|
782
|
+
this.config = withGuarantees(options.config ?? loadConfig());
|
|
783
|
+
this.logger = options.logger ?? createLogger(this.config.logLevel);
|
|
784
|
+
this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });
|
|
785
|
+
this.catalogueCache = new TtlLruCache(
|
|
786
|
+
this.config.cacheMaxEntries,
|
|
787
|
+
this.config.cacheTtlMs
|
|
788
|
+
);
|
|
789
|
+
this.scoresCache = new TtlLruCache(
|
|
790
|
+
this.config.cacheMaxEntries,
|
|
791
|
+
this.config.scoresCacheTtlMs
|
|
792
|
+
);
|
|
793
|
+
this.fetchImpl = options.fetchImpl;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Search across films, shows and games.
|
|
797
|
+
*
|
|
798
|
+
* The return type carries rows only. The upstream response embeds artwork
|
|
799
|
+
* variants on every match, and dropping them here rather than in a tool means
|
|
800
|
+
* they have no path out of this layer.
|
|
801
|
+
*/
|
|
802
|
+
async search(query, limit, offset) {
|
|
803
|
+
const url = searchUrl(query, limit, offset);
|
|
804
|
+
return this.fetchParsed(
|
|
805
|
+
url,
|
|
806
|
+
this.catalogueCache,
|
|
807
|
+
(body) => parseTitlePage(body, url, `the query "${query}"`)
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
async browse(options) {
|
|
811
|
+
const url = browseUrl(options);
|
|
812
|
+
return this.fetchParsed(
|
|
813
|
+
url,
|
|
814
|
+
this.catalogueCache,
|
|
815
|
+
(body) => parseTitlePage(body, url, `that ${options.kind} listing`)
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
async getDetail(kind, slug) {
|
|
819
|
+
const url = detailUrl(kind, slug);
|
|
820
|
+
const clean = slug.trim();
|
|
821
|
+
return this.fetchParsed(
|
|
822
|
+
url,
|
|
823
|
+
this.catalogueCache,
|
|
824
|
+
(body) => parseDetail(body, url, kind, clean)
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
async getScore(kind, slug, source) {
|
|
828
|
+
const url = scoreUrl(kind, slug, source);
|
|
829
|
+
return this.fetchParsed(
|
|
830
|
+
url,
|
|
831
|
+
this.scoresCache,
|
|
832
|
+
(body) => parseScore(body, url, `the ${source} score of ${kind} "${slug}"`)
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
async getCriticReviews(options) {
|
|
836
|
+
const url = reviewsUrl({ ...options, source: "critic" });
|
|
837
|
+
return this.fetchParsed(
|
|
838
|
+
url,
|
|
839
|
+
this.scoresCache,
|
|
840
|
+
(body) => parseCriticReviews(
|
|
841
|
+
body,
|
|
842
|
+
url,
|
|
843
|
+
`reviews of ${options.kind} "${options.slug}"`,
|
|
844
|
+
options.sentiment
|
|
845
|
+
),
|
|
846
|
+
options.sentiment
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
async getUserReviews(options) {
|
|
850
|
+
const url = reviewsUrl({ ...options, source: "user" });
|
|
851
|
+
return this.fetchParsed(
|
|
852
|
+
url,
|
|
853
|
+
this.scoresCache,
|
|
854
|
+
(body) => parseUserReviews(
|
|
855
|
+
body,
|
|
856
|
+
url,
|
|
857
|
+
`reviews of ${options.kind} "${options.slug}"`,
|
|
858
|
+
options.sentiment
|
|
859
|
+
),
|
|
860
|
+
options.sentiment
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
async getWatchOffers(imdbId, kind) {
|
|
864
|
+
const url = watchUrl(imdbId, kind);
|
|
865
|
+
return this.fetchParsed(
|
|
866
|
+
url,
|
|
867
|
+
this.scoresCache,
|
|
868
|
+
(body) => parseOffers(body, url, `streaming offers for ${imdbId}`)
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Fetch, parse, then cache. In that order: a response that could not be read
|
|
873
|
+
* is never stored, so a bad minute upstream cannot be replayed from memory
|
|
874
|
+
* for the rest of the cache lifetime.
|
|
875
|
+
*/
|
|
876
|
+
async fetchParsed(url, cache, parse, variant = "") {
|
|
877
|
+
const key = variant ? `${url}#${variant}` : url;
|
|
878
|
+
const hit = cache.get(key);
|
|
879
|
+
if (hit !== void 0) {
|
|
880
|
+
this.logger.debug(`cache hit ${key}`);
|
|
881
|
+
return { data: hit, cached: true };
|
|
882
|
+
}
|
|
883
|
+
const body = await fetchText(url, {
|
|
884
|
+
config: this.config,
|
|
885
|
+
limiter: this.limiter,
|
|
886
|
+
logger: this.logger,
|
|
887
|
+
...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
|
|
888
|
+
});
|
|
889
|
+
const data = parse(body);
|
|
890
|
+
cache.set(key, data);
|
|
891
|
+
return { data, cached: false };
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// src/tools/browseTitles.ts
|
|
896
|
+
import { z as z2 } from "zod";
|
|
897
|
+
|
|
898
|
+
// src/tools/shared.ts
|
|
899
|
+
import { z } from "zod";
|
|
900
|
+
var MAX_TEXT_MIRROR_CHARS = 2e3;
|
|
901
|
+
var ATTRIBUTION = "Source: Metacritic";
|
|
902
|
+
var kindSchema = z.enum(["movie", "show", "game"]).describe("Which catalogue the entry belongs to. Pass it back with the slug.");
|
|
903
|
+
var titleSummarySchema = z.object({
|
|
904
|
+
id: z.number().int(),
|
|
905
|
+
kind: z.enum(["movie", "show", "game"]),
|
|
906
|
+
title: z.string(),
|
|
907
|
+
slug: z.string().describe("Identifier to pass to get_title and get_reviews, with 'kind'."),
|
|
908
|
+
year: z.number().int().nullable(),
|
|
909
|
+
release_date: z.string().nullable(),
|
|
910
|
+
rating: z.string().nullable().describe("Age rating as published, such as R or TV-MA."),
|
|
911
|
+
metascore: z.number().nullable().describe("Critic score, out of 100. Not comparable to user_score without rescaling."),
|
|
912
|
+
user_score: z.number().nullable().describe("Audience score, out of 10. Not comparable to metascore without rescaling."),
|
|
913
|
+
source_url: z.string().describe("Metacritic page. Show this when citing the entry.")
|
|
914
|
+
});
|
|
915
|
+
function toTitleSummaryOut(title) {
|
|
916
|
+
return {
|
|
917
|
+
id: title.id,
|
|
918
|
+
kind: title.kind,
|
|
919
|
+
title: title.title,
|
|
920
|
+
slug: title.slug,
|
|
921
|
+
year: title.year,
|
|
922
|
+
release_date: title.releaseDate,
|
|
923
|
+
rating: title.rating,
|
|
924
|
+
metascore: title.metascore,
|
|
925
|
+
user_score: title.userScore,
|
|
926
|
+
source_url: title.sourceUrl
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
var scoreSchema = z.object({
|
|
930
|
+
score: z.number().nullable(),
|
|
931
|
+
max: z.number().describe("Scale this score is on: 100 for critics, 10 for users."),
|
|
932
|
+
review_count: z.number().int().nullable(),
|
|
933
|
+
positive_count: z.number().int().nullable(),
|
|
934
|
+
neutral_count: z.number().int().nullable(),
|
|
935
|
+
negative_count: z.number().int().nullable(),
|
|
936
|
+
sentiment: z.string().nullable().describe("Metacritic's own wording for the verdict.")
|
|
937
|
+
});
|
|
938
|
+
function toScoreOut(score) {
|
|
939
|
+
return {
|
|
940
|
+
score: score.score,
|
|
941
|
+
max: score.max,
|
|
942
|
+
review_count: score.reviewCount,
|
|
943
|
+
positive_count: score.positiveCount,
|
|
944
|
+
neutral_count: score.neutralCount,
|
|
945
|
+
negative_count: score.negativeCount,
|
|
946
|
+
sentiment: score.sentiment
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
function ok(structured, body, trailer = ATTRIBUTION) {
|
|
950
|
+
const cutMarker = "\n\n[shortened; the full result is in the structured output]";
|
|
951
|
+
const reserved = `
|
|
952
|
+
|
|
953
|
+
${trailer}`.length;
|
|
954
|
+
const budget = MAX_TEXT_MIRROR_CHARS - reserved;
|
|
955
|
+
let text;
|
|
956
|
+
if (body.length <= budget) {
|
|
957
|
+
text = `${body}
|
|
958
|
+
|
|
959
|
+
${trailer}`;
|
|
960
|
+
} else {
|
|
961
|
+
const kept = truncate(body, Math.max(0, budget - cutMarker.length));
|
|
962
|
+
text = `${kept}${cutMarker}
|
|
963
|
+
|
|
964
|
+
${trailer}`;
|
|
965
|
+
}
|
|
966
|
+
return { content: [{ type: "text", text }], structuredContent: structured };
|
|
967
|
+
}
|
|
968
|
+
function toToolError(error) {
|
|
969
|
+
const known = error instanceof McError ? error : new McError("network_error", error instanceof Error ? error.message : String(error));
|
|
970
|
+
const lines = [`[${known.code}] ${known.message}`];
|
|
971
|
+
if (known.details.hint) lines.push(`Hint: ${known.details.hint}`);
|
|
972
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
|
|
973
|
+
}
|
|
974
|
+
function truncate(text, maxChars) {
|
|
975
|
+
if (text.length <= maxChars) return text;
|
|
976
|
+
return `${text.slice(0, maxChars - 1).trimEnd()}\u2026`;
|
|
977
|
+
}
|
|
978
|
+
function sliceAtLineBoundary(text, offset, maxChars) {
|
|
979
|
+
const rest = text.slice(offset);
|
|
980
|
+
if (rest.length <= maxChars) return { slice: rest, nextOffset: null };
|
|
981
|
+
const window = rest.slice(0, maxChars);
|
|
982
|
+
const lastBreak = window.lastIndexOf("\n");
|
|
983
|
+
let cut = lastBreak > 0 ? lastBreak : maxChars;
|
|
984
|
+
const code = rest.charCodeAt(cut - 1);
|
|
985
|
+
if (code >= 55296 && code <= 56319) cut -= 1;
|
|
986
|
+
return { slice: rest.slice(0, cut), nextOffset: offset + cut };
|
|
987
|
+
}
|
|
988
|
+
function renderTitleList(titles) {
|
|
989
|
+
return titles.map((title, index) => {
|
|
990
|
+
const scores = [
|
|
991
|
+
title.metascore === null ? "" : `critics ${title.metascore}/100`,
|
|
992
|
+
title.user_score === null ? "" : `users ${title.user_score}/10`
|
|
993
|
+
].filter(Boolean);
|
|
994
|
+
const yearShown = title.year !== null && !title.title.includes(`(${title.year})`);
|
|
995
|
+
const parts = [
|
|
996
|
+
`${index + 1}. ${title.title}`,
|
|
997
|
+
yearShown ? `(${title.year})` : "",
|
|
998
|
+
`\xB7 ${title.kind}`,
|
|
999
|
+
scores.length > 0 ? `\xB7 ${scores.join(", ")}` : "",
|
|
1000
|
+
`\xB7 slug: ${title.slug}`
|
|
1001
|
+
];
|
|
1002
|
+
return parts.filter(Boolean).join(" ");
|
|
1003
|
+
}).join("\n");
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// src/tools/browseTitles.ts
|
|
1007
|
+
var browseTitlesDescription = [
|
|
1008
|
+
"List films, shows or games by score, by release date or by current popularity.",
|
|
1009
|
+
"Use this when there is no specific title to look up: best rated, newest, what people are looking at now.",
|
|
1010
|
+
"Filter by genre with a single name such as Horror, Comedy or Action.",
|
|
1011
|
+
"Sorting by score returns the all-time ranking, which is dominated by older titles: combine with a genre to narrow it.",
|
|
1012
|
+
"Scores come back on their own scales, 100 for critics and 10 for users.",
|
|
1013
|
+
"Paging is approximate: Metacritic does not order tied entries stably, so an entry can appear on two consecutive pages. Deduplicate by slug rather than counting rows."
|
|
1014
|
+
].join(" ");
|
|
1015
|
+
var browseTitlesInputShape = {
|
|
1016
|
+
kind: z2.enum(["movie", "show", "game"]).default("movie").describe("Which catalogue to list."),
|
|
1017
|
+
sort: z2.enum(["score", "recent", "popular"]).default("score").describe(
|
|
1018
|
+
"'score' is the critic ranking, 'recent' is by release date, 'popular' is current attention."
|
|
1019
|
+
),
|
|
1020
|
+
genre: z2.string().optional().describe("Single genre name, such as Horror. Omit for all genres."),
|
|
1021
|
+
limit: z2.number().int().min(1).max(50).default(20).describe("How many rows to return."),
|
|
1022
|
+
offset: z2.number().int().min(0).default(0).describe("How many rows to skip, for paging.")
|
|
1023
|
+
};
|
|
1024
|
+
var browseTitlesOutputShape = {
|
|
1025
|
+
kind: z2.string(),
|
|
1026
|
+
sort: z2.string(),
|
|
1027
|
+
genre: z2.string().nullable(),
|
|
1028
|
+
results: z2.array(titleSummarySchema),
|
|
1029
|
+
total_available: z2.number().int().describe("How many entries match upstream, which is the size of what this samples."),
|
|
1030
|
+
offset: z2.number().int(),
|
|
1031
|
+
next_offset: z2.number().int().nullable().describe(
|
|
1032
|
+
"Pass as 'offset' for the next page. Tied entries are not ordered stably upstream, so deduplicate by slug across pages."
|
|
1033
|
+
),
|
|
1034
|
+
notes: z2.array(z2.string())
|
|
1035
|
+
};
|
|
1036
|
+
async function runBrowseTitles(client, args) {
|
|
1037
|
+
try {
|
|
1038
|
+
const { data, cached } = await client.browse({
|
|
1039
|
+
kind: args.kind,
|
|
1040
|
+
sort: args.sort,
|
|
1041
|
+
...args.genre ? { genre: args.genre } : {},
|
|
1042
|
+
limit: args.limit,
|
|
1043
|
+
offset: args.offset
|
|
1044
|
+
});
|
|
1045
|
+
const results = data.titles.map(toTitleSummaryOut);
|
|
1046
|
+
const notes = [];
|
|
1047
|
+
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
1048
|
+
if (results.length === 0) {
|
|
1049
|
+
notes.push(
|
|
1050
|
+
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."
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
if (data.itemCount > data.titles.length) {
|
|
1054
|
+
notes.push(
|
|
1055
|
+
`${data.itemCount - data.titles.length} entries on this page could not be read and were skipped.`
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
const nextOffset = data.itemCount < args.limit ? null : args.offset + data.itemCount;
|
|
1059
|
+
const heading = [
|
|
1060
|
+
args.genre ? `${args.genre} ` : "",
|
|
1061
|
+
`${args.kind}s`,
|
|
1062
|
+
args.sort === "score" ? " by critic score" : args.sort === "recent" ? " by release date" : " by current popularity"
|
|
1063
|
+
].join("");
|
|
1064
|
+
const summary = results.length === 0 ? `No rows for ${heading}.` : `${results.length} of ${data.totalResults} ${heading}:
|
|
1065
|
+
${renderTitleList(results)}`;
|
|
1066
|
+
return ok(
|
|
1067
|
+
{
|
|
1068
|
+
kind: args.kind,
|
|
1069
|
+
sort: args.sort,
|
|
1070
|
+
genre: args.genre ?? null,
|
|
1071
|
+
results,
|
|
1072
|
+
total_available: data.totalResults,
|
|
1073
|
+
offset: args.offset,
|
|
1074
|
+
next_offset: nextOffset,
|
|
1075
|
+
notes
|
|
1076
|
+
},
|
|
1077
|
+
summary
|
|
1078
|
+
);
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
return toToolError(error);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// src/tools/getReviews.ts
|
|
1085
|
+
import { z as z3 } from "zod";
|
|
1086
|
+
var MAX_QUOTE_CHARS = 600;
|
|
1087
|
+
var getReviewsDescription = [
|
|
1088
|
+
"Read individual reviews of a Metacritic entry, from critics or from users.",
|
|
1089
|
+
"Get the slug and kind from search_titles first.",
|
|
1090
|
+
"Filter with 'sentiment' to read only what praised or panned it, which Metacritic groups itself.",
|
|
1091
|
+
"Metacritic returns a fixed sample rather than the full list, so 'total_available' is usually far larger than what comes back, and there is no way to page past the sample.",
|
|
1092
|
+
"Critic reviews carry the publication and a link to the original article: quote them with both.",
|
|
1093
|
+
"Critic scores run to 100 and user scores to 10, so do not average the two together."
|
|
1094
|
+
].join(" ");
|
|
1095
|
+
var getReviewsInputShape = {
|
|
1096
|
+
slug: z3.string().min(1).describe("Identifier from search_titles, such as 'the-matrix'."),
|
|
1097
|
+
kind: kindSchema,
|
|
1098
|
+
source: z3.enum(["critic", "user"]).default("critic").describe("'critic' is the professional press, 'user' is the audience."),
|
|
1099
|
+
sentiment: z3.enum(["all", "positive", "neutral", "negative"]).default("all").describe("Which slice to read. Metacritic groups reviews this way itself."),
|
|
1100
|
+
limit: z3.number().int().min(1).max(50).default(10).describe("How many reviews to return from the sample Metacritic publishes."),
|
|
1101
|
+
offset: z3.number().int().min(0).default(0).describe("How many of the sampled reviews to skip. The sample itself cannot be paged past.")
|
|
1102
|
+
};
|
|
1103
|
+
var reviewSchema = z3.object({
|
|
1104
|
+
quote: z3.string().nullable().describe("Excerpt as published, truncated if long."),
|
|
1105
|
+
score: z3.number().nullable(),
|
|
1106
|
+
max: z3.number().describe("Scale for this review's score: 100 for critics, 10 for users."),
|
|
1107
|
+
publication: z3.string().nullable().describe("Who ran it. Name it when quoting."),
|
|
1108
|
+
author: z3.string().nullable(),
|
|
1109
|
+
url: z3.string().nullable().describe("Original article. Link it when quoting."),
|
|
1110
|
+
date: z3.string().nullable()
|
|
1111
|
+
});
|
|
1112
|
+
var getReviewsOutputShape = {
|
|
1113
|
+
slug: z3.string(),
|
|
1114
|
+
kind: z3.string(),
|
|
1115
|
+
source_url: z3.string().describe("Metacritic page these reviews belong to. Cite it when an article link is missing."),
|
|
1116
|
+
source: z3.string(),
|
|
1117
|
+
sentiment: z3.string(),
|
|
1118
|
+
reviews: z3.array(reviewSchema),
|
|
1119
|
+
total_available: z3.number().int().describe("How many reviews Metacritic counts in total. Usually far more than it serves here."),
|
|
1120
|
+
offset: z3.number().int(),
|
|
1121
|
+
next_offset: z3.number().int().nullable().describe("Pass as 'offset' for the next page."),
|
|
1122
|
+
notes: z3.array(z3.string())
|
|
1123
|
+
};
|
|
1124
|
+
async function runGetReviews(client, args) {
|
|
1125
|
+
try {
|
|
1126
|
+
const sourceUrl = titlePageUrl(args.kind, args.slug.trim());
|
|
1127
|
+
const request = {
|
|
1128
|
+
kind: args.kind,
|
|
1129
|
+
slug: args.slug,
|
|
1130
|
+
sentiment: args.sentiment,
|
|
1131
|
+
limit: args.limit,
|
|
1132
|
+
offset: args.offset
|
|
1133
|
+
};
|
|
1134
|
+
const max = args.source === "critic" ? 100 : 10;
|
|
1135
|
+
const { data, cached } = args.source === "critic" ? await client.getCriticReviews(request) : await client.getUserReviews(request);
|
|
1136
|
+
const sampled = data.reviews.slice(args.offset, args.offset + args.limit);
|
|
1137
|
+
const reviews = sampled.map((review) => ({
|
|
1138
|
+
quote: review.quote === null ? null : truncate(review.quote, MAX_QUOTE_CHARS),
|
|
1139
|
+
score: review.score,
|
|
1140
|
+
max,
|
|
1141
|
+
publication: "publication" in review ? review.publication : null,
|
|
1142
|
+
author: "author" in review ? review.author : null,
|
|
1143
|
+
url: "url" in review ? review.url : null,
|
|
1144
|
+
date: review.date
|
|
1145
|
+
}));
|
|
1146
|
+
const notes = [];
|
|
1147
|
+
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
1148
|
+
if (reviews.length === 0) {
|
|
1149
|
+
if (args.offset > 0 && args.offset >= data.reviews.length) {
|
|
1150
|
+
notes.push(
|
|
1151
|
+
`offset=${args.offset} is past the end of a sample of ${data.reviews.length} ${args.source} reviews. Call again with offset=0.`
|
|
1152
|
+
);
|
|
1153
|
+
} else if (args.sentiment === "all") {
|
|
1154
|
+
notes.push("Metacritic publishes no review in this slice.");
|
|
1155
|
+
} else {
|
|
1156
|
+
notes.push(
|
|
1157
|
+
`Metacritic publishes no ${args.sentiment} review here. Call again with sentiment="all" to see the rest.`
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (data.itemCount > data.reviews.length) {
|
|
1162
|
+
notes.push(
|
|
1163
|
+
`${data.itemCount - data.reviews.length} entries in the sample could not be attributed and were skipped.`
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
if (data.totalResults > data.reviews.length) {
|
|
1167
|
+
const slice = args.sentiment === "all" ? "" : ` in the ${args.sentiment} slice`;
|
|
1168
|
+
notes.push(
|
|
1169
|
+
`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}`
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
const consumed = args.offset + reviews.length;
|
|
1173
|
+
const nextOffset = consumed < data.reviews.length ? consumed : null;
|
|
1174
|
+
const listing = reviews.map((review, index) => {
|
|
1175
|
+
const who = [review.publication, review.author].filter(Boolean).join(", ");
|
|
1176
|
+
const head = `${index + 1}. ${review.score === null ? "" : `${review.score}/${max} `}${who || "anonymous"}`;
|
|
1177
|
+
const quoted = (review.quote ?? "").split("\n").map((line) => ` ${line}`).join("\n");
|
|
1178
|
+
return `${head}
|
|
1179
|
+
${quoted}
|
|
1180
|
+
${review.url ?? sourceUrl}`;
|
|
1181
|
+
}).join("\n");
|
|
1182
|
+
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}":
|
|
1183
|
+
${listing}`;
|
|
1184
|
+
return ok(
|
|
1185
|
+
{
|
|
1186
|
+
slug: args.slug,
|
|
1187
|
+
kind: args.kind,
|
|
1188
|
+
source_url: sourceUrl,
|
|
1189
|
+
source: args.source,
|
|
1190
|
+
sentiment: args.sentiment,
|
|
1191
|
+
reviews,
|
|
1192
|
+
total_available: data.totalResults,
|
|
1193
|
+
offset: args.offset,
|
|
1194
|
+
next_offset: nextOffset,
|
|
1195
|
+
notes
|
|
1196
|
+
},
|
|
1197
|
+
summary
|
|
1198
|
+
);
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
return toToolError(error);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// src/tools/getTitle.ts
|
|
1205
|
+
import { z as z4 } from "zod";
|
|
1206
|
+
var SECTIONS = ["basic", "scores", "awards", "production", "networks", "where_to_watch"];
|
|
1207
|
+
var getTitleDescription = [
|
|
1208
|
+
"Read one Metacritic entry by slug and kind, both from search_titles.",
|
|
1209
|
+
"Sections are opt-in because a full entry is large: 'basic' and 'scores' are the default and cover most questions.",
|
|
1210
|
+
"'scores' fetches the critic and audience breakdowns, each with its own scale, so never compare the two numbers directly.",
|
|
1211
|
+
"Sections gate the payload: asking for 'scores' alone returns no description, and 'basic' alone returns no scores.",
|
|
1212
|
+
"'networks' lists the broadcasters of a show; 'where_to_watch' costs an extra request and covers films and shows only.",
|
|
1213
|
+
"A long description paginates: when 'truncated' is true, call again with 'offset' set to 'next_offset'."
|
|
1214
|
+
].join(" ");
|
|
1215
|
+
var getTitleInputShape = {
|
|
1216
|
+
slug: z4.string().min(1).describe("Identifier from search_titles, such as 'the-matrix'."),
|
|
1217
|
+
kind: kindSchema,
|
|
1218
|
+
sections: z4.array(z4.enum(SECTIONS)).default(["basic", "scores"]).describe("Which parts to return. Each extra section beyond the default costs a request."),
|
|
1219
|
+
max_chars: z4.number().int().min(200).max(2e4).default(4e3).describe("Character budget for the description."),
|
|
1220
|
+
offset: z4.number().int().min(0).default(0).describe("Where to resume the description.")
|
|
1221
|
+
};
|
|
1222
|
+
var getTitleOutputShape = {
|
|
1223
|
+
title: titleSummarySchema,
|
|
1224
|
+
description: z4.string().nullable(),
|
|
1225
|
+
tagline: z4.string().nullable(),
|
|
1226
|
+
genres: z4.array(z4.string()),
|
|
1227
|
+
duration_minutes: z4.number().int().nullable().describe("Runtime in minutes for a film, or the length of a typical episode for a show."),
|
|
1228
|
+
imdb_id: z4.string().nullable().describe("IMDb identifier, usable to cross-reference other sources."),
|
|
1229
|
+
total_chars: z4.number().int().describe("Length of the full description."),
|
|
1230
|
+
returned_chars: z4.number().int(),
|
|
1231
|
+
offset: z4.number().int(),
|
|
1232
|
+
next_offset: z4.number().int().nullable().describe("Pass as 'offset' to read the rest."),
|
|
1233
|
+
truncated: z4.boolean(),
|
|
1234
|
+
critic_score: scoreSchema.nullable(),
|
|
1235
|
+
user_score: scoreSchema.nullable(),
|
|
1236
|
+
awards: z4.array(
|
|
1237
|
+
z4.object({
|
|
1238
|
+
ceremony: z4.string().describe("Awards body, such as 'Academy Awards, USA'."),
|
|
1239
|
+
wins: z4.number().int().nullable().describe("Null when the source records no win."),
|
|
1240
|
+
nominations: z4.number().int().nullable()
|
|
1241
|
+
})
|
|
1242
|
+
).optional().describe("One tally per ceremony. Metacritic publishes no per-category detail."),
|
|
1243
|
+
networks: z4.array(z4.string()).optional().describe("Broadcasters, for shows."),
|
|
1244
|
+
production: z4.array(z4.object({ name: z4.string(), id: z4.number().int().nullable() })).optional(),
|
|
1245
|
+
where_to_watch: z4.array(z4.object({ provider: z4.string(), kind: z4.string(), url: z4.string().nullable() })).optional(),
|
|
1246
|
+
notes: z4.array(z4.string())
|
|
1247
|
+
};
|
|
1248
|
+
async function runGetTitle(client, args) {
|
|
1249
|
+
try {
|
|
1250
|
+
const wanted = new Set(args.sections);
|
|
1251
|
+
const notes = [];
|
|
1252
|
+
const detail = await client.getDetail(args.kind, args.slug);
|
|
1253
|
+
if (detail.cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
1254
|
+
const item = detail.data;
|
|
1255
|
+
let criticScore = null;
|
|
1256
|
+
let userScore = null;
|
|
1257
|
+
if (wanted.has("scores")) {
|
|
1258
|
+
[criticScore, userScore] = await Promise.all([
|
|
1259
|
+
optionalScore(client, args.kind, args.slug, "critic", notes),
|
|
1260
|
+
optionalScore(client, args.kind, args.slug, "user", notes)
|
|
1261
|
+
]);
|
|
1262
|
+
}
|
|
1263
|
+
const basic = wanted.has("basic");
|
|
1264
|
+
const full = basic ? item.description ?? "" : "";
|
|
1265
|
+
const { slice, nextOffset } = sliceAtLineBoundary(full, args.offset, args.max_chars);
|
|
1266
|
+
if (basic && nextOffset !== null) {
|
|
1267
|
+
notes.push(
|
|
1268
|
+
`The description is longer than ${args.max_chars} characters. Call again with offset=${nextOffset} for the rest.`
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
if (basic && slice === "" && args.offset > 0 && full.length > 0) {
|
|
1272
|
+
notes.push(
|
|
1273
|
+
`offset=${args.offset} is past the end of a description of ${full.length} characters. Call again with offset=0 to read it from the start.`
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
if (!wanted.has("scores")) {
|
|
1277
|
+
notes.push(
|
|
1278
|
+
"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."
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
const structured = {
|
|
1282
|
+
title: {
|
|
1283
|
+
...toTitleSummaryOut(item),
|
|
1284
|
+
metascore: criticScore?.score ?? item.metascore,
|
|
1285
|
+
user_score: userScore?.score ?? null
|
|
1286
|
+
},
|
|
1287
|
+
description: basic && slice !== "" ? slice : null,
|
|
1288
|
+
tagline: basic ? item.tagline : null,
|
|
1289
|
+
genres: basic ? item.genres : [],
|
|
1290
|
+
duration_minutes: basic ? item.duration : null,
|
|
1291
|
+
imdb_id: basic ? item.imdbId : null,
|
|
1292
|
+
total_chars: basic ? full.length : 0,
|
|
1293
|
+
returned_chars: basic ? slice.length : 0,
|
|
1294
|
+
offset: args.offset,
|
|
1295
|
+
next_offset: basic ? nextOffset : null,
|
|
1296
|
+
truncated: basic && nextOffset !== null,
|
|
1297
|
+
critic_score: criticScore ? toScoreOut(criticScore) : null,
|
|
1298
|
+
user_score: userScore ? toScoreOut(userScore) : null,
|
|
1299
|
+
notes
|
|
1300
|
+
};
|
|
1301
|
+
if (wanted.has("awards")) structured.awards = item.awards;
|
|
1302
|
+
if (wanted.has("production")) {
|
|
1303
|
+
const CAP = 25;
|
|
1304
|
+
structured.production = item.production.slice(0, CAP);
|
|
1305
|
+
if (item.production.length > CAP) {
|
|
1306
|
+
notes.push(
|
|
1307
|
+
`${item.production.length} companies are credited and the first ${CAP} are shown. The list mixes producers with distributors and home-video labels across every territory.`
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (wanted.has("networks")) structured.networks = item.networks;
|
|
1312
|
+
if (wanted.has("where_to_watch")) {
|
|
1313
|
+
structured.where_to_watch = await watchOffers(client, item, args.kind, notes);
|
|
1314
|
+
}
|
|
1315
|
+
return ok(
|
|
1316
|
+
structured,
|
|
1317
|
+
render(item, slice, criticScore, userScore),
|
|
1318
|
+
`${ATTRIBUTION} \u2014 ${item.sourceUrl}`
|
|
1319
|
+
);
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
return toToolError(error);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
async function optionalScore(client, kind, slug, source, notes) {
|
|
1325
|
+
try {
|
|
1326
|
+
const { data } = await client.getScore(kind, slug, source);
|
|
1327
|
+
return data;
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
if (error instanceof McError && error.code === "not_found") {
|
|
1330
|
+
notes.push(`Metacritic publishes no ${source} score for this entry.`);
|
|
1331
|
+
} else {
|
|
1332
|
+
const reason = error instanceof McError ? error.code : "an unexpected error";
|
|
1333
|
+
notes.push(
|
|
1334
|
+
`The ${source} score could not be read (${reason}), so it is missing here rather than absent from Metacritic. Call again to retry.`
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
return null;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
async function watchOffers(client, item, kind, notes) {
|
|
1341
|
+
if (kind === "game") {
|
|
1342
|
+
notes.push("Streaming offers do not apply to games.");
|
|
1343
|
+
return [];
|
|
1344
|
+
}
|
|
1345
|
+
if (!item.imdbId) {
|
|
1346
|
+
notes.push("This entry carries no IMDb id, which is what streaming offers are keyed by.");
|
|
1347
|
+
return [];
|
|
1348
|
+
}
|
|
1349
|
+
try {
|
|
1350
|
+
const { data } = await client.getWatchOffers(item.imdbId, kind);
|
|
1351
|
+
return data;
|
|
1352
|
+
} catch {
|
|
1353
|
+
notes.push("Streaming offers could not be read for this entry.");
|
|
1354
|
+
return [];
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
function render(item, description, critic, user) {
|
|
1358
|
+
const yearShown = item.year !== null && !item.title.includes(`(${item.year})`);
|
|
1359
|
+
const header = [item.title, yearShown ? `(${item.year})` : "", `\xB7 ${item.kind}`].filter(Boolean).join(" ");
|
|
1360
|
+
const lines = [header];
|
|
1361
|
+
if (item.genres.length > 0) lines.push(`Genres: ${item.genres.join(", ")}`);
|
|
1362
|
+
if (critic?.score !== null && critic !== null) {
|
|
1363
|
+
lines.push(
|
|
1364
|
+
`Critics: ${critic.score}/${critic.max} from ${critic.reviewCount ?? "?"} reviews${critic.sentiment ? ` (${critic.sentiment})` : ""}`
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
if (user?.score !== null && user !== null) {
|
|
1368
|
+
lines.push(
|
|
1369
|
+
`Users: ${user.score}/${user.max} from ${user.reviewCount ?? "?"} ratings${user.sentiment ? ` (${user.sentiment})` : ""}`
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
if (description) lines.push("", description);
|
|
1373
|
+
return lines.join("\n");
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// src/tools/searchTitles.ts
|
|
1377
|
+
import { z as z5 } from "zod";
|
|
1378
|
+
var searchTitlesDescription = [
|
|
1379
|
+
"Search Metacritic for films, shows and games by title.",
|
|
1380
|
+
"Returns one compact row per match, carrying the critic Metascore, so a question about the critical verdict needs no second call.",
|
|
1381
|
+
"Search rows carry no audience score: use get_title for that, or browse_titles, which does return it.",
|
|
1382
|
+
"Use the slug and kind with get_title for the full entry, or with get_reviews for what critics wrote.",
|
|
1383
|
+
"There is no paging: 'limit' is the only lever, and results always start from the most relevant match.",
|
|
1384
|
+
"This searches titles only. It cannot find an entry from a plot detail, a person or a studio."
|
|
1385
|
+
].join(" ");
|
|
1386
|
+
var searchTitlesInputShape = {
|
|
1387
|
+
query: z5.string().min(1).describe("Title or part of one, for example 'the matrix'."),
|
|
1388
|
+
kind: z5.enum(["movie", "show", "game", "any"]).default("any").describe("Restrict results to one catalogue. Metacritic searches all three at once."),
|
|
1389
|
+
limit: z5.number().int().min(1).max(50).default(10).describe("How many rows to return.")
|
|
1390
|
+
};
|
|
1391
|
+
var searchTitlesOutputShape = {
|
|
1392
|
+
query: z5.string(),
|
|
1393
|
+
results: z5.array(titleSummarySchema),
|
|
1394
|
+
total_available: z5.number().int().describe(
|
|
1395
|
+
"How many entries Metacritic counted. For a query of several words it counts entries matching any one of them, so the number is far larger than the useful matches and should not be read as a match count."
|
|
1396
|
+
),
|
|
1397
|
+
notes: z5.array(z5.string())
|
|
1398
|
+
};
|
|
1399
|
+
async function runSearchTitles(client, args) {
|
|
1400
|
+
try {
|
|
1401
|
+
const upstreamLimit = args.kind === "any" ? args.limit : Math.min(50, args.limit * 3);
|
|
1402
|
+
const { data, cached } = await client.search(args.query, upstreamLimit, 0);
|
|
1403
|
+
const matching = args.kind === "any" ? data.titles : data.titles.filter((title) => title.kind === args.kind);
|
|
1404
|
+
const results = matching.slice(0, args.limit).map(toTitleSummaryOut);
|
|
1405
|
+
const windowExhausted = args.kind !== "any" && matching.length === results.length;
|
|
1406
|
+
const notes = [];
|
|
1407
|
+
if (cached) notes.push("Served from this server's short-lived in-memory cache.");
|
|
1408
|
+
const looseCount = /\s/.test(args.query.trim());
|
|
1409
|
+
if (looseCount) {
|
|
1410
|
+
notes.push(
|
|
1411
|
+
"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."
|
|
1412
|
+
);
|
|
1413
|
+
} else if (data.totalResults > results.length) {
|
|
1414
|
+
notes.push(
|
|
1415
|
+
`${data.totalResults} entries matched and ${results.length} are shown. Raise 'limit' for more, or narrow the query: this tool cannot page.`
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
if (results.length === 0) {
|
|
1419
|
+
notes.push(
|
|
1420
|
+
args.kind === "any" ? "No entry matched. Metacritic matches on the title alone, so try a shorter fragment of it." : `No ${args.kind} matched. Other kinds may still match: call again with kind="any".`
|
|
1421
|
+
);
|
|
1422
|
+
} else if (windowExhausted && results.length < args.limit) {
|
|
1423
|
+
notes.push(
|
|
1424
|
+
`Every ${args.kind} in the window Metacritic returned is shown. There may be more further down its ranking, which this tool cannot reach.`
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
const summary = results.length === 0 ? `No Metacritic entry matched "${args.query}".` : `${results.length} entr${results.length === 1 ? "y" : "ies"} for "${args.query}":
|
|
1428
|
+
${renderTitleList(results)}`;
|
|
1429
|
+
return ok({ query: args.query, results, total_available: data.totalResults, notes }, summary);
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
return toToolError(error);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// src/server.ts
|
|
1436
|
+
var READ_ONLY = {
|
|
1437
|
+
readOnlyHint: true,
|
|
1438
|
+
destructiveHint: false,
|
|
1439
|
+
idempotentHint: true,
|
|
1440
|
+
openWorldHint: true
|
|
1441
|
+
};
|
|
1442
|
+
function createServer(options = {}) {
|
|
1443
|
+
const config = options.config ?? loadConfig();
|
|
1444
|
+
const logger = options.logger ?? createLogger(config.logLevel);
|
|
1445
|
+
const client = new McClient({
|
|
1446
|
+
config,
|
|
1447
|
+
logger,
|
|
1448
|
+
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
1449
|
+
});
|
|
1450
|
+
const server = new McpServer(
|
|
1451
|
+
{ name: "mcp-metacritic", version: PKG_VERSION },
|
|
1452
|
+
{
|
|
1453
|
+
instructions: "Tools for Metacritic: films, shows and games, their scores, and the reviews behind them. No API key is needed. Typical flow: search_titles to find an entry and its slug and kind, then get_title for the entry or get_reviews for what critics wrote. search_titles carries the critic Metascore, so a question about the critical verdict needs no second call, but it carries no audience score: read that with get_title, or with browse_titles. The two scores are on different scales: the critic Metascore runs to 100 and the audience score to 10, so never compare or average them without rescaling; every score is returned with its own 'max' for that reason. Use browse_titles when there is no title to look up, such as best rated or newest releases. When you repeat a review, name the publication and link the original article, and when you cite a score, credit Metacritic and link the entry: every result carries a source_url."
|
|
1454
|
+
}
|
|
1455
|
+
);
|
|
1456
|
+
server.registerTool(
|
|
1457
|
+
"search_titles",
|
|
1458
|
+
{
|
|
1459
|
+
title: "Search Metacritic",
|
|
1460
|
+
description: searchTitlesDescription,
|
|
1461
|
+
inputSchema: searchTitlesInputShape,
|
|
1462
|
+
outputSchema: searchTitlesOutputShape,
|
|
1463
|
+
annotations: READ_ONLY
|
|
1464
|
+
},
|
|
1465
|
+
async (args) => runSearchTitles(client, args)
|
|
1466
|
+
);
|
|
1467
|
+
server.registerTool(
|
|
1468
|
+
"get_title",
|
|
1469
|
+
{
|
|
1470
|
+
title: "Read an entry",
|
|
1471
|
+
description: getTitleDescription,
|
|
1472
|
+
inputSchema: getTitleInputShape,
|
|
1473
|
+
outputSchema: getTitleOutputShape,
|
|
1474
|
+
annotations: READ_ONLY
|
|
1475
|
+
},
|
|
1476
|
+
async (args) => runGetTitle(client, args)
|
|
1477
|
+
);
|
|
1478
|
+
server.registerTool(
|
|
1479
|
+
"get_reviews",
|
|
1480
|
+
{
|
|
1481
|
+
title: "Read reviews",
|
|
1482
|
+
description: getReviewsDescription,
|
|
1483
|
+
inputSchema: getReviewsInputShape,
|
|
1484
|
+
outputSchema: getReviewsOutputShape,
|
|
1485
|
+
annotations: READ_ONLY
|
|
1486
|
+
},
|
|
1487
|
+
async (args) => runGetReviews(client, args)
|
|
1488
|
+
);
|
|
1489
|
+
server.registerTool(
|
|
1490
|
+
"browse_titles",
|
|
1491
|
+
{
|
|
1492
|
+
title: "Browse rankings",
|
|
1493
|
+
description: browseTitlesDescription,
|
|
1494
|
+
inputSchema: browseTitlesInputShape,
|
|
1495
|
+
outputSchema: browseTitlesOutputShape,
|
|
1496
|
+
annotations: READ_ONLY
|
|
1497
|
+
},
|
|
1498
|
+
async (args) => runBrowseTitles(client, args)
|
|
1499
|
+
);
|
|
1500
|
+
logger.info(
|
|
1501
|
+
`ready: user-agent="${config.userAgent}", min interval ${config.minIntervalMs}ms, catalogue cache ${config.cacheTtlMs}ms, scores cache ${config.scoresCacheTtlMs}ms`
|
|
1502
|
+
);
|
|
1503
|
+
return server;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// src/index.ts
|
|
1507
|
+
async function main() {
|
|
1508
|
+
const config = loadConfig();
|
|
1509
|
+
const logger = createLogger(config.logLevel);
|
|
1510
|
+
const server = createServer({ config, logger });
|
|
1511
|
+
const transport = new StdioServerTransport();
|
|
1512
|
+
await server.connect(transport);
|
|
1513
|
+
const shutdown = () => {
|
|
1514
|
+
void server.close().finally(() => process.exit(0));
|
|
1515
|
+
};
|
|
1516
|
+
process.on("SIGINT", shutdown);
|
|
1517
|
+
process.on("SIGTERM", shutdown);
|
|
1518
|
+
}
|
|
1519
|
+
main().catch((error) => {
|
|
1520
|
+
process.stderr.write(
|
|
1521
|
+
`[mcp-metacritic] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}
|
|
1522
|
+
`
|
|
1523
|
+
);
|
|
1524
|
+
process.exit(1);
|
|
1525
|
+
});
|
|
1526
|
+
//# sourceMappingURL=index.js.map
|