@nitpicker/crawler 0.9.0 → 0.12.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 (148) hide show
  1. package/lib/archive/archive-accessor.d.ts +87 -14
  2. package/lib/archive/archive-accessor.js +162 -36
  3. package/lib/archive/archive.d.ts +262 -24
  4. package/lib/archive/archive.js +397 -86
  5. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  6. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  7. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  8. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  9. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  10. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  11. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  12. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  13. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  14. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  15. package/lib/archive/database.d.ts +411 -26
  16. package/lib/archive/database.js +1881 -540
  17. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  18. package/lib/archive/derive-lineage-from-parent.js +42 -0
  19. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  20. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  21. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  22. package/lib/archive/get-failed-page-messages.js +131 -0
  23. package/lib/archive/init-schema.d.ts +53 -2
  24. package/lib/archive/init-schema.js +399 -15
  25. package/lib/archive/is-inventory-source.d.ts +21 -0
  26. package/lib/archive/is-inventory-source.js +22 -0
  27. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  28. package/lib/archive/meta/assert-compatible-version.js +72 -0
  29. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  30. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  31. package/lib/archive/meta/compare-semver.d.ts +23 -0
  32. package/lib/archive/meta/compare-semver.js +51 -0
  33. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  34. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  35. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  36. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  37. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  38. package/lib/archive/meta/derive-meta-extras.js +23 -0
  39. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  40. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  41. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  42. package/lib/archive/meta/summarize-jsonld.js +29 -0
  43. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  44. package/lib/archive/meta/summarize-tags.js +33 -0
  45. package/lib/archive/meta/types.d.ts +207 -0
  46. package/lib/archive/meta/types.js +33 -0
  47. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  48. package/lib/archive/migrate-crawl-errors.js +38 -0
  49. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  50. package/lib/archive/migrate-html-blob-tables.js +53 -0
  51. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  52. package/lib/archive/migrate-inventory-runs.js +52 -0
  53. package/lib/archive/migrate-page-errors.d.ts +16 -0
  54. package/lib/archive/migrate-page-errors.js +35 -0
  55. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  56. package/lib/archive/migrate-pages-resources-source.js +46 -0
  57. package/lib/archive/page.d.ts +187 -49
  58. package/lib/archive/page.js +258 -63
  59. package/lib/archive/peek-archive-lock.d.ts +40 -0
  60. package/lib/archive/peek-archive-lock.js +62 -0
  61. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  62. package/lib/archive/resolve-redirect-chain.js +27 -0
  63. package/lib/archive/types.d.ts +168 -26
  64. package/lib/classify-error-kind.d.ts +19 -0
  65. package/lib/classify-error-kind.js +122 -0
  66. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  67. package/lib/crawler/build-js-redirect-edge.js +57 -0
  68. package/lib/crawler/build-redirect-event.d.ts +24 -0
  69. package/lib/crawler/build-redirect-event.js +28 -0
  70. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  71. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  72. package/lib/crawler/close-browser-safely.d.ts +64 -0
  73. package/lib/crawler/close-browser-safely.js +73 -0
  74. package/lib/crawler/crawler.d.ts +7 -2
  75. package/lib/crawler/crawler.js +919 -113
  76. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  77. package/lib/crawler/create-change-phase-handler.js +44 -0
  78. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  79. package/lib/crawler/derive-js-redirect-target.js +129 -0
  80. package/lib/crawler/derive-page-source.d.ts +23 -0
  81. package/lib/crawler/derive-page-source.js +28 -0
  82. package/lib/crawler/derive-resource-source.d.ts +33 -0
  83. package/lib/crawler/derive-resource-source.js +37 -0
  84. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  85. package/lib/crawler/dns-burned-host-cache.js +25 -0
  86. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  87. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  88. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  89. package/lib/crawler/drain-phase-errors.js +35 -0
  90. package/lib/crawler/fetch-destination.d.ts +12 -4
  91. package/lib/crawler/fetch-destination.js +132 -18
  92. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  93. package/lib/crawler/format-crawl-progress.js +14 -6
  94. package/lib/crawler/handle-browser-close.d.ts +29 -0
  95. package/lib/crawler/handle-browser-close.js +28 -0
  96. package/lib/crawler/is-html-content-type.d.ts +17 -0
  97. package/lib/crawler/is-html-content-type.js +19 -0
  98. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  99. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  100. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  101. package/lib/crawler/is-likely-html-url.js +65 -0
  102. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  103. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  104. package/lib/crawler/kill-process-tree.d.ts +94 -0
  105. package/lib/crawler/kill-process-tree.js +178 -0
  106. package/lib/crawler/link-list.d.ts +21 -1
  107. package/lib/crawler/link-list.js +25 -4
  108. package/lib/crawler/link-to-page-data.d.ts +13 -5
  109. package/lib/crawler/link-to-page-data.js +26 -5
  110. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  111. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  112. package/lib/crawler/normalize-content-type.d.ts +14 -0
  113. package/lib/crawler/normalize-content-type.js +20 -0
  114. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  115. package/lib/crawler/partition-urls-by-html.js +23 -0
  116. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  117. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  118. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  119. package/lib/crawler/preload-short-circuit-error.js +25 -0
  120. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  121. package/lib/crawler/redirect-dest-key.js +27 -0
  122. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  123. package/lib/crawler/resource-to-page-data.js +59 -0
  124. package/lib/crawler/should-burn-host.d.ts +78 -0
  125. package/lib/crawler/should-burn-host.js +61 -0
  126. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  127. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  128. package/lib/crawler/types.d.ts +229 -1
  129. package/lib/crawler-orchestrator.d.ts +105 -3
  130. package/lib/crawler-orchestrator.js +620 -20
  131. package/lib/crawler.d.ts +8 -2
  132. package/lib/crawler.js +6 -1
  133. package/lib/permanent-error-kinds.d.ts +43 -0
  134. package/lib/permanent-error-kinds.js +48 -0
  135. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  136. package/lib/resource-row-to-lookup-result.js +20 -0
  137. package/lib/types.d.ts +95 -1
  138. package/lib/utils/compute-file-sha256.d.ts +23 -0
  139. package/lib/utils/compute-file-sha256.js +55 -0
  140. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  141. package/lib/utils/error/emit-error-with-retry.js +44 -0
  142. package/lib/utils/error/emit-error.d.ts +39 -0
  143. package/lib/utils/error/emit-error.js +41 -0
  144. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  145. package/lib/utils/object/parse-response-headers.js +26 -0
  146. package/package.json +11 -11
  147. package/lib/utils/error/error-emitter.d.ts +0 -18
  148. package/lib/utils/error/error-emitter.js +0 -29
@@ -3,28 +3,58 @@ import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import Scraper from '@d-zero/beholder';
5
5
  import { deal } from '@d-zero/dealer';
6
+ import { delay } from '@d-zero/shared/delay';
6
7
  import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
7
8
  import { retryCall } from '@d-zero/shared/retry';
8
9
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
9
10
  import c from 'ansi-colors';
10
11
  import pkg from '../../package.json' with { type: 'json' };
12
+ import { classifyErrorKind } from '../classify-error-kind.js';
11
13
  import { crawlerLog } from '../debug.js';
14
+ import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
15
+ import { buildRedirectEvent } from './build-redirect-event.js';
16
+ import { createChangePhaseHandler } from './create-change-phase-handler.js';
17
+ import { derivePageSource } from './derive-page-source.js';
12
18
  import { detectPaginationPattern } from './detect-pagination-pattern.js';
19
+ import { dnsBurnedHostCache } from './dns-burned-host-cache.js';
20
+ import { dnsBurnedHostShortCircuitCounter } from './dns-burned-host-short-circuit-counter.js';
21
+ import { drainPhaseErrors } from './drain-phase-errors.js';
13
22
  import { fetchDestination } from './fetch-destination.js';
14
23
  import { findScopeEntry } from './find-scope-entry.js';
15
24
  import { formatCrawlProgress } from './format-crawl-progress.js';
16
25
  import { generatePredictedUrls } from './generate-predicted-urls.js';
26
+ import { handleBrowserClose } from './handle-browser-close.js';
17
27
  import { handleIgnoreAndSkip } from './handle-ignore-and-skip.js';
18
- import { handleResourceResponse } from './handle-resource-response.js';
19
28
  import { handleScrapeEnd } from './handle-scrape-end.js';
20
29
  import { handleScrapeError } from './handle-scrape-error.js';
21
30
  import { injectScopeAuth } from './inject-scope-auth.js';
31
+ import { isHtmlContentType } from './is-html-content-type.js';
32
+ import { isLikelyHtmlUrl } from './is-likely-html-url.js';
33
+ import { isPuppeteerFallbackCandidate } from './is-puppeteer-fallback-candidate.js';
22
34
  import LinkList from './link-list.js';
23
35
  import { linkToPageData } from './link-to-page-data.js';
36
+ import { logUndrainedPhaseErrors } from './log-undrained-phase-errors.js';
37
+ import { partitionUrlsByHtml } from './partition-urls-by-html.js';
38
+ import { planSubResourceEmits } from './plan-sub-resource-emits.js';
39
+ import { PreloadShortCircuitError } from './preload-short-circuit-error.js';
24
40
  import { protocolAgnosticKey } from './protocol-agnostic-key.js';
41
+ import { redirectDestKey } from './redirect-dest-key.js';
42
+ import { resourceToPageData } from './resource-to-page-data.js';
25
43
  import { RobotsChecker } from './robots-checker.js';
44
+ import { shouldBurnHost } from './should-burn-host.js';
26
45
  import { shouldDiscardPredicted } from './should-discard-predicted.js';
27
46
  import { shouldSkipUrl } from './should-skip-url.js';
47
+ /**
48
+ * Per-attempt HEAD pre-flight timeouts in milliseconds.
49
+ *
50
+ * `retryCall` re-invokes the work function up to `retry + 1` times; we keep
51
+ * the first attempt short so a fast healthy site never pays the slow-server
52
+ * tax, then escalate so that a slow-but-eventually-responsive host gets a
53
+ * larger budget on retry. The attempt index is clamped to the last element
54
+ * of the array, so configurations with `retry > escalation.length - 1` just
55
+ * stay on the final (longest) timeout for any additional attempts.
56
+ */
57
+ const HEAD_TIMEOUT_ESCALATION_MS = [10_000, 30_000, 60_000];
28
58
  /**
29
59
  * The core crawler engine that discovers and scrapes web pages.
30
60
  *
@@ -43,8 +73,18 @@ class Crawler extends EventEmitter {
43
73
  #linkList = new LinkList();
44
74
  /** Merged crawler configuration (user overrides + defaults). */
45
75
  #options;
76
+ /**
77
+ * Phase errors observed during {@link Crawler._launchBrowserAndScrape},
78
+ * buffered per URL href so they can be emitted as `pageError` events
79
+ * AFTER the corresponding `page` / `externalPage` event. This ordering
80
+ * lets the orchestrator's WriteQueue serialise `setPage` before
81
+ * `insertPageError`, so the FK resolution via URL always finds the row.
82
+ */
83
+ #pendingPhaseErrors = new Map();
46
84
  /** Set of resource URLs (without hash) already captured, for deduplication. */
47
85
  #resources = new Set();
86
+ /** Number of HTML pages (isTarget=1) scraped in previous sessions, used to seed the progress counter on resume. */
87
+ #resumedPagesScraped = 0;
48
88
  /** URLs restored from a previous session that still need to be scraped. */
49
89
  #resumedPending = [];
50
90
  /** URLs already scraped in a previous session, used to populate the `seen` set in {@link #runDeal}. */
@@ -53,6 +93,27 @@ class Crawler extends EventEmitter {
53
93
  #robotsChecker;
54
94
  /** Maps hostnames to their scope URLs. Defines the crawl boundary for internal/external classification. */
55
95
  #scope = new Map();
96
+ /**
97
+ * Protocol-agnostic keys of redirect destinations already rendered (and stored)
98
+ * during this crawl. When many URLs redirect to one destination, only the first
99
+ * renders it; the rest record the redirect edge and skip the browser (#73).
100
+ * Keyed by {@link redirectDestKey}. Reset at the start of {@link #runDeal}.
101
+ */
102
+ #scrapedDestinations = new Set();
103
+ /**
104
+ * Lower-cased hostnames for which at least one URL has returned an
105
+ * HTTP response (any status) via `fetchDestination` in this session.
106
+ * Consulted by {@link shouldBurnHost} as the cascade guard against
107
+ * "transient local DNS hiccup wipes out a healthy host": a host that
108
+ * responded earlier is treated as still alive even when the next URL on
109
+ * it exhausts retries with a `getaddrinfo ENOTFOUND`, since the most
110
+ * likely cause is the operator's resolver flipping mid-crawl rather than
111
+ * the host suddenly disappearing. Populated by {@link #sendHeadRequest}
112
+ * on the success path; reset at the start of {@link #runDeal} alongside
113
+ * {@link #scrapedDestinations} so a fresh session does not inherit
114
+ * stale liveness assumptions.
115
+ */
116
+ #successfulHosts = new Set();
56
117
  /**
57
118
  * The AbortSignal associated with this crawler's AbortController.
58
119
  *
@@ -87,6 +148,9 @@ class Crawler extends EventEmitter {
87
148
  verbose: options?.verbose ?? false,
88
149
  userAgent: options?.userAgent || `Nitpicker/${pkg.version}`,
89
150
  ignoreRobots: options?.ignoreRobots ?? false,
151
+ lookupResource: options?.lookupResource ?? null,
152
+ lookupPageSource: options?.lookupPageSource ?? null,
153
+ inventoryMode: options?.inventoryMode ?? null,
90
154
  };
91
155
  this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
92
156
  for (const urlStr of this.#options.roots) {
@@ -126,10 +190,14 @@ class Crawler extends EventEmitter {
126
190
  * @param pending - URLs that were pending (not yet scraped) in the previous session.
127
191
  * @param scraped - URLs that were already scraped in the previous session.
128
192
  * @param resources - Resource URLs that were already captured in the previous session.
193
+ * @param pagesScrapedOffset - Number of HTML pages already rendered in previous
194
+ * sessions, used to seed the session-spanning progress counter. Defaults to 0
195
+ * for callers that don't need cross-session accuracy in the progress display.
129
196
  */
130
- resume(pending, scraped, resources) {
197
+ resume(pending, scraped, resources, pagesScrapedOffset = 0) {
131
198
  this.#resumedPending = this.#linkList.resume(pending, scraped, this.#options);
132
199
  this.#resumedScraped = scraped;
200
+ this.#resumedPagesScraped = pagesScrapedOffset;
133
201
  for (const resource of resources) {
134
202
  this.#resources.add(resource);
135
203
  }
@@ -147,21 +215,27 @@ class Crawler extends EventEmitter {
147
215
  * before reaching the dealer so a URL that exists in both sources — which
148
216
  * is common in append-mode when a new root coincides with a repromoted
149
217
  * previously-external page — does not race on two parallel slots.
150
- * @param urls - The list of root URLs to begin crawling from. Must be non-empty.
218
+ * @param urls - The list of root URLs to begin crawling from. May be empty
219
+ * when resumed pending URLs already exist (for example `--retry-failed`).
151
220
  * @param opts - Optional overrides; currently only `recursive` is honoured.
152
221
  * @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
153
222
  * Defaults to the constructor option's `recursive` value.
154
223
  * @throws {Error} If the URL list is empty.
155
224
  */
156
225
  start(urls, opts) {
157
- const root = urls[0];
158
- if (!root) {
159
- throw new Error('urls is empty');
160
- }
226
+ // Inventory mode pre-loads tens of thousands of seed URLs that all
227
+ // fall under archived `roots` (already populated into `#scope` by
228
+ // the constructor). Adding each seed as its own scope entry was
229
+ // O(N²) on build (per-host `existing.some` + array spread) AND
230
+ // turned every later `findScopeEntry` into a 70k linear scan. Skip
231
+ // the scope add — seeds remain entry points via `#linkList`.
232
+ const skipScopeAdd = this.#options.inventoryMode != null;
161
233
  for (const url of urls) {
162
- const existing = this.#scope.get(url.hostname) || [];
163
- if (!existing.some((u) => u.href === url.href)) {
164
- this.#scope.set(url.hostname, [...existing, url]);
234
+ if (!skipScopeAdd) {
235
+ const existing = this.#scope.get(url.hostname) || [];
236
+ if (!existing.some((u) => u.href === url.href)) {
237
+ this.#scope.set(url.hostname, [...existing, url]);
238
+ }
165
239
  }
166
240
  this.#linkList.add(url);
167
241
  }
@@ -170,7 +244,12 @@ class Crawler extends EventEmitter {
170
244
  this.#options.recursive = false;
171
245
  this.#options.fromList = true;
172
246
  }
173
- const isResuming = this.#resumedScraped.length > 0;
247
+ // A resume can have an empty scraped set — e.g. a crawl interrupted before
248
+ // any page finished, or a `--retry-failed` run where every page in the
249
+ // archive was a failure and got reset to pending. Keying purely on
250
+ // `#resumedScraped` would then mistake the session for a fresh crawl and
251
+ // drop every resumed pending URL, so honour the pending set too.
252
+ const isResuming = this.#resumedScraped.length > 0 || this.#resumedPending.length > 0;
174
253
  // Dedupe by the same protocol-agnostic key the dealer uses internally.
175
254
  // Append-mode in particular can put the same URL into both
176
255
  // `#resumedPending` (via `repromoteExternalPages`) and `urls` (the
@@ -185,18 +264,43 @@ class Crawler extends EventEmitter {
185
264
  seenInitial.add(key);
186
265
  initialUrls.push(url);
187
266
  }
188
- const resumeOffset = this.#resumedScraped.length;
189
- if (initialUrls.length === 0) {
190
- crawlerLog('Crawl End (nothing to resume)');
191
- void this.emit('crawlEnd', {});
192
- return;
267
+ const root = initialUrls[0];
268
+ if (!root) {
269
+ if (isResuming) {
270
+ crawlerLog('Crawl End (nothing to resume)');
271
+ void this.emit('crawlEnd', {});
272
+ return;
273
+ }
274
+ throw new Error('urls is empty');
193
275
  }
194
- void this.#runDeal(initialUrls, resumeOffset).catch((error) => {
276
+ const resumeOffset = this.#resumedScraped.length;
277
+ const pagesScrapedOffset = this.#resumedPagesScraped;
278
+ void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset).catch((error) => {
195
279
  crawlerLog('runDeal error: %O', error);
196
280
  this.#emitDealErrors(error, root.href);
197
281
  void this.emit('crawlEnd', {});
198
282
  });
199
283
  }
284
+ /**
285
+ * Thin instance-bound adapter over {@link drainPhaseErrors}. Flushes
286
+ * `#pendingPhaseErrors` for `url` as `pageError` events. Idempotent.
287
+ *
288
+ * **Test gap (known)**: this adapter is invoked from the worker body in
289
+ * {@link Crawler.#runDeal} at three call sites — after `#handleResult`,
290
+ * inside the worker's `catch`, and via `logUndrainedPhaseErrors` in
291
+ * `finally`. The drain logic itself is unit-tested in
292
+ * `drain-phase-errors.spec.ts`; the wiring (whether the worker actually
293
+ * calls it on each path) is verified by code review only, because
294
+ * driving the worker requires a Puppeteer + beholder mock stack whose
295
+ * cost outweighs the regression it would catch.
296
+ * @param url - URL whose buffered errors should be flushed.
297
+ * @param isExternal - Whether the URL is external to the crawl scope.
298
+ */
299
+ #drainPhaseErrors(url, isExternal) {
300
+ drainPhaseErrors(this.#pendingPhaseErrors, url.href, isExternal, (payload) => {
301
+ void this.emit('pageError', payload);
302
+ });
303
+ }
200
304
  /**
201
305
  * Emits error events for a deal-level failure.
202
306
  *
@@ -222,19 +326,22 @@ class Crawler extends EventEmitter {
222
326
  * Processes captured sub-resources from a page scrape, deduplicates them,
223
327
  * and emits `response` / `responseReferrers` events for new resources.
224
328
  * @param resources - Sub-resource entries captured during the page load
329
+ * @param parentSource
225
330
  */
226
- #handleResources(resources) {
227
- for (const { resource, pageUrl } of resources) {
228
- const { isNew } = handleResourceResponse(resource, this.#resources);
229
- if (isNew) {
230
- void this.emit('response', {
231
- resource: resource,
232
- });
233
- }
234
- void this.emit('responseReferrers', {
235
- url: pageUrl,
236
- src: resource.url.withoutHash,
237
- });
331
+ #handleResources(resources, parentSource) {
332
+ // Decide the full emit plan first via the pure planner — that lets
333
+ // the lineage propagation contract (parent source → sub-resource
334
+ // `source`) be unit-tested in `plan-sub-resource-emits.spec.ts`
335
+ // without spinning up the puppeteer stack here. The previous
336
+ // inline shape made the `source` value invisible to tests because
337
+ // emit() side effects were only observable via a full scrape run
338
+ // that requires a mocked Chromium instance.
339
+ const { responseEmits, referrerEmits } = planSubResourceEmits(resources, parentSource, this.#resources);
340
+ for (const payload of responseEmits) {
341
+ void this.emit('response', payload);
342
+ }
343
+ for (const payload of referrerEmits) {
344
+ void this.emit('responseReferrers', payload);
238
345
  }
239
346
  }
240
347
  /**
@@ -246,20 +353,22 @@ class Crawler extends EventEmitter {
246
353
  * - `error`: Creates a fallback PageData, marks as done, and emits `error`.
247
354
  * @param result - The scrape result from beholder
248
355
  * @param url - The URL that was scraped
249
- * @param push - Dealer's push callback to enqueue newly discovered URLs
356
+ * @param enqueue - Callback to enqueue newly discovered URLs into the dealer
357
+ * queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}).
358
+ * Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order.
250
359
  * @param paginationState - Mutable state for predicted pagination cascade prevention
251
360
  * @param paginationState.lastPushedUrl
252
361
  * @param paginationState.lastPushedWasPredicted
253
362
  * @param concurrency - Current concurrency level, used to determine predicted URL count
254
363
  */
255
- #handleResult(result, url, push, paginationState, concurrency) {
364
+ #handleResult(result, url, enqueue, paginationState, concurrency) {
256
365
  switch (result.type) {
257
366
  case 'success': {
258
367
  if (!result.pageData)
259
368
  break;
260
369
  handleScrapeEnd(result.pageData, this.#linkList, this.#scope, this.#options, (newUrl, opts) => {
261
370
  this.#linkList.add(newUrl, opts);
262
- void push(newUrl);
371
+ void enqueue(newUrl);
263
372
  // Predicted pagination detection
264
373
  if (!paginationState || !concurrency)
265
374
  return;
@@ -276,13 +385,18 @@ class Crawler extends EventEmitter {
276
385
  const pattern = detectPaginationPattern(paginationState.lastPushedUrl, newUrl.withoutHashAndAuth);
277
386
  if (pattern) {
278
387
  const urls = generatePredictedUrls(pattern, newUrl.withoutHashAndAuth, concurrency);
388
+ const specUrls = [];
279
389
  for (const specUrlStr of urls) {
280
390
  const specUrl = parseUrl(specUrlStr, this.#options);
281
391
  if (specUrl) {
282
392
  this.#linkList.add(specUrl, { predicted: true });
283
- void push(specUrl);
393
+ specUrls.push(specUrl);
284
394
  }
285
395
  }
396
+ // Enqueue as one batch so ascending page order is kept
397
+ // at the front of the queue (see enqueue in #runDeal).
398
+ if (specUrls.length > 0)
399
+ void enqueue(...specUrls);
286
400
  paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
287
401
  paginationState.lastPushedWasPredicted = true;
288
402
  return;
@@ -291,11 +405,20 @@ class Crawler extends EventEmitter {
291
405
  paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
292
406
  paginationState.lastPushedWasPredicted = false;
293
407
  });
294
- if (result.pageData.isExternal) {
295
- void this.emit('externalPage', { result: result.pageData });
296
- }
297
- else {
298
- void this.emit('page', { result: result.pageData });
408
+ {
409
+ const pageSource = derivePageSource(this.#options.inventoryMode, result.pageData.url.withoutHashAndAuth);
410
+ if (result.pageData.isExternal) {
411
+ void this.emit('externalPage', {
412
+ result: result.pageData,
413
+ source: pageSource,
414
+ });
415
+ }
416
+ else {
417
+ void this.emit('page', {
418
+ result: result.pageData,
419
+ source: pageSource,
420
+ });
421
+ }
299
422
  }
300
423
  break;
301
424
  }
@@ -324,11 +447,15 @@ class Crawler extends EventEmitter {
324
447
  }, this.#linkList, this.#scope, this.#options);
325
448
  const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
326
449
  if (pageResult) {
450
+ const pageSource = derivePageSource(this.#options.inventoryMode, pageResult.url.withoutHashAndAuth);
327
451
  if (pageResult.isExternal) {
328
- void this.emit('externalPage', { result: pageResult });
452
+ void this.emit('externalPage', {
453
+ result: pageResult,
454
+ source: pageSource,
455
+ });
329
456
  }
330
457
  else {
331
- void this.emit('page', { result: pageResult });
458
+ void this.emit('page', { result: pageResult, source: pageSource });
332
459
  }
333
460
  }
334
461
  void this.emit('error', {
@@ -355,68 +482,60 @@ class Crawler extends EventEmitter {
355
482
  * @param headCheckResult - Optional HEAD result to pass to the scraper, avoiding a redundant request
356
483
  * @returns The scrape result from beholder
357
484
  */
358
- async #launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult) {
359
- update('Launching browser%dots%');
360
- if (this.#options.executablePath) {
361
- const execPath = path.resolve(this.#options.executablePath);
362
- if (!existsSync(execPath)) {
363
- throw new Error(`Executable path does not exist: ${execPath}`);
364
- }
485
+ /**
486
+ * @param url
487
+ * @param update
488
+ * @param isExternal
489
+ * @param metadataOnly
490
+ * @param headCheckResult
491
+ * @internal
492
+ * cascade-guard contract for the puppeteer-fallback success / skipped
493
+ * branches can be exercised via `vi.spyOn(Crawler.prototype,
494
+ * '_launchBrowserAndScrape')` in unit tests. There is no production
495
+ * consumer outside this class.
496
+ */
497
+ /**
498
+ * Resolve the source label of the page being scraped so sub-resources
499
+ * captured during its render can inherit the correct lineage label
500
+ * (`'inventory-discovered'` when the parent is in the inventory chain,
501
+ * `undefined` otherwise so the DB DEFAULT `'crawled'` lands).
502
+ *
503
+ * Two-stage resolution:
504
+ *
505
+ * 1. If `inventoryMode` is active (live `--inventory` session), use
506
+ * `derivePageSource` directly — the in-memory seed set is the
507
+ * authoritative answer and no DB round-trip is needed.
508
+ *
509
+ * 2. Otherwise (`--resume`, `--retry-failed`, `--append`, or a normal
510
+ * `crawl` of a previously-inventoried archive), ask the injected
511
+ * `lookupPageSource` callback. The orchestrator wires that callback
512
+ * to `Archive.getPageSourceByUrl` so the parent's lineage from
513
+ * earlier sessions survives across sessions.
514
+ *
515
+ * One round-trip per page render at most — the result is not memoised
516
+ * because each worker scrapes a single page per `#scrapePage` call
517
+ * and the cost is amortised across every sub-resource of that page.
518
+ * @param url - The URL of the page being scraped.
519
+ * @returns The parent page's source, or `undefined` when none applies.
520
+ */
521
+ async #resolveParentSource(url) {
522
+ const fromInventoryMode = derivePageSource(this.#options.inventoryMode, url.withoutHashAndAuth);
523
+ if (fromInventoryMode !== undefined) {
524
+ return fromInventoryMode;
525
+ }
526
+ const lookupPageSource = this.#options.lookupPageSource;
527
+ if (!lookupPageSource) {
528
+ return undefined;
365
529
  }
366
- const puppeteer = await import('puppeteer');
367
- const browser = await puppeteer.launch({
368
- headless: true,
369
- ...(this.#options.executablePath
370
- ? { executablePath: this.#options.executablePath }
371
- : {}),
372
- });
373
530
  try {
374
- update('Creating page%dots%');
375
- const page = await browser.newPage();
376
- await page.setUserAgent(this.#options.userAgent);
377
- // Defence-in-depth: beholder sets Authorization via setExtraHTTPHeaders,
378
- // but page.authenticate() handles Chromium-level HTTP auth challenges
379
- // (401 + WWW-Authenticate) that setExtraHTTPHeaders cannot cover.
380
- if (url.username && url.password) {
381
- await page.authenticate({
382
- username: url.username,
383
- password: url.password,
384
- });
385
- }
386
- const scraper = new Scraper();
387
- scraper.on('changePhase', (e) => {
388
- const msg = formatPhaseLog(e);
389
- if (msg) {
390
- update(msg);
391
- }
392
- void this.emit('changePhase', e);
393
- });
394
- const result = await scraper.scrapeStart(page, url, {
395
- isExternal,
396
- captureImages: !isExternal && this.#options.captureImages,
397
- excludeKeywords: this.#options.excludeKeywords,
398
- disableQueries: this.#options.disableQueries,
399
- metadataOnly,
400
- retries: this.#options.retry,
401
- headCheckResult,
402
- });
403
- update('Closing browser%dots%');
404
- return result;
531
+ return await lookupPageSource(url.withoutHashAndAuth);
405
532
  }
406
533
  catch (error) {
407
- return {
408
- type: 'error',
409
- resources: [],
410
- error: {
411
- name: error instanceof Error ? error.name : 'Error',
412
- message: error instanceof Error ? error.message : String(error),
413
- stack: error instanceof Error ? error.stack : undefined,
414
- shutdown: true,
415
- },
416
- };
417
- }
418
- finally {
419
- await browser.close().catch(() => { });
534
+ // A lookup failure must never be worse than not having lineage
535
+ // — fall back to undefined so the sub-resources land at the DB
536
+ // DEFAULT `'crawled'` rather than crashing the whole worker.
537
+ crawlerLog('Parent source lookup failed for %s: %O', url.href, error);
538
+ return undefined;
420
539
  }
421
540
  }
422
541
  /**
@@ -429,16 +548,29 @@ class Crawler extends EventEmitter {
429
548
  * @param initialUrls - Starting URLs to seed the deal queue
430
549
  * @param resumeOffset - Number of URLs already scraped in a previous session,
431
550
  * added to the progress counter for accurate display
551
+ * @param pagesScrapedOffset - Number of HTML pages already rendered in previous
552
+ * sessions, used to seed the per-session HTML-pages counter so the display
553
+ * remains accurate across resumes
432
554
  */
433
- async #runDeal(initialUrls, resumeOffset = 0) {
555
+ async #runDeal(initialUrls, resumeOffset = 0, pagesScrapedOffset = 0) {
434
556
  const seen = new Set(initialUrls.map((u) => protocolAgnosticKey(u.withoutHashAndAuth)));
435
557
  // Add scraped URLs to seen to prevent re-processing during resume
436
558
  for (const url of this.#resumedScraped) {
437
559
  seen.add(protocolAgnosticKey(url));
438
560
  }
561
+ // Redirect-destination dedup is per-crawl; clear any state from a prior run.
562
+ this.#scrapedDestinations.clear();
563
+ // Session-liveness signal is per-crawl too; clear so a fresh session
564
+ // does not inherit "host alive" claims from a prior run that may have
565
+ // happened on an entirely different network.
566
+ this.#successfulHosts.clear();
439
567
  // external URL の追跡(target は deal の total/done から導出)
440
568
  const externalUrls = new Set();
441
569
  const externalDoneUrls = new Set();
570
+ // HTML ページとしてブラウザでレンダリングし、かつアーカイブに保存されたページ数。
571
+ // HEAD のみ・title 取得のみ・skip・ブラウザ起動失敗・predicted-discard は含まない。
572
+ // 過去セッションぶんは pagesScrapedOffset として init される。
573
+ let pagesScraped = pagesScrapedOffset;
442
574
  // 初期 URL を分類(onPush を通らないため)
443
575
  for (const url of initialUrls) {
444
576
  if (findScopeEntry(url, this.#scope, this.#options) === null) {
@@ -453,7 +585,7 @@ class Crawler extends EventEmitter {
453
585
  lastPushedUrl: null,
454
586
  lastPushedWasPredicted: false,
455
587
  };
456
- await deal(initialUrls, (url, update, _index, setLineHeader, push) => {
588
+ await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
457
589
  const matchedScope = findScopeEntry(url, this.#scope, this.#options);
458
590
  const isExternal = matchedScope === null;
459
591
  const urlText = isExternal ? c.dim(url.href) : c.cyan(url.href);
@@ -463,8 +595,44 @@ class Crawler extends EventEmitter {
463
595
  }
464
596
  this.#linkList.add(url);
465
597
  this.#linkList.progress(url);
598
+ // Likely-HTML URLs jump to the front of the queue (unshift) so page
599
+ // crawling advances ahead of asset/document fetches; everything else
600
+ // is appended (push). partitionUrlsByHtml splits the batch by the
601
+ // URL-only heuristic. Variadic so a batch (e.g. predicted pagination)
602
+ // keeps its order: a single unshift(...html) preserves ascending order
603
+ // at the front, whereas unshifting one-by-one would reverse it.
604
+ const enqueue = (...newUrls) => {
605
+ const [html, other] = partitionUrlsByHtml(newUrls);
606
+ const ops = [];
607
+ if (html.length > 0)
608
+ ops.push(unshift(...html));
609
+ if (other.length > 0)
610
+ ops.push(push(...other));
611
+ return Promise.all(ops).then(() => { });
612
+ };
466
613
  return async () => {
614
+ // Interval delay is handled here instead of by dealer because
615
+ // DNS-burned hosts must skip the wait entirely. Spending the
616
+ // per-URL interval on a host the cache already knows is dead
617
+ // just slows the crawl down for zero benefit — the HEAD won't
618
+ // be fired and `Crawler.#sendHeadRequest` will throw the
619
+ // preload short-circuit immediately. For all other URLs, run
620
+ // the same `delay()` + `%countdown(...)` log that dealer would
621
+ // have emitted, so the dealer display reads identically.
622
+ const burned = dnsBurnedHostCache.has(url.hostname.toLowerCase());
623
+ if (!burned && this.#options.interval && this.#options.interval > 0) {
624
+ await delay(this.#options.interval, (determinedInterval) => {
625
+ update(`Waiting interval: %countdown(${determinedInterval},${_index}_interval)%ms`);
626
+ });
627
+ }
467
628
  const log = createTimedUpdate(update, this.#options.verbose);
629
+ // `#scrapePage` 内のブラウザ HTML レンダーが成功したかをマークするフラグ。
630
+ // 成功時のみ #scrapePage 側で true に設定される。
631
+ // discard 判定後にこのフラグを見てカウントするので、launch 失敗や predicted-discard は除外される。
632
+ let renderedInBrowser = false;
633
+ const markBrowserScrape = () => {
634
+ renderedInBrowser = true;
635
+ };
468
636
  try {
469
637
  const robotsAllowed = await this.#robotsChecker.isAllowed(url);
470
638
  if (!robotsAllowed) {
@@ -496,24 +664,112 @@ class Crawler extends EventEmitter {
496
664
  isLowerLayer: false,
497
665
  });
498
666
  this.#linkList.done(url, this.#scope, { page: pageData }, this.#options);
499
- void this.emit('externalPage', { result: pageData });
667
+ void this.emit('externalPage', {
668
+ result: pageData,
669
+ source: derivePageSource(this.#options.inventoryMode, url.withoutHashAndAuth),
670
+ });
500
671
  log(c.dim('External (skip fetch)'));
501
672
  return;
502
673
  }
503
674
  const metadataOnly = this.#linkList.isMetadataOnly(url.withoutHash);
504
675
  const isPredicted = this.#linkList.isPredicted(url.withoutHashAndAuth);
505
676
  log('Scraping%dots%');
506
- const result = await this.#scrapePage(url, log, metadataOnly, _index);
677
+ const result = await this.#scrapePage(url, log, metadataOnly, _index, markBrowserScrape);
678
+ // Redirect convergence (#73): the destination was already
679
+ // rendered during this crawl, so only the redirect edge is
680
+ // recorded and the browser was never launched. Mark the URL
681
+ // done and emit `redirect` (routed to `Archive.setRedirect`,
682
+ // which writes the edge without touching the destination's
683
+ // content). This URL does not count toward pagesScraped.
684
+ if (result.type === 'redirect-edge') {
685
+ // Note: a predicted (speculative) URL that reaches here genuinely
686
+ // redirects (the server returned 3xx), so it is a real URL — we
687
+ // record its edge rather than discard it. This matches the render
688
+ // path, where the first predicted source to a destination renders
689
+ // it and is recorded as a redirect source the same way; only 404 /
690
+ // error predicted URLs are dropped (by `shouldDiscardPredicted`).
691
+ //
692
+ // The `source` discriminator divides this branch in two:
693
+ //
694
+ // - `'http-chain'` — the HEAD pre-flight resolved a real 3xx chain
695
+ // and the destination is already rendered (`#scrapedDestinations`
696
+ // claim). Every URL in `redirectPaths` is intermediate / known,
697
+ // so the existing behaviour applies: `linkList.done` folds the
698
+ // whole chain into the done-set so later references skip cleanly.
699
+ //
700
+ // - `'js-redirect'` — `scraper.scrapeStart` threw because
701
+ // `page.goto()` returned null (`window.location.replace()` /
702
+ // meta-refresh fired mid-navigation), and `redirectPaths`
703
+ // carries the single JS target Chromium ended up on. That target
704
+ // has NOT been rendered yet — it must enter the crawl queue, and
705
+ // `linkList.done` MUST NOT fold it into the done-set (otherwise
706
+ // the dealer's `seen` rejects the push and the destination is
707
+ // silently lost from the archive).
708
+ if (result.source === 'js-redirect') {
709
+ const destination = result.pageData.redirectPaths.at(-1);
710
+ if (destination) {
711
+ const destinationUrl = parseUrl(destination, this.#options);
712
+ if (destinationUrl) {
713
+ this.#linkList.add(destinationUrl);
714
+ void enqueue(destinationUrl);
715
+ }
716
+ else {
717
+ // `deriveJsRedirectTarget` already canonicalises
718
+ // via WHATWG URL parsing, so reaching the
719
+ // `parseUrl === null` branch here would mean
720
+ // `@d-zero/shared/parse-url` rejected what
721
+ // WHATWG accepted — unexpected, and silently
722
+ // dropping the destination would be a silent
723
+ // archive loss. Log it so DEBUG=Nitpicker:Crawler
724
+ // catches the case.
725
+ crawlerLog('JS-redirect destination %s failed to parse — dropping enqueue', destination);
726
+ }
727
+ }
728
+ else {
729
+ crawlerLog('JS-redirect result for %s had no redirectPaths destination — dropping enqueue', url.href);
730
+ }
731
+ this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options, { includeRedirectPaths: false });
732
+ }
733
+ else {
734
+ this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options);
735
+ }
736
+ // The redirect-edge call path may INSERT a brand-new
737
+ // destination row (js-redirect rescue, #73
738
+ // convergence on first sight). Forward the
739
+ // originating page's inventory provenance so the
740
+ // destination + intermediate hops inherit the
741
+ // chain's lineage instead of laundering to DB
742
+ // DEFAULT `'crawled'`. `inventoryMode === null`
743
+ // (resume / retry-failed) yields `undefined`,
744
+ // which is correct: the DB-side lookup in
745
+ // `#linkRedirectSources` reads the destination's
746
+ // stored source for those sessions.
747
+ void this.emit('redirect', buildRedirectEvent(result.pageData, this.#options.inventoryMode, url.withoutHashAndAuth));
748
+ log(c.dim('Redirect (dest already scraped)'));
749
+ return;
750
+ }
507
751
  // Discard predicted URLs that failed (404, error, etc.)
508
752
  if (isPredicted && shouldDiscardPredicted(result)) {
509
753
  handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
510
754
  log(c.dim('Predicted (discarded)'));
511
755
  return;
512
756
  }
757
+ // Count only after discard check: rendered HTML pages that
758
+ // will be persisted to the archive. Launch failures bypass
759
+ // this point via the catch block; discarded predicted URLs
760
+ // return above without reaching here.
761
+ if (renderedInBrowser) {
762
+ pagesScraped++;
763
+ }
513
764
  log('Saving results%dots%');
514
- this.#handleResult(result, url, push, paginationState, concurrency);
515
- this.#handleResources(result.resources);
765
+ this.#handleResult(result, url, enqueue, paginationState, concurrency);
766
+ const parentSource = await this.#resolveParentSource(url);
767
+ this.#handleResources(result.resources, parentSource);
516
768
  log(formatResultSummary(result));
769
+ // Phase errors must be emitted AFTER 'page' / 'externalPage'
770
+ // so the orchestrator's WriteQueue sees `setPage` before
771
+ // `insertPageError` and the URL→pageId resolution succeeds.
772
+ this.#drainPhaseErrors(url, isExternal);
517
773
  }
518
774
  catch (error) {
519
775
  crawlerLog('Worker error for %s: %O', url.href, error);
@@ -532,16 +788,29 @@ class Crawler extends EventEmitter {
532
788
  isExternal,
533
789
  error: workerError,
534
790
  });
791
+ // Hard-error path: persist whatever phase errors we have
792
+ // already buffered so they are not lost.
793
+ this.#drainPhaseErrors(url, isExternal);
535
794
  }
536
795
  finally {
537
796
  if (isExternal) {
538
797
  externalDoneUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
539
798
  }
799
+ // Phase errors still in the buffer here were not drained
800
+ // by the success or catch paths — typically because a
801
+ // predicted URL was discarded before reaching the drain
802
+ // point. The helper logs the drop (observable via
803
+ // DEBUG=Nitpicker:Crawler) and removes the entry so the
804
+ // Map cannot leak across crawls.
805
+ logUndrainedPhaseErrors(this.#pendingPhaseErrors, url.href, crawlerLog);
540
806
  }
541
807
  };
542
808
  }, {
543
809
  limit: concurrency,
544
- interval: this.#options.interval,
810
+ // Interval is applied per-URL inside the worker callback above so
811
+ // DNS-burned hosts can skip it. Letting dealer handle interval
812
+ // would run the wait before our short-circuit check fires.
813
+ interval: 0,
545
814
  verbose: this.#options.verbose || !process.stdout.isTTY,
546
815
  signal: this.#abortController.signal,
547
816
  header: (_progress, done, total, limit) => {
@@ -551,6 +820,7 @@ class Crawler extends EventEmitter {
551
820
  resumeOffset,
552
821
  externalTotal: externalUrls.size,
553
822
  externalDone: externalDoneUrls.size,
823
+ pagesScraped,
554
824
  limit,
555
825
  });
556
826
  },
@@ -581,13 +851,55 @@ class Crawler extends EventEmitter {
581
851
  * @param update - Callback for progress messages
582
852
  * @param metadataOnly - When true, only extract title metadata without full browser scraping
583
853
  * @param laneIndex - The dealer lane index, used to create unique countdown IDs
854
+ * @param markBrowserScrape - Called once **after** the browser successfully
855
+ * renders an HTML page (i.e. `_launchBrowserAndScrape` resolved with
856
+ * `type: 'success'`). Not called for HEAD-only, title-only, captured-resource
857
+ * reuse, non-HTML responses, non-HTTP protocols (mailto:, tel:), browser
858
+ * launch throws (e.g. invalid executablePath), or scraper-returned
859
+ * `type: 'error'` results. The caller is responsible for further filtering
860
+ * (e.g. predicted-discard).
584
861
  * @returns The scrape result
585
862
  */
586
- async #scrapePage(url, update, metadataOnly, laneIndex) {
863
+ async #scrapePage(url, update, metadataOnly, laneIndex, markBrowserScrape) {
587
864
  const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
588
865
  // Non-HTTP protocols (mailto:, tel:, etc.) — let the scraper handle early return
589
866
  if (!url.isHTTP) {
590
- return this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly);
867
+ return this._launchBrowserAndScrape(url, update, isExternal, metadataOnly);
868
+ }
869
+ // Reuse captured resource data — when this URL was already observed as a
870
+ // sub-resource during page rendering, its response data is recorded and
871
+ // the HEAD pre-flight is redundant. Only 2xx non-HTML rows are eligible
872
+ // (see resourceToPageData); anything else falls through to the pre-flight.
873
+ // Both URL variants are checked because scope-auth injection adds
874
+ // credentials to queued URLs while browser-captured resource URLs have none.
875
+ // The result is deliberately NOT written to destinationCache: a queued URL
876
+ // is processed at most once (the dealer dedupes by protocol-agnostic key),
877
+ // so a URL that takes this path never reaches fetchDestination again.
878
+ const lookupResource = this.#options.lookupResource;
879
+ if (lookupResource &&
880
+ (this.#resources.has(url.withoutHash) ||
881
+ this.#resources.has(url.withoutHashAndAuth))) {
882
+ update('Checking captured resource%dots%');
883
+ let resource = null;
884
+ try {
885
+ resource = await lookupResource([url.withoutHash, url.withoutHashAndAuth]);
886
+ }
887
+ catch (error) {
888
+ // A lookup failure must never be worse than not having the
889
+ // optimization — fall back to the HEAD pre-flight below.
890
+ crawlerLog('Resource lookup failed for %s, falling back: %O', url.href, error);
891
+ }
892
+ const pageData = resource
893
+ ? resourceToPageData({ url, isExternal, resource })
894
+ : null;
895
+ if (pageData) {
896
+ crawlerLog('Reused captured resource for %s', url.href);
897
+ return {
898
+ type: 'success',
899
+ pageData: metadataOnly ? { ...pageData, isTarget: false } : pageData,
900
+ resources: [],
901
+ };
902
+ }
591
903
  }
592
904
  // Pre-flight: lightweight HEAD request to check server availability
593
905
  update('HEAD request%dots%');
@@ -596,6 +908,131 @@ class Crawler extends EventEmitter {
596
908
  headCheckResult = await this.#sendHeadRequest(url, isExternal, update, laneIndex);
597
909
  }
598
910
  catch (error) {
911
+ const errorMessage = error instanceof Error ? error.message : String(error);
912
+ // Puppeteer-only fallback: when the HEAD pre-flight (and its GET
913
+ // companion inside `fetchDestination`) exhaust retries on what
914
+ // looks like an HTML URL, give the browser exactly one chance
915
+ // before recording the page as `status = -1`. Some middleboxes /
916
+ // WAF configurations drop bare HEAD/GET probes (parse-error,
917
+ // reset, silent timeout) while still answering a real puppeteer
918
+ // navigation; those URLs would otherwise be permanently lost.
919
+ //
920
+ // Restricted to non-metadataOnly scrapes because metadata-only
921
+ // mode is a bandwidth-saving path for external pages — there is
922
+ // no payoff in spinning up puppeteer when the row was never
923
+ // going to be fully rendered. `isPuppeteerFallbackCandidate`
924
+ // filters PreloadShortCircuitError automatically via its
925
+ // classifier check (its synthesised message classifies as `dns`).
926
+ if (!metadataOnly &&
927
+ isLikelyHtmlUrl(url) &&
928
+ isPuppeteerFallbackCandidate(errorMessage)) {
929
+ update(c.yellow('HEAD/GET unreachable — trying puppeteer once'));
930
+ try {
931
+ const fallback = await this._launchBrowserAndScrape(url, update, isExternal, metadataOnly);
932
+ if (fallback.type === 'success') {
933
+ if (fallback.pageData) {
934
+ const renderedKey = redirectDestKey(url, fallback.pageData.redirectPaths);
935
+ this.#scrapedDestinations.add(renderedKey);
936
+ }
937
+ // Puppeteer fallback proved the host is reachable
938
+ // (HEAD/GET probes died at a middlebox / WAF but the
939
+ // real browser navigation got a response). Mark the
940
+ // host alive for the cascade guard — without this, a
941
+ // host whose first URL only succeeded via the
942
+ // browser-rescue path would still be vulnerable to
943
+ // the next URL's HEAD failure burning it.
944
+ this.#successfulHosts.add(url.hostname.toLowerCase());
945
+ markBrowserScrape();
946
+ return fallback;
947
+ }
948
+ if (fallback.type === 'skipped') {
949
+ // Puppeteer rendered the page far enough for the scraper
950
+ // to match an `excludeKeywords` rule. That is a definitive
951
+ // "skip" verdict from the browser, NOT an unreachable
952
+ // host — surface the skip so downstream handling (skip
953
+ // counter, anchor-extraction suppression, `setSkippedPage`
954
+ // in the archive) behaves identically to the case where
955
+ // HEAD had succeeded. Without this branch, the page would
956
+ // be recorded as `status = -1` with the HEAD timeout
957
+ // message — a misleading entry that conflates
958
+ // "operator-intended skip" with "network failure".
959
+ //
960
+ // Skipped also counts as proof-of-life: the browser
961
+ // reached the page far enough to match exclude rules,
962
+ // so the host was clearly responding.
963
+ this.#successfulHosts.add(url.hostname.toLowerCase());
964
+ return fallback;
965
+ }
966
+ // `fallback.type === 'error'`. `_launchBrowserAndScrape`
967
+ // catches its own exceptions and returns
968
+ // `{type:'error', shutdown:...}` rather than throwing, so
969
+ // the `catch` arm below would NOT see this branch. Log
970
+ // the puppeteer-side cause (and any `shutdown` flag the
971
+ // scraper attached) so operators have a breadcrumb that
972
+ // the safety net actually fired and lost — otherwise
973
+ // only the HEAD error reaches `crawl_errors` and the
974
+ // browser failure mode is invisible.
975
+ crawlerLog('Puppeteer fallback returned error for %s: %s (shutdown=%s)', url.href, fallback.error?.message ?? '(no message)', fallback.error?.shutdown ?? false);
976
+ // JS-redirect rescue on the puppeteer-fallback branch:
977
+ // the HEAD/GET probes died (the kind set in
978
+ // `isPuppeteerFallbackCandidate` — middlebox / WAF
979
+ // shapes), the one-shot puppeteer attempt also threw,
980
+ // but `page.url()` reported a different post-navigation
981
+ // URL. This is the same WAF-+-JS-redirect shape the
982
+ // HEAD-success rescue handles one branch below, applied
983
+ // to the prior failure layer. Without this, a URL whose
984
+ // only sin is "HEAD blocked + JS-redirected body" falls
985
+ // to `status = -1` and joins the retry-forever loop the
986
+ // rescue is supposed to break. The trigger is the same
987
+ // narrow `Page.goto returned null` shape — anything
988
+ // else (TLS failure inside puppeteer, target crash, …)
989
+ // must fall through to the unreachable path so the real
990
+ // failure surfaces. We synthesise the redirect-edge
991
+ // PageData from the HEAD error (status = -1) instead of
992
+ // from a HEAD success, so `#linkRedirectSources` still
993
+ // stamps the source as 301 and the edge wires the dest
994
+ // in.
995
+ const fallbackRescue = buildJsRedirectEdge({
996
+ url,
997
+ isExternal,
998
+ errorMessage: fallback.error?.message,
999
+ postNavigationUrl: fallback.postNavigationUrl,
1000
+ // No `headCheckResult`: HEAD itself died on this
1001
+ // path, so the synthesised PageData starts from a
1002
+ // `linkToPageData` placeholder with `status = -1`
1003
+ // carrying the original HEAD error message.
1004
+ // `#linkRedirectSources` still flips the source row
1005
+ // to 301 because NULL/-1 satisfies its conditional
1006
+ // stamp predicate.
1007
+ });
1008
+ if (fallbackRescue !== null) {
1009
+ return fallbackRescue;
1010
+ }
1011
+ }
1012
+ catch (browserError) {
1013
+ // Browser launch / runtime crash — fall through to the
1014
+ // unreachable path below. The original HEAD error is more
1015
+ // informative about WHY the URL wasn't reachable, so it
1016
+ // (not the puppeteer noise) is what we surface in
1017
+ // `crawl_errors`. The lane display flag below (
1018
+ // "Unreachable (fallback failed)") preserves the fact
1019
+ // that puppeteer also tried, so operators reading the
1020
+ // progress log can tell this URL got the safety-net
1021
+ // attempt versus the cheap-probe-only path.
1022
+ crawlerLog('Puppeteer fallback also failed for %s: %O', url.href, browserError);
1023
+ }
1024
+ update(c.red('Unreachable (fallback failed)'));
1025
+ return {
1026
+ type: 'error',
1027
+ resources: [],
1028
+ error: {
1029
+ name: error instanceof Error ? error.name : 'Error',
1030
+ message: errorMessage,
1031
+ stack: error instanceof Error ? error.stack : undefined,
1032
+ shutdown: false,
1033
+ },
1034
+ };
1035
+ }
599
1036
  // Server unreachable — skip browser launch entirely
600
1037
  update(c.red('Unreachable'));
601
1038
  return {
@@ -603,16 +1040,38 @@ class Crawler extends EventEmitter {
603
1040
  resources: [],
604
1041
  error: {
605
1042
  name: error instanceof Error ? error.name : 'Error',
606
- message: error instanceof Error ? error.message : String(error),
1043
+ message: errorMessage,
607
1044
  stack: error instanceof Error ? error.stack : undefined,
608
1045
  shutdown: false,
609
1046
  },
610
1047
  };
611
1048
  }
1049
+ // Redirect convergence (#73): `finalKey` is the destination this URL lands
1050
+ // on after following its redirect chain (or the URL itself when it does not
1051
+ // redirect). When that destination has already been rendered and stored
1052
+ // during this crawl, do NOT process this URL further — record the redirect
1053
+ // edge only and skip everything below, regardless of content type. This is
1054
+ // the root fix for the many-to-one redirect duplication (#70): every source
1055
+ // URL that 301s to one destination otherwise re-renders/re-stores it. The
1056
+ // check sits ABOVE the metadata-only and non-HTML branches on purpose — both
1057
+ // route their HEAD/title result through `updatePage`, which would funnel a
1058
+ // content-less result into `#insertPage` and overwrite the already-rendered
1059
+ // destination's title / meta / isExternal. The edge-only path leaves the
1060
+ // destination row intact.
1061
+ //
1062
+ // `finalKey` is also claimed for destinations reached directly (no redirect;
1063
+ // see the claim after a successful render below), so a destination that is
1064
+ // both linked directly and arrived at via a redirect is rendered by whichever
1065
+ // path wins the race, not both.
1066
+ const finalKey = redirectDestKey(url, headCheckResult.redirectPaths);
1067
+ if (this.#scrapedDestinations.has(finalKey)) {
1068
+ crawlerLog('Redirect dest already rendered, edge only: %s', url.href);
1069
+ return { type: 'redirect-edge', source: 'http-chain', pageData: headCheckResult };
1070
+ }
612
1071
  // Title-only mode — extract <title> via partial GET for HTML, skip browser
613
1072
  if (metadataOnly) {
614
1073
  if (headCheckResult.contentType === null ||
615
- headCheckResult.contentType === 'text/html') {
1074
+ isHtmlContentType(headCheckResult.contentType)) {
616
1075
  update('Fetching title%dots%');
617
1076
  try {
618
1077
  const titleResult = await fetchDestination({
@@ -640,15 +1099,135 @@ class Crawler extends EventEmitter {
640
1099
  }
641
1100
  // Non-HTML content — skip browser
642
1101
  if (headCheckResult.contentType !== null &&
643
- headCheckResult.contentType !== 'text/html') {
1102
+ !isHtmlContentType(headCheckResult.contentType)) {
644
1103
  return {
645
1104
  type: 'success',
646
1105
  pageData: headCheckResult,
647
1106
  resources: [],
648
1107
  };
649
1108
  }
650
- // HTML or unknown content type — launch browser with preflight result
651
- return this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
1109
+ // HTML or unknown content type — launch browser with preflight result.
1110
+ // markBrowserScrape() fires only when the result is `success`.
1111
+ // `_launchBrowserAndScrape` catches internal errors and returns
1112
+ // `{ type: 'error', ... }` instead of throwing (see its catch block),
1113
+ // so awaiting alone does NOT prove the page was rendered. The explicit
1114
+ // success check excludes navigation failures, scraper exceptions, and
1115
+ // shutdown-class errors from the pages-rendered count.
1116
+ const browserResult = await this._launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
1117
+ if (browserResult.type === 'success') {
1118
+ markBrowserScrape();
1119
+ // Claim the destination that was ACTUALLY rendered, keyed off the
1120
+ // browser's own redirect resolution rather than the HEAD pre-flight's
1121
+ // guess (`finalKey`). The browser is authoritative for what got stored;
1122
+ // if HEAD and the browser disagree on the final URL (method-conditional
1123
+ // / JS / meta-refresh redirects), keying the claim off the HEAD guess
1124
+ // would route a sibling source to an edge pointing at a never-rendered
1125
+ // phantom row. By claiming the rendered URL, a divergent sibling simply
1126
+ // re-renders (dedup misses) instead — correct, just less optimal. In the
1127
+ // common case HEAD and the browser agree, so the keys are identical.
1128
+ //
1129
+ // Claimed only after a successful render, so a failed render leaves the
1130
+ // destination unclaimed and a later source retries it. Concurrent
1131
+ // in-flight sources to the same destination (bounded by the concurrency
1132
+ // limit) may still each render before any claim lands; the storage-layer
1133
+ // replace in `updatePage` (#70) keeps the resulting anchors / images
1134
+ // correct (sub-resources may briefly duplicate, far below the pre-#73
1135
+ // once-per-source blow-up).
1136
+ const renderedKey = browserResult.pageData
1137
+ ? redirectDestKey(url, browserResult.pageData.redirectPaths)
1138
+ : finalKey;
1139
+ this.#scrapedDestinations.add(renderedKey);
1140
+ return browserResult;
1141
+ }
1142
+ // Browser scrape failed but the HEAD pre-flight already resolved a
1143
+ // redirect chain — fall back to the redirect-edge path so the chain
1144
+ // is not lost. Without this, a URL whose final destination is on
1145
+ // HTTPS→HTTP downgrade (or any other navigation Chromium refuses
1146
+ // to complete while the underlying redirect was a normal 301/302)
1147
+ // would be persisted as `status = -1` with NULL `redirectDestId`,
1148
+ // then re-picked up by every `--retry-failed` pass forever — the
1149
+ // HEAD answer is the authoritative truth and the browser cannot
1150
+ // invalidate it.
1151
+ //
1152
+ // Restricted to `type === 'error'` because:
1153
+ // - `'skipped'` is an `excludeKeywords` verdict from the browser
1154
+ // on the rendered URL and is its own definitive outcome —
1155
+ // surfacing it as a redirect-edge would lose the skip signal.
1156
+ // - `'success'` is handled above.
1157
+ //
1158
+ // The destination is claimed even though no row was rendered for
1159
+ // it: subsequent siblings on the same chain should also fold into
1160
+ // the same edge instead of re-firing the same failing browser
1161
+ // attempt. If the destination URL itself reaches the queue later,
1162
+ // it goes through the normal `#scrapePage` path (the claim only
1163
+ // short-circuits sibling redirect SOURCES, not the destination
1164
+ // itself).
1165
+ if (browserResult.type === 'error' && headCheckResult.redirectPaths.length > 0) {
1166
+ this.#scrapedDestinations.add(finalKey);
1167
+ crawlerLog('Browser scrape failed for %s but HEAD resolved a redirect chain — recording as edge', url.href);
1168
+ return { type: 'redirect-edge', source: 'http-chain', pageData: headCheckResult };
1169
+ }
1170
+ // JS-redirect rescue: HEAD returned a definitive response (no chain),
1171
+ // the browser scrape threw with the specific `Page.goto returned null`
1172
+ // shape (gated by `isJsRedirectErrorShape` below), and puppeteer
1173
+ // reports a different post-navigation URL via `page.url()`. The
1174
+ // motivating case is a server returning `200 OK` whose body contains
1175
+ // `window.location.replace(...)` or `<meta http-equiv="refresh">` —
1176
+ // `page.goto()` resolves to `null` once the JS-driven navigation
1177
+ // supersedes the original, and the scraper throws
1178
+ // `The method Page.goto returned null`. Recording the edge preserves
1179
+ // the link from the source to the JS-redirect target, removes the
1180
+ // page from `--retry-failed`'s candidate pool (the SQL filter
1181
+ // excludes rows with a non-null `redirectDestId`), and matches what
1182
+ // a real browser shows the user.
1183
+ //
1184
+ // What the source row reads as:
1185
+ // - the source is not committed via `setPage`/`updatePage` on this
1186
+ // path (the redirect-edge handler in `#runDeal` only calls
1187
+ // `linkList.done` + `emit('redirect', ...)` → `Archive.setRedirect`),
1188
+ // so `recordRedirect` → `#getIdByUrl` creates a NULL-status
1189
+ // placeholder row for the source if it did not already exist;
1190
+ // - `#linkRedirectSources` then stamps `status = 301
1191
+ // statusText='Moved Permanently'` because NULL satisfies its
1192
+ // conditional-update predicate.
1193
+ // That is the same shape an HTTP 301 source ends up with — the
1194
+ // truthful HTTP layer (the upstream's 200) is lost on this path, but
1195
+ // the alternative (status=-1 retry-forever) is strictly worse. A
1196
+ // future refinement could keep the HEAD-derived status by routing
1197
+ // the source through `setPage` before `setRedirect`; intentionally
1198
+ // deferred to keep this rescue minimal.
1199
+ //
1200
+ // Pre-claiming the destination in `#scrapedDestinations` would
1201
+ // short-circuit the freshly-enqueued destination at the top of
1202
+ // `#scrapePage` (the `if (#scrapedDestinations.has(finalKey))` guard
1203
+ // at line 1213), leaving the dest row as a content-less HEAD edge
1204
+ // instead of a fully rendered page. So we *do not* claim here — the
1205
+ // destination renders normally via the queue, and `#scrapedDestinations`
1206
+ // is populated at line ~1322 of the render-success path the way every
1207
+ // other URL is. Sibling JS-redirect sources to the same destination
1208
+ // still converge: the second sibling enters this branch, observes its
1209
+ // own `page.url()` landing on the same target, records its own
1210
+ // redirect-edge, and re-enqueues — the dealer's `seen` dedup absorbs
1211
+ // the duplicate push, so the destination renders exactly once.
1212
+ if (browserResult.type === 'error') {
1213
+ const headSuccessRescue = buildJsRedirectEdge({
1214
+ url,
1215
+ isExternal,
1216
+ errorMessage: browserResult.error?.message,
1217
+ postNavigationUrl: browserResult.postNavigationUrl,
1218
+ // `headCheckResult` is supplied here so the synthesised
1219
+ // PageData carries the real HTTP-level status / content
1220
+ // type from the HEAD pre-flight. `#linkRedirectSources`
1221
+ // only stamps 301 onto NULL/-1 status rows, so the
1222
+ // HEAD-derived status DOES survive on this path — the
1223
+ // truthful HTTP 200 is preserved.
1224
+ headCheckResult,
1225
+ });
1226
+ if (headSuccessRescue !== null) {
1227
+ return headSuccessRescue;
1228
+ }
1229
+ }
1230
+ return browserResult;
652
1231
  }
653
1232
  /**
654
1233
  * Performs a pre-flight HTTP HEAD request with retry logic.
@@ -663,17 +1242,244 @@ class Crawler extends EventEmitter {
663
1242
  * @returns Lightweight page data from the HEAD response
664
1243
  */
665
1244
  async #sendHeadRequest(url, isExternal, update, laneIndex) {
666
- return retryCall(() => fetchDestination({ url, isExternal, userAgent: this.#options.userAgent }), {
1245
+ const host = url.hostname.toLowerCase();
1246
+ if (dnsBurnedHostCache.has(host)) {
1247
+ // Either session-learned earlier in this crawl (one URL on this host
1248
+ // already exhausted retries with a DNS error) or preload-seeded from
1249
+ // `crawl_errors` on archive open. Either way: skip the HEAD entirely.
1250
+ // The orchestrator's error-channel listener detects
1251
+ // PreloadShortCircuitError via instanceof and refuses to write it to
1252
+ // `crawl_errors`, preventing self-amplification across crawls.
1253
+ dnsBurnedHostShortCircuitCounter.count++;
1254
+ update(c.red(`HEAD request: host ${host} DNS-burned — skipping`));
1255
+ throw new PreloadShortCircuitError(host);
1256
+ }
1257
+ // Escalating per-attempt timeout: a slow-but-reachable server (e.g. some
1258
+ // government sites under load) often answers in 20-40 s but is missed by
1259
+ // a flat 10 s race on every retry. Start short to keep crawl throughput
1260
+ // up on healthy URLs, then back off so the last attempt is generous
1261
+ // enough that "really slow" gets a fair shot before we give up.
1262
+ let attempt = 0;
1263
+ return retryCall(async () => {
1264
+ // Clamp the attempt index to the last entry of the escalation array
1265
+ // so retry counts past the array length keep using the longest
1266
+ // budget instead of falling off into `undefined`. `as number`
1267
+ // only because TS can't see that a positive-length readonly array
1268
+ // always has a defined last element.
1269
+ const escalationIndex = Math.min(attempt, HEAD_TIMEOUT_ESCALATION_MS.length - 1);
1270
+ const timeoutMs = HEAD_TIMEOUT_ESCALATION_MS[escalationIndex];
1271
+ attempt += 1;
1272
+ const headResult = await fetchDestination({
1273
+ url,
1274
+ isExternal,
1275
+ userAgent: this.#options.userAgent,
1276
+ timeout: timeoutMs,
1277
+ });
1278
+ // Mark host alive the MOMENT an HTTP response is observed,
1279
+ // before retryCall's outer resolution settles. A later attempt
1280
+ // (or a sibling worker's onGiveUp) racing this success would
1281
+ // otherwise see an empty `#successfulHosts` and burn the host
1282
+ // — exactly the cascade the guard is here to prevent. Any HTTP
1283
+ // status counts: the guard cares about DNS-and-TCP reachability,
1284
+ // not application-level success, and `fetchDestination` only
1285
+ // resolves when an HTTP response was actually received.
1286
+ this.#successfulHosts.add(host);
1287
+ return headResult;
1288
+ }, {
667
1289
  retries: this.#options.retry,
668
1290
  label: 'HEAD request',
669
1291
  onWait: (determinedInterval, retryCount, label, error) => {
670
1292
  update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
671
1293
  },
672
1294
  onGiveUp: (retryCount, error, label) => {
1295
+ // Burn the host so subsequent URLs short-circuit — but ONLY
1296
+ // when this is the first time we've ever seen the host fail
1297
+ // in this session. A host that responded earlier is treated
1298
+ // as transiently unreachable (operator's resolver flipped
1299
+ // mid-crawl etc.), not a dead domain. `shouldBurnHost`
1300
+ // encapsulates this decision so the cascade guard is
1301
+ // independently testable. Also gated to `onGiveUp` rather
1302
+ // than `onWait` so an `EAI_AGAIN` that recovers on retry
1303
+ // doesn't trip the guard prematurely.
1304
+ if (shouldBurnHost({
1305
+ errorKind: classifyErrorKind(error.message),
1306
+ host,
1307
+ successfulHosts: this.#successfulHosts,
1308
+ })) {
1309
+ dnsBurnedHostCache.set(host, 'dns');
1310
+ }
673
1311
  update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
674
1312
  },
675
1313
  });
676
1314
  }
1315
+ // eslint-disable-next-line no-restricted-syntax -- intentional `private` (vs `#`) so tests can spyOn the prototype to drive the puppeteer-fallback cascade-guard branches without a full browser mock; see JSDoc above.
1316
+ async _launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult) {
1317
+ update('Launching browser%dots%');
1318
+ if (this.#options.executablePath) {
1319
+ const execPath = path.resolve(this.#options.executablePath);
1320
+ if (!existsSync(execPath)) {
1321
+ throw new Error(`Executable path does not exist: ${execPath}`);
1322
+ }
1323
+ }
1324
+ const puppeteer = await import('puppeteer');
1325
+ const browser = await puppeteer.launch({
1326
+ headless: true,
1327
+ ...(this.#options.executablePath
1328
+ ? { executablePath: this.#options.executablePath }
1329
+ : {}),
1330
+ });
1331
+ // `page` is hoisted out of the try-block so the catch arm can read
1332
+ // `page.url()` for JS-redirect detection. See `BrowserScrapeResult`
1333
+ // JSDoc for the full why; in short, when `scrapeStart` throws because
1334
+ // `page.goto()` returned `null`, the puppeteer page object still
1335
+ // holds the URL Chromium actually navigated to via the offending
1336
+ // `window.location.replace()` / meta-refresh, and that is the only
1337
+ // authoritative source for the JS-redirect destination.
1338
+ let page = null;
1339
+ try {
1340
+ update('Creating page%dots%');
1341
+ page = await browser.newPage();
1342
+ await page.setUserAgent(this.#options.userAgent);
1343
+ // HTTP-auth handling — two cooperating pieces, BOTH required:
1344
+ //
1345
+ // 1. `page.authenticate({user, pass})` (always, even with empty
1346
+ // strings) registers a Fetch-domain auth handler with
1347
+ // Chromium. With empty credentials it ALSO drains Chromium's
1348
+ // native HTTP-auth dialog without sending anything
1349
+ // privileged — the dialog cannot be captured by
1350
+ // `page.on('dialog')` (HTTP-auth is not a JS dialog) and
1351
+ // would otherwise hang the navigation until puppeteer's
1352
+ // timeout fires. With non-empty credentials it provides the
1353
+ // scope's auth so the in-scope navigation succeeds.
1354
+ //
1355
+ // 2. Stripping URL-embedded credentials from the navigation
1356
+ // target. **This is the credential-leak guard.** When the
1357
+ // URL we hand puppeteer carries `user:pass@host`, Chromium
1358
+ // promotes those credentials into its HTTP-auth cache
1359
+ // keyed by (scheme, host, port, realm). Subsequent
1360
+ // sub-resource requests issued from the same page —
1361
+ // including cross-origin requests to a different hostname
1362
+ // sharing the same IP / port (e.g. an embedded
1363
+ // `<img src="http://127.0.0.1:8010/…">` loaded from a
1364
+ // `localhost:8010` page) — get the cached `Authorization`
1365
+ // header re-attached by the network stack. The
1366
+ // `Fetch.authRequired` event never fires for these
1367
+ // pre-emptive attachments, so neither `page.authenticate`
1368
+ // nor any custom Fetch listener can filter them. The only
1369
+ // way to keep the cred out of the cross-origin request is
1370
+ // to make sure it never enters the cache in the first
1371
+ // place — hence stripping the URL before navigation.
1372
+ //
1373
+ // Verified by `scope-auth-leak.e2e.ts`: removing either piece
1374
+ // causes that test to fail (without auth → main 401 hangs;
1375
+ // without strip → scope cred leaks to off-scope sub-resource).
1376
+ await page.authenticate({
1377
+ username: url.username ?? '',
1378
+ password: url.password ?? '',
1379
+ });
1380
+ // Re-parse from `withoutHashAndAuth` rather than mutating the
1381
+ // re-parsed `url.href` object: ExURL pre-computes `href`,
1382
+ // `withoutHash` and other derived strings at parse time, and
1383
+ // post-hoc field assignment (`navigateUrl.username = ''`)
1384
+ // leaves those derived strings stale. Anything downstream that
1385
+ // reads `navigateUrl.href` (e.g. a future beholder bump that
1386
+ // switches `page.goto` from `withoutHashAndAuth` to `href`)
1387
+ // would silently get back the credentialed string — defeating
1388
+ // the leak guard. Building the navigation URL from a known
1389
+ // credential-free string guarantees every field is consistent.
1390
+ const navigateUrl = parseUrl(url.withoutHashAndAuth) ?? url;
1391
+ const scraper = new Scraper();
1392
+ scraper.on('changePhase', createChangePhaseHandler({
1393
+ emit: (event) => void this.emit('changePhase', event),
1394
+ update,
1395
+ formatLog: formatPhaseLog,
1396
+ buffer: this.#pendingPhaseErrors,
1397
+ urlHref: url.href,
1398
+ }));
1399
+ const result = await scraper.scrapeStart(page, navigateUrl, {
1400
+ isExternal,
1401
+ captureImages: !isExternal && this.#options.captureImages,
1402
+ excludeKeywords: this.#options.excludeKeywords,
1403
+ disableQueries: this.#options.disableQueries,
1404
+ metadataOnly,
1405
+ retries: this.#options.retry,
1406
+ headCheckResult,
1407
+ });
1408
+ update('Closing browser%dots%');
1409
+ // JS-redirect rescue capture: when `scrapeStart` catches a
1410
+ // `#fetchData` throw internally (e.g. `Page.goto returned null`
1411
+ // because a client-side `window.location.replace()` /
1412
+ // meta-refresh fired), it returns `{ type: 'error', ... }`
1413
+ // instead of re-throwing — so the `catch` arm below never
1414
+ // sees those cases. Read `page.url()` here while `page` is
1415
+ // still alive (finally still hasn't called `handleBrowserClose`)
1416
+ // and attach it to the result so `#scrapePage` can fold the
1417
+ // source into a redirect edge. Without this capture, the
1418
+ // rescue path is dead for the most common failure shape it
1419
+ // was designed to handle.
1420
+ //
1421
+ // `page.url()` itself can throw when the browser context died
1422
+ // mid-scrape (target crashed, session killed). On failure we
1423
+ // fall through with `postNavigationUrl` unset so the existing
1424
+ // HEAD-chain rescue / normal error path takes over.
1425
+ if (result.type === 'error') {
1426
+ try {
1427
+ const postNavigationUrl = page.url();
1428
+ return { ...result, postNavigationUrl };
1429
+ }
1430
+ catch (urlReadError) {
1431
+ crawlerLog('Reading page.url() for JS-redirect detection failed on %s: %O', url.href, urlReadError);
1432
+ }
1433
+ }
1434
+ return result;
1435
+ }
1436
+ catch (error) {
1437
+ // JS-redirect rescue: when `scrapeStart` throws because
1438
+ // `page.goto()` returned `null` (the symptom of a client-side
1439
+ // `window.location.replace()` / meta-refresh navigating away
1440
+ // before the original response materialised), `page.url()` still
1441
+ // reports the destination Chromium ended up on. Capturing it
1442
+ // here lets `#scrapePage` fold the source into a redirect edge
1443
+ // instead of recording a hard `status = -1` — `Page.goto returned
1444
+ // null` classifies as `protocol`, which is neither permanent nor
1445
+ // a puppeteer-fallback kind, so without this rescue the page
1446
+ // loops through `--retry-failed` forever with the same failure.
1447
+ //
1448
+ // `page.url()` itself can throw when the browser context is
1449
+ // already torn down (target closed, session killed). Treat any
1450
+ // such failure as "no extra information" and fall back to the
1451
+ // normal error path — the existing redirect-edge fallback that
1452
+ // keys off `headCheckResult.redirectPaths` may still rescue the
1453
+ // page when the HEAD pre-flight resolved a chain.
1454
+ let postNavigationUrl;
1455
+ if (page) {
1456
+ try {
1457
+ postNavigationUrl = page.url();
1458
+ }
1459
+ catch (urlReadError) {
1460
+ crawlerLog('Reading page.url() for JS-redirect detection failed on %s: %O', url.href, urlReadError);
1461
+ }
1462
+ }
1463
+ return {
1464
+ type: 'error',
1465
+ resources: [],
1466
+ error: {
1467
+ name: error instanceof Error ? error.name : 'Error',
1468
+ message: error instanceof Error ? error.message : String(error),
1469
+ stack: error instanceof Error ? error.stack : undefined,
1470
+ shutdown: true,
1471
+ },
1472
+ ...(postNavigationUrl === undefined ? {} : { postNavigationUrl }),
1473
+ };
1474
+ }
1475
+ finally {
1476
+ // handleBrowserClose force-kills the underlying Chromium when a
1477
+ // graceful close() hangs (e.g. the session died mid-scrape) and
1478
+ // guarantees the finally never throws, so the try-block's return
1479
+ // value or caught error is never masked.
1480
+ await handleBrowserClose(browser, url.href, crawlerLog);
1481
+ }
1482
+ }
677
1483
  /**
678
1484
  * The default maximum number of concurrent scraping processes.
679
1485
  *