mcp-metacritic 2.0.0 → 2.1.0

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