@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
@@ -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
+ }
@@ -1,23 +1,33 @@
1
- import type { InventoryMode } from './types.js';
2
1
  import type { PageSource } from '../archive/types.js';
3
2
  /**
4
3
  * Decide which {@link PageSource} label a newly-captured sub-resource row
5
- * (CSS / JS / image / fetch response) should carry.
4
+ * (CSS / JS / image / fetch response) should carry, based on the lineage of
5
+ * the page that is currently being rendered.
6
6
  *
7
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
9
- * are downstream and must be labelled `'inventory-discovered'`. The seed
10
- * label is reserved for URLs that were explicitly handed in by the user
11
- * via the `--inventory` file.
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.
12
15
  *
13
- * Outside inventory mode (`inventoryMode === null`) this returns
14
- * `undefined` so the caller emits no `source` and the DB DEFAULT
15
- * (`'crawled'`) lands on the row keeps the normal crawl path
16
- * untouched. This is the sub-resource counterpart of
17
- * {@link import('./derive-page-source.js').derivePageSource}; the two
18
- * helpers exist as a pair so a future addition to {@link PageSource}
19
- * forces a parallel update.
20
- * @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
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).
21
31
  * @returns The label to write to `resources.source`, or `undefined` for the DB default.
22
32
  */
23
- export declare function deriveResourceSource(inventoryMode: InventoryMode | null): PageSource | undefined;
33
+ export declare function deriveResourceSource(parentSource: PageSource | undefined): PageSource | undefined;
@@ -1,26 +1,37 @@
1
1
  /**
2
2
  * Decide which {@link PageSource} label a newly-captured sub-resource row
3
- * (CSS / JS / image / fetch response) should carry.
3
+ * (CSS / JS / image / fetch response) should carry, based on the lineage of
4
+ * the page that is currently being rendered.
4
5
  *
5
6
  * Sub-resources are NEVER themselves seeds — even when puppeteer is
6
- * rendering a page that *is* an inventory seed, the assets it pulls in
7
- * are downstream and must be labelled `'inventory-discovered'`. The seed
8
- * label is reserved for URLs that were explicitly handed in by the user
9
- * via the `--inventory` file.
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.
10
14
  *
11
- * Outside inventory mode (`inventoryMode === null`) this returns
12
- * `undefined` so the caller emits no `source` and the DB DEFAULT
13
- * (`'crawled'`) lands on the row keeps the normal crawl path
14
- * untouched. This is the sub-resource counterpart of
15
- * {@link import('./derive-page-source.js').derivePageSource}; the two
16
- * helpers exist as a pair so a future addition to {@link PageSource}
17
- * forces a parallel update.
18
- * @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
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).
19
30
  * @returns The label to write to `resources.source`, or `undefined` for the DB default.
20
31
  */
21
- export function deriveResourceSource(inventoryMode) {
22
- if (inventoryMode === null) {
23
- return undefined;
32
+ export function deriveResourceSource(parentSource) {
33
+ if (parentSource === 'inventory-seed' || parentSource === 'inventory-discovered') {
34
+ return 'inventory-discovered';
24
35
  }
25
- return 'inventory-discovered';
36
+ return undefined;
26
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 };
@@ -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>;
@@ -2,22 +2,26 @@ import { delay } from '@d-zero/shared/delay';
2
2
  import redirects from 'follow-redirects';
3
3
  import { destinationCache } from './destination-cache.js';
4
4
  import NetTimeoutError from './net-timeout-error.js';
5
+ import { shouldGetFallbackOnHeadFailure } from './should-get-fallback-on-head-failure.js';
6
+ /** Default race timeout for the HEAD pre-flight, in milliseconds. */
7
+ const DEFAULT_HEAD_TIMEOUT_MS = 10 * 1000;
5
8
  /**
6
9
  * Fetches the destination metadata for a URL using an HTTP HEAD request (or GET as fallback).
7
10
  *
8
11
  * Results are cached in memory so that repeated calls for the same URL
9
- * (without hash) return immediately. The request races against a 10-second
10
- * timeout; if the server does not respond in time, a {@link NetTimeoutError} is thrown.
12
+ * (without hash) return immediately. The request races against a configurable
13
+ * timeout (defaults to {@link DEFAULT_HEAD_TIMEOUT_MS}, 10 seconds); if the
14
+ * server does not respond in time, a {@link NetTimeoutError} is thrown.
11
15
  *
12
16
  * If the server returns 405 (Method Not Allowed), 501 (Not Implemented), or 503
13
17
  * (Service Unavailable) for a HEAD request, the function automatically retries with GET.
14
- * @param params - Parameters containing URL, external flag, method, options, and optional User-Agent.
18
+ * @param params - Parameters containing URL, external flag, method, options, optional User-Agent, and optional timeout.
15
19
  * @returns The page metadata obtained from the HTTP response.
16
- * @throws {NetTimeoutError} If the request exceeds the 10-second timeout.
20
+ * @throws {NetTimeoutError} If the request exceeds the configured timeout.
17
21
  * @throws {Error} If the HTTP request fails for any other reason.
18
22
  */
19
23
  export async function fetchDestination(params) {
20
- const { url, isExternal, method = 'HEAD', options, userAgent } = params;
24
+ const { url, isExternal, method = 'HEAD', options, userAgent, timeout } = params;
21
25
  const titleBytesLimit = options?.titleBytesLimit;
22
26
  const cacheKey = titleBytesLimit == null ? url.withoutHash : `${url.withoutHash}:title`;
23
27
  if (destinationCache.has(cacheKey)) {
@@ -28,20 +32,65 @@ export async function fetchDestination(params) {
28
32
  return cache;
29
33
  }
30
34
  const effectiveMethod = titleBytesLimit == null ? method : 'GET';
31
- // Race the fetch against a 10-second timeout. The losing timer is cleared
35
+ const raceTimeoutMs = timeout ?? DEFAULT_HEAD_TIMEOUT_MS;
36
+ // Race the fetch against the requested timeout. The losing timer is cleared
32
37
  // explicitly so it never keeps the event loop alive after the race settles
33
38
  // (a plain `delay()` in `Promise.race` would leak the timer until it fires).
34
39
  let timeoutHandle;
35
40
  const result = await Promise.race([
36
- _fetchHead(url, isExternal, effectiveMethod, titleBytesLimit, userAgent).catch((error) => (error instanceof Error ? error : new Error(String(error)))),
41
+ _fetchHead(url, isExternal, effectiveMethod, titleBytesLimit, userAgent, timeout).catch((error) => error instanceof Error ? error : new Error(String(error))),
37
42
  new Promise((resolve) => {
38
- timeoutHandle = setTimeout(() => resolve(new NetTimeoutError(url.href)), 10 * 1000);
43
+ timeoutHandle = setTimeout(() => resolve(new NetTimeoutError(url.href)), raceTimeoutMs);
39
44
  }),
40
45
  ]).finally(() => {
41
46
  if (timeoutHandle)
42
47
  clearTimeout(timeoutHandle);
43
48
  });
44
- destinationCache.set(cacheKey, result);
49
+ // HEAD failure fallback: a WAF / middlebox that silently drops HEAD will
50
+ // surface as NetTimeoutError / parse-error / connection-reset here even
51
+ // though the same URL serves a normal GET response. Try GET once (using
52
+ // the same timeout budget) before giving up on the URL. Only when
53
+ // `method === 'HEAD'` to avoid infinite recursion if the GET itself
54
+ // times out — at that point the server really is unreachable.
55
+ if (method === 'HEAD' &&
56
+ result instanceof Error &&
57
+ shouldGetFallbackOnHeadFailure(result)) {
58
+ try {
59
+ const getResult = await fetchDestination({
60
+ url,
61
+ isExternal,
62
+ method: 'GET',
63
+ userAgent,
64
+ timeout,
65
+ });
66
+ // GET succeeded — that is the canonical answer for this URL, so
67
+ // cache it under the HEAD cacheKey too (same key, since cacheKey
68
+ // only depends on URL + titleBytesLimit, not on method). The
69
+ // inner GET call already wrote to the cache under the same key,
70
+ // but a future caller hitting the HEAD path will find it there.
71
+ return getResult;
72
+ }
73
+ catch {
74
+ // GET fallback failed too; fall through to surface the original
75
+ // HEAD failure so retry / classification / DNS-burned cache see
76
+ // the actual underlying cause.
77
+ }
78
+ }
79
+ // Errors that are RECOVERABLE on retry — NetTimeoutError plus the kinds
80
+ // `shouldGetFallbackOnHeadFailure` already singles out as
81
+ // possibly-recoverable (parse-error, connection-reset) — are
82
+ // intentionally NOT cached. Caching a recoverable failure would freeze
83
+ // the first slow probe as the verdict for every later caller on the
84
+ // same host AND defeat `Crawler.#sendHeadRequest`'s
85
+ // HEAD_TIMEOUT_ESCALATION_MS (the 30s/60s retry would hit the cache and
86
+ // re-throw the stale 10s failure instead of getting the longer
87
+ // budget). DNS / TLS / refused / blocked are persistent within a crawl
88
+ // session so caching them is what keeps a doomed host from re-paying
89
+ // the network cost N times.
90
+ const isRecoverableError = result instanceof Error && shouldGetFallbackOnHeadFailure(result);
91
+ if (!isRecoverableError) {
92
+ destinationCache.set(cacheKey, result);
93
+ }
45
94
  if (result instanceof Error) {
46
95
  throw result;
47
96
  }
@@ -58,9 +107,11 @@ export async function fetchDestination(params) {
58
107
  * @param titleBytesLimit - When set, reads up to this many bytes from the response body
59
108
  * to extract a `<title>` tag, then destroys the connection.
60
109
  * @param userAgent - Optional User-Agent string to send with the request.
110
+ * @param timeout - Optional race timeout in ms, forwarded to GET fallback so the
111
+ * second pass keeps the same budget as the original HEAD attempt.
61
112
  * @returns A promise resolving to {@link PageData} with response metadata.
62
113
  */
63
- async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
114
+ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent, timeout) {
64
115
  return new Promise((resolve, reject) => {
65
116
  const hostHeader = url.port ? `${url.hostname}:${url.port}` : url.hostname;
66
117
  // `trackRedirects` makes follow-redirects populate `res.redirects` with the
@@ -154,11 +205,21 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
154
205
  let rep = buildPageData('');
155
206
  if (rep.status === 405) {
156
207
  if (method === 'GET') {
157
- reject(new Error(`Method Not Allowed: ${url.href} ${rep.statusText}`));
208
+ // GET fallback also returned 405 — the server really does
209
+ // reject both methods. Resolve with the PageData so the
210
+ // archive records `status: 405` instead of the `-1`
211
+ // sentinel a reject would land on (which would erase the
212
+ // only useful diagnostic the server gave us).
213
+ resolve(rep);
158
214
  return;
159
215
  }
160
216
  try {
161
- rep = await fetchDestination({ url, isExternal, method: 'GET' });
217
+ rep = await fetchDestination({
218
+ url,
219
+ isExternal,
220
+ method: 'GET',
221
+ timeout,
222
+ });
162
223
  }
163
224
  catch (error) {
164
225
  reject(error);
@@ -167,12 +228,19 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
167
228
  }
168
229
  if (rep.status === 501) {
169
230
  if (method === 'GET') {
170
- reject(new Error(`Method Not Implemented: ${url.href} ${rep.statusText}`));
231
+ // GET fallback also returned 501 — preserve the status
232
+ // rather than dropping it into the `-1` bucket.
233
+ resolve(rep);
171
234
  return;
172
235
  }
173
236
  await delay(5 * 1000);
174
237
  try {
175
- rep = await fetchDestination({ url, isExternal, method: 'GET' });
238
+ rep = await fetchDestination({
239
+ url,
240
+ isExternal,
241
+ method: 'GET',
242
+ timeout,
243
+ });
176
244
  }
177
245
  catch (error) {
178
246
  reject(error);
@@ -181,12 +249,22 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
181
249
  }
182
250
  if (rep.status === 503) {
183
251
  if (method === 'GET') {
184
- reject(new Error(`Retrying failed: ${url.href} ${rep.statusText}`));
252
+ // GET fallback also returned 503 — preserve the status.
253
+ // A second-pass 5xx from a different method is the
254
+ // server's real answer, not a transient HEAD-only quirk,
255
+ // so the archive should remember it as 503 instead of
256
+ // the generic `-1` sentinel.
257
+ resolve(rep);
185
258
  return;
186
259
  }
187
260
  await delay(5 * 1000);
188
261
  try {
189
- rep = await fetchDestination({ url, isExternal, method: 'GET' });
262
+ rep = await fetchDestination({
263
+ url,
264
+ isExternal,
265
+ method: 'GET',
266
+ timeout,
267
+ });
190
268
  }
191
269
  catch (error) {
192
270
  reject(error);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Decide whether a browser-scrape error message is the specific
3
+ * `Page.goto() returned null` shape that the JS-redirect rescue is
4
+ * designed to recover from.
5
+ *
6
+ * **Why this gate exists:** before the gate, the rescue fired on *any*
7
+ * thrown error from `scraper.scrapeStart` as long as `page.url()` happened
8
+ * to report a different http(s) URL. That made every browser failure
9
+ * (TLS, target-crashed, OOM, navigation timeout, …) that incidentally
10
+ * left the page on a follow-up URL look like a JS redirect, hiding the
11
+ * real failure mode and stamping a phantom `status = 301` on the source.
12
+ *
13
+ * The narrow trigger only fires on the upstream's exact sentinel —
14
+ * `Page.goto returned null` — which beholder's scraper throws *only*
15
+ * when puppeteer's `page.goto()` resolved to `null`. Substring match (not
16
+ * equality) so wrapped variants like `[Retried 3 times] The method
17
+ * Page.goto returned null` (which surface in `crawl_errors` after retry
18
+ * exhaustion at outer layers) still classify, even though the rescue
19
+ * sees the bare form. Case-insensitive on the marker so a future
20
+ * beholder bump that lowercases the message keeps working.
21
+ *
22
+ * The trigger keys off the message *string*, not the message-classifier
23
+ * `kind`, because the rescue runs *before* the kind decision: the kind
24
+ * classifier would already wash this into `protocol`, and `protocol`
25
+ * covers more than just goto-null (Target closed / Session closed /
26
+ * detached Frame …) — none of which leave puppeteer with a meaningful
27
+ * post-navigation URL to recover.
28
+ * @param message - The raw error message from
29
+ * `BrowserScrapeResult.error.message` (or any string that may carry
30
+ * the sentinel inside a wrapper). `null` / `undefined` returns `false`.
31
+ * @returns `true` iff the message carries the `Page.goto returned null`
32
+ * sentinel.
33
+ * @example
34
+ * ```ts
35
+ * isJsRedirectErrorShape('The method Page.goto returned null'); // → true
36
+ * isJsRedirectErrorShape('Navigation timeout of 60000 ms exceeded'); // → false
37
+ * isJsRedirectErrorShape(undefined); // → false
38
+ * ```
39
+ */
40
+ export declare function isJsRedirectErrorShape(message: string | null | undefined): boolean;