@nitpicker/crawler 0.20.0 → 0.22.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 (113) hide show
  1. package/lib/append-retry-failed-common-setup-phases.d.ts +1 -1
  2. package/lib/append-retry-failed-common-setup-phases.js +5 -1
  3. package/lib/append-setup-phases.d.ts +1 -1
  4. package/lib/archive/archive.d.ts +57 -8
  5. package/lib/archive/archive.js +83 -12
  6. package/lib/archive/create-adjunct-tables.d.ts +3 -2
  7. package/lib/archive/create-adjunct-tables.js +12 -9
  8. package/lib/archive/create-entity-tables.js +3 -0
  9. package/lib/archive/database.d.ts +39 -7
  10. package/lib/archive/database.js +49 -9
  11. package/lib/archive/db-ops/_shared/keyset-paginate-by-id.d.ts +49 -0
  12. package/lib/archive/db-ops/_shared/keyset-paginate-by-id.js +69 -0
  13. package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +13 -12
  14. package/lib/archive/db-ops/_shared/resolve-content-item-id.js +63 -31
  15. package/lib/archive/db-ops/_shared/types.d.ts +39 -0
  16. package/lib/archive/db-ops/analysis/replace-analysis-violations.js +7 -4
  17. package/lib/archive/db-ops/config/get-created-cwd.d.ts +11 -0
  18. package/lib/archive/db-ops/config/get-created-cwd.js +13 -0
  19. package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
  20. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.d.ts +57 -0
  21. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.js +87 -0
  22. package/lib/archive/db-ops/errors/insert-page-error.js +3 -1
  23. package/lib/archive/db-ops/lifecycle/init.d.ts +13 -6
  24. package/lib/archive/db-ops/lifecycle/init.js +30 -10
  25. package/lib/archive/db-ops/list-reconcile/record-list-reconcile-run.d.ts +22 -0
  26. package/lib/archive/db-ops/{inventory/record-inventory-run.js → list-reconcile/record-list-reconcile-run.js} +11 -10
  27. package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
  28. package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +9 -2
  29. package/lib/archive/db-ops/pages/read/get-crawling-state.js +11 -3
  30. package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.d.ts +20 -0
  31. package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.js +37 -0
  32. package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +3 -2
  33. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +17 -37
  34. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +22 -1
  35. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +79 -33
  36. package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.d.ts +78 -0
  37. package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.js +146 -0
  38. package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.d.ts +24 -0
  39. package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.js +24 -0
  40. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +2 -1
  41. package/lib/archive/db-ops/pages/write/insert-page.js +4 -2
  42. package/lib/archive/db-ops/pages/write/link-redirect-sources.js +3 -1
  43. package/lib/archive/db-ops/pages/write/record-redirect.js +1 -1
  44. package/lib/archive/db-ops/pages/write/set-skipped-page.js +3 -1
  45. package/lib/archive/db-ops/pages/write/update-page.d.ts +9 -1
  46. package/lib/archive/db-ops/pages/write/update-page.js +28 -6
  47. package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +3 -6
  48. package/lib/archive/db-ops/resources/get-resource-url-list.js +10 -35
  49. package/lib/archive/init-schema.d.ts +1 -1
  50. package/lib/archive/init-schema.js +3 -2
  51. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +11 -3
  52. package/lib/archive/meta/compute-main-contents-denormalized.js +14 -2
  53. package/lib/archive/meta/types.d.ts +24 -0
  54. package/lib/archive/migrate-content-items-is-metadata-only.d.ts +44 -0
  55. package/lib/archive/migrate-content-items-is-metadata-only.js +60 -0
  56. package/lib/archive/migrate-info-created-cwd.d.ts +17 -0
  57. package/lib/archive/migrate-info-created-cwd.js +36 -0
  58. package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.d.ts +34 -0
  59. package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.js +51 -0
  60. package/lib/archive/{migrate-inventory-runs-exclude-skipped.d.ts → migrate-list-reconcile-runs-exclude-skipped.d.ts} +8 -8
  61. package/lib/archive/{migrate-inventory-runs-exclude-skipped.js → migrate-list-reconcile-runs-exclude-skipped.js} +12 -12
  62. package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.d.ts +16 -0
  63. package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.js +35 -0
  64. package/lib/archive/migrate-page-meta-image-scan.d.ts +27 -0
  65. package/lib/archive/migrate-page-meta-image-scan.js +52 -0
  66. package/lib/archive/page.d.ts +12 -0
  67. package/lib/archive/page.js +16 -0
  68. package/lib/archive/types.d.ts +97 -10
  69. package/lib/compute-auto-retry-backoff-delay.d.ts +30 -0
  70. package/lib/compute-auto-retry-backoff-delay.js +36 -0
  71. package/lib/crawler/apply-crawl-runtime-options-patch.d.ts +32 -0
  72. package/lib/crawler/apply-crawl-runtime-options-patch.js +129 -0
  73. package/lib/crawler/crawler.d.ts +35 -3
  74. package/lib/crawler/crawler.js +486 -338
  75. package/lib/crawler/dedupe/build-dedupe-cap-observation.d.ts +74 -0
  76. package/lib/crawler/dedupe/build-dedupe-cap-observation.js +97 -0
  77. package/lib/crawler/dedupe/compute-meta-signature.d.ts +7 -5
  78. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  79. package/lib/crawler/dedupe/compute-shape-key.d.ts +20 -0
  80. package/lib/crawler/dedupe/compute-shape-key.js +73 -1
  81. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +6 -1
  82. package/lib/crawler/dedupe/dedupe-cap-tracker.js +6 -1
  83. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +7 -5
  84. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +5 -3
  85. package/lib/crawler/dedupe/types.d.ts +18 -0
  86. package/lib/crawler/fetch-destination.js +1 -0
  87. package/lib/crawler/link-list.d.ts +9 -1
  88. package/lib/crawler/link-list.js +12 -2
  89. package/lib/crawler/link-to-page-data.js +1 -0
  90. package/lib/crawler/resource-to-page-data.js +1 -0
  91. package/lib/crawler/types.d.ts +75 -0
  92. package/lib/crawler-orchestrator.d.ts +171 -8
  93. package/lib/crawler-orchestrator.js +1177 -234
  94. package/lib/crawler.d.ts +3 -0
  95. package/lib/crawler.js +3 -0
  96. package/lib/delay-or-abort.d.ts +20 -0
  97. package/lib/delay-or-abort.js +35 -0
  98. package/lib/pending-urls-remain-error.d.ts +35 -0
  99. package/lib/pending-urls-remain-error.js +43 -0
  100. package/lib/recrawl-setup-phases.d.ts +38 -0
  101. package/lib/recrawl-setup-phases.js +52 -0
  102. package/lib/resume-setup-phases.d.ts +1 -1
  103. package/lib/resume-setup-phases.js +5 -1
  104. package/lib/retry-failed-setup-phases.d.ts +1 -1
  105. package/lib/setup-recovery-phase-labels.d.ts +17 -8
  106. package/lib/setup-recovery-phase-labels.js +17 -8
  107. package/lib/types.d.ts +62 -12
  108. package/lib/utils/compute-file-sha256.d.ts +1 -1
  109. package/lib/utils/compute-file-sha256.js +1 -1
  110. package/package.json +4 -4
  111. package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +0 -21
  112. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +0 -16
  113. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +0 -35
@@ -11,6 +11,7 @@ import pkg from '../../package.json' with { type: 'json' };
11
11
  import { computeBodyHash } from '../archive/body-hash/compute-body-hash.js';
12
12
  import { classifyErrorKind } from '../classify-error-kind.js';
13
13
  import { crawlerLog } from '../debug.js';
14
+ import { applyCrawlRuntimeOptionsPatch } from './apply-crawl-runtime-options-patch.js';
14
15
  import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
15
16
  import { buildRedirectEvent } from './build-redirect-event.js';
16
17
  import { captureCustomElements } from './capture-custom-elements.js';
@@ -96,6 +97,17 @@ const DEFAULT_DEDUPE_MAP_CAP = 100_000;
96
97
  export default class Crawler extends EventEmitter {
97
98
  /** Controller used to cancel the deal-based crawl via its AbortSignal. */
98
99
  #abortController = new AbortController();
100
+ /**
101
+ * Handle into the currently-running `deal()` call's `Dealer`, letting
102
+ * {@link updateRuntimeOptions} apply a `parallels` change immediately via
103
+ * `setLimit()`. `null` outside of an active `deal()` call — auto-retry
104
+ * (`CrawlerOrchestrator#crawlUntilPendingClears`) runs `deal()` in
105
+ * separate rounds, and this is only valid for the round currently in
106
+ * flight (`#runDeal`'s `onStart`/after-`deal()` reset it each round).
107
+ * When `null`, a `parallels` patch still updates `#options.parallels` so
108
+ * the *next* round picks it up.
109
+ */
110
+ #dealController = null;
99
111
  /**
100
112
  * Per-shape count of anchors rejected by the dedupe-cap enqueue gates
101
113
  * after that shape capped. Read by {@link getDedupeCapRejections} at
@@ -105,6 +117,13 @@ export default class Crawler extends EventEmitter {
105
117
  * to the archive incrementally).
106
118
  */
107
119
  #dedupeCapRejectionCounts = new Map();
120
+ /**
121
+ * Guards {@link #pendingReplayCapEvents}' drain so a second {@link start}
122
+ * call on the same instance — the auto-retry continuation path
123
+ * (`opts.isRetryContinuation`, issue #350) — does not re-emit the first
124
+ * call's already-drained events.
125
+ */
126
+ #dedupeCapReplayDrained = false;
108
127
  /**
109
128
  * Opt-in (`--dedupe-cap`) same-cluster soft cap. Always constructed
110
129
  * (Misra-Gries state stays empty when {@link CrawlerOptions.dedupeCap} is
@@ -157,6 +176,17 @@ export default class Crawler extends EventEmitter {
157
176
  * `insertPageError`, so the FK resolution via URL always finds the row.
158
177
  */
159
178
  #pendingPhaseErrors = new Map();
179
+ /**
180
+ * `DedupeCapEvent`s produced by replaying
181
+ * {@link CrawlerOptions.preloadedDedupeObservations} into
182
+ * {@link #dedupeCapTracker} in the constructor. Buffered here (rather
183
+ * than emitted immediately) because the constructor runs before
184
+ * `CrawlerOrchestrator.crawling()` registers its `dedupeCap` listener —
185
+ * emitting eagerly would be silently dropped. Drained once, at the top
186
+ * of {@link start}, by which point the caller has always finished
187
+ * registering listeners (see `start`'s own JSDoc on this point).
188
+ */
189
+ #pendingReplayCapEvents = [];
160
190
  /**
161
191
  * Predicted-pagination body-hash tracking (always-on — independent of
162
192
  * the opt-in `--dedupe-cap` tracker). Maps a URL shape key
@@ -253,6 +283,8 @@ export default class Crawler extends EventEmitter {
253
283
  dedupeCap: options?.dedupeCap ?? null,
254
284
  dedupeMapCap: options?.dedupeMapCap ?? DEFAULT_DEDUPE_MAP_CAP,
255
285
  preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
286
+ preloadedDedupeObservations: options?.preloadedDedupeObservations ?? [],
287
+ lanes: options?.lanes,
256
288
  };
257
289
  this.#networkOutageDetector = new NetworkOutageDetector({
258
290
  windowMs: this.#options.networkOutageWindowMs,
@@ -260,6 +292,27 @@ export default class Crawler extends EventEmitter {
260
292
  hostThreshold: this.#options.networkOutageHostThreshold,
261
293
  });
262
294
  this.#dedupeCapTracker = new DedupeCapTracker({ cap: this.#options.dedupeCap ?? 0, mapCap: this.#options.dedupeMapCap }, this.#options.preloadedStickyShapeKeys);
295
+ // Replay a prior session's observations synchronously, right here —
296
+ // this is the entire burst-prevention fix for a `--retry-failed`
297
+ // remaining-count spike: by the time the first real anchor reaches
298
+ // gate 1 in `#handleResult`, `#dedupeCapTracker`'s Misra-Gries
299
+ // counters already reflect every qualifying page this archive has
300
+ // ever scraped, not just the shapes that were already confirmed
301
+ // capped (`preloadedStickyShapeKeys`). A shape one observation short
302
+ // of its threshold when the previous session ended resumes from
303
+ // that count instead of 0. Any event this replay newly crosses the
304
+ // threshold for is buffered, not emitted — see
305
+ // `#pendingReplayCapEvents`'s JSDoc — the drain in `start()` is only
306
+ // about the `dedupe_cap_events` audit row / post-hoc marking /
307
+ // `rejected_count`, not this gating effect. `preloadedDedupeObservations`
308
+ // is empty for a fresh (non-resuming) crawl, so this loop is a no-op
309
+ // there.
310
+ for (const observation of this.#options.preloadedDedupeObservations) {
311
+ const event = this.#dedupeCapTracker.observe(observation);
312
+ if (event) {
313
+ this.#pendingReplayCapEvents.push(event);
314
+ }
315
+ }
263
316
  this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
264
317
  for (const urlStr of this.#options.roots) {
265
318
  const url = parseUrl(urlStr, this.#options);
@@ -315,9 +368,12 @@ export default class Crawler extends EventEmitter {
315
368
  * @param pagesScrapedOffset - Number of HTML pages already rendered in previous
316
369
  * sessions, used to seed the session-spanning progress counter. Defaults to 0
317
370
  * for callers that don't need cross-session accuracy in the progress display.
371
+ * @param metadataOnlyUrls - The subset of `pending` fated for a
372
+ * metadata-only scrape — see `LinkList#resume`'s `metadataOnlyUrls`
373
+ * doc (#369).
318
374
  */
319
- resume(pending, scraped, resources, pagesScrapedOffset = 0) {
320
- this.#resumedPending = this.#linkList.resume(pending, scraped, this.#options);
375
+ resume(pending, scraped, resources, pagesScrapedOffset = 0, metadataOnlyUrls = []) {
376
+ this.#resumedPending = this.#linkList.resume(pending, scraped, this.#options, metadataOnlyUrls);
321
377
  this.#resumedScraped = scraped;
322
378
  this.#resumedPagesScraped = pagesScrapedOffset;
323
379
  for (const resource of resources) {
@@ -339,12 +395,32 @@ export default class Crawler extends EventEmitter {
339
395
  * previously-external page — does not race on two parallel slots.
340
396
  * @param urls - The list of root URLs to begin crawling from. May be empty
341
397
  * when resumed pending URLs already exist (for example `--retry-failed`).
342
- * @param opts - Optional overrides; currently only `recursive` is honoured.
398
+ * @param opts - Optional overrides.
343
399
  * @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
344
400
  * Defaults to the constructor option's `recursive` value.
401
+ * @param opts.isRetryContinuation - Forwarded to `#runDeal` (issue #350):
402
+ * when `true`, this is `CrawlerOrchestrator`'s auto-retry loop
403
+ * re-invoking `start()` on the same `Crawler` instance after a prior
404
+ * pass ended with pages still pending, not an unrelated fresh
405
+ * session — see `#runDeal`'s JSDoc for what that changes.
345
406
  * @throws {Error} If the URL list is empty.
346
407
  */
347
408
  start(urls, opts) {
409
+ // Drain constructor-time replay cap events (see
410
+ // `#pendingReplayCapEvents`'s JSDoc) exactly once, on this instance's
411
+ // first `start()` call. `CrawlerOrchestrator.crawling()` always
412
+ // registers its `dedupeCap` listener before calling `start()`, so by
413
+ // this point the emit is guaranteed to reach it — emitting from the
414
+ // constructor instead would be silently dropped (no listener exists
415
+ // yet at that point). The auto-retry continuation path re-invokes
416
+ // `start()` on the same instance; `#dedupeCapReplayDrained` stops
417
+ // this from re-emitting the same events on that second call.
418
+ if (!this.#dedupeCapReplayDrained) {
419
+ this.#dedupeCapReplayDrained = true;
420
+ for (const event of this.#pendingReplayCapEvents) {
421
+ void this.emit('dedupeCap', event);
422
+ }
423
+ }
348
424
  // Inventory mode pre-loads tens of thousands of seed URLs that all
349
425
  // fall under archived `roots` (already populated into `#scope` by
350
426
  // the constructor). Adding each seed as its own scope entry was
@@ -397,12 +473,57 @@ export default class Crawler extends EventEmitter {
397
473
  }
398
474
  const resumeOffset = this.#resumedScraped.length;
399
475
  const pagesScrapedOffset = this.#resumedPagesScraped;
400
- void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset).catch((error) => {
476
+ void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset, opts?.isRetryContinuation ?? false).catch((error) => {
401
477
  crawlerLog('runDeal error: %O', error);
402
478
  this.#emitDealErrors(error, root.href);
403
479
  void this.emit('crawlEnd', {});
404
480
  });
405
481
  }
482
+ /**
483
+ * Applies a runtime change to this crawl's tunable options
484
+ * (`parallels`/`interval`/the three exclude arrays) while it is in
485
+ * progress. See {@link applyCrawlRuntimeOptionsPatch} for merge
486
+ * semantics and validation.
487
+ *
488
+ * A `parallels` change takes effect immediately (via the live
489
+ * `Dealer`'s `setLimit()`) when this is called while a `deal()` round
490
+ * is actually in flight ({@link #dealController} non-`null`). Between
491
+ * auto-retry rounds ({@link #dealController} `null`), only
492
+ * `#options.parallels` is updated — the next round's `#runDeal` reads it
493
+ * when computing that round's `concurrency`.
494
+ * @param patch - The runtime change to apply.
495
+ * @returns A snapshot of the tunable options after applying `patch`.
496
+ * @throws {RangeError} If `parallels` is present and not an integer `>= 1`, or `interval` is present and not an integer `>= 0`.
497
+ * @throws {TypeError} If any exclude entry is present and not a non-empty string.
498
+ * @example
499
+ * ```ts
500
+ * const snapshot = crawler.updateRuntimeOptions({ parallels: 4 });
501
+ * console.log(snapshot.parallels); // 4
502
+ * ```
503
+ */
504
+ updateRuntimeOptions(patch) {
505
+ const snapshot = applyCrawlRuntimeOptionsPatch(this.#options, patch);
506
+ if (patch.parallels !== undefined) {
507
+ this.#dealController?.setLimit(patch.parallels);
508
+ }
509
+ return snapshot;
510
+ }
511
+ /**
512
+ * The current effective concurrency: `this.#options.parallels` (at least
513
+ * 1) if set, else the historical default {@link Crawler.MAX_PROCESS_LENGTH}.
514
+ * A method, not a value `#runDeal` captures once per round, so a
515
+ * mid-round `updateRuntimeOptions({ parallels })` is reflected
516
+ * immediately wherever this is read afterwards (`#handleResult`'s
517
+ * `concurrency` param, which sizes predicted-pagination batches) —
518
+ * unlike the live `Dealer`'s own worker slot count, this has no reactive
519
+ * setter to piggyback on.
520
+ * @returns The current concurrency value.
521
+ */
522
+ #currentConcurrency() {
523
+ return this.#options.parallels
524
+ ? Math.max(this.#options.parallels, 1)
525
+ : Crawler.MAX_PROCESS_LENGTH;
526
+ }
406
527
  /**
407
528
  * Thin instance-bound adapter over {@link drainPhaseErrors}. Flushes
408
529
  * `#pendingPhaseErrors` for `url` as `pageError` events. Idempotent.
@@ -895,25 +1016,37 @@ export default class Crawler extends EventEmitter {
895
1016
  * @param pagesScrapedOffset - Number of HTML pages already rendered in previous
896
1017
  * sessions, used to seed the per-session HTML-pages counter so the display
897
1018
  * remains accurate across resumes
1019
+ * @param isRetryContinuation - `true` when `CrawlerOrchestrator`'s
1020
+ * auto-retry loop (issue #350) is re-invoking `start()` on this same
1021
+ * `Crawler` instance after a prior pass ended with pages still
1022
+ * pending, rather than an unrelated fresh session (a plain resume,
1023
+ * append, etc.). Skips the four per-session resets below: retrying is
1024
+ * specifically trying to avoid re-paying network-outage/host-burn
1025
+ * detection cost against the same underlying network condition, so
1026
+ * discarding what the prior attempt already learned would defeat the
1027
+ * point. Defaults to `false` — every other caller of `start()` keeps
1028
+ * the pre-#350 "always reset" behaviour.
898
1029
  */
899
- async #runDeal(initialUrls, resumeOffset = 0, pagesScrapedOffset = 0) {
1030
+ async #runDeal(initialUrls, resumeOffset = 0, pagesScrapedOffset = 0, isRetryContinuation = false) {
900
1031
  const seen = new Set(initialUrls.map((u) => protocolAgnosticKey(u.withoutHashAndAuth)));
901
1032
  // Add scraped URLs to seen to prevent re-processing during resume
902
1033
  for (const url of this.#resumedScraped) {
903
1034
  seen.add(protocolAgnosticKey(url));
904
1035
  }
905
- // Redirect-destination dedup is per-crawl; clear any state from a prior run.
906
- this.#scrapedDestinations.clear();
907
- // Session-liveness signal is per-crawl too; clear so a fresh session
908
- // does not inherit "host alive" claims from a prior run that may have
909
- // happened on an entirely different network.
910
- this.#successfulHosts.clear();
911
- // Network-outage state is per-crawl too: a sliding window of errors
912
- // (or a gate left closed) from a prior run on this same `Crawler`
913
- // instance must not leak into a fresh session. `#networkGate.open()`
914
- // is a no-op if already open.
915
- this.#networkOutageDetector.reset();
916
- this.#networkGate.open();
1036
+ if (!isRetryContinuation) {
1037
+ // Redirect-destination dedup is per-crawl; clear any state from a prior run.
1038
+ this.#scrapedDestinations.clear();
1039
+ // Session-liveness signal is per-crawl too; clear so a fresh session
1040
+ // does not inherit "host alive" claims from a prior run that may have
1041
+ // happened on an entirely different network.
1042
+ this.#successfulHosts.clear();
1043
+ // Network-outage state is per-crawl too: a sliding window of errors
1044
+ // (or a gate left closed) from a prior run on this same `Crawler`
1045
+ // instance must not leak into a fresh session. `#networkGate.open()`
1046
+ // is a no-op if already open.
1047
+ this.#networkOutageDetector.reset();
1048
+ this.#networkGate.open();
1049
+ }
917
1050
  // external URL の追跡(target は deal の total/done から導出)
918
1051
  const externalUrls = new Set();
919
1052
  const externalDoneUrls = new Set();
@@ -927,352 +1060,367 @@ export default class Crawler extends EventEmitter {
927
1060
  externalUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
928
1061
  }
929
1062
  }
930
- const concurrency = this.#options.parallels
931
- ? Math.max(this.#options.parallels, 1)
932
- : Crawler.MAX_PROCESS_LENGTH;
933
- await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
934
- const matchedScope = findScopeEntry(url, this.#scope, this.#options);
935
- const isExternal = matchedScope === null;
936
- const urlText = isExternal ? c.dim(url.href) : c.cyan(url.href);
937
- setLineHeader(`%braille% ${urlText}: `);
938
- if (matchedScope) {
939
- injectScopeAuth(url, matchedScope);
940
- }
941
- this.#linkList.add(url);
942
- this.#linkList.progress(url);
943
- // Likely-HTML URLs jump to the front of the queue (unshift) so page
944
- // crawling advances ahead of asset/document fetches; everything else
945
- // is appended (push). partitionUrlsByHtml splits the batch by the
946
- // URL-only heuristic. Variadic so a batch (e.g. predicted pagination)
947
- // keeps its order: a single unshift(...html) preserves ascending order
948
- // at the front, whereas unshifting one-by-one would reverse it.
949
- const enqueue = (...newUrls) => {
950
- const [html, other] = partitionUrlsByHtml(newUrls);
951
- const ops = [];
952
- if (html.length > 0)
953
- ops.push(unshift(...html));
954
- if (other.length > 0)
955
- ops.push(push(...other));
956
- return Promise.all(ops).then(() => { });
957
- };
958
- return async () => {
959
- // Pause here, not inside `fetchDestination` or deeper, so a
960
- // paused worker shows as a long-running dealer task instead
961
- // of requiring any change to `@d-zero/dealer` itself — a
962
- // closed gate resolves the instant `#handleOutageSuspect`'s
963
- // recovery probe succeeds (see `network-gate.ts`).
964
- await this.#networkGate.wait();
965
- // Interval delay is handled here instead of by dealer because
966
- // DNS-burned hosts must skip the wait entirely. Spending the
967
- // per-URL interval on a host the cache already knows is dead
968
- // just slows the crawl down for zero benefit — the HEAD won't
969
- // be fired and `Crawler.#sendHeadRequest` will throw the
970
- // preload short-circuit immediately. For all other URLs, run
971
- // the same `delay()` + `%countdown(...)` log that dealer would
972
- // have emitted, so the dealer display reads identically.
973
- const burned = dnsBurnedHostCache.has(url.hostname.toLowerCase());
974
- if (!burned && this.#options.interval && this.#options.interval > 0) {
975
- await delay(this.#options.interval, (determinedInterval) => {
976
- update(`Waiting interval: %countdown(${determinedInterval},${_index}_interval)%ms`);
977
- });
1063
+ const concurrency = this.#currentConcurrency();
1064
+ try {
1065
+ await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
1066
+ const matchedScope = findScopeEntry(url, this.#scope, this.#options);
1067
+ const isExternal = matchedScope === null;
1068
+ const urlText = isExternal ? c.dim(url.href) : c.cyan(url.href);
1069
+ setLineHeader(`%braille% ${urlText}: `);
1070
+ if (matchedScope) {
1071
+ injectScopeAuth(url, matchedScope);
978
1072
  }
979
- const log = createTimedUpdate(update, this.#options.verbose);
980
- // `#scrapePage` 内のブラウザ HTML レンダーが成功したかをマークするフラグ。
981
- // 成功時のみ #scrapePage 側で true に設定される。
982
- // discard 判定後にこのフラグを見てカウントするので、launch 失敗や predicted-discard は除外される。
983
- let renderedInBrowser = false;
984
- const markBrowserScrape = () => {
985
- renderedInBrowser = true;
1073
+ this.#linkList.add(url);
1074
+ this.#linkList.progress(url);
1075
+ // Likely-HTML URLs jump to the front of the queue (unshift) so page
1076
+ // crawling advances ahead of asset/document fetches; everything else
1077
+ // is appended (push). partitionUrlsByHtml splits the batch by the
1078
+ // URL-only heuristic. Variadic so a batch (e.g. predicted pagination)
1079
+ // keeps its order: a single unshift(...html) preserves ascending order
1080
+ // at the front, whereas unshifting one-by-one would reverse it.
1081
+ const enqueue = (...newUrls) => {
1082
+ const [html, other] = partitionUrlsByHtml(newUrls);
1083
+ const ops = [];
1084
+ if (html.length > 0)
1085
+ ops.push(unshift(...html));
1086
+ if (other.length > 0)
1087
+ ops.push(push(...other));
1088
+ return Promise.all(ops).then(() => { });
986
1089
  };
987
- // Set below for every internal page with a rendered HTML body
988
- // (not just predicted ones see the computation site's comment),
989
- // so both `#handleResult`'s dedupe-cap observation and the `page`
990
- // event's `bodyHash` payload (ultimately consumed by
991
- // `update-page.ts`'s `page_meta.body_hash` write) reuse this one
992
- // value instead of each hashing the same html again.
993
- let precomputedBodyHash = null;
994
- try {
995
- const robotsAllowed = await this.#robotsChecker.isAllowed(url);
996
- if (!robotsAllowed) {
997
- handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
998
- void this.emit('skip', {
999
- url: url.href,
1000
- reason: 'blocked by robots.txt',
1001
- isExternal,
1090
+ return async () => {
1091
+ // Pause here, not inside `fetchDestination` or deeper, so a
1092
+ // paused worker shows as a long-running dealer task instead
1093
+ // of requiring any change to `@d-zero/dealer` itself a
1094
+ // closed gate resolves the instant `#handleOutageSuspect`'s
1095
+ // recovery probe succeeds (see `network-gate.ts`).
1096
+ await this.#networkGate.wait();
1097
+ // Interval delay is handled here instead of by dealer because
1098
+ // DNS-burned hosts must skip the wait entirely. Spending the
1099
+ // per-URL interval on a host the cache already knows is dead
1100
+ // just slows the crawl down for zero benefit — the HEAD won't
1101
+ // be fired and `Crawler.#sendHeadRequest` will throw the
1102
+ // preload short-circuit immediately. For all other URLs, run
1103
+ // the same `delay()` + `%countdown(...)` log that dealer would
1104
+ // have emitted, so the dealer display reads identically.
1105
+ const burned = dnsBurnedHostCache.has(url.hostname.toLowerCase());
1106
+ if (!burned && this.#options.interval && this.#options.interval > 0) {
1107
+ await delay(this.#options.interval, (determinedInterval) => {
1108
+ update(`Waiting interval: %countdown(${determinedInterval},${_index}_interval)%ms`);
1002
1109
  });
1003
- log(c.gray('Blocked by robots.txt'));
1004
- return;
1005
1110
  }
1006
- const isSkip = shouldSkipUrl({
1007
- url,
1008
- excludes: this.#options.excludes,
1009
- excludeUrls: this.#options.excludeUrls,
1010
- options: this.#options,
1011
- });
1012
- if (isSkip) {
1013
- handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1014
- void this.emit('skip', { url: url.href, reason: 'excluded', isExternal });
1015
- log(c.gray('Skipped'));
1016
- return;
1017
- }
1018
- if (!this.#options.fetchExternal && isExternal) {
1019
- const pageData = linkToPageData({
1111
+ const log = createTimedUpdate(update, this.#options.verbose);
1112
+ // `#scrapePage` 内のブラウザ HTML レンダーが成功したかをマークするフラグ。
1113
+ // 成功時のみ #scrapePage 側で true に設定される。
1114
+ // discard 判定後にこのフラグを見てカウントするので、launch 失敗や predicted-discard は除外される。
1115
+ let renderedInBrowser = false;
1116
+ const markBrowserScrape = () => {
1117
+ renderedInBrowser = true;
1118
+ };
1119
+ // Set below for every internal page with a rendered HTML body
1120
+ // (not just predicted ones — see the computation site's comment),
1121
+ // so both `#handleResult`'s dedupe-cap observation and the `page`
1122
+ // event's `bodyHash` payload (ultimately consumed by
1123
+ // `update-page.ts`'s `page_meta.body_hash` write) reuse this one
1124
+ // value instead of each hashing the same html again.
1125
+ let precomputedBodyHash = null;
1126
+ try {
1127
+ const robotsAllowed = await this.#robotsChecker.isAllowed(url);
1128
+ if (!robotsAllowed) {
1129
+ handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1130
+ void this.emit('skip', {
1131
+ url: url.href,
1132
+ reason: 'blocked by robots.txt',
1133
+ isExternal,
1134
+ });
1135
+ log(c.gray('Blocked by robots.txt'));
1136
+ return;
1137
+ }
1138
+ const isSkip = shouldSkipUrl({
1020
1139
  url,
1021
- isExternal,
1022
- isLowerLayer: false,
1140
+ excludes: this.#options.excludes,
1141
+ excludeUrls: this.#options.excludeUrls,
1142
+ options: this.#options,
1023
1143
  });
1024
- this.#linkList.done(url, this.#scope, { page: pageData }, this.#options);
1025
- void this.emit('externalPage', {
1026
- result: pageData,
1027
- source: derivePageSource(this.#options.inventoryMode, url.withoutHashAndAuth),
1028
- });
1029
- log(c.dim('External (skip fetch)'));
1030
- return;
1031
- }
1032
- const metadataOnly = this.#linkList.isMetadataOnly(url.withoutHash);
1033
- const isPredicted = this.#linkList.isPredicted(url.withoutHashAndAuth);
1034
- log('Scraping%dots%');
1035
- const result = await this.#scrapePage(url, log, metadataOnly, _index, markBrowserScrape);
1036
- // Redirect convergence (#73): the destination was already
1037
- // rendered during this crawl, so only the redirect edge is
1038
- // recorded and the browser was never launched. Mark the URL
1039
- // done and emit `redirect` (routed to `Archive.setRedirect`,
1040
- // which writes the edge without touching the destination's
1041
- // content). This URL does not count toward pagesScraped.
1042
- if (result.type === 'redirect-edge') {
1043
- // Note: a predicted (speculative) URL that reaches here genuinely
1044
- // redirects (the server returned 3xx), so it is a real URL — we
1045
- // record its edge rather than discard it. This matches the render
1046
- // path, where the first predicted source to a destination renders
1047
- // it and is recorded as a redirect source the same way; only 404 /
1048
- // error predicted URLs are dropped (by `shouldDiscardPredicted`).
1049
- //
1050
- // The `source` discriminator divides this branch in two:
1051
- //
1052
- // - `'http-chain'` the HEAD pre-flight resolved a real 3xx chain
1053
- // and the destination is already rendered (`#scrapedDestinations`
1054
- // claim). Every URL in `redirectPaths` is intermediate / known,
1055
- // so the existing behaviour applies: `linkList.done` folds the
1056
- // whole chain into the done-set so later references skip cleanly.
1057
- //
1058
- // - `'js-redirect'` `scraper.scrapeStart` threw because
1059
- // `page.goto()` returned null (`window.location.replace()` /
1060
- // meta-refresh fired mid-navigation), and `redirectPaths`
1061
- // carries the single JS target Chromium ended up on. That target
1062
- // has NOT been rendered yet it must enter the crawl queue, and
1063
- // `linkList.done` MUST NOT fold it into the done-set (otherwise
1064
- // the dealer's `seen` rejects the push and the destination is
1065
- // silently lost from the archive).
1066
- if (result.source === 'js-redirect') {
1067
- const destination = result.pageData.redirectPaths.at(-1);
1068
- if (destination) {
1069
- const destinationUrl = parseUrl(destination, this.#options);
1070
- if (destinationUrl) {
1071
- // Gate 2: this direct enqueue does not go through
1072
- // `#handleResult`'s addUrl closure (gate 1), so it needs
1073
- // its own same-cluster-cap check a JS-redirect trap
1074
- // that advances a parameter via `location.replace()`
1075
- // would otherwise keep re-entering the queue here.
1076
- const gateShapeKey = computeShapeKey(destinationUrl.withoutHashAndAuth);
1077
- const isCapped = this.#options.dedupeCap !== null &&
1078
- gateShapeKey !== null &&
1079
- findScopeEntry(destinationUrl, this.#scope, this.#options) !==
1080
- null &&
1081
- this.#dedupeCapTracker.isCapped(gateShapeKey);
1082
- if (isCapped) {
1083
- if (gateShapeKey)
1084
- this.#recordDedupeCapRejection(gateShapeKey);
1144
+ if (isSkip) {
1145
+ handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1146
+ void this.emit('skip', { url: url.href, reason: 'excluded', isExternal });
1147
+ log(c.gray('Skipped'));
1148
+ return;
1149
+ }
1150
+ if (!this.#options.fetchExternal && isExternal) {
1151
+ const pageData = linkToPageData({
1152
+ url,
1153
+ isExternal,
1154
+ isLowerLayer: false,
1155
+ });
1156
+ this.#linkList.done(url, this.#scope, { page: pageData }, this.#options);
1157
+ void this.emit('externalPage', {
1158
+ result: pageData,
1159
+ source: derivePageSource(this.#options.inventoryMode, url.withoutHashAndAuth),
1160
+ });
1161
+ log(c.dim('External (skip fetch)'));
1162
+ return;
1163
+ }
1164
+ const metadataOnly = this.#linkList.isMetadataOnly(url.withoutHash);
1165
+ const isPredicted = this.#linkList.isPredicted(url.withoutHashAndAuth);
1166
+ log('Scraping%dots%');
1167
+ const result = await this.#scrapePage(url, log, metadataOnly, _index, markBrowserScrape);
1168
+ // Redirect convergence (#73): the destination was already
1169
+ // rendered during this crawl, so only the redirect edge is
1170
+ // recorded and the browser was never launched. Mark the URL
1171
+ // done and emit `redirect` (routed to `Archive.setRedirect`,
1172
+ // which writes the edge without touching the destination's
1173
+ // content). This URL does not count toward pagesScraped.
1174
+ if (result.type === 'redirect-edge') {
1175
+ // Note: a predicted (speculative) URL that reaches here genuinely
1176
+ // redirects (the server returned 3xx), so it is a real URL — we
1177
+ // record its edge rather than discard it. This matches the render
1178
+ // path, where the first predicted source to a destination renders
1179
+ // it and is recorded as a redirect source the same way; only 404 /
1180
+ // error predicted URLs are dropped (by `shouldDiscardPredicted`).
1181
+ //
1182
+ // The `source` discriminator divides this branch in two:
1183
+ //
1184
+ // - `'http-chain'` the HEAD pre-flight resolved a real 3xx chain
1185
+ // and the destination is already rendered (`#scrapedDestinations`
1186
+ // claim). Every URL in `redirectPaths` is intermediate / known,
1187
+ // so the existing behaviour applies: `linkList.done` folds the
1188
+ // whole chain into the done-set so later references skip cleanly.
1189
+ //
1190
+ // - `'js-redirect'` — `scraper.scrapeStart` threw because
1191
+ // `page.goto()` returned null (`window.location.replace()` /
1192
+ // meta-refresh fired mid-navigation), and `redirectPaths`
1193
+ // carries the single JS target Chromium ended up on. That target
1194
+ // has NOT been rendered yet it must enter the crawl queue, and
1195
+ // `linkList.done` MUST NOT fold it into the done-set (otherwise
1196
+ // the dealer's `seen` rejects the push and the destination is
1197
+ // silently lost from the archive).
1198
+ if (result.source === 'js-redirect') {
1199
+ const destination = result.pageData.redirectPaths.at(-1);
1200
+ if (destination) {
1201
+ const destinationUrl = parseUrl(destination, this.#options);
1202
+ if (destinationUrl) {
1203
+ // Gate 2: this direct enqueue does not go through
1204
+ // `#handleResult`'s addUrl closure (gate 1), so it needs
1205
+ // its own same-cluster-cap check — a JS-redirect trap
1206
+ // that advances a parameter via `location.replace()`
1207
+ // would otherwise keep re-entering the queue here.
1208
+ const gateShapeKey = computeShapeKey(destinationUrl.withoutHashAndAuth);
1209
+ const isCapped = this.#options.dedupeCap !== null &&
1210
+ gateShapeKey !== null &&
1211
+ findScopeEntry(destinationUrl, this.#scope, this.#options) !==
1212
+ null &&
1213
+ this.#dedupeCapTracker.isCapped(gateShapeKey);
1214
+ if (isCapped) {
1215
+ if (gateShapeKey)
1216
+ this.#recordDedupeCapRejection(gateShapeKey);
1217
+ }
1218
+ else {
1219
+ this.#linkList.add(destinationUrl);
1220
+ void enqueue(destinationUrl);
1221
+ }
1085
1222
  }
1086
1223
  else {
1087
- this.#linkList.add(destinationUrl);
1088
- void enqueue(destinationUrl);
1224
+ // `deriveJsRedirectTarget` already canonicalises
1225
+ // via WHATWG URL parsing, so reaching the
1226
+ // `parseUrl === null` branch here would mean
1227
+ // `@d-zero/shared/parse-url` rejected what
1228
+ // WHATWG accepted — unexpected, and silently
1229
+ // dropping the destination would be a silent
1230
+ // archive loss. Log it so DEBUG=Nitpicker:Crawler
1231
+ // catches the case.
1232
+ crawlerLog('JS-redirect destination %s failed to parse — dropping enqueue', destination);
1089
1233
  }
1090
1234
  }
1091
1235
  else {
1092
- // `deriveJsRedirectTarget` already canonicalises
1093
- // via WHATWG URL parsing, so reaching the
1094
- // `parseUrl === null` branch here would mean
1095
- // `@d-zero/shared/parse-url` rejected what
1096
- // WHATWG accepted — unexpected, and silently
1097
- // dropping the destination would be a silent
1098
- // archive loss. Log it so DEBUG=Nitpicker:Crawler
1099
- // catches the case.
1100
- crawlerLog('JS-redirect destination %s failed to parse — dropping enqueue', destination);
1236
+ crawlerLog('JS-redirect result for %s had no redirectPaths destination — dropping enqueue', url.href);
1101
1237
  }
1238
+ this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options, { includeRedirectPaths: false });
1102
1239
  }
1103
1240
  else {
1104
- crawlerLog('JS-redirect result for %s had no redirectPaths destination — dropping enqueue', url.href);
1241
+ this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options);
1105
1242
  }
1106
- this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options, { includeRedirectPaths: false });
1243
+ // The redirect-edge call path may INSERT a brand-new
1244
+ // destination row (js-redirect rescue, #73
1245
+ // convergence on first sight). Forward the
1246
+ // originating page's inventory provenance so the
1247
+ // destination + intermediate hops inherit the
1248
+ // chain's lineage instead of laundering to DB
1249
+ // DEFAULT `'crawled'`. `inventoryMode === null`
1250
+ // (resume / retry-failed) yields `undefined`,
1251
+ // which is correct: the DB-side lookup in
1252
+ // `#linkRedirectSources` reads the destination's
1253
+ // stored source for those sessions.
1254
+ void this.emit('redirect', buildRedirectEvent(result.pageData, this.#options.inventoryMode, url.withoutHashAndAuth));
1255
+ log(c.dim('Redirect (dest already scraped)'));
1256
+ return;
1107
1257
  }
1108
- else {
1109
- this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options);
1258
+ // Discard predicted URLs that failed (404, error, etc.)
1259
+ if (isPredicted && shouldDiscardPredicted(result)) {
1260
+ handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1261
+ log(c.dim('Predicted (discarded)'));
1262
+ return;
1110
1263
  }
1111
- // The redirect-edge call path may INSERT a brand-new
1112
- // destination row (js-redirect rescue, #73
1113
- // convergence on first sight). Forward the
1114
- // originating page's inventory provenance so the
1115
- // destination + intermediate hops inherit the
1116
- // chain's lineage instead of laundering to DB
1117
- // DEFAULT `'crawled'`. `inventoryMode === null`
1118
- // (resume / retry-failed) yields `undefined`,
1119
- // which is correct: the DB-side lookup in
1120
- // `#linkRedirectSources` reads the destination's
1121
- // stored source for those sessions.
1122
- void this.emit('redirect', buildRedirectEvent(result.pageData, this.#options.inventoryMode, url.withoutHashAndAuth));
1123
- log(c.dim('Redirect (dest already scraped)'));
1124
- return;
1125
- }
1126
- // Discard predicted URLs that failed (404, error, etc.)
1127
- if (isPredicted && shouldDiscardPredicted(result)) {
1128
- handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1129
- log(c.dim('Predicted (discarded)'));
1130
- return;
1131
- }
1132
- // Compute this page's body hash once, up front, for every
1133
- // internal page with a rendered HTML body — not just predicted
1134
- // ones. This condition intentionally mirrors `update-page.ts`'s
1135
- // `writeHtml && page.html.length > 0` write gate (internal pages
1136
- // are exactly the ones `setPage` — as opposed to
1137
- // `setExternalPage` writes a body through), so the value
1138
- // computed here can be forwarded through the `page` event all
1139
- // the way to that write and reused there instead of hashing the
1140
- // same html a second time.
1141
- if (result.type === 'success' &&
1142
- result.pageData &&
1143
- !result.pageData.isExternal &&
1144
- result.pageData.html.length > 0) {
1145
- precomputedBodyHash = computeBodyHash(result.pageData.html);
1146
- // Discard a predicted URL whose rendered body is a
1147
- // byte-for-byte duplicate of the previous predicted page of
1148
- // the same shape, and stop generating further predictions for
1149
- // that shape (checked above, in the pagination-pattern
1150
- // branch). This is the always-on backstop against a site
1151
- // that returns 2xx for any extrapolated token but ignores it
1152
- // entirely (e.g. always serving the same "no results"
1153
- // template) — `shouldDiscardPredicted` alone cannot see
1154
- // this, since it only inspects HTTP status.
1155
- if (isPredicted) {
1156
- const shapeKey = computeShapeKey(url.withoutHashAndAuth);
1157
- if (shapeKey) {
1158
- const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
1159
- if (isPredictedContentDuplicate(precomputedBodyHash, lastBodyHash)) {
1160
- this.#predictedShapeStopped.add(shapeKey);
1161
- handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1162
- log(c.dim('Predicted (content duplicate, discarded)'));
1163
- return;
1264
+ // Compute this page's body hash once, up front, for every
1265
+ // internal page with a rendered HTML body — not just predicted
1266
+ // ones. This condition intentionally mirrors `update-page.ts`'s
1267
+ // `writeHtml && page.html.length > 0` write gate (internal pages
1268
+ // are exactly the ones `setPage` — as opposed to
1269
+ // `setExternalPage` writes a body through), so the value
1270
+ // computed here can be forwarded through the `page` event all
1271
+ // the way to that write and reused there instead of hashing the
1272
+ // same html a second time.
1273
+ if (result.type === 'success' &&
1274
+ result.pageData &&
1275
+ !result.pageData.isExternal &&
1276
+ result.pageData.html.length > 0) {
1277
+ precomputedBodyHash = computeBodyHash(result.pageData.html);
1278
+ // Discard a predicted URL whose rendered body is a
1279
+ // byte-for-byte duplicate of the previous predicted page of
1280
+ // the same shape, and stop generating further predictions for
1281
+ // that shape (checked above, in the pagination-pattern
1282
+ // branch). This is the always-on backstop against a site
1283
+ // that returns 2xx for any extrapolated token but ignores it
1284
+ // entirely (e.g. always serving the same "no results"
1285
+ // template) `shouldDiscardPredicted` alone cannot see
1286
+ // this, since it only inspects HTTP status.
1287
+ if (isPredicted) {
1288
+ const shapeKey = computeShapeKey(url.withoutHashAndAuth);
1289
+ if (shapeKey) {
1290
+ const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
1291
+ if (isPredictedContentDuplicate(precomputedBodyHash, lastBodyHash)) {
1292
+ this.#predictedShapeStopped.add(shapeKey);
1293
+ handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1294
+ log(c.dim('Predicted (content duplicate, discarded)'));
1295
+ return;
1296
+ }
1297
+ this.#predictedShapeBodyHashes.set(shapeKey, precomputedBodyHash);
1164
1298
  }
1165
- this.#predictedShapeBodyHashes.set(shapeKey, precomputedBodyHash);
1166
1299
  }
1167
1300
  }
1301
+ // Count only after discard check: rendered HTML pages that
1302
+ // will be persisted to the archive. Launch failures bypass
1303
+ // this point via the catch block; discarded predicted URLs
1304
+ // return above without reaching here.
1305
+ if (renderedInBrowser) {
1306
+ pagesScraped++;
1307
+ }
1308
+ log('Saving results%dots%');
1309
+ this.#handleResult(result, url, enqueue, this.#currentConcurrency(), precomputedBodyHash);
1310
+ // Skip sub-resources / console logs for a result that turned out
1311
+ // external — NOT the same as this worker's own `isExternal`
1312
+ // (computed from `url` before navigation). Beholder decides
1313
+ // `isExternal: false` before navigating and only flips it to
1314
+ // `true` after seeing the destination's hostname, so a
1315
+ // same-host source that redirects cross-host still has its
1316
+ // request/response/console listeners attached under the
1317
+ // pre-navigation `isExternal: false` for the whole trip. Those
1318
+ // listeners keep capturing the destination's sub-resources and
1319
+ // console output even after the flip, so without this guard a
1320
+ // cross-host redirect leaks the OFF-SCOPE destination's data
1321
+ // into this archive: its console output would be recorded as
1322
+ // this page's quality signal, and its resources would leave a
1323
+ // `resource_ref_edges` row on the (now content-less) redirect
1324
+ // SOURCE — `linkRedirectSources` deletes that source's
1325
+ // `anchor_edges` / `image_items` but not `resource_ref_edges`.
1326
+ // A genuinely external URL never reaches this branch with
1327
+ // non-empty `resources` / `consoleLogs` in the first place —
1328
+ // beholder never attaches these listeners for one, per the
1329
+ // `isExternal` gate in `#fetchData` — so this guard is a no-op
1330
+ // outside the cross-host-redirect case. See
1331
+ // `resolveResultWentOffHost`'s JSDoc for how it answers this for
1332
+ // a `type: 'error'` result, which has no `pageData` to read.
1333
+ if (!resolveResultWentOffHost(result, url)) {
1334
+ const parentSource = await this.#resolveParentSource(url);
1335
+ this.#handleResources(result.resources, parentSource);
1336
+ this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
1337
+ }
1338
+ log(formatResultSummary(result));
1339
+ // Phase errors must be emitted AFTER 'page' / 'externalPage'
1340
+ // so the orchestrator's WriteQueue sees `setPage` before
1341
+ // `insertPageError` and the URL→pageId resolution succeeds.
1342
+ this.#drainPhaseErrors(url, isExternal);
1168
1343
  }
1169
- // Count only after discard check: rendered HTML pages that
1170
- // will be persisted to the archive. Launch failures bypass
1171
- // this point via the catch block; discarded predicted URLs
1172
- // return above without reaching here.
1173
- if (renderedInBrowser) {
1174
- pagesScraped++;
1344
+ catch (error) {
1345
+ crawlerLog('Worker error for %s: %O', url.href, error);
1346
+ log(c.red('Error'));
1347
+ const workerError = error instanceof Error ? error : new Error(String(error));
1348
+ handleScrapeError({
1349
+ url,
1350
+ error: workerError,
1351
+ shutdown: false,
1352
+ pid: process.pid,
1353
+ }, this.#linkList, this.#scope, this.#options);
1354
+ void this.emit('error', {
1355
+ pid: process.pid,
1356
+ isMainProcess: true,
1357
+ url: url.href,
1358
+ isExternal,
1359
+ error: workerError,
1360
+ });
1361
+ // Hard-error path: persist whatever phase errors we have
1362
+ // already buffered so they are not lost.
1363
+ this.#drainPhaseErrors(url, isExternal);
1175
1364
  }
1176
- log('Saving results%dots%');
1177
- this.#handleResult(result, url, enqueue, concurrency, precomputedBodyHash);
1178
- // Skip sub-resources / console logs for a result that turned out
1179
- // external — NOT the same as this worker's own `isExternal`
1180
- // (computed from `url` before navigation). Beholder decides
1181
- // `isExternal: false` before navigating and only flips it to
1182
- // `true` after seeing the destination's hostname, so a
1183
- // same-host source that redirects cross-host still has its
1184
- // request/response/console listeners attached under the
1185
- // pre-navigation `isExternal: false` for the whole trip. Those
1186
- // listeners keep capturing the destination's sub-resources and
1187
- // console output even after the flip, so without this guard a
1188
- // cross-host redirect leaks the OFF-SCOPE destination's data
1189
- // into this archive: its console output would be recorded as
1190
- // this page's quality signal, and its resources would leave a
1191
- // `resource_ref_edges` row on the (now content-less) redirect
1192
- // SOURCE — `linkRedirectSources` deletes that source's
1193
- // `anchor_edges` / `image_items` but not `resource_ref_edges`.
1194
- // A genuinely external URL never reaches this branch with
1195
- // non-empty `resources` / `consoleLogs` in the first place —
1196
- // beholder never attaches these listeners for one, per the
1197
- // `isExternal` gate in `#fetchData` — so this guard is a no-op
1198
- // outside the cross-host-redirect case. See
1199
- // `resolveResultWentOffHost`'s JSDoc for how it answers this for
1200
- // a `type: 'error'` result, which has no `pageData` to read.
1201
- if (!resolveResultWentOffHost(result, url)) {
1202
- const parentSource = await this.#resolveParentSource(url);
1203
- this.#handleResources(result.resources, parentSource);
1204
- this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
1365
+ finally {
1366
+ if (isExternal) {
1367
+ externalDoneUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
1368
+ }
1369
+ // Phase errors still in the buffer here were not drained
1370
+ // by the success or catch paths typically because a
1371
+ // predicted URL was discarded before reaching the drain
1372
+ // point. The helper logs the drop (observable via
1373
+ // DEBUG=Nitpicker:Crawler) and removes the entry so the
1374
+ // Map cannot leak across crawls.
1375
+ logUndrainedPhaseErrors(this.#pendingPhaseErrors, url.href, crawlerLog);
1205
1376
  }
1206
- log(formatResultSummary(result));
1207
- // Phase errors must be emitted AFTER 'page' / 'externalPage'
1208
- // so the orchestrator's WriteQueue sees `setPage` before
1209
- // `insertPageError` and the URL→pageId resolution succeeds.
1210
- this.#drainPhaseErrors(url, isExternal);
1211
- }
1212
- catch (error) {
1213
- crawlerLog('Worker error for %s: %O', url.href, error);
1214
- log(c.red('Error'));
1215
- const workerError = error instanceof Error ? error : new Error(String(error));
1216
- handleScrapeError({
1217
- url,
1218
- error: workerError,
1219
- shutdown: false,
1220
- pid: process.pid,
1221
- }, this.#linkList, this.#scope, this.#options);
1222
- void this.emit('error', {
1223
- pid: process.pid,
1224
- isMainProcess: true,
1225
- url: url.href,
1226
- isExternal,
1227
- error: workerError,
1377
+ };
1378
+ }, {
1379
+ limit: concurrency,
1380
+ // Interval is applied per-URL inside the worker callback above so
1381
+ // DNS-burned hosts can skip it. Letting dealer handle interval
1382
+ // would run the wait before our short-circuit check fires.
1383
+ interval: 0,
1384
+ verbose: this.#options.verbose || !process.stdout.isTTY,
1385
+ signal: this.#abortController.signal,
1386
+ // `undefined` falls back to deal() building its own Lanes off
1387
+ // `verbose` above; a caller-supplied Lanes (e.g. the CLI's,
1388
+ // carrying its runtime-input footer) is reused as-is instead.
1389
+ lanes: this.#options.lanes,
1390
+ onStart: (controller) => {
1391
+ this.#dealController = controller;
1392
+ },
1393
+ header: (_progress, done, total, limit) => {
1394
+ return formatCrawlProgress({
1395
+ done,
1396
+ total,
1397
+ resumeOffset,
1398
+ externalTotal: externalUrls.size,
1399
+ externalDone: externalDoneUrls.size,
1400
+ pagesScraped,
1401
+ limit,
1228
1402
  });
1229
- // Hard-error path: persist whatever phase errors we have
1230
- // already buffered so they are not lost.
1231
- this.#drainPhaseErrors(url, isExternal);
1232
- }
1233
- finally {
1234
- if (isExternal) {
1235
- externalDoneUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
1403
+ },
1404
+ onPush: (url) => {
1405
+ const key = protocolAgnosticKey(url.withoutHashAndAuth);
1406
+ if (seen.has(key))
1407
+ return false;
1408
+ seen.add(key);
1409
+ if (findScopeEntry(url, this.#scope, this.#options) === null) {
1410
+ externalUrls.add(key);
1236
1411
  }
1237
- // Phase errors still in the buffer here were not drained
1238
- // by the success or catch paths — typically because a
1239
- // predicted URL was discarded before reaching the drain
1240
- // point. The helper logs the drop (observable via
1241
- // DEBUG=Nitpicker:Crawler) and removes the entry so the
1242
- // Map cannot leak across crawls.
1243
- logUndrainedPhaseErrors(this.#pendingPhaseErrors, url.href, crawlerLog);
1244
- }
1245
- };
1246
- }, {
1247
- limit: concurrency,
1248
- // Interval is applied per-URL inside the worker callback above so
1249
- // DNS-burned hosts can skip it. Letting dealer handle interval
1250
- // would run the wait before our short-circuit check fires.
1251
- interval: 0,
1252
- verbose: this.#options.verbose || !process.stdout.isTTY,
1253
- signal: this.#abortController.signal,
1254
- header: (_progress, done, total, limit) => {
1255
- return formatCrawlProgress({
1256
- done,
1257
- total,
1258
- resumeOffset,
1259
- externalTotal: externalUrls.size,
1260
- externalDone: externalDoneUrls.size,
1261
- pagesScraped,
1262
- limit,
1263
- });
1264
- },
1265
- onPush: (url) => {
1266
- const key = protocolAgnosticKey(url.withoutHashAndAuth);
1267
- if (seen.has(key))
1268
- return false;
1269
- seen.add(key);
1270
- if (findScopeEntry(url, this.#scope, this.#options) === null) {
1271
- externalUrls.add(key);
1272
- }
1273
- return true;
1274
- },
1275
- });
1412
+ return true;
1413
+ },
1414
+ });
1415
+ }
1416
+ finally {
1417
+ // Reset even when `deal()` rejects (propagating out of this method
1418
+ // to `start()`/`resume()`'s `.catch()`) — leaving a stale
1419
+ // controller pointed at a dead round would let a later
1420
+ // `updateRuntimeOptions({ parallels })` call `setLimit()` on a
1421
+ // `Dealer` instance that has already finished.
1422
+ this.#dealController = null;
1423
+ }
1276
1424
  crawlerLog('Crawl End');
1277
1425
  void this.emit('crawlEnd', {});
1278
1426
  }