@nitpicker/crawler 0.11.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 (75) hide show
  1. package/lib/archive/archive.d.ts +117 -2
  2. package/lib/archive/archive.js +147 -2
  3. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  4. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  5. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  6. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  7. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  8. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  9. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  10. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  11. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  12. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  13. package/lib/archive/database.d.ts +216 -15
  14. package/lib/archive/database.js +1459 -938
  15. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  16. package/lib/archive/derive-lineage-from-parent.js +42 -0
  17. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  18. package/lib/archive/get-failed-page-messages.js +131 -0
  19. package/lib/archive/init-schema.js +153 -1
  20. package/lib/archive/is-inventory-source.d.ts +21 -0
  21. package/lib/archive/is-inventory-source.js +22 -0
  22. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  23. package/lib/archive/migrate-inventory-runs.js +52 -0
  24. package/lib/archive/types.d.ts +33 -0
  25. package/lib/classify-error-kind.d.ts +19 -0
  26. package/lib/classify-error-kind.js +122 -0
  27. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  28. package/lib/crawler/build-js-redirect-edge.js +57 -0
  29. package/lib/crawler/build-redirect-event.d.ts +24 -0
  30. package/lib/crawler/build-redirect-event.js +28 -0
  31. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  32. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  33. package/lib/crawler/crawler.d.ts +3 -1
  34. package/lib/crawler/crawler.js +655 -107
  35. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  36. package/lib/crawler/derive-js-redirect-target.js +129 -0
  37. package/lib/crawler/derive-resource-source.d.ts +25 -15
  38. package/lib/crawler/derive-resource-source.js +28 -17
  39. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  40. package/lib/crawler/dns-burned-host-cache.js +25 -0
  41. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  42. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  43. package/lib/crawler/fetch-destination.d.ts +12 -4
  44. package/lib/crawler/fetch-destination.js +94 -16
  45. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  46. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  47. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  48. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  49. package/lib/crawler/link-list.d.ts +21 -1
  50. package/lib/crawler/link-list.js +23 -3
  51. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  52. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  53. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  54. package/lib/crawler/preload-short-circuit-error.js +25 -0
  55. package/lib/crawler/should-burn-host.d.ts +78 -0
  56. package/lib/crawler/should-burn-host.js +61 -0
  57. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  58. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  59. package/lib/crawler/types.d.ts +107 -0
  60. package/lib/crawler-orchestrator.d.ts +13 -3
  61. package/lib/crawler-orchestrator.js +292 -69
  62. package/lib/crawler.d.ts +3 -2
  63. package/lib/crawler.js +3 -1
  64. package/lib/permanent-error-kinds.d.ts +43 -0
  65. package/lib/permanent-error-kinds.js +48 -0
  66. package/lib/types.d.ts +84 -0
  67. package/lib/utils/compute-file-sha256.d.ts +23 -0
  68. package/lib/utils/compute-file-sha256.js +55 -0
  69. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  70. package/lib/utils/error/emit-error-with-retry.js +44 -0
  71. package/lib/utils/error/emit-error.d.ts +39 -0
  72. package/lib/utils/error/emit-error.js +41 -0
  73. package/package.json +11 -11
  74. package/lib/utils/error/error-emitter.d.ts +0 -18
  75. package/lib/utils/error/error-emitter.js +0 -29
@@ -3,16 +3,21 @@ 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';
12
16
  import { createChangePhaseHandler } from './create-change-phase-handler.js';
13
17
  import { derivePageSource } from './derive-page-source.js';
14
- import { deriveResourceSource } from './derive-resource-source.js';
15
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';
16
21
  import { drainPhaseErrors } from './drain-phase-errors.js';
17
22
  import { fetchDestination } from './fetch-destination.js';
18
23
  import { findScopeEntry } from './find-scope-entry.js';
@@ -20,21 +25,36 @@ import { formatCrawlProgress } from './format-crawl-progress.js';
20
25
  import { generatePredictedUrls } from './generate-predicted-urls.js';
21
26
  import { handleBrowserClose } from './handle-browser-close.js';
22
27
  import { handleIgnoreAndSkip } from './handle-ignore-and-skip.js';
23
- import { handleResourceResponse } from './handle-resource-response.js';
24
28
  import { handleScrapeEnd } from './handle-scrape-end.js';
25
29
  import { handleScrapeError } from './handle-scrape-error.js';
26
30
  import { injectScopeAuth } from './inject-scope-auth.js';
27
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';
28
34
  import LinkList from './link-list.js';
29
35
  import { linkToPageData } from './link-to-page-data.js';
30
36
  import { logUndrainedPhaseErrors } from './log-undrained-phase-errors.js';
31
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';
32
40
  import { protocolAgnosticKey } from './protocol-agnostic-key.js';
33
41
  import { redirectDestKey } from './redirect-dest-key.js';
34
42
  import { resourceToPageData } from './resource-to-page-data.js';
35
43
  import { RobotsChecker } from './robots-checker.js';
44
+ import { shouldBurnHost } from './should-burn-host.js';
36
45
  import { shouldDiscardPredicted } from './should-discard-predicted.js';
37
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];
38
58
  /**
39
59
  * The core crawler engine that discovers and scrapes web pages.
40
60
  *
@@ -54,7 +74,7 @@ class Crawler extends EventEmitter {
54
74
  /** Merged crawler configuration (user overrides + defaults). */
55
75
  #options;
56
76
  /**
57
- * Phase errors observed during {@link Crawler.#launchBrowserAndScrape},
77
+ * Phase errors observed during {@link Crawler._launchBrowserAndScrape},
58
78
  * buffered per URL href so they can be emitted as `pageError` events
59
79
  * AFTER the corresponding `page` / `externalPage` event. This ordering
60
80
  * lets the orchestrator's WriteQueue serialise `setPage` before
@@ -80,6 +100,20 @@ class Crawler extends EventEmitter {
80
100
  * Keyed by {@link redirectDestKey}. Reset at the start of {@link #runDeal}.
81
101
  */
82
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();
83
117
  /**
84
118
  * The AbortSignal associated with this crawler's AbortController.
85
119
  *
@@ -115,6 +149,7 @@ class Crawler extends EventEmitter {
115
149
  userAgent: options?.userAgent || `Nitpicker/${pkg.version}`,
116
150
  ignoreRobots: options?.ignoreRobots ?? false,
117
151
  lookupResource: options?.lookupResource ?? null,
152
+ lookupPageSource: options?.lookupPageSource ?? null,
118
153
  inventoryMode: options?.inventoryMode ?? null,
119
154
  };
120
155
  this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
@@ -180,21 +215,27 @@ class Crawler extends EventEmitter {
180
215
  * before reaching the dealer so a URL that exists in both sources — which
181
216
  * is common in append-mode when a new root coincides with a repromoted
182
217
  * previously-external page — does not race on two parallel slots.
183
- * @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`).
184
220
  * @param opts - Optional overrides; currently only `recursive` is honoured.
185
221
  * @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
186
222
  * Defaults to the constructor option's `recursive` value.
187
223
  * @throws {Error} If the URL list is empty.
188
224
  */
189
225
  start(urls, opts) {
190
- const root = urls[0];
191
- if (!root) {
192
- throw new Error('urls is empty');
193
- }
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;
194
233
  for (const url of urls) {
195
- const existing = this.#scope.get(url.hostname) || [];
196
- if (!existing.some((u) => u.href === url.href)) {
197
- 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
+ }
198
239
  }
199
240
  this.#linkList.add(url);
200
241
  }
@@ -223,13 +264,17 @@ class Crawler extends EventEmitter {
223
264
  seenInitial.add(key);
224
265
  initialUrls.push(url);
225
266
  }
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');
275
+ }
226
276
  const resumeOffset = this.#resumedScraped.length;
227
277
  const pagesScrapedOffset = this.#resumedPagesScraped;
228
- if (initialUrls.length === 0) {
229
- crawlerLog('Crawl End (nothing to resume)');
230
- void this.emit('crawlEnd', {});
231
- return;
232
- }
233
278
  void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset).catch((error) => {
234
279
  crawlerLog('runDeal error: %O', error);
235
280
  this.#emitDealErrors(error, root.href);
@@ -281,25 +326,22 @@ class Crawler extends EventEmitter {
281
326
  * Processes captured sub-resources from a page scrape, deduplicates them,
282
327
  * and emits `response` / `responseReferrers` events for new resources.
283
328
  * @param resources - Sub-resource entries captured during the page load
329
+ * @param parentSource
284
330
  */
285
- #handleResources(resources) {
286
- // `deriveResourceSource` encodes the "sub-resources are never seeds"
287
- // rule and stays in lockstep with `derivePageSource` if PageSource
288
- // gains new variants. Computed once outside the loop because the
289
- // inventoryMode reference does not change mid-batch.
290
- const subResourceSource = deriveResourceSource(this.#options.inventoryMode);
291
- for (const { resource, pageUrl } of resources) {
292
- const { isNew } = handleResourceResponse(resource, this.#resources);
293
- if (isNew) {
294
- void this.emit('response', {
295
- resource: resource,
296
- source: subResourceSource,
297
- });
298
- }
299
- void this.emit('responseReferrers', {
300
- url: pageUrl,
301
- src: resource.url.withoutHash,
302
- });
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);
303
345
  }
304
346
  }
305
347
  /**
@@ -440,72 +482,60 @@ class Crawler extends EventEmitter {
440
482
  * @param headCheckResult - Optional HEAD result to pass to the scraper, avoiding a redundant request
441
483
  * @returns The scrape result from beholder
442
484
  */
443
- async #launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult) {
444
- update('Launching browser%dots%');
445
- if (this.#options.executablePath) {
446
- const execPath = path.resolve(this.#options.executablePath);
447
- if (!existsSync(execPath)) {
448
- throw new Error(`Executable path does not exist: ${execPath}`);
449
- }
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;
450
529
  }
451
- const puppeteer = await import('puppeteer');
452
- const browser = await puppeteer.launch({
453
- headless: true,
454
- ...(this.#options.executablePath
455
- ? { executablePath: this.#options.executablePath }
456
- : {}),
457
- });
458
530
  try {
459
- update('Creating page%dots%');
460
- const page = await browser.newPage();
461
- await page.setUserAgent(this.#options.userAgent);
462
- // Defence-in-depth: beholder sets Authorization via setExtraHTTPHeaders,
463
- // but page.authenticate() handles Chromium-level HTTP auth challenges
464
- // (401 + WWW-Authenticate) that setExtraHTTPHeaders cannot cover.
465
- if (url.username && url.password) {
466
- await page.authenticate({
467
- username: url.username,
468
- password: url.password,
469
- });
470
- }
471
- const scraper = new Scraper();
472
- scraper.on('changePhase', createChangePhaseHandler({
473
- emit: (event) => void this.emit('changePhase', event),
474
- update,
475
- formatLog: formatPhaseLog,
476
- buffer: this.#pendingPhaseErrors,
477
- urlHref: url.href,
478
- }));
479
- const result = await scraper.scrapeStart(page, url, {
480
- isExternal,
481
- captureImages: !isExternal && this.#options.captureImages,
482
- excludeKeywords: this.#options.excludeKeywords,
483
- disableQueries: this.#options.disableQueries,
484
- metadataOnly,
485
- retries: this.#options.retry,
486
- headCheckResult,
487
- });
488
- update('Closing browser%dots%');
489
- return result;
531
+ return await lookupPageSource(url.withoutHashAndAuth);
490
532
  }
491
533
  catch (error) {
492
- return {
493
- type: 'error',
494
- resources: [],
495
- error: {
496
- name: error instanceof Error ? error.name : 'Error',
497
- message: error instanceof Error ? error.message : String(error),
498
- stack: error instanceof Error ? error.stack : undefined,
499
- shutdown: true,
500
- },
501
- };
502
- }
503
- finally {
504
- // handleBrowserClose force-kills the underlying Chromium when a
505
- // graceful close() hangs (e.g. the session died mid-scrape) and
506
- // guarantees the finally never throws, so the try-block's return
507
- // value or caught error is never masked.
508
- await handleBrowserClose(browser, url.href, crawlerLog);
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;
509
539
  }
510
540
  }
511
541
  /**
@@ -530,6 +560,10 @@ class Crawler extends EventEmitter {
530
560
  }
531
561
  // Redirect-destination dedup is per-crawl; clear any state from a prior run.
532
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();
533
567
  // external URL の追跡(target は deal の total/done から導出)
534
568
  const externalUrls = new Set();
535
569
  const externalDoneUrls = new Set();
@@ -577,6 +611,20 @@ class Crawler extends EventEmitter {
577
611
  return Promise.all(ops).then(() => { });
578
612
  };
579
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
+ }
580
628
  const log = createTimedUpdate(update, this.#options.verbose);
581
629
  // `#scrapePage` 内のブラウザ HTML レンダーが成功したかをマークするフラグ。
582
630
  // 成功時のみ #scrapePage 側で true に設定される。
@@ -640,8 +688,63 @@ class Crawler extends EventEmitter {
640
688
  // path, where the first predicted source to a destination renders
641
689
  // it and is recorded as a redirect source the same way; only 404 /
642
690
  // error predicted URLs are dropped (by `shouldDiscardPredicted`).
643
- this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options);
644
- void this.emit('redirect', { result: result.pageData });
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));
645
748
  log(c.dim('Redirect (dest already scraped)'));
646
749
  return;
647
750
  }
@@ -660,7 +763,8 @@ class Crawler extends EventEmitter {
660
763
  }
661
764
  log('Saving results%dots%');
662
765
  this.#handleResult(result, url, enqueue, paginationState, concurrency);
663
- this.#handleResources(result.resources);
766
+ const parentSource = await this.#resolveParentSource(url);
767
+ this.#handleResources(result.resources, parentSource);
664
768
  log(formatResultSummary(result));
665
769
  // Phase errors must be emitted AFTER 'page' / 'externalPage'
666
770
  // so the orchestrator's WriteQueue sees `setPage` before
@@ -703,7 +807,10 @@ class Crawler extends EventEmitter {
703
807
  };
704
808
  }, {
705
809
  limit: concurrency,
706
- 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,
707
814
  verbose: this.#options.verbose || !process.stdout.isTTY,
708
815
  signal: this.#abortController.signal,
709
816
  header: (_progress, done, total, limit) => {
@@ -745,7 +852,7 @@ class Crawler extends EventEmitter {
745
852
  * @param metadataOnly - When true, only extract title metadata without full browser scraping
746
853
  * @param laneIndex - The dealer lane index, used to create unique countdown IDs
747
854
  * @param markBrowserScrape - Called once **after** the browser successfully
748
- * renders an HTML page (i.e. `#launchBrowserAndScrape` resolved with
855
+ * renders an HTML page (i.e. `_launchBrowserAndScrape` resolved with
749
856
  * `type: 'success'`). Not called for HEAD-only, title-only, captured-resource
750
857
  * reuse, non-HTML responses, non-HTTP protocols (mailto:, tel:), browser
751
858
  * launch throws (e.g. invalid executablePath), or scraper-returned
@@ -757,7 +864,7 @@ class Crawler extends EventEmitter {
757
864
  const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
758
865
  // Non-HTTP protocols (mailto:, tel:, etc.) — let the scraper handle early return
759
866
  if (!url.isHTTP) {
760
- return this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly);
867
+ return this._launchBrowserAndScrape(url, update, isExternal, metadataOnly);
761
868
  }
762
869
  // Reuse captured resource data — when this URL was already observed as a
763
870
  // sub-resource during page rendering, its response data is recorded and
@@ -801,6 +908,131 @@ class Crawler extends EventEmitter {
801
908
  headCheckResult = await this.#sendHeadRequest(url, isExternal, update, laneIndex);
802
909
  }
803
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
+ }
804
1036
  // Server unreachable — skip browser launch entirely
805
1037
  update(c.red('Unreachable'));
806
1038
  return {
@@ -808,7 +1040,7 @@ class Crawler extends EventEmitter {
808
1040
  resources: [],
809
1041
  error: {
810
1042
  name: error instanceof Error ? error.name : 'Error',
811
- message: error instanceof Error ? error.message : String(error),
1043
+ message: errorMessage,
812
1044
  stack: error instanceof Error ? error.stack : undefined,
813
1045
  shutdown: false,
814
1046
  },
@@ -834,7 +1066,7 @@ class Crawler extends EventEmitter {
834
1066
  const finalKey = redirectDestKey(url, headCheckResult.redirectPaths);
835
1067
  if (this.#scrapedDestinations.has(finalKey)) {
836
1068
  crawlerLog('Redirect dest already rendered, edge only: %s', url.href);
837
- return { type: 'redirect-edge', pageData: headCheckResult };
1069
+ return { type: 'redirect-edge', source: 'http-chain', pageData: headCheckResult };
838
1070
  }
839
1071
  // Title-only mode — extract <title> via partial GET for HTML, skip browser
840
1072
  if (metadataOnly) {
@@ -876,12 +1108,12 @@ class Crawler extends EventEmitter {
876
1108
  }
877
1109
  // HTML or unknown content type — launch browser with preflight result.
878
1110
  // markBrowserScrape() fires only when the result is `success`.
879
- // `#launchBrowserAndScrape` catches internal errors and returns
1111
+ // `_launchBrowserAndScrape` catches internal errors and returns
880
1112
  // `{ type: 'error', ... }` instead of throwing (see its catch block),
881
1113
  // so awaiting alone does NOT prove the page was rendered. The explicit
882
1114
  // success check excludes navigation failures, scraper exceptions, and
883
1115
  // shutdown-class errors from the pages-rendered count.
884
- const browserResult = await this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
1116
+ const browserResult = await this._launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
885
1117
  if (browserResult.type === 'success') {
886
1118
  markBrowserScrape();
887
1119
  // Claim the destination that was ACTUALLY rendered, keyed off the
@@ -905,6 +1137,95 @@ class Crawler extends EventEmitter {
905
1137
  ? redirectDestKey(url, browserResult.pageData.redirectPaths)
906
1138
  : finalKey;
907
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
+ }
908
1229
  }
909
1230
  return browserResult;
910
1231
  }
@@ -921,17 +1242,244 @@ class Crawler extends EventEmitter {
921
1242
  * @returns Lightweight page data from the HEAD response
922
1243
  */
923
1244
  async #sendHeadRequest(url, isExternal, update, laneIndex) {
924
- 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
+ }, {
925
1289
  retries: this.#options.retry,
926
1290
  label: 'HEAD request',
927
1291
  onWait: (determinedInterval, retryCount, label, error) => {
928
1292
  update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
929
1293
  },
930
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
+ }
931
1311
  update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
932
1312
  },
933
1313
  });
934
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
+ }
935
1483
  /**
936
1484
  * The default maximum number of concurrent scraping processes.
937
1485
  *