@nitpicker/crawler 0.15.0 → 0.16.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.
Files changed (50) hide show
  1. package/lib/archive/archive.d.ts +39 -1
  2. package/lib/archive/archive.js +49 -0
  3. package/lib/archive/create-adjunct-tables.d.ts +3 -0
  4. package/lib/archive/create-adjunct-tables.js +42 -0
  5. package/lib/archive/database.d.ts +31 -1
  6. package/lib/archive/database.js +42 -0
  7. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
  8. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
  9. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
  10. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
  11. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
  12. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
  13. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
  14. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
  15. package/lib/archive/types.d.ts +13 -0
  16. package/lib/classify-error-kind.d.ts +1 -0
  17. package/lib/classify-error-kind.js +14 -0
  18. package/lib/crawler/assert-chrome-installed.d.ts +24 -0
  19. package/lib/crawler/assert-chrome-installed.js +43 -0
  20. package/lib/crawler/crawler.d.ts +12 -0
  21. package/lib/crawler/crawler.js +239 -29
  22. package/lib/crawler/decode-auth-credential.d.ts +29 -0
  23. package/lib/crawler/decode-auth-credential.js +39 -0
  24. package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
  25. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  26. package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
  27. package/lib/crawler/dedupe/compute-shape-key.js +56 -0
  28. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
  29. package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
  30. package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
  31. package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
  32. package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
  33. package/lib/crawler/dedupe/is-shape-capped.js +12 -0
  34. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
  35. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
  36. package/lib/crawler/dedupe/types.d.ts +42 -0
  37. package/lib/crawler/dedupe/types.js +1 -0
  38. package/lib/crawler/fetch-destination.js +14 -2
  39. package/lib/crawler/generate-predicted-urls.d.ts +12 -0
  40. package/lib/crawler/generate-predicted-urls.js +33 -2
  41. package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
  42. package/lib/crawler/types.d.ts +38 -0
  43. package/lib/crawler-orchestrator.d.ts +12 -0
  44. package/lib/crawler-orchestrator.js +106 -1
  45. package/lib/crawler.d.ts +1 -0
  46. package/lib/crawler.js +1 -0
  47. package/lib/permanent-error-kinds.d.ts +9 -4
  48. package/lib/permanent-error-kinds.js +10 -4
  49. package/lib/types.d.ts +2 -1
  50. package/package.json +2 -2
@@ -0,0 +1,43 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import pkg from '../../package.json' with { type: 'json' };
4
+ /**
5
+ * Verifies that Puppeteer can resolve an installed Chrome/Chromium executable
6
+ * before a crawl starts.
7
+ *
8
+ * A crawl otherwise only discovers a missing browser deep inside the
9
+ * per-URL scrape loop (`Crawler#_launchBrowserAndScrape`), where it surfaces
10
+ * as one more scrape error among many — the CLI still prints "Crawl
11
+ * completed" and writes an archive, so a missing Chrome (a fatal
12
+ * precondition, not a per-page failure) is easy to miss. Calling this once,
13
+ * before any archive I/O begins, turns it into an immediate, actionable
14
+ * failure instead.
15
+ * @param executablePath - Explicit override, matching
16
+ * {@link CrawlerOptions.executablePath}. Pass `null` (or omit) to check
17
+ * Puppeteer's own pinned Chrome resolution instead.
18
+ * @throws {Error} When the resolved executable path does not exist on disk.
19
+ * @example
20
+ * ```ts
21
+ * import { assertChromeIsInstalled } from '@nitpicker/crawler';
22
+ *
23
+ * // Throws with install instructions before any crawl work starts.
24
+ * await assertChromeIsInstalled();
25
+ * ```
26
+ */
27
+ export async function assertChromeIsInstalled(executablePath) {
28
+ if (executablePath) {
29
+ const execPath = path.resolve(executablePath);
30
+ if (existsSync(execPath)) {
31
+ return;
32
+ }
33
+ throw new Error(`Executable path does not exist: ${execPath}`);
34
+ }
35
+ const puppeteer = await import('puppeteer');
36
+ const resolvedPath = await puppeteer.executablePath();
37
+ if (existsSync(resolvedPath)) {
38
+ return;
39
+ }
40
+ const puppeteerVersion = pkg.dependencies.puppeteer;
41
+ throw new Error(`Chrome executable not found at: ${resolvedPath}\n` +
42
+ `Run \`npx puppeteer@${puppeteerVersion} browsers install chrome\` to install the Chrome build Puppeteer expects, then retry.`);
43
+ }
@@ -37,6 +37,18 @@ export default class Crawler extends EventEmitter<CrawlerEventTypes> {
37
37
  * {@link #runDeal}.
38
38
  */
39
39
  abort(): void;
40
+ /**
41
+ * Per-shape count of anchors the dedupe-cap enqueue gates rejected after
42
+ * that shape capped (opt-in `--dedupe-cap`). Read by
43
+ * `CrawlerOrchestrator` at `crawlEnd` to finalize each
44
+ * `dedupe_cap_events.rejected_count` exactly once — rejections are
45
+ * accumulated in memory rather than written to the archive per-rejection
46
+ * to avoid write amplification (a capped trap can generate an unbounded
47
+ * number of rejected anchors).
48
+ * @returns A snapshot of the per-shape rejection counts. Empty when
49
+ * `--dedupe-cap` was not enabled or no shape has capped yet.
50
+ */
51
+ getDedupeCapRejections(): ReadonlyMap<string, number>;
40
52
  /**
41
53
  * Retrieve the list of Chromium process IDs that are still running.
42
54
  *
@@ -9,6 +9,7 @@ import { retryCall } from '@d-zero/shared/retry';
9
9
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
10
10
  import c from 'ansi-colors';
11
11
  import pkg from '../../package.json' with { type: 'json' };
12
+ import { computeBodyHash } from '../archive/body-hash/compute-body-hash.js';
12
13
  import { classifyErrorKind } from '../classify-error-kind.js';
13
14
  import { crawlerLog } from '../debug.js';
14
15
  import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
@@ -16,6 +17,12 @@ import { buildRedirectEvent } from './build-redirect-event.js';
16
17
  import { captureImageDomPaths } from './capture-image-dom-paths.js';
17
18
  import { chooseProbeHost } from './choose-probe-host.js';
18
19
  import { createChangePhaseHandler } from './create-change-phase-handler.js';
20
+ import { decodeAuthCredential } from './decode-auth-credential.js';
21
+ import { computeMetaSignature } from './dedupe/compute-meta-signature.js';
22
+ import { computeShapeKey } from './dedupe/compute-shape-key.js';
23
+ import DedupeCapTracker from './dedupe/dedupe-cap-tracker.js';
24
+ import { isPredictedContentDuplicate } from './dedupe/is-predicted-content-duplicate.js';
25
+ import { resolveOgUrlMismatch } from './dedupe/resolve-og-url-mismatch.js';
19
26
  import { derivePageSource } from './derive-page-source.js';
20
27
  import { destinationCache } from './destination-cache.js';
21
28
  import { detectPaginationPattern } from './detect-pagination-pattern.js';
@@ -72,6 +79,8 @@ const DEFAULT_NETWORK_OUTAGE_ERROR_THRESHOLD = 5;
72
79
  const DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD = 2;
73
80
  /** Default {@link CrawlerOptions.networkOutageProbeIntervalMs}. */
74
81
  const DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS = 10_000;
82
+ /** Default {@link CrawlerOptions.dedupeMapCap}. */
83
+ const DEFAULT_DEDUPE_MAP_CAP = 100_000;
75
84
  /**
76
85
  * The core crawler engine that discovers and scrapes web pages.
77
86
  *
@@ -86,6 +95,24 @@ const DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS = 10_000;
86
95
  class Crawler extends EventEmitter {
87
96
  /** Controller used to cancel the deal-based crawl via its AbortSignal. */
88
97
  #abortController = new AbortController();
98
+ /**
99
+ * Per-shape count of anchors rejected by the dedupe-cap enqueue gates
100
+ * after that shape capped. Read by {@link getDedupeCapRejections} at
101
+ * `crawlEnd` so the orchestrator can finalize each
102
+ * `dedupe_cap_events.rejected_count` exactly once (see
103
+ * `Crawler#getDedupeCapRejections`'s JSDoc for why this is not written
104
+ * to the archive incrementally).
105
+ */
106
+ #dedupeCapRejectionCounts = new Map();
107
+ /**
108
+ * Opt-in (`--dedupe-cap`) same-cluster soft cap. Always constructed
109
+ * (Misra-Gries state stays empty when {@link CrawlerOptions.dedupeCap} is
110
+ * `null`), gated on by `#options.dedupeCap !== null` at each call site
111
+ * rather than being conditionally `undefined`, so the two enqueue gates
112
+ * and the observation call in {@link #handleResult} do not need to
113
+ * null-check a class field.
114
+ */
115
+ #dedupeCapTracker;
89
116
  /** Tracks discovered URLs, their scrape status, and deduplication. */
90
117
  #linkList = new LinkList();
91
118
  /**
@@ -129,6 +156,23 @@ class Crawler extends EventEmitter {
129
156
  * `insertPageError`, so the FK resolution via URL always finds the row.
130
157
  */
131
158
  #pendingPhaseErrors = new Map();
159
+ /**
160
+ * Predicted-pagination body-hash tracking (always-on — independent of
161
+ * the opt-in `--dedupe-cap` tracker). Maps a URL shape key
162
+ * ({@link computeShapeKey}) to the {@link computeBodyHash} of the most
163
+ * recently scraped *predicted* page of that shape. Never reset mid-crawl
164
+ * (persists for the whole session, like {@link #scrapedDestinations}).
165
+ */
166
+ #predictedShapeBodyHashes = new Map();
167
+ /**
168
+ * Shapes for which {@link #predictedShapeBodyHashes} detected a
169
+ * content-duplicate predicted page (see {@link isPredictedContentDuplicate}).
170
+ * Once a shape lands here, no further predicted URLs are generated for it
171
+ * (checked in {@link #handleResult}'s pagination-pattern branch) — the
172
+ * cheapest possible way to stop a self-generating trap without needing
173
+ * the opt-in dedupe-cap machinery.
174
+ */
175
+ #predictedShapeStopped = new Set();
132
176
  /** Set of resource URLs (without hash) already captured, for deduplication. */
133
177
  #resources = new Set();
134
178
  /** Number of HTML pages (isTarget=1) scraped in previous sessions, used to seed the progress counter on resume. */
@@ -205,12 +249,16 @@ class Crawler extends EventEmitter {
205
249
  networkOutageHostThreshold: options?.networkOutageHostThreshold ?? DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD,
206
250
  networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs ?? DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS,
207
251
  networkProbe: options?.networkProbe ?? null,
252
+ dedupeCap: options?.dedupeCap ?? null,
253
+ dedupeMapCap: options?.dedupeMapCap ?? DEFAULT_DEDUPE_MAP_CAP,
254
+ preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
208
255
  };
209
256
  this.#networkOutageDetector = new NetworkOutageDetector({
210
257
  windowMs: this.#options.networkOutageWindowMs,
211
258
  errorThreshold: this.#options.networkOutageErrorThreshold,
212
259
  hostThreshold: this.#options.networkOutageHostThreshold,
213
260
  });
261
+ this.#dedupeCapTracker = new DedupeCapTracker({ cap: this.#options.dedupeCap ?? 0, mapCap: this.#options.dedupeMapCap }, this.#options.preloadedStickyShapeKeys);
214
262
  this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
215
263
  for (const urlStr of this.#options.roots) {
216
264
  const url = parseUrl(urlStr, this.#options);
@@ -231,6 +279,20 @@ class Crawler extends EventEmitter {
231
279
  abort() {
232
280
  this.#abortController.abort();
233
281
  }
282
+ /**
283
+ * Per-shape count of anchors the dedupe-cap enqueue gates rejected after
284
+ * that shape capped (opt-in `--dedupe-cap`). Read by
285
+ * `CrawlerOrchestrator` at `crawlEnd` to finalize each
286
+ * `dedupe_cap_events.rejected_count` exactly once — rejections are
287
+ * accumulated in memory rather than written to the archive per-rejection
288
+ * to avoid write amplification (a capped trap can generate an unbounded
289
+ * number of rejected anchors).
290
+ * @returns A snapshot of the per-shape rejection counts. Empty when
291
+ * `--dedupe-cap` was not enabled or no shape has capped yet.
292
+ */
293
+ getDedupeCapRejections() {
294
+ return this.#dedupeCapRejectionCounts;
295
+ }
234
296
  /**
235
297
  * Retrieve the list of Chromium process IDs that are still running.
236
298
  *
@@ -493,21 +555,92 @@ class Crawler extends EventEmitter {
493
555
  * @param enqueue - Callback to enqueue newly discovered URLs into the dealer
494
556
  * queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}).
495
557
  * Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order.
496
- * @param paginationState - Mutable state for predicted pagination cascade prevention
497
- * @param paginationState.lastPushedUrl
498
- * @param paginationState.lastPushedWasPredicted
499
558
  * @param concurrency - Current concurrency level, used to determine predicted URL count
559
+ * @param precomputedBodyHash - This page's body hash, if the caller already
560
+ * computed it (the predicted-content-duplicate check, A-3, computes it for
561
+ * every predicted page regardless of `--dedupe-cap`) — reused for the
562
+ * dedupe-cap observation below instead of hashing the same html twice.
500
563
  */
501
- #handleResult(result, url, enqueue, paginationState, concurrency) {
564
+ #handleResult(result, url, enqueue, concurrency, precomputedBodyHash) {
502
565
  switch (result.type) {
503
566
  case 'success': {
504
567
  if (!result.pageData)
505
568
  break;
569
+ // Scoped to this one page's anchor list (fresh per `#handleResult`
570
+ // call, not shared across pages): pagination-pattern detection
571
+ // compares consecutive anchors as they are discovered by
572
+ // `processAnchors`'s single synchronous loop below, so "consecutive"
573
+ // must mean "adjacent in this document", not "adjacent in whatever
574
+ // order the crawl's workers happened to finish". Sharing this state
575
+ // across pages/workers let `step` be computed from two unrelated
576
+ // URLs, compounding across rounds until a `/news/date/{year}/`
577
+ // pager's predicted token overflowed into scientific notation
578
+ // (`1.7715854126052197e+120`, observed in production).
579
+ const paginationState = {
580
+ lastPushedUrl: null,
581
+ lastPushedWasPredicted: false,
582
+ };
583
+ // Feed this page's own signature into the same-cluster tracker
584
+ // (opt-in via `--dedupe-cap`). This is deliberately separate
585
+ // from the enqueue gates below: gating decides whether to
586
+ // admit a not-yet-scraped anchor based on shape alone; this
587
+ // observes the page that was JUST scraped, using its actual
588
+ // meta/body content. External and metadata-only pages carry no
589
+ // useful signal for this feature and are skipped, matching the
590
+ // signature-scope exclusions in `computeMetaSignature`'s design.
591
+ if (this.#options.dedupeCap !== null &&
592
+ !result.pageData.isExternal &&
593
+ !this.#linkList.isMetadataOnly(result.pageData.url.withoutHash) &&
594
+ result.pageData.html.length > 0) {
595
+ const shapeKey = computeShapeKey(result.pageData.url.withoutHashAndAuth);
596
+ const metaSig = computeMetaSignature(result.pageData.meta);
597
+ if (shapeKey && metaSig) {
598
+ const bodyHash = precomputedBodyHash ?? computeBodyHash(result.pageData.html);
599
+ const ogUrlMismatch = resolveOgUrlMismatch(result.pageData.meta, result.pageData.url.href);
600
+ const event = this.#dedupeCapTracker.observe({
601
+ shapeKey,
602
+ metaSig,
603
+ bodyHash,
604
+ ogUrlMismatch,
605
+ url: result.pageData.url.href,
606
+ });
607
+ if (event) {
608
+ void this.emit('dedupeCap', event);
609
+ }
610
+ }
611
+ }
506
612
  handleScrapeEnd(result.pageData, this.#linkList, this.#scope, this.#options, (newUrl, opts) => {
613
+ // Gate 1: blocks real anchors discovered on this page whose
614
+ // shape is already confirmed as a trap. This does NOT cover
615
+ // predicted URLs — `generatePredictedUrls`'s output is
616
+ // pushed directly below (`this.#linkList.add(specUrl, ...)`),
617
+ // bypassing this closure entirely — so the predicted-URL
618
+ // generation site below has its own equivalent check
619
+ // (`shapeIsStopped`, combined with `#predictedShapeStopped`).
620
+ // External anchors are out of scope for the cap (issue #208:
621
+ // "cap 適用は internal only"), enforced by the scope check
622
+ // below. Deliberately NOT also excluding `opts?.metadataOnly`
623
+ // (unlike the tracker's observation side, which does skip
624
+ // metadata-only pages — they carry no reliable signature): with
625
+ // `--recursive=false`, `handle-scrape-end.ts` marks EVERY anchor
626
+ // metadata-only, internal or not, so excluding them here would
627
+ // silently disable `--dedupe-cap` for anchor discovery whenever
628
+ // `--recursive=false` is set — while gate 2 (the JS-redirect
629
+ // direct enqueue below) has no such exclusion and would still
630
+ // cap the very same shape, an inconsistency between the two
631
+ // discovery paths.
632
+ if (this.#options.dedupeCap !== null &&
633
+ findScopeEntry(newUrl, this.#scope, this.#options) !== null) {
634
+ const gateShapeKey = computeShapeKey(newUrl.withoutHashAndAuth);
635
+ if (gateShapeKey && this.#dedupeCapTracker.isCapped(gateShapeKey)) {
636
+ this.#recordDedupeCapRejection(gateShapeKey);
637
+ return;
638
+ }
639
+ }
507
640
  this.#linkList.add(newUrl, opts);
508
641
  void enqueue(newUrl);
509
642
  // Predicted pagination detection
510
- if (!paginationState || !concurrency)
643
+ if (!concurrency)
511
644
  return;
512
645
  // metadataOnly / external: update tracking but skip pattern detection
513
646
  if (opts?.metadataOnly ||
@@ -521,22 +654,37 @@ class Crawler extends EventEmitter {
521
654
  !paginationState.lastPushedWasPredicted) {
522
655
  const pattern = detectPaginationPattern(paginationState.lastPushedUrl, newUrl.withoutHashAndAuth);
523
656
  if (pattern) {
524
- const urls = generatePredictedUrls(pattern, newUrl.withoutHashAndAuth, concurrency);
525
- const specUrls = [];
526
- for (const specUrlStr of urls) {
527
- const specUrl = parseUrl(specUrlStr, this.#options);
528
- if (specUrl) {
529
- this.#linkList.add(specUrl, { predicted: true });
530
- specUrls.push(specUrl);
657
+ // Stop generating further predicted URLs for this shape
658
+ // once EITHER confirmation mechanism has fired — the
659
+ // always-on content-duplication check
660
+ // (`#predictedShapeStopped`), or the opt-in
661
+ // `--dedupe-cap` tracker (`#dedupeCapTracker.isCapped`,
662
+ // only consulted when the flag is set). Falls through to
663
+ // the plain (non-predicted) bookkeeping below instead of
664
+ // returning, since the anchor itself is still real.
665
+ const shapeKey = computeShapeKey(newUrl.withoutHashAndAuth);
666
+ const shapeIsStopped = shapeKey !== null &&
667
+ (this.#predictedShapeStopped.has(shapeKey) ||
668
+ (this.#options.dedupeCap !== null &&
669
+ this.#dedupeCapTracker.isCapped(shapeKey)));
670
+ if (!shapeIsStopped) {
671
+ const urls = generatePredictedUrls(pattern, newUrl.withoutHashAndAuth, concurrency);
672
+ const specUrls = [];
673
+ for (const specUrlStr of urls) {
674
+ const specUrl = parseUrl(specUrlStr, this.#options);
675
+ if (specUrl) {
676
+ this.#linkList.add(specUrl, { predicted: true });
677
+ specUrls.push(specUrl);
678
+ }
531
679
  }
680
+ // Enqueue as one batch so ascending page order is kept
681
+ // at the front of the queue (see enqueue in #runDeal).
682
+ if (specUrls.length > 0)
683
+ void enqueue(...specUrls);
684
+ paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
685
+ paginationState.lastPushedWasPredicted = true;
686
+ return;
532
687
  }
533
- // Enqueue as one batch so ascending page order is kept
534
- // at the front of the queue (see enqueue in #runDeal).
535
- if (specUrls.length > 0)
536
- void enqueue(...specUrls);
537
- paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
538
- paginationState.lastPushedWasPredicted = true;
539
- return;
540
688
  }
541
689
  }
542
690
  paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
@@ -630,6 +778,19 @@ class Crawler extends EventEmitter {
630
778
  window: { startedAt, endedAt },
631
779
  });
632
780
  }
781
+ /**
782
+ * Increments {@link #dedupeCapRejectionCounts} for one shape. Scoped to
783
+ * the two concrete enqueue-time rejections (a real anchor or a
784
+ * JS-redirect destination that was discovered but blocked) — it does
785
+ * NOT count predicted URLs that were never generated at all because
786
+ * their shape was already stopped (see the `shapeIsStopped` check in
787
+ * {@link #handleResult}), since nothing concrete existed there to
788
+ * reject.
789
+ * @param shapeKey - The capped shape a rejection is being recorded for.
790
+ */
791
+ #recordDedupeCapRejection(shapeKey) {
792
+ this.#dedupeCapRejectionCounts.set(shapeKey, (this.#dedupeCapRejectionCounts.get(shapeKey) ?? 0) + 1);
793
+ }
633
794
  /**
634
795
  * Feed one observed network-layer error into
635
796
  * {@link #networkOutageDetector} and hand off to
@@ -746,11 +907,6 @@ class Crawler extends EventEmitter {
746
907
  const concurrency = this.#options.parallels
747
908
  ? Math.max(this.#options.parallels, 1)
748
909
  : _a.MAX_PROCESS_LENGTH;
749
- // Predicted pagination state
750
- const paginationState = {
751
- lastPushedUrl: null,
752
- lastPushedWasPredicted: false,
753
- };
754
910
  await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
755
911
  const matchedScope = findScopeEntry(url, this.#scope, this.#options);
756
912
  const isExternal = matchedScope === null;
@@ -805,6 +961,11 @@ class Crawler extends EventEmitter {
805
961
  const markBrowserScrape = () => {
806
962
  renderedInBrowser = true;
807
963
  };
964
+ // Set by the predicted-content-duplicate check below (A-3) when it
965
+ // computes this page's body hash, so `#handleResult`'s dedupe-cap
966
+ // observation (also gated on this page's html) can reuse it instead
967
+ // of hashing the same html a second time.
968
+ let precomputedBodyHash = null;
808
969
  try {
809
970
  const robotsAllowed = await this.#robotsChecker.isAllowed(url);
810
971
  if (!robotsAllowed) {
@@ -882,8 +1043,25 @@ class Crawler extends EventEmitter {
882
1043
  if (destination) {
883
1044
  const destinationUrl = parseUrl(destination, this.#options);
884
1045
  if (destinationUrl) {
885
- this.#linkList.add(destinationUrl);
886
- void enqueue(destinationUrl);
1046
+ // Gate 2: this direct enqueue does not go through
1047
+ // `#handleResult`'s addUrl closure (gate 1), so it needs
1048
+ // its own same-cluster-cap check — a JS-redirect trap
1049
+ // that advances a parameter via `location.replace()`
1050
+ // would otherwise keep re-entering the queue here.
1051
+ const gateShapeKey = computeShapeKey(destinationUrl.withoutHashAndAuth);
1052
+ const isCapped = this.#options.dedupeCap !== null &&
1053
+ gateShapeKey !== null &&
1054
+ findScopeEntry(destinationUrl, this.#scope, this.#options) !==
1055
+ null &&
1056
+ this.#dedupeCapTracker.isCapped(gateShapeKey);
1057
+ if (isCapped) {
1058
+ if (gateShapeKey)
1059
+ this.#recordDedupeCapRejection(gateShapeKey);
1060
+ }
1061
+ else {
1062
+ this.#linkList.add(destinationUrl);
1063
+ void enqueue(destinationUrl);
1064
+ }
887
1065
  }
888
1066
  else {
889
1067
  // `deriveJsRedirectTarget` already canonicalises
@@ -926,6 +1104,32 @@ class Crawler extends EventEmitter {
926
1104
  log(c.dim('Predicted (discarded)'));
927
1105
  return;
928
1106
  }
1107
+ // Discard a predicted URL whose rendered body is a
1108
+ // byte-for-byte duplicate of the previous predicted page of the
1109
+ // same shape, and stop generating further predictions for that
1110
+ // shape (checked above, in the pagination-pattern branch). This
1111
+ // is the always-on backstop against a site that returns 2xx for
1112
+ // any extrapolated token but ignores it entirely (e.g. always
1113
+ // serving the same "no results" template) — `shouldDiscardPredicted`
1114
+ // alone cannot see this, since it only inspects HTTP status.
1115
+ if (isPredicted &&
1116
+ result.type === 'success' &&
1117
+ result.pageData &&
1118
+ result.pageData.html.length > 0) {
1119
+ const shapeKey = computeShapeKey(url.withoutHashAndAuth);
1120
+ if (shapeKey) {
1121
+ const bodyHash = computeBodyHash(result.pageData.html);
1122
+ precomputedBodyHash = bodyHash;
1123
+ const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
1124
+ if (isPredictedContentDuplicate(bodyHash, lastBodyHash)) {
1125
+ this.#predictedShapeStopped.add(shapeKey);
1126
+ handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1127
+ log(c.dim('Predicted (content duplicate, discarded)'));
1128
+ return;
1129
+ }
1130
+ this.#predictedShapeBodyHashes.set(shapeKey, bodyHash);
1131
+ }
1132
+ }
929
1133
  // Count only after discard check: rendered HTML pages that
930
1134
  // will be persisted to the archive. Launch failures bypass
931
1135
  // this point via the catch block; discarded predicted URLs
@@ -934,7 +1138,7 @@ class Crawler extends EventEmitter {
934
1138
  pagesScraped++;
935
1139
  }
936
1140
  log('Saving results%dots%');
937
- this.#handleResult(result, url, enqueue, paginationState, concurrency);
1141
+ this.#handleResult(result, url, enqueue, concurrency, precomputedBodyHash);
938
1142
  const parentSource = await this.#resolveParentSource(url);
939
1143
  this.#handleResources(result.resources, parentSource);
940
1144
  this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
@@ -1625,9 +1829,15 @@ class Crawler extends EventEmitter {
1625
1829
  // Verified by `scope-auth-leak.e2e.ts`: removing either piece
1626
1830
  // causes that test to fail (without auth → main 401 hangs;
1627
1831
  // without strip → scope cred leaks to off-scope sub-resource).
1832
+ //
1833
+ // The ExURL fields keep the WHATWG percent-encoded form, but
1834
+ // `page.authenticate` sends its arguments verbatim — decode
1835
+ // first or a password containing `[`/`]`/`{`/`}`/`=` etc.
1836
+ // authenticates with the wrong literal (see
1837
+ // `decode-auth-credential.ts`).
1628
1838
  await page.authenticate({
1629
- username: url.username ?? '',
1630
- password: url.password ?? '',
1839
+ username: decodeAuthCredential(url.username),
1840
+ password: decodeAuthCredential(url.password),
1631
1841
  });
1632
1842
  // Re-parse from `withoutHashAndAuth` rather than mutating the
1633
1843
  // re-parsed `url.href` object: ExURL pre-computes `href`,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Decode a percent-encoded userinfo credential (username or password)
3
+ * parsed out of a URL into the literal string the origin server expects.
4
+ *
5
+ * The WHATWG URL parser percent-encodes characters outside the userinfo
6
+ * set (`[`, `]`, `{`, `}`, `=`, `:`, `@`, non-ASCII, …) and keeps the
7
+ * `username` / `password` fields in that encoded form. Consumers that
8
+ * forward credentials out-of-band — `page.authenticate()` for the
9
+ * browser session, the `auth` request option for the HEAD pre-flight —
10
+ * must send the decoded literal, or any credential containing such a
11
+ * character silently authenticates with the wrong string and the server
12
+ * answers 401. Node's own `urlToOptions` applies the same
13
+ * `decodeURIComponent` step for `http.request(url)`.
14
+ *
15
+ * A malformed sequence (a literal `%` the parser left untouched, e.g. a
16
+ * user typing `pa%ssword` without encoding it) would make
17
+ * `decodeURIComponent` throw, so the raw value is returned as a
18
+ * fallback — identical to the pre-decode behavior for that input.
19
+ * @param value - The raw (possibly percent-encoded) credential field, or
20
+ * `null` when the URL carries no userinfo.
21
+ * @returns The decoded credential, or an empty string for `null` input.
22
+ * @example
23
+ * ```ts
24
+ * const url = parseUrl('https://user:pa%5Dss%7Bword%3D@example.com/')!;
25
+ * decodeAuthCredential(url.password); // => 'pa]ss{word='
26
+ * decodeAuthCredential(null); // => ''
27
+ * ```
28
+ */
29
+ export declare function decodeAuthCredential(value: string | null): string;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Decode a percent-encoded userinfo credential (username or password)
3
+ * parsed out of a URL into the literal string the origin server expects.
4
+ *
5
+ * The WHATWG URL parser percent-encodes characters outside the userinfo
6
+ * set (`[`, `]`, `{`, `}`, `=`, `:`, `@`, non-ASCII, …) and keeps the
7
+ * `username` / `password` fields in that encoded form. Consumers that
8
+ * forward credentials out-of-band — `page.authenticate()` for the
9
+ * browser session, the `auth` request option for the HEAD pre-flight —
10
+ * must send the decoded literal, or any credential containing such a
11
+ * character silently authenticates with the wrong string and the server
12
+ * answers 401. Node's own `urlToOptions` applies the same
13
+ * `decodeURIComponent` step for `http.request(url)`.
14
+ *
15
+ * A malformed sequence (a literal `%` the parser left untouched, e.g. a
16
+ * user typing `pa%ssword` without encoding it) would make
17
+ * `decodeURIComponent` throw, so the raw value is returned as a
18
+ * fallback — identical to the pre-decode behavior for that input.
19
+ * @param value - The raw (possibly percent-encoded) credential field, or
20
+ * `null` when the URL carries no userinfo.
21
+ * @returns The decoded credential, or an empty string for `null` input.
22
+ * @example
23
+ * ```ts
24
+ * const url = parseUrl('https://user:pa%5Dss%7Bword%3D@example.com/')!;
25
+ * decodeAuthCredential(url.password); // => 'pa]ss{word='
26
+ * decodeAuthCredential(null); // => ''
27
+ * ```
28
+ */
29
+ export function decodeAuthCredential(value) {
30
+ if (!value) {
31
+ return '';
32
+ }
33
+ try {
34
+ return decodeURIComponent(value);
35
+ }
36
+ catch {
37
+ return value;
38
+ }
39
+ }
@@ -0,0 +1,30 @@
1
+ import type { Meta } from '@d-zero/beholder';
2
+ /**
3
+ * Computes a signature of the four meta fields most likely to be identical
4
+ * across a self-generating pager/cluster trap: `<title>`, `<meta
5
+ * name="description">`, `og:title`, `og:url`.
6
+ *
7
+ * Returns `null` when the page carries no useful signal — an empty `<title>`
8
+ * with `og.title` and `og.url` both absent (the two Open Graph fields this
9
+ * signature actually hashes) is typical of a non-content page (e.g. a bare
10
+ * external redirect stub), and counting it would let a `null`-ish signature
11
+ * accidentally look like a majority match against unrelated pages.
12
+ *
13
+ * Uses the four fields as-written (no URL absolutisation for `og.url`): all
14
+ * pages on one site are rendered by the same template, so `og:url` is either
15
+ * consistently relative or consistently absolute across a trap's pages, and
16
+ * hashing the raw value keeps this computation a pure string op with no need
17
+ * for a page-URL argument. Absolutisation only matters when *comparing*
18
+ * `og:url` against the page's own URL (a different signal, computed
19
+ * separately at cap-scoring time).
20
+ * @param meta - Beholder-derived metadata for the page.
21
+ * @returns A hex-encoded SHA-1 signature, or `null` if the page has no title
22
+ * and no Open Graph tags.
23
+ * @example
24
+ * ```ts
25
+ * computeMetaSignature({ title: 'お知らせ', og: { url: '/news' } } as Meta);
26
+ * // => a stable hex digest
27
+ * computeMetaSignature({ title: '', og: {} } as Meta); // => null
28
+ * ```
29
+ */
30
+ export declare function computeMetaSignature(meta: Meta): string | null;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Computes a URL "shape" key: the host plus path/query with every path
3
+ * segment that contains a digit collapsed to a fixed placeholder, and every
4
+ * query value (regardless of content) collapsed to a fixed placeholder.
5
+ *
6
+ * This absorbs both the "numeric pager" trap shape (`/news/date/2024/` and
7
+ * `/news/date/1.5e+32/` collapse to the same key) and the "query trap" shape
8
+ * (`?page=1` / `?page=2` / `?session=ab12cd` all collapse to the same key),
9
+ * without needing two separate `parentPath` definitions the way issue
10
+ * #208's original proposal did.
11
+ *
12
+ * Uses `../decompose-url.ts` (the pagination-detection one) — NOT
13
+ * `../../archive/populate-ref-tables/decompose-url.ts`, an unrelated same-named
14
+ * module with a different `DecomposedUrl` shape used for ref-table population.
15
+ *
16
+ * The masking rule here is the deliberate inverse of
17
+ * `../../archive/body-hash/mask-dynamic-ids.ts`: that module leaves
18
+ * pure-digit tokens untouched (they are more likely stable content than a
19
+ * dynamic id) and only masks mixed alphanumeric runs. A shape key needs the
20
+ * opposite: ANY digit inside a path segment marks it as "probably a
21
+ * pagination/date/id token", so the whole segment is collapsed. Do not share
22
+ * masking logic between the two — they classify the same kind of text for
23
+ * opposite purposes.
24
+ * @param url - A URL string (protocol-agnostic `//host/...` or full
25
+ * `https://host/...`), typically `ExURL.withoutHashAndAuth`.
26
+ * @returns The shape key, or `null` if `url` cannot be decomposed.
27
+ * @example
28
+ * ```ts
29
+ * computeShapeKey('//example.com/news/date/2024/');
30
+ * // => 'example.com/news/date/{n}/'
31
+ * computeShapeKey('//example.com/news/date/1.5e+32/');
32
+ * // => 'example.com/news/date/{n}/' — same shape
33
+ * computeShapeKey('//example.com/list?page=1');
34
+ * // => 'example.com/list?page={v}'
35
+ * ```
36
+ */
37
+ export declare function computeShapeKey(url: string): string | null;