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/mc/client.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import process from "process";
|
|
3
|
+
|
|
1
4
|
// src/version.ts
|
|
2
|
-
var PKG_VERSION = "
|
|
5
|
+
var PKG_VERSION = "2.0.0";
|
|
3
6
|
var REPO_URL = "https://github.com/smeet666/mcp-metacritic";
|
|
4
7
|
|
|
5
8
|
// src/config.ts
|
|
@@ -23,7 +26,9 @@ var MAX_ALLOWED_INTERVAL_MS = 6e4;
|
|
|
23
26
|
var LOG_LEVELS = ["silent", "error", "info", "debug"];
|
|
24
27
|
function readNumber(name, env, range) {
|
|
25
28
|
const raw = env[name];
|
|
26
|
-
if (raw === void 0 || raw.trim() === "")
|
|
29
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
30
|
+
return range.fallback;
|
|
31
|
+
}
|
|
27
32
|
const parsed = Number(raw);
|
|
28
33
|
if (!Number.isFinite(parsed)) {
|
|
29
34
|
warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
|
|
@@ -44,7 +49,9 @@ function warn(message) {
|
|
|
44
49
|
}
|
|
45
50
|
function readInterval(env) {
|
|
46
51
|
const raw = env.MC_MIN_INTERVAL_MS;
|
|
47
|
-
if (raw === void 0 || raw.trim() === "")
|
|
52
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
53
|
+
return DEFAULTS.minIntervalMs;
|
|
54
|
+
}
|
|
48
55
|
const parsed = Number(raw);
|
|
49
56
|
if (!Number.isFinite(parsed)) {
|
|
50
57
|
warn(`MC_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
|
|
@@ -133,7 +140,9 @@ var TtlLruCache = class {
|
|
|
133
140
|
store = /* @__PURE__ */ new Map();
|
|
134
141
|
get(key) {
|
|
135
142
|
const entry = this.store.get(key);
|
|
136
|
-
if (!entry)
|
|
143
|
+
if (!entry) {
|
|
144
|
+
return void 0;
|
|
145
|
+
}
|
|
137
146
|
if (Date.now() >= entry.expiresAt) {
|
|
138
147
|
this.store.delete(key);
|
|
139
148
|
return void 0;
|
|
@@ -143,12 +152,16 @@ var TtlLruCache = class {
|
|
|
143
152
|
return entry.value;
|
|
144
153
|
}
|
|
145
154
|
set(key, value) {
|
|
146
|
-
if (this.maxEntries <= 0 || this.ttlMs <= 0)
|
|
155
|
+
if (this.maxEntries <= 0 || this.ttlMs <= 0) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
147
158
|
this.store.delete(key);
|
|
148
159
|
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
149
160
|
while (this.store.size > this.maxEntries) {
|
|
150
161
|
const oldest = this.store.keys().next();
|
|
151
|
-
if (oldest.done)
|
|
162
|
+
if (oldest.done) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
152
165
|
this.store.delete(oldest.value);
|
|
153
166
|
}
|
|
154
167
|
}
|
|
@@ -163,7 +176,7 @@ var TtlLruCache = class {
|
|
|
163
176
|
// src/errors.ts
|
|
164
177
|
var McError = class extends Error {
|
|
165
178
|
constructor(code, message, details = {}) {
|
|
166
|
-
super(message);
|
|
179
|
+
super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
|
|
167
180
|
this.code = code;
|
|
168
181
|
this.details = details;
|
|
169
182
|
this.name = "McError";
|
|
@@ -193,11 +206,11 @@ function rateLimited(url, retryAfterMs) {
|
|
|
193
206
|
}
|
|
194
207
|
);
|
|
195
208
|
}
|
|
196
|
-
function parseFailure(url, what) {
|
|
209
|
+
function parseFailure(url, what, cause) {
|
|
197
210
|
return new McError(
|
|
198
211
|
"parse_failure",
|
|
199
212
|
`Metacritic answered, but the response did not have the expected shape (${what}).`,
|
|
200
|
-
{ url, hint: `Please report this, with the request you made, at ${ISSUES_URL}
|
|
213
|
+
{ url, hint: `Please report this, with the request you made, at ${ISSUES_URL}`, cause }
|
|
201
214
|
);
|
|
202
215
|
}
|
|
203
216
|
function upstreamError(url, status) {
|
|
@@ -265,10 +278,14 @@ var RateLimiter = class {
|
|
|
265
278
|
this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
|
|
266
279
|
}
|
|
267
280
|
async waitForSlot() {
|
|
268
|
-
if (this.intervalMs === 0 || this.lastStart === 0)
|
|
281
|
+
if (this.intervalMs === 0 || this.lastStart === 0) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
269
284
|
const elapsed = Date.now() - this.lastStart;
|
|
270
285
|
const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
|
|
271
|
-
if (remaining > 0)
|
|
286
|
+
if (remaining > 0) {
|
|
287
|
+
await sleep(remaining);
|
|
288
|
+
}
|
|
272
289
|
}
|
|
273
290
|
};
|
|
274
291
|
function sleep(ms) {
|
|
@@ -283,10 +300,57 @@ function backoffDelay(attempt, random = Math.random) {
|
|
|
283
300
|
const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
|
|
284
301
|
return Math.round(capped * (0.5 + random() * 0.5));
|
|
285
302
|
}
|
|
303
|
+
function readAnswer(url, status, body, retryAfterMs, ownGuessMs) {
|
|
304
|
+
if (status === 429 || status === 503 || status === 403) {
|
|
305
|
+
return {
|
|
306
|
+
kind: "again",
|
|
307
|
+
error: rateLimited(url, retryAfterMs ?? ownGuessMs),
|
|
308
|
+
// A server that says when to come back knows better than our own guess.
|
|
309
|
+
waitMs: retryAfterMs,
|
|
310
|
+
penalise: true,
|
|
311
|
+
because: `refused with ${status}`
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (status >= 500) {
|
|
315
|
+
return {
|
|
316
|
+
kind: "again",
|
|
317
|
+
error: upstreamError(url, status),
|
|
318
|
+
waitMs: null,
|
|
319
|
+
penalise: false,
|
|
320
|
+
because: `status ${status}`
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (status === 404) {
|
|
324
|
+
return { kind: "refused", error: notFound(url, "that request") };
|
|
325
|
+
}
|
|
326
|
+
if (status >= 400) {
|
|
327
|
+
return { kind: "refused", error: upstreamError(url, status) };
|
|
328
|
+
}
|
|
329
|
+
const trimmed = body.trim();
|
|
330
|
+
if (trimmed === "") {
|
|
331
|
+
return {
|
|
332
|
+
kind: "again",
|
|
333
|
+
error: rateLimited(url, ownGuessMs),
|
|
334
|
+
waitMs: null,
|
|
335
|
+
penalise: true,
|
|
336
|
+
because: "empty body, treated as a refusal"
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) {
|
|
340
|
+
return {
|
|
341
|
+
kind: "again",
|
|
342
|
+
error: parseFailure(url, "the body is not JSON"),
|
|
343
|
+
waitMs: retryAfterMs,
|
|
344
|
+
penalise: true,
|
|
345
|
+
because: "non-JSON body"
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return { kind: "usable" };
|
|
349
|
+
}
|
|
286
350
|
async function fetchText(url, deps) {
|
|
287
351
|
const { config, limiter, logger } = deps;
|
|
288
352
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
289
|
-
return limiter.schedule(async () => {
|
|
353
|
+
return await limiter.schedule(async () => {
|
|
290
354
|
let lastError;
|
|
291
355
|
let askedWaitMs = null;
|
|
292
356
|
for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
|
|
@@ -317,33 +381,17 @@ async function fetchText(url, deps) {
|
|
|
317
381
|
logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
|
|
318
382
|
continue;
|
|
319
383
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
lastError = rateLimited(url, retryAfterMs ?? backoffDelay(attempt));
|
|
324
|
-
logger.info(
|
|
325
|
-
`refused on ${url} with ${status}, interval now ${limiter.currentIntervalMs}ms`
|
|
326
|
-
);
|
|
327
|
-
continue;
|
|
328
|
-
}
|
|
329
|
-
if (status >= 500) {
|
|
330
|
-
lastError = upstreamError(url, status);
|
|
331
|
-
continue;
|
|
384
|
+
const verdict = readAnswer(url, status, body, retryAfterMs, backoffDelay(attempt));
|
|
385
|
+
if (verdict.kind === "refused") {
|
|
386
|
+
throw verdict.error;
|
|
332
387
|
}
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
343
|
-
limiter.penalize();
|
|
344
|
-
lastError = parseFailure(url, "the body is not JSON");
|
|
345
|
-
askedWaitMs = retryAfterMs;
|
|
346
|
-
logger.info(`non-JSON body on ${url}, retrying`);
|
|
388
|
+
if (verdict.kind === "again") {
|
|
389
|
+
if (verdict.penalise) {
|
|
390
|
+
limiter.penalize();
|
|
391
|
+
logger.info(`${verdict.because} on ${url}, interval now ${limiter.currentIntervalMs}ms`);
|
|
392
|
+
}
|
|
393
|
+
askedWaitMs = verdict.waitMs;
|
|
394
|
+
lastError = verdict.error;
|
|
347
395
|
continue;
|
|
348
396
|
}
|
|
349
397
|
limiter.relax();
|
|
@@ -353,15 +401,23 @@ async function fetchText(url, deps) {
|
|
|
353
401
|
});
|
|
354
402
|
}
|
|
355
403
|
function parseRetryAfter(raw) {
|
|
356
|
-
if (!raw)
|
|
404
|
+
if (!raw) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
357
407
|
const seconds = Number(raw.trim());
|
|
358
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
408
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
409
|
+
return Math.round(seconds * 1e3);
|
|
410
|
+
}
|
|
359
411
|
const when = Date.parse(raw);
|
|
360
|
-
if (Number.isNaN(when))
|
|
412
|
+
if (Number.isNaN(when)) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
361
415
|
return Math.max(0, when - Date.now());
|
|
362
416
|
}
|
|
363
417
|
function asTransportError(error, url) {
|
|
364
|
-
if (error instanceof McError)
|
|
418
|
+
if (error instanceof McError) {
|
|
419
|
+
return error;
|
|
420
|
+
}
|
|
365
421
|
const name = error instanceof Error ? error.name : "";
|
|
366
422
|
if (name === "TimeoutError" || name === "AbortError") {
|
|
367
423
|
return new McError("timeout", "Metacritic did not answer in time.", {
|
|
@@ -419,10 +475,14 @@ var FIELD = {
|
|
|
419
475
|
};
|
|
420
476
|
|
|
421
477
|
// src/mc/urls.ts
|
|
478
|
+
var SLUG = /^[a-z0-9][a-z0-9-]*$/i;
|
|
479
|
+
var IMDB_ID = /^tt\d+$/;
|
|
422
480
|
function build(path, params) {
|
|
423
481
|
const query = new URLSearchParams();
|
|
424
482
|
for (const [key, value] of Object.entries(params)) {
|
|
425
|
-
if (value !== void 0 && value !== "")
|
|
483
|
+
if (value !== void 0 && value !== "") {
|
|
484
|
+
query.set(key, String(value));
|
|
485
|
+
}
|
|
426
486
|
}
|
|
427
487
|
const suffix = query.toString();
|
|
428
488
|
return suffix ? `${API_BASE}${path}?${suffix}` : `${API_BASE}${path}`;
|
|
@@ -432,7 +492,7 @@ function checkSlug(slug) {
|
|
|
432
492
|
if (trimmed === "") {
|
|
433
493
|
throw invalidInput("The slug is empty.", "Take it from a search_titles result.");
|
|
434
494
|
}
|
|
435
|
-
if (
|
|
495
|
+
if (!SLUG.test(trimmed)) {
|
|
436
496
|
throw invalidInput(
|
|
437
497
|
`"${slug}" does not look like a Metacritic slug.`,
|
|
438
498
|
"Slugs are lowercase words joined by hyphens, such as the-matrix. Use search_titles to get one."
|
|
@@ -475,7 +535,7 @@ function reviewsUrl(options) {
|
|
|
475
535
|
}
|
|
476
536
|
function watchUrl(imdbId, kind) {
|
|
477
537
|
const trimmed = imdbId.trim();
|
|
478
|
-
if (
|
|
538
|
+
if (!IMDB_ID.test(trimmed)) {
|
|
479
539
|
throw invalidInput(
|
|
480
540
|
`"${imdbId}" is not an IMDb id.`,
|
|
481
541
|
"IMDb ids look like tt0133093. get_title returns one as imdb_id."
|
|
@@ -494,38 +554,51 @@ function bucketFor(sentiment) {
|
|
|
494
554
|
}
|
|
495
555
|
|
|
496
556
|
// src/mc/parse.ts
|
|
557
|
+
var ABSOLUTE_URL = /^https?:\/\//i;
|
|
497
558
|
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
498
559
|
function envelope(raw, url, what) {
|
|
499
560
|
let body;
|
|
500
561
|
try {
|
|
501
562
|
body = JSON.parse(raw);
|
|
502
|
-
} catch {
|
|
503
|
-
throw parseFailure(url, "the body is not valid JSON");
|
|
563
|
+
} catch (cause) {
|
|
564
|
+
throw parseFailure(url, "the body is not valid JSON", cause);
|
|
565
|
+
}
|
|
566
|
+
if (!isObject(body)) {
|
|
567
|
+
throw parseFailure(url, "the body is not an object");
|
|
504
568
|
}
|
|
505
|
-
if (!isObject(body)) throw parseFailure(url, "the body is not an object");
|
|
506
569
|
const errors = body[FIELD.errors];
|
|
507
570
|
if (Array.isArray(errors) && errors.length > 0) {
|
|
508
571
|
const first = isObject(errors[0]) ? errors[0] : {};
|
|
509
|
-
if (first.code === 404)
|
|
572
|
+
if (first.code === 404) {
|
|
573
|
+
throw notFound(url, what);
|
|
574
|
+
}
|
|
510
575
|
throw parseFailure(
|
|
511
576
|
url,
|
|
512
577
|
`the site reported ${String(first.reason ?? first.code ?? "an error")}`
|
|
513
578
|
);
|
|
514
579
|
}
|
|
515
580
|
const data = body[FIELD.data];
|
|
516
|
-
if (!isObject(data))
|
|
581
|
+
if (!isObject(data)) {
|
|
582
|
+
throw parseFailure(url, "the response carries no data object");
|
|
583
|
+
}
|
|
517
584
|
return data;
|
|
518
585
|
}
|
|
519
586
|
var str = (value) => {
|
|
520
|
-
if (typeof value !== "string")
|
|
587
|
+
if (typeof value !== "string") {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
521
590
|
const trimmed = value.trim();
|
|
522
591
|
return trimmed === "" ? null : trimmed;
|
|
523
592
|
};
|
|
524
593
|
var num = (value) => {
|
|
525
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
594
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
595
|
+
return value;
|
|
596
|
+
}
|
|
526
597
|
if (typeof value === "string") {
|
|
527
598
|
const parsed = Number(value);
|
|
528
|
-
if (Number.isFinite(parsed))
|
|
599
|
+
if (Number.isFinite(parsed)) {
|
|
600
|
+
return parsed;
|
|
601
|
+
}
|
|
529
602
|
}
|
|
530
603
|
return null;
|
|
531
604
|
};
|
|
@@ -539,18 +612,28 @@ var aggregate = (value) => {
|
|
|
539
612
|
};
|
|
540
613
|
function kindOf(value) {
|
|
541
614
|
const type = str(value);
|
|
542
|
-
if (type === SEARCH_TYPE.movie)
|
|
543
|
-
|
|
544
|
-
|
|
615
|
+
if (type === SEARCH_TYPE.movie) {
|
|
616
|
+
return "movie";
|
|
617
|
+
}
|
|
618
|
+
if (type === SEARCH_TYPE.show) {
|
|
619
|
+
return "show";
|
|
620
|
+
}
|
|
621
|
+
if (type === SEARCH_TYPE.game) {
|
|
622
|
+
return "game";
|
|
623
|
+
}
|
|
545
624
|
return null;
|
|
546
625
|
}
|
|
547
626
|
function toSummary(node) {
|
|
548
|
-
if (!isObject(node))
|
|
627
|
+
if (!isObject(node)) {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
549
630
|
const id = intOf(node.id);
|
|
550
631
|
const slug = str(node.slug);
|
|
551
632
|
const title = str(node.title);
|
|
552
633
|
const kind = kindOf(node.type);
|
|
553
|
-
if (id === null || !slug || !title || !kind)
|
|
634
|
+
if (id === null || !slug || !title || !kind) {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
554
637
|
const critic = isObject(node.criticScoreSummary) ? node.criticScoreSummary : {};
|
|
555
638
|
const userSummary = isObject(node.userScoreSummary) ? node.userScoreSummary : {};
|
|
556
639
|
const userObject = isObject(node.userScore) ? node.userScore : {};
|
|
@@ -571,11 +654,15 @@ function toSummary(node) {
|
|
|
571
654
|
function parseTitlePage(raw, url, what) {
|
|
572
655
|
const data = envelope(raw, url, what);
|
|
573
656
|
const items = data[FIELD.items];
|
|
574
|
-
if (!Array.isArray(items))
|
|
657
|
+
if (!Array.isArray(items)) {
|
|
658
|
+
throw parseFailure(url, "the response carries no items array");
|
|
659
|
+
}
|
|
575
660
|
const titles = [];
|
|
576
661
|
for (const node of items) {
|
|
577
662
|
const row = toSummary(node);
|
|
578
|
-
if (row)
|
|
663
|
+
if (row) {
|
|
664
|
+
titles.push(row);
|
|
665
|
+
}
|
|
579
666
|
}
|
|
580
667
|
if (items.length > 0 && titles.length === 0) {
|
|
581
668
|
throw parseFailure(url, `${items.length} rows but none could be read`);
|
|
@@ -588,35 +675,51 @@ function parseTitlePage(raw, url, what) {
|
|
|
588
675
|
}
|
|
589
676
|
function toCompanies(value) {
|
|
590
677
|
const source = isObject(value) ? value.companies : value;
|
|
591
|
-
if (!Array.isArray(source))
|
|
678
|
+
if (!Array.isArray(source)) {
|
|
679
|
+
return [];
|
|
680
|
+
}
|
|
592
681
|
const seen = /* @__PURE__ */ new Set();
|
|
593
682
|
const out = [];
|
|
594
683
|
for (const node of source) {
|
|
595
684
|
const name = isObject(node) ? str(node.name) : str(node);
|
|
596
|
-
if (!name)
|
|
685
|
+
if (!name) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
597
688
|
const key = name.toLowerCase();
|
|
598
|
-
if (seen.has(key))
|
|
689
|
+
if (seen.has(key)) {
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
599
692
|
seen.add(key);
|
|
600
693
|
out.push({ name, id: isObject(node) ? intOf(node.id) : null });
|
|
601
694
|
}
|
|
602
695
|
return out;
|
|
603
696
|
}
|
|
604
697
|
function toStringList(value) {
|
|
605
|
-
if (!Array.isArray(value))
|
|
698
|
+
if (!Array.isArray(value)) {
|
|
699
|
+
return [];
|
|
700
|
+
}
|
|
606
701
|
const out = [];
|
|
607
702
|
for (const node of value) {
|
|
608
703
|
const name = isObject(node) ? str(node.name) : str(node);
|
|
609
|
-
if (name)
|
|
704
|
+
if (name) {
|
|
705
|
+
out.push(name);
|
|
706
|
+
}
|
|
610
707
|
}
|
|
611
708
|
return out;
|
|
612
709
|
}
|
|
613
710
|
function toAwards(value) {
|
|
614
|
-
if (!Array.isArray(value))
|
|
711
|
+
if (!Array.isArray(value)) {
|
|
712
|
+
return [];
|
|
713
|
+
}
|
|
615
714
|
const out = [];
|
|
616
715
|
for (const node of value) {
|
|
617
|
-
if (!isObject(node))
|
|
716
|
+
if (!isObject(node)) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
618
719
|
const ceremony = str(node.awardEvent);
|
|
619
|
-
if (!ceremony)
|
|
720
|
+
if (!ceremony) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
620
723
|
out.push({
|
|
621
724
|
ceremony,
|
|
622
725
|
wins: intOf(node.wins),
|
|
@@ -628,10 +731,14 @@ function toAwards(value) {
|
|
|
628
731
|
function parseDetail(raw, url, kind, slug) {
|
|
629
732
|
const data = envelope(raw, url, `${kind} "${slug}"`);
|
|
630
733
|
const item = data[FIELD.item];
|
|
631
|
-
if (!isObject(item))
|
|
734
|
+
if (!isObject(item)) {
|
|
735
|
+
throw parseFailure(url, "the response carries no item object");
|
|
736
|
+
}
|
|
632
737
|
const id = intOf(item.id);
|
|
633
738
|
const title = str(item.title);
|
|
634
|
-
if (id === null || !title)
|
|
739
|
+
if (id === null || !title) {
|
|
740
|
+
throw parseFailure(url, "the entry has no id or no title");
|
|
741
|
+
}
|
|
635
742
|
const critic = isObject(item.criticScoreSummary) ? item.criticScoreSummary : {};
|
|
636
743
|
return {
|
|
637
744
|
id,
|
|
@@ -659,9 +766,13 @@ function parseDetail(raw, url, kind, slug) {
|
|
|
659
766
|
function parseScore(raw, url, what) {
|
|
660
767
|
const data = envelope(raw, url, what);
|
|
661
768
|
const item = data[FIELD.item];
|
|
662
|
-
if (!isObject(item))
|
|
769
|
+
if (!isObject(item)) {
|
|
770
|
+
throw parseFailure(url, "the response carries no score object");
|
|
771
|
+
}
|
|
663
772
|
const max = num(item.max);
|
|
664
|
-
if (max === null)
|
|
773
|
+
if (max === null) {
|
|
774
|
+
throw parseFailure(url, "the score carries no scale");
|
|
775
|
+
}
|
|
665
776
|
return {
|
|
666
777
|
score: aggregate(item.score),
|
|
667
778
|
max,
|
|
@@ -674,7 +785,9 @@ function parseScore(raw, url, what) {
|
|
|
674
785
|
}
|
|
675
786
|
function reviewBucket(data, url, sentiment) {
|
|
676
787
|
const item = data[FIELD.item];
|
|
677
|
-
if (!isObject(item))
|
|
788
|
+
if (!isObject(item)) {
|
|
789
|
+
throw parseFailure(url, "the response carries no reviews object");
|
|
790
|
+
}
|
|
678
791
|
const bucket = item[bucketFor(sentiment)];
|
|
679
792
|
if (!Array.isArray(bucket)) {
|
|
680
793
|
throw parseFailure(url, `there is no "${bucketFor(sentiment)}" group of reviews`);
|
|
@@ -686,10 +799,14 @@ function parseCriticReviews(raw, url, what, sentiment) {
|
|
|
686
799
|
const bucket = reviewBucket(data, url, sentiment);
|
|
687
800
|
const reviews = [];
|
|
688
801
|
for (const node of bucket) {
|
|
689
|
-
if (!isObject(node))
|
|
802
|
+
if (!isObject(node)) {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
690
805
|
const quote = str(node.quote);
|
|
691
806
|
const publication = str(node.publicationName);
|
|
692
|
-
if (!quote
|
|
807
|
+
if (!(quote && publication)) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
693
810
|
reviews.push({
|
|
694
811
|
quote,
|
|
695
812
|
score: num(node.score),
|
|
@@ -713,9 +830,13 @@ function parseUserReviews(raw, url, what, sentiment) {
|
|
|
713
830
|
const bucket = reviewBucket(data, url, sentiment);
|
|
714
831
|
const reviews = [];
|
|
715
832
|
for (const node of bucket) {
|
|
716
|
-
if (!isObject(node))
|
|
833
|
+
if (!isObject(node)) {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
717
836
|
const quote = str(node.quote);
|
|
718
|
-
if (!quote)
|
|
837
|
+
if (!quote) {
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
719
840
|
reviews.push({ quote, score: num(node.score), date: str(node.date) });
|
|
720
841
|
}
|
|
721
842
|
if (bucket.length > 0 && reviews.length === 0) {
|
|
@@ -728,10 +849,14 @@ function parseUserReviews(raw, url, what, sentiment) {
|
|
|
728
849
|
};
|
|
729
850
|
}
|
|
730
851
|
function unwrapLink(link) {
|
|
731
|
-
if (!link)
|
|
852
|
+
if (!link) {
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
732
855
|
try {
|
|
733
856
|
const target = new URL(link).searchParams.get("r");
|
|
734
|
-
if (target &&
|
|
857
|
+
if (target && ABSOLUTE_URL.test(target)) {
|
|
858
|
+
return target;
|
|
859
|
+
}
|
|
735
860
|
} catch {
|
|
736
861
|
}
|
|
737
862
|
return link;
|
|
@@ -739,14 +864,22 @@ function unwrapLink(link) {
|
|
|
739
864
|
function parseOffers(raw, url, what) {
|
|
740
865
|
const data = envelope(raw, url, what);
|
|
741
866
|
const item = data[FIELD.item];
|
|
742
|
-
if (!isObject(item))
|
|
867
|
+
if (!isObject(item)) {
|
|
868
|
+
throw parseFailure(url, "the response carries no offers object");
|
|
869
|
+
}
|
|
743
870
|
const offers = [];
|
|
744
871
|
for (const [group, value] of Object.entries(item)) {
|
|
745
|
-
if (!Array.isArray(value))
|
|
872
|
+
if (!Array.isArray(value)) {
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
746
875
|
for (const node of value) {
|
|
747
|
-
if (!isObject(node))
|
|
876
|
+
if (!isObject(node)) {
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
748
879
|
const provider = str(node.providerName);
|
|
749
|
-
if (!provider)
|
|
880
|
+
if (!provider) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
750
883
|
offers.push({ provider, kind: group, url: unwrapLink(str(node.link)) });
|
|
751
884
|
}
|
|
752
885
|
}
|
|
@@ -754,8 +887,9 @@ function parseOffers(raw, url, what) {
|
|
|
754
887
|
}
|
|
755
888
|
|
|
756
889
|
// src/mc/client.ts
|
|
890
|
+
var BROWSER_IDENTITY = /mozilla\/|applewebkit|chrome\/|safari\/|gecko/i;
|
|
757
891
|
function withGuarantees(config) {
|
|
758
|
-
const userAgent =
|
|
892
|
+
const userAgent = BROWSER_IDENTITY.test(config.userAgent) ? `${config.userAgent} ${DEFAULT_USER_AGENT}` : config.userAgent;
|
|
759
893
|
return {
|
|
760
894
|
...config,
|
|
761
895
|
userAgent,
|
|
@@ -797,7 +931,7 @@ var McClient = class {
|
|
|
797
931
|
*/
|
|
798
932
|
async search(query, limit, offset) {
|
|
799
933
|
const url = searchUrl(query, limit, offset);
|
|
800
|
-
return this.fetchParsed(
|
|
934
|
+
return await this.fetchParsed(
|
|
801
935
|
url,
|
|
802
936
|
this.catalogueCache,
|
|
803
937
|
(body) => parseTitlePage(body, url, `the query "${query}"`)
|
|
@@ -805,7 +939,7 @@ var McClient = class {
|
|
|
805
939
|
}
|
|
806
940
|
async browse(options) {
|
|
807
941
|
const url = browseUrl(options);
|
|
808
|
-
return this.fetchParsed(
|
|
942
|
+
return await this.fetchParsed(
|
|
809
943
|
url,
|
|
810
944
|
this.catalogueCache,
|
|
811
945
|
(body) => parseTitlePage(body, url, `that ${options.kind} listing`)
|
|
@@ -814,7 +948,7 @@ var McClient = class {
|
|
|
814
948
|
async getDetail(kind, slug) {
|
|
815
949
|
const url = detailUrl(kind, slug);
|
|
816
950
|
const clean = slug.trim();
|
|
817
|
-
return this.fetchParsed(
|
|
951
|
+
return await this.fetchParsed(
|
|
818
952
|
url,
|
|
819
953
|
this.catalogueCache,
|
|
820
954
|
(body) => parseDetail(body, url, kind, clean)
|
|
@@ -822,7 +956,7 @@ var McClient = class {
|
|
|
822
956
|
}
|
|
823
957
|
async getScore(kind, slug, source) {
|
|
824
958
|
const url = scoreUrl(kind, slug, source);
|
|
825
|
-
return this.fetchParsed(
|
|
959
|
+
return await this.fetchParsed(
|
|
826
960
|
url,
|
|
827
961
|
this.scoresCache,
|
|
828
962
|
(body) => parseScore(body, url, `the ${source} score of ${kind} "${slug}"`)
|
|
@@ -830,7 +964,7 @@ var McClient = class {
|
|
|
830
964
|
}
|
|
831
965
|
async getCriticReviews(options) {
|
|
832
966
|
const url = reviewsUrl({ ...options, source: "critic" });
|
|
833
|
-
return this.fetchParsed(
|
|
967
|
+
return await this.fetchParsed(
|
|
834
968
|
url,
|
|
835
969
|
this.scoresCache,
|
|
836
970
|
(body) => parseCriticReviews(
|
|
@@ -844,7 +978,7 @@ var McClient = class {
|
|
|
844
978
|
}
|
|
845
979
|
async getUserReviews(options) {
|
|
846
980
|
const url = reviewsUrl({ ...options, source: "user" });
|
|
847
|
-
return this.fetchParsed(
|
|
981
|
+
return await this.fetchParsed(
|
|
848
982
|
url,
|
|
849
983
|
this.scoresCache,
|
|
850
984
|
(body) => parseUserReviews(
|
|
@@ -858,7 +992,7 @@ var McClient = class {
|
|
|
858
992
|
}
|
|
859
993
|
async getWatchOffers(imdbId, kind) {
|
|
860
994
|
const url = watchUrl(imdbId, kind);
|
|
861
|
-
return this.fetchParsed(
|
|
995
|
+
return await this.fetchParsed(
|
|
862
996
|
url,
|
|
863
997
|
this.scoresCache,
|
|
864
998
|
(body) => parseOffers(body, url, `streaming offers for ${imdbId}`)
|