@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.
- package/lib/archive/archive.d.ts +117 -2
- package/lib/archive/archive.js +147 -2
- package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
- package/lib/archive/cache/compute-archive-cache-key.js +95 -0
- package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
- package/lib/archive/cache/extract-archive-to-cache.js +309 -0
- package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
- package/lib/archive/cache/get-archive-cache-root.js +53 -0
- package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
- package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
- package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
- package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
- package/lib/archive/database.d.ts +216 -15
- package/lib/archive/database.js +1459 -938
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- package/lib/archive/get-failed-page-messages.d.ts +43 -0
- package/lib/archive/get-failed-page-messages.js +131 -0
- package/lib/archive/init-schema.js +153 -1
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/migrate-inventory-runs.d.ts +29 -0
- package/lib/archive/migrate-inventory-runs.js +52 -0
- package/lib/archive/types.d.ts +33 -0
- package/lib/classify-error-kind.d.ts +19 -0
- package/lib/classify-error-kind.js +122 -0
- package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
- package/lib/crawler/build-js-redirect-edge.js +57 -0
- package/lib/crawler/build-redirect-event.d.ts +24 -0
- package/lib/crawler/build-redirect-event.js +28 -0
- package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
- package/lib/crawler/crawler.d.ts +3 -1
- package/lib/crawler/crawler.js +655 -107
- package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
- package/lib/crawler/derive-js-redirect-target.js +129 -0
- package/lib/crawler/derive-resource-source.d.ts +25 -15
- package/lib/crawler/derive-resource-source.js +28 -17
- package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
- package/lib/crawler/dns-burned-host-cache.js +25 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
- package/lib/crawler/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +94 -16
- package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
- package/lib/crawler/is-js-redirect-error-shape.js +53 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +23 -3
- package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
- package/lib/crawler/plan-sub-resource-emits.js +44 -0
- package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
- package/lib/crawler/preload-short-circuit-error.js +25 -0
- package/lib/crawler/should-burn-host.d.ts +78 -0
- package/lib/crawler/should-burn-host.js +61 -0
- package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
- package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
- package/lib/crawler/types.d.ts +107 -0
- package/lib/crawler-orchestrator.d.ts +13 -3
- package/lib/crawler-orchestrator.js +292 -69
- package/lib/crawler.d.ts +3 -2
- package/lib/crawler.js +3 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/types.d.ts +84 -0
- package/lib/utils/compute-file-sha256.d.ts +23 -0
- package/lib/utils/compute-file-sha256.js +55 -0
- package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
- package/lib/utils/error/emit-error-with-retry.js +44 -0
- package/lib/utils/error/emit-error.d.ts +39 -0
- package/lib/utils/error/emit-error.js +41 -0
- package/package.json +11 -11
- package/lib/utils/error/error-emitter.d.ts +0 -18
- 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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
14
|
-
* `undefined` so the caller emits no `source` and the
|
|
15
|
-
* (`'crawled'`) lands on the row
|
|
16
|
-
* untouched.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* @
|
|
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(
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
* `undefined` so the caller emits no `source` and the
|
|
13
|
-
* (`'crawled'`) lands on the row
|
|
14
|
-
* untouched.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* @
|
|
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(
|
|
22
|
-
if (
|
|
23
|
-
return
|
|
32
|
+
export function deriveResourceSource(parentSource) {
|
|
33
|
+
if (parentSource === 'inventory-seed' || parentSource === 'inventory-discovered') {
|
|
34
|
+
return 'inventory-discovered';
|
|
24
35
|
}
|
|
25
|
-
return
|
|
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
|
|
29
|
-
* timeout
|
|
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,
|
|
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
|
|
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
|
-
* timeout
|
|
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,
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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)),
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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;
|