@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
@@ -0,0 +1,54 @@
1
+ import type { BufferedPhaseError } from './drain-phase-errors.js';
2
+ import type { ChangePhaseEvent } from '@d-zero/beholder';
3
+ /**
4
+ * Options for {@link createChangePhaseHandler}. Declared structurally so
5
+ * tests can pass plain `vi.fn()` stubs without spinning up a real Crawler.
6
+ */
7
+ export interface ChangePhaseHandlerOptions {
8
+ /**
9
+ * Forwards the raw {@link ChangePhaseEvent} so external listeners on the
10
+ * Crawler still see every transition (typically `this.emit.bind(this)`
11
+ * narrowed to the `changePhase` channel).
12
+ */
13
+ emit: (event: ChangePhaseEvent) => void;
14
+ /** Receives the formatted progress log line. Skipped when empty. */
15
+ update: (log: string) => void;
16
+ /**
17
+ * Renders the human-readable progress message for an event. Injected so
18
+ * the handler stays free of the Crawler's internal log formatter.
19
+ * Returns `null` for events that should not surface to `update`.
20
+ */
21
+ formatLog: (event: ChangePhaseEvent) => string | null;
22
+ /**
23
+ * Per-URL buffer of `retryExhausted` failures. The handler appends to
24
+ * this map; it does not drain (that is `drainPhaseErrors`'s job).
25
+ */
26
+ buffer: Map<string, BufferedPhaseError[]>;
27
+ /** URL href used as the buffer key for this scrape. */
28
+ urlHref: string;
29
+ }
30
+ /**
31
+ * Builds the `scraper.on('changePhase', ...)` listener used by
32
+ * {@link Crawler.#launchBrowserAndScrape}.
33
+ *
34
+ * Three responsibilities:
35
+ * 1. Render the phase log via the injected `formatLog` and pipe it to `update`.
36
+ * 2. Forward the raw event so external consumers (CLI progress UI etc.) see
37
+ * every transition.
38
+ * 3. Buffer `retryExhausted` events into the per-URL phase-error map so they
39
+ * can be drained as `pageError` events AFTER the `page` event fires.
40
+ *
41
+ * WHY a factory: the listener captures per-scrape state (`buffer`, `urlHref`,
42
+ * `update`). Extracting the factory makes the wiring directly unit-testable
43
+ * with plain stubs, instead of requiring a mocked Puppeteer + beholder + dealer
44
+ * stack to drive the worker.
45
+ *
46
+ * **Caller contract**: register the returned handler at most once per
47
+ * `scraper` instance. The Crawler creates a fresh Scraper per URL so this
48
+ * holds today; if scraper pooling is ever introduced, register exactly one
49
+ * handler per scrape and unregister it on completion to avoid duplicate
50
+ * buffer entries.
51
+ * @param options - Wiring dependencies for the handler.
52
+ * @returns A function suitable for `scraper.on('changePhase', ...)`.
53
+ */
54
+ export declare function createChangePhaseHandler(options: ChangePhaseHandlerOptions): (event: ChangePhaseEvent) => void;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Builds the `scraper.on('changePhase', ...)` listener used by
3
+ * {@link Crawler.#launchBrowserAndScrape}.
4
+ *
5
+ * Three responsibilities:
6
+ * 1. Render the phase log via the injected `formatLog` and pipe it to `update`.
7
+ * 2. Forward the raw event so external consumers (CLI progress UI etc.) see
8
+ * every transition.
9
+ * 3. Buffer `retryExhausted` events into the per-URL phase-error map so they
10
+ * can be drained as `pageError` events AFTER the `page` event fires.
11
+ *
12
+ * WHY a factory: the listener captures per-scrape state (`buffer`, `urlHref`,
13
+ * `update`). Extracting the factory makes the wiring directly unit-testable
14
+ * with plain stubs, instead of requiring a mocked Puppeteer + beholder + dealer
15
+ * stack to drive the worker.
16
+ *
17
+ * **Caller contract**: register the returned handler at most once per
18
+ * `scraper` instance. The Crawler creates a fresh Scraper per URL so this
19
+ * holds today; if scraper pooling is ever introduced, register exactly one
20
+ * handler per scrape and unregister it on completion to avoid duplicate
21
+ * buffer entries.
22
+ * @param options - Wiring dependencies for the handler.
23
+ * @returns A function suitable for `scraper.on('changePhase', ...)`.
24
+ */
25
+ export function createChangePhaseHandler(options) {
26
+ const { emit, update, formatLog, buffer, urlHref } = options;
27
+ return (event) => {
28
+ const msg = formatLog(event);
29
+ if (msg) {
30
+ update(msg);
31
+ }
32
+ emit(event);
33
+ // retryExhausted fires when beholder's @retryable gives up on a
34
+ // secondary scrape step (e.g. a viewport switch detaching the frame
35
+ // in #fetchImages). The page itself still completes, so we buffer
36
+ // the failure here and emit it as a pageError after the page event
37
+ // has been emitted.
38
+ if (event.name === 'retryExhausted') {
39
+ const list = buffer.get(urlHref) ?? [];
40
+ list.push({ phase: event.name, message: event.message });
41
+ buffer.set(urlHref, list);
42
+ }
43
+ };
44
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Decide whether a puppeteer post-navigation URL represents a real
3
+ * client-side redirect (JS / meta-refresh) or just noise.
4
+ *
5
+ * **Why this helper exists:** when `page.goto()` returns `null`, the upstream
6
+ * scraper throws `The method Page.goto returned null` — classified as
7
+ * `protocol`, neither permanent nor a puppeteer-fallback kind, so
8
+ * `--retry-failed` never converges. Reading `page.url()` after the throw is
9
+ * the only way to recover the destination Chromium actually navigated to (the
10
+ * thrown error carries no URL), so we can record the source as a redirect
11
+ * edge instead of a hard `status = -1`. But `page.url()` can also report
12
+ * uninformative values — `about:blank` before the first navigation completes,
13
+ * the original URL when nothing happened — and a naive "different ?" check
14
+ * would create a phantom redirect every time the navigation simply failed at
15
+ * the same URL.
16
+ *
17
+ * The filter is intentionally narrow: anything that does not look like an
18
+ * `http(s):` URL semantically distinct from the originally requested
19
+ * location is discarded. Edge cases the test pins:
20
+ *
21
+ * - identity after URL canonicalisation (case / trailing-slash / default
22
+ * port / credentials / fragment) → null. WHATWG URL parsing handles
23
+ * `https://Example.COM` vs `https://example.com/`, `https://host:443/`
24
+ * vs `https://host/`, `https://u:p@host/x` vs `https://host/x`, etc.
25
+ * - `about:blank` / `chrome-error://...` / `data:` / `file:` → null
26
+ * (browser-internal sentinels, never a legitimate destination)
27
+ * - empty / whitespace string → null (defensive against
28
+ * `page.url()` returning `''` on a brand-new context)
29
+ * - any genuine `http(s):` URL semantically different from the source →
30
+ * returned credential-/fragment-stripped (defence-in-depth against a
31
+ * pre-RFC server issuing `Location: https://user:pass@host/path`, which
32
+ * would otherwise persist credentials into the `.nitpicker` archive
33
+ * — exactly the leak class the scope-auth-leak guard at the navigation
34
+ * side blocks)
35
+ *
36
+ * The caller does NOT need to pre-normalise URLs — this helper normalises
37
+ * both sides via the WHATWG URL parser before comparing. The production
38
+ * call site passes `url.withoutHashAndAuth` for `originalUrl`, but the
39
+ * normalisation here is idempotent so passing `url.href` would also work.
40
+ * @param originalUrl - The URL puppeteer was asked to navigate to.
41
+ * @param postNavigationUrl - The URL reported by `page.url()` after the
42
+ * throw. May be `null` / `undefined` when reading the URL itself failed.
43
+ * @returns The credential-/fragment-stripped post-navigation URL when it
44
+ * represents a real JS redirect, otherwise `null`.
45
+ * @example
46
+ * ```ts
47
+ * deriveJsRedirectTarget(
48
+ * 'https://www.example.com/old',
49
+ * 'https://www.example.com/new',
50
+ * ); // → 'https://www.example.com/new'
51
+ *
52
+ * deriveJsRedirectTarget('https://www.example.com/old', 'about:blank');
53
+ * // → null
54
+ *
55
+ * // Case / trailing-slash noise — no phantom self-redirect:
56
+ * deriveJsRedirectTarget(
57
+ * 'https://www.example.com',
58
+ * 'https://www.example.com/',
59
+ * ); // → null
60
+ *
61
+ * // Credentials in destination — stripped before return:
62
+ * deriveJsRedirectTarget(
63
+ * 'https://www.example.com/',
64
+ * 'https://user:pass@www.example.com/dest',
65
+ * ); // → 'https://www.example.com/dest'
66
+ * ```
67
+ */
68
+ export declare function deriveJsRedirectTarget(originalUrl: string, postNavigationUrl: string | null | undefined): string | null;
@@ -0,0 +1,129 @@
1
+ /** Matches only the `http:` and `https:` schemes (case-insensitive). */
2
+ const HTTP_SCHEME_PATTERN = /^https?:\/\//i;
3
+ /**
4
+ * Canonicalise a URL for the JS-redirect identity check and return value:
5
+ * strip credentials and fragment, normalise case / default-ports /
6
+ * trailing-slash via the WHATWG URL parser. Returns `null` when the input
7
+ * is unparseable.
8
+ *
9
+ * Used for both sides of the comparison so case-only or trailing-slash-only
10
+ * differences (`https://Example.com` vs `https://example.com/`) do not
11
+ * produce phantom self-redirects, and for the returned target so a pre-RFC
12
+ * `Location: https://user:pass@host/path` is never persisted into the
13
+ * archive with credentials (same threat model the scope-auth-leak guard
14
+ * mitigates on the navigation side).
15
+ * @param rawUrl - The URL string to canonicalise.
16
+ * @returns The canonical URL string, or `null` when unparseable.
17
+ */
18
+ function canonicaliseForComparison(rawUrl) {
19
+ try {
20
+ const parsed = new URL(rawUrl);
21
+ parsed.username = '';
22
+ parsed.password = '';
23
+ parsed.hash = '';
24
+ return parsed.href;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /**
31
+ * Decide whether a puppeteer post-navigation URL represents a real
32
+ * client-side redirect (JS / meta-refresh) or just noise.
33
+ *
34
+ * **Why this helper exists:** when `page.goto()` returns `null`, the upstream
35
+ * scraper throws `The method Page.goto returned null` — classified as
36
+ * `protocol`, neither permanent nor a puppeteer-fallback kind, so
37
+ * `--retry-failed` never converges. Reading `page.url()` after the throw is
38
+ * the only way to recover the destination Chromium actually navigated to (the
39
+ * thrown error carries no URL), so we can record the source as a redirect
40
+ * edge instead of a hard `status = -1`. But `page.url()` can also report
41
+ * uninformative values — `about:blank` before the first navigation completes,
42
+ * the original URL when nothing happened — and a naive "different ?" check
43
+ * would create a phantom redirect every time the navigation simply failed at
44
+ * the same URL.
45
+ *
46
+ * The filter is intentionally narrow: anything that does not look like an
47
+ * `http(s):` URL semantically distinct from the originally requested
48
+ * location is discarded. Edge cases the test pins:
49
+ *
50
+ * - identity after URL canonicalisation (case / trailing-slash / default
51
+ * port / credentials / fragment) → null. WHATWG URL parsing handles
52
+ * `https://Example.COM` vs `https://example.com/`, `https://host:443/`
53
+ * vs `https://host/`, `https://u:p@host/x` vs `https://host/x`, etc.
54
+ * - `about:blank` / `chrome-error://...` / `data:` / `file:` → null
55
+ * (browser-internal sentinels, never a legitimate destination)
56
+ * - empty / whitespace string → null (defensive against
57
+ * `page.url()` returning `''` on a brand-new context)
58
+ * - any genuine `http(s):` URL semantically different from the source →
59
+ * returned credential-/fragment-stripped (defence-in-depth against a
60
+ * pre-RFC server issuing `Location: https://user:pass@host/path`, which
61
+ * would otherwise persist credentials into the `.nitpicker` archive
62
+ * — exactly the leak class the scope-auth-leak guard at the navigation
63
+ * side blocks)
64
+ *
65
+ * The caller does NOT need to pre-normalise URLs — this helper normalises
66
+ * both sides via the WHATWG URL parser before comparing. The production
67
+ * call site passes `url.withoutHashAndAuth` for `originalUrl`, but the
68
+ * normalisation here is idempotent so passing `url.href` would also work.
69
+ * @param originalUrl - The URL puppeteer was asked to navigate to.
70
+ * @param postNavigationUrl - The URL reported by `page.url()` after the
71
+ * throw. May be `null` / `undefined` when reading the URL itself failed.
72
+ * @returns The credential-/fragment-stripped post-navigation URL when it
73
+ * represents a real JS redirect, otherwise `null`.
74
+ * @example
75
+ * ```ts
76
+ * deriveJsRedirectTarget(
77
+ * 'https://www.example.com/old',
78
+ * 'https://www.example.com/new',
79
+ * ); // → 'https://www.example.com/new'
80
+ *
81
+ * deriveJsRedirectTarget('https://www.example.com/old', 'about:blank');
82
+ * // → null
83
+ *
84
+ * // Case / trailing-slash noise — no phantom self-redirect:
85
+ * deriveJsRedirectTarget(
86
+ * 'https://www.example.com',
87
+ * 'https://www.example.com/',
88
+ * ); // → null
89
+ *
90
+ * // Credentials in destination — stripped before return:
91
+ * deriveJsRedirectTarget(
92
+ * 'https://www.example.com/',
93
+ * 'https://user:pass@www.example.com/dest',
94
+ * ); // → 'https://www.example.com/dest'
95
+ * ```
96
+ */
97
+ export function deriveJsRedirectTarget(originalUrl, postNavigationUrl) {
98
+ if (typeof postNavigationUrl !== 'string') {
99
+ return null;
100
+ }
101
+ const trimmed = postNavigationUrl.trim();
102
+ if (trimmed === '') {
103
+ return null;
104
+ }
105
+ // Browser-internal sentinels: `about:blank` appears before the first
106
+ // navigation completes, `chrome-error://...` after a network error
107
+ // renders the Chromium error page, and `data:` / `file:` / `javascript:`
108
+ // can never be the destination of an off-page redirect we want to record.
109
+ // `http:` / `https:` is the only safe positive match.
110
+ if (!HTTP_SCHEME_PATTERN.test(trimmed)) {
111
+ return null;
112
+ }
113
+ const destinationCanonical = canonicaliseForComparison(trimmed);
114
+ if (destinationCanonical === null) {
115
+ return null;
116
+ }
117
+ const originalCanonical = canonicaliseForComparison(originalUrl);
118
+ // `originalCanonical === null` only happens for a degenerate (unparseable)
119
+ // `originalUrl` — fall back to a raw string check so we never claim a
120
+ // redirect we cannot prove. The production call site always supplies a
121
+ // parseable form.
122
+ if (originalCanonical === null) {
123
+ return trimmed === originalUrl ? null : destinationCanonical;
124
+ }
125
+ if (destinationCanonical === originalCanonical) {
126
+ return null;
127
+ }
128
+ return destinationCanonical;
129
+ }
@@ -0,0 +1,23 @@
1
+ import type { InventoryMode } from './types.js';
2
+ import type { PageSource } from '../archive/types.js';
3
+ /**
4
+ * Decide which {@link PageSource} label a newly-scraped page row should carry.
5
+ *
6
+ * When the crawler is NOT in inventory mode (`inventoryMode === null`),
7
+ * returns `undefined` — the caller emits no `source` and the DB DEFAULT
8
+ * `'crawled'` ends up on the row. This keeps the normal crawl path
9
+ * untouched.
10
+ *
11
+ * When inventory mode is active, the URL is matched against
12
+ * `inventoryMode.seedUrls`. A hit means the URL came straight from the
13
+ * user-supplied list (`'inventory-seed'`); a miss means the URL was found
14
+ * by following links from a seed page (`'inventory-discovered'`).
15
+ *
16
+ * Sub-resources captured by puppeteer during inventory-mode rendering are
17
+ * NEVER seeds — the caller for those events always passes
18
+ * `'inventory-discovered'` directly without consulting this helper.
19
+ * @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
20
+ * @param pageUrlWithoutHashAndAuth - The page URL keyed by `withoutHashAndAuth` (auth credentials stripped, hash dropped).
21
+ * @returns The label to write to `pages.source`, or `undefined` for the DB default.
22
+ */
23
+ export declare function derivePageSource(inventoryMode: InventoryMode | null, pageUrlWithoutHashAndAuth: string): PageSource | undefined;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Decide which {@link PageSource} label a newly-scraped page row should carry.
3
+ *
4
+ * When the crawler is NOT in inventory mode (`inventoryMode === null`),
5
+ * returns `undefined` — the caller emits no `source` and the DB DEFAULT
6
+ * `'crawled'` ends up on the row. This keeps the normal crawl path
7
+ * untouched.
8
+ *
9
+ * When inventory mode is active, the URL is matched against
10
+ * `inventoryMode.seedUrls`. A hit means the URL came straight from the
11
+ * user-supplied list (`'inventory-seed'`); a miss means the URL was found
12
+ * by following links from a seed page (`'inventory-discovered'`).
13
+ *
14
+ * Sub-resources captured by puppeteer during inventory-mode rendering are
15
+ * NEVER seeds — the caller for those events always passes
16
+ * `'inventory-discovered'` directly without consulting this helper.
17
+ * @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
18
+ * @param pageUrlWithoutHashAndAuth - The page URL keyed by `withoutHashAndAuth` (auth credentials stripped, hash dropped).
19
+ * @returns The label to write to `pages.source`, or `undefined` for the DB default.
20
+ */
21
+ export function derivePageSource(inventoryMode, pageUrlWithoutHashAndAuth) {
22
+ if (inventoryMode === null) {
23
+ return undefined;
24
+ }
25
+ return inventoryMode.seedUrls.has(pageUrlWithoutHashAndAuth)
26
+ ? 'inventory-seed'
27
+ : 'inventory-discovered';
28
+ }
@@ -0,0 +1,33 @@
1
+ import type { PageSource } from '../archive/types.js';
2
+ /**
3
+ * Decide which {@link PageSource} label a newly-captured sub-resource row
4
+ * (CSS / JS / image / fetch response) should carry, based on the lineage of
5
+ * the page that is currently being rendered.
6
+ *
7
+ * Sub-resources are NEVER themselves seeds — even when puppeteer is
8
+ * rendering a page that *is* an inventory seed, the assets it pulls in are
9
+ * downstream and must be labelled `'inventory-discovered'`. The seed label
10
+ * is reserved for URLs that were explicitly handed in by the user via the
11
+ * `--inventory` file. Likewise, when a sub-resource is captured during a
12
+ * render of an `'inventory-discovered'` page (a page reached transitively
13
+ * through the inventory chain), the asset is still inventory-discovered —
14
+ * not a new seed.
15
+ *
16
+ * Outside the inventory chain (parent is `'crawled'` or has no source
17
+ * record) this returns `undefined` so the caller emits no `source` and the
18
+ * DB DEFAULT (`'crawled'`) lands on the row, leaving the normal crawl path
19
+ * untouched.
20
+ *
21
+ * `parentSource` must reflect the MERGED source of the page being scraped:
22
+ * for an active `--inventory` session that comes from
23
+ * {@link import('./derive-page-source.js').derivePageSource}, for a
24
+ * `--resume` / `--retry-failed` session it comes from a DB lookup
25
+ * (`PageSourceLookup`) because `inventoryMode` is not persisted across
26
+ * sessions. The two-stage resolution is what keeps sub-resource labels
27
+ * correct on resume: even though the orchestrator no longer carries the
28
+ * inventory seed set in memory, the DB still records the parent's lineage
29
+ * and we propagate it forward.
30
+ * @param parentSource - Merged source of the page that is producing this sub-resource (or `undefined` for an unknown / `'crawled'` parent).
31
+ * @returns The label to write to `resources.source`, or `undefined` for the DB default.
32
+ */
33
+ export declare function deriveResourceSource(parentSource: PageSource | undefined): PageSource | undefined;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Decide which {@link PageSource} label a newly-captured sub-resource row
3
+ * (CSS / JS / image / fetch response) should carry, based on the lineage of
4
+ * the page that is currently being rendered.
5
+ *
6
+ * Sub-resources are NEVER themselves seeds — even when puppeteer is
7
+ * rendering a page that *is* an inventory seed, the assets it pulls in are
8
+ * downstream and must be labelled `'inventory-discovered'`. The seed label
9
+ * is reserved for URLs that were explicitly handed in by the user via the
10
+ * `--inventory` file. Likewise, when a sub-resource is captured during a
11
+ * render of an `'inventory-discovered'` page (a page reached transitively
12
+ * through the inventory chain), the asset is still inventory-discovered —
13
+ * not a new seed.
14
+ *
15
+ * Outside the inventory chain (parent is `'crawled'` or has no source
16
+ * record) this returns `undefined` so the caller emits no `source` and the
17
+ * DB DEFAULT (`'crawled'`) lands on the row, leaving the normal crawl path
18
+ * untouched.
19
+ *
20
+ * `parentSource` must reflect the MERGED source of the page being scraped:
21
+ * for an active `--inventory` session that comes from
22
+ * {@link import('./derive-page-source.js').derivePageSource}, for a
23
+ * `--resume` / `--retry-failed` session it comes from a DB lookup
24
+ * (`PageSourceLookup`) because `inventoryMode` is not persisted across
25
+ * sessions. The two-stage resolution is what keeps sub-resource labels
26
+ * correct on resume: even though the orchestrator no longer carries the
27
+ * inventory seed set in memory, the DB still records the parent's lineage
28
+ * and we propagate it forward.
29
+ * @param parentSource - Merged source of the page that is producing this sub-resource (or `undefined` for an unknown / `'crawled'` parent).
30
+ * @returns The label to write to `resources.source`, or `undefined` for the DB default.
31
+ */
32
+ export function deriveResourceSource(parentSource) {
33
+ if (parentSource === 'inventory-seed' || parentSource === 'inventory-discovered') {
34
+ return 'inventory-discovered';
35
+ }
36
+ return undefined;
37
+ }
@@ -0,0 +1,26 @@
1
+ import type { ErrorKind } from '../types.js';
2
+ /**
3
+ * In-memory set of hostnames known to be unreachable due to DNS errors
4
+ * (e.g. `getaddrinfo ENOTFOUND`).
5
+ *
6
+ * Lives for one crawl session — cleared by {@link clearDnsBurnedHostCache} at
7
+ * the same four orchestrator sites as {@link destinationCache}. Hosts are
8
+ * marked in two ways:
9
+ *
10
+ * - **Session learning**: the `onGiveUp` callback of `#sendHeadRequest`
11
+ * classifies the final retry error and stores `'dns'` when the matcher
12
+ * fires. The next URL on the same hostname short-circuits before retry.
13
+ * - **Session preload**: re-open paths (`append` / `inventory` / `retryFailed`
14
+ * / `resume`) call {@link Archive.listDnsBurnedHostCandidates} and seed the
15
+ * map from `crawl_errors`, so previously-burned hosts cost zero retries on
16
+ * the next crawl.
17
+ *
18
+ * Keys are always `url.hostname.toLowerCase()` — WHATWG URL has already
19
+ * Punycoded IDNs and stripped the port, so no extra normalization is needed
20
+ * for IPv4 / IPv6 literals or international hostnames.
21
+ *
22
+ * The value records the originating {@link ErrorKind}. Only `'dns'` is set
23
+ * today; the union shape is preserved so future error classes
24
+ * (e.g. `tls`, `connection-refused`) can extend the same cache.
25
+ */
26
+ export declare const dnsBurnedHostCache: Map<string, ErrorKind>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * In-memory set of hostnames known to be unreachable due to DNS errors
3
+ * (e.g. `getaddrinfo ENOTFOUND`).
4
+ *
5
+ * Lives for one crawl session — cleared by {@link clearDnsBurnedHostCache} at
6
+ * the same four orchestrator sites as {@link destinationCache}. Hosts are
7
+ * marked in two ways:
8
+ *
9
+ * - **Session learning**: the `onGiveUp` callback of `#sendHeadRequest`
10
+ * classifies the final retry error and stores `'dns'` when the matcher
11
+ * fires. The next URL on the same hostname short-circuits before retry.
12
+ * - **Session preload**: re-open paths (`append` / `inventory` / `retryFailed`
13
+ * / `resume`) call {@link Archive.listDnsBurnedHostCandidates} and seed the
14
+ * map from `crawl_errors`, so previously-burned hosts cost zero retries on
15
+ * the next crawl.
16
+ *
17
+ * Keys are always `url.hostname.toLowerCase()` — WHATWG URL has already
18
+ * Punycoded IDNs and stripped the port, so no extra normalization is needed
19
+ * for IPv4 / IPv6 literals or international hostnames.
20
+ *
21
+ * The value records the originating {@link ErrorKind}. Only `'dns'` is set
22
+ * today; the union shape is preserved so future error classes
23
+ * (e.g. `tls`, `connection-refused`) can extend the same cache.
24
+ */
25
+ export const dnsBurnedHostCache = new Map();
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Session-scoped counter for how many URL fetches were short-circuited by the
3
+ * {@link dnsBurnedHostCache}. The orchestrator reads this at crawl shutdown
4
+ * to emit a `[preload] Short-circuited N URL(s)` summary line, then
5
+ * {@link clearDnsBurnedHostCache} zeroes it for the next session.
6
+ *
7
+ * Exposed as a mutable object (not a plain `let`) so that the counter remains
8
+ * a single shared reference across crawler / orchestrator imports — `let`
9
+ * bindings cannot be mutated from another module.
10
+ */
11
+ export declare const dnsBurnedHostShortCircuitCounter: {
12
+ count: number;
13
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Session-scoped counter for how many URL fetches were short-circuited by the
3
+ * {@link dnsBurnedHostCache}. The orchestrator reads this at crawl shutdown
4
+ * to emit a `[preload] Short-circuited N URL(s)` summary line, then
5
+ * {@link clearDnsBurnedHostCache} zeroes it for the next session.
6
+ *
7
+ * Exposed as a mutable object (not a plain `let`) so that the counter remains
8
+ * a single shared reference across crawler / orchestrator imports — `let`
9
+ * bindings cannot be mutated from another module.
10
+ */
11
+ export const dnsBurnedHostShortCircuitCounter = { count: 0 };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A buffered phase-error record awaiting emission as a `pageError` event.
3
+ *
4
+ * `phase` is the beholder phase name (typically `'retryExhausted'`) and
5
+ * `message` is the human-readable failure text.
6
+ */
7
+ export interface BufferedPhaseError {
8
+ /** Scrape phase name. */
9
+ phase: string;
10
+ /** Human-readable failure message. */
11
+ message: string;
12
+ }
13
+ /**
14
+ * Emitter signature accepted by {@link drainPhaseErrors} for the `pageError`
15
+ * event. Declared structurally so the Crawler's typed event emitter can be
16
+ * adapted with a thin closure at the call site without leaking through here.
17
+ */
18
+ export type DrainPhaseErrorsEmit = (payload: {
19
+ /** URL of the affected page. */
20
+ url: string;
21
+ /** Scrape phase name. */
22
+ phase: string;
23
+ /** Human-readable failure message. */
24
+ message: string;
25
+ /** Whether the URL is external to the crawl scope. */
26
+ isExternal: boolean;
27
+ }) => void;
28
+ /**
29
+ * Drains the buffered phase errors for a URL: removes the entry from
30
+ * `buffer` and invokes `emit` once per buffered record.
31
+ *
32
+ * WHY a standalone function: the Crawler buffers `retryExhausted` events
33
+ * keyed by `url.href` during scrapeStart, then flushes them as
34
+ * `pageError` events AFTER `page` / `externalPage` has been emitted so the
35
+ * orchestrator's WriteQueue serialises `setPage` before `insertPageError`.
36
+ * Extracting the drain step here makes the flush + delete contract
37
+ * directly unit-testable without spinning up a real Crawler.
38
+ *
39
+ * Idempotent: calling twice for the same `urlHref` is safe — the second
40
+ * call sees an empty buffer and is a no-op.
41
+ * @param buffer - The pending-phase-errors map, keyed by URL href.
42
+ * @param urlHref - URL whose buffered errors should be drained.
43
+ * @param isExternal - Whether the URL is external to the crawl scope.
44
+ * @param emit - Callback invoked once per buffered phase-error record.
45
+ * @returns The number of phase-error events emitted (0 when the buffer
46
+ * had no entry for `urlHref`).
47
+ */
48
+ export declare function drainPhaseErrors(buffer: Map<string, BufferedPhaseError[]>, urlHref: string, isExternal: boolean, emit: DrainPhaseErrorsEmit): number;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Drains the buffered phase errors for a URL: removes the entry from
3
+ * `buffer` and invokes `emit` once per buffered record.
4
+ *
5
+ * WHY a standalone function: the Crawler buffers `retryExhausted` events
6
+ * keyed by `url.href` during scrapeStart, then flushes them as
7
+ * `pageError` events AFTER `page` / `externalPage` has been emitted so the
8
+ * orchestrator's WriteQueue serialises `setPage` before `insertPageError`.
9
+ * Extracting the drain step here makes the flush + delete contract
10
+ * directly unit-testable without spinning up a real Crawler.
11
+ *
12
+ * Idempotent: calling twice for the same `urlHref` is safe — the second
13
+ * call sees an empty buffer and is a no-op.
14
+ * @param buffer - The pending-phase-errors map, keyed by URL href.
15
+ * @param urlHref - URL whose buffered errors should be drained.
16
+ * @param isExternal - Whether the URL is external to the crawl scope.
17
+ * @param emit - Callback invoked once per buffered phase-error record.
18
+ * @returns The number of phase-error events emitted (0 when the buffer
19
+ * had no entry for `urlHref`).
20
+ */
21
+ export function drainPhaseErrors(buffer, urlHref, isExternal, emit) {
22
+ const errors = buffer.get(urlHref);
23
+ if (!errors || errors.length === 0)
24
+ return 0;
25
+ buffer.delete(urlHref);
26
+ for (const err of errors) {
27
+ emit({
28
+ url: urlHref,
29
+ phase: err.phase,
30
+ message: err.message,
31
+ isExternal,
32
+ });
33
+ }
34
+ return errors.length;
35
+ }
@@ -20,19 +20,27 @@ export interface FetchDestinationParams {
20
20
  };
21
21
  /** User-Agent string to send with the request. */
22
22
  readonly userAgent?: string;
23
+ /**
24
+ * Race timeout for the network request in milliseconds. Defaults to
25
+ * {@link DEFAULT_HEAD_TIMEOUT_MS} (10s). `Crawler.#sendHeadRequest` passes
26
+ * a longer value on later retry attempts so a slow-but-reachable server
27
+ * gets another chance before being given up on.
28
+ */
29
+ readonly timeout?: number;
23
30
  }
24
31
  /**
25
32
  * Fetches the destination metadata for a URL using an HTTP HEAD request (or GET as fallback).
26
33
  *
27
34
  * Results are cached in memory so that repeated calls for the same URL
28
- * (without hash) return immediately. The request races against a 10-second
29
- * timeout; if the server does not respond in time, a {@link NetTimeoutError} is thrown.
35
+ * (without hash) return immediately. The request races against a configurable
36
+ * timeout (defaults to {@link DEFAULT_HEAD_TIMEOUT_MS}, 10 seconds); if the
37
+ * server does not respond in time, a {@link NetTimeoutError} is thrown.
30
38
  *
31
39
  * If the server returns 405 (Method Not Allowed), 501 (Not Implemented), or 503
32
40
  * (Service Unavailable) for a HEAD request, the function automatically retries with GET.
33
- * @param params - Parameters containing URL, external flag, method, options, and optional User-Agent.
41
+ * @param params - Parameters containing URL, external flag, method, options, optional User-Agent, and optional timeout.
34
42
  * @returns The page metadata obtained from the HTTP response.
35
- * @throws {NetTimeoutError} If the request exceeds the 10-second timeout.
43
+ * @throws {NetTimeoutError} If the request exceeds the configured timeout.
36
44
  * @throws {Error} If the HTTP request fails for any other reason.
37
45
  */
38
46
  export declare function fetchDestination(params: FetchDestinationParams): Promise<PageData>;