@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,53 @@
1
+ /**
2
+ * Sentinel string emitted by `@d-zero/beholder`'s scraper when
3
+ * `await page.goto(...)` resolves to `null`. Pinned here because the
4
+ * JS-redirect rescue in `Crawler.#scrapePage` keys off the exact text —
5
+ * an upstream rename would silently disable the rescue, but the
6
+ * spec on this helper would also break, surfacing the drift in CI.
7
+ */
8
+ const PAGE_GOTO_NULL_MARKER = 'Page.goto returned null';
9
+ /**
10
+ * Decide whether a browser-scrape error message is the specific
11
+ * `Page.goto() returned null` shape that the JS-redirect rescue is
12
+ * designed to recover from.
13
+ *
14
+ * **Why this gate exists:** before the gate, the rescue fired on *any*
15
+ * thrown error from `scraper.scrapeStart` as long as `page.url()` happened
16
+ * to report a different http(s) URL. That made every browser failure
17
+ * (TLS, target-crashed, OOM, navigation timeout, …) that incidentally
18
+ * left the page on a follow-up URL look like a JS redirect, hiding the
19
+ * real failure mode and stamping a phantom `status = 301` on the source.
20
+ *
21
+ * The narrow trigger only fires on the upstream's exact sentinel —
22
+ * `Page.goto returned null` — which beholder's scraper throws *only*
23
+ * when puppeteer's `page.goto()` resolved to `null`. Substring match (not
24
+ * equality) so wrapped variants like `[Retried 3 times] The method
25
+ * Page.goto returned null` (which surface in `crawl_errors` after retry
26
+ * exhaustion at outer layers) still classify, even though the rescue
27
+ * sees the bare form. Case-insensitive on the marker so a future
28
+ * beholder bump that lowercases the message keeps working.
29
+ *
30
+ * The trigger keys off the message *string*, not the message-classifier
31
+ * `kind`, because the rescue runs *before* the kind decision: the kind
32
+ * classifier would already wash this into `protocol`, and `protocol`
33
+ * covers more than just goto-null (Target closed / Session closed /
34
+ * detached Frame …) — none of which leave puppeteer with a meaningful
35
+ * post-navigation URL to recover.
36
+ * @param message - The raw error message from
37
+ * `BrowserScrapeResult.error.message` (or any string that may carry
38
+ * the sentinel inside a wrapper). `null` / `undefined` returns `false`.
39
+ * @returns `true` iff the message carries the `Page.goto returned null`
40
+ * sentinel.
41
+ * @example
42
+ * ```ts
43
+ * isJsRedirectErrorShape('The method Page.goto returned null'); // → true
44
+ * isJsRedirectErrorShape('Navigation timeout of 60000 ms exceeded'); // → false
45
+ * isJsRedirectErrorShape(undefined); // → false
46
+ * ```
47
+ */
48
+ export function isJsRedirectErrorShape(message) {
49
+ if (typeof message !== 'string' || message === '') {
50
+ return false;
51
+ }
52
+ return message.toLowerCase().includes(PAGE_GOTO_NULL_MARKER.toLowerCase());
53
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Decide whether a failed HEAD/GET pre-flight error message warrants one
3
+ * puppeteer fallback attempt. Pure and deterministic — the same message
4
+ * always gives the same answer, so the decision can be unit-tested without
5
+ * spinning up a browser.
6
+ * @param message - The pre-flight error message (typically the last rejected
7
+ * `retryCall` attempt).
8
+ * @returns `true` when puppeteer should be tried once, `false` to give up.
9
+ * @example
10
+ * ```ts
11
+ * isPuppeteerFallbackCandidate('Timeout: https://slow.example.org/'); // true
12
+ * isPuppeteerFallbackCandidate('getaddrinfo ENOTFOUND host.invalid'); // false
13
+ * isPuppeteerFallbackCandidate('net::ERR_CERT_DATE_INVALID'); // false
14
+ * ```
15
+ */
16
+ export declare function isPuppeteerFallbackCandidate(message: string): boolean;
@@ -0,0 +1,63 @@
1
+ import { classifyErrorKind } from '../classify-error-kind.js';
2
+ /**
3
+ * Error kinds where a full puppeteer navigation has a realistic chance of
4
+ * succeeding even though the HEAD pre-flight (and its GET fallback) failed.
5
+ *
6
+ * These are the failure modes that a misconfigured WAF / middlebox / slow
7
+ * origin tends to produce against a bare HEAD/GET probe while still letting
8
+ * a real browser through — the browser uses a different request shape (full
9
+ * navigation lifecycle, JS-capable Accept headers, real cookies, optionally
10
+ * client TLS hints), and some hostile middleboxes only inspect the cheap
11
+ * shape. The fallback is one attempt only; if puppeteer also fails the URL
12
+ * is recorded as `status = -1` like before.
13
+ *
14
+ * Excluded kinds:
15
+ * - **dns / dns-transient** — DNS resolution happens at the OS level before
16
+ * any browser request; puppeteer hits the same `getaddrinfo` outcome.
17
+ * - **tls** — Chromium will refuse the same certificate the Node TLS stack
18
+ * refused (expired, wrong SAN, untrusted CA).
19
+ * - **client-blocked** — by definition the browser is the one rejecting.
20
+ * - **connection-refused** — TCP RST from the listener; same answer regardless
21
+ * of client.
22
+ * - **connection-timeout** — `ETIMEDOUT` at the TCP connect stage means the
23
+ * packets never reached the host (no SYN-ACK); puppeteer issues the same
24
+ * `connect()` call and gets the same answer. Reserved for the middlebox
25
+ * case (request reached the server, response timed out), which classifies
26
+ * as `timeout` via the `NetTimeoutError "Timeout: <url>"` shape.
27
+ * - **local-network** — operator-side connectivity loss; nothing on this
28
+ * machine will reach the host.
29
+ * - **protocol** — puppeteer lifecycle race; bouncing back to puppeteer
30
+ * reproduces the same race.
31
+ * - **unknown** — by design. Spinning up a fresh Chromium for every
32
+ * unclassifiable error is too expensive; if a real-world WAF / middlebox
33
+ * pattern lands in `unknown`, add a matcher to {@link classifyErrorKind}
34
+ * so it lands in one of the four included kinds above (where the fallback
35
+ * has a meaningful chance of succeeding) instead of widening this set.
36
+ *
37
+ * `PreloadShortCircuitError`'s synthesised `getaddrinfo ENOTFOUND` message
38
+ * classifies into `dns` and is therefore filtered out automatically — no
39
+ * separate instanceof guard is needed at the call site.
40
+ */
41
+ const PUPPETEER_FALLBACK_KINDS = new Set([
42
+ 'timeout',
43
+ 'connection-reset',
44
+ 'parse-error',
45
+ ]);
46
+ /**
47
+ * Decide whether a failed HEAD/GET pre-flight error message warrants one
48
+ * puppeteer fallback attempt. Pure and deterministic — the same message
49
+ * always gives the same answer, so the decision can be unit-tested without
50
+ * spinning up a browser.
51
+ * @param message - The pre-flight error message (typically the last rejected
52
+ * `retryCall` attempt).
53
+ * @returns `true` when puppeteer should be tried once, `false` to give up.
54
+ * @example
55
+ * ```ts
56
+ * isPuppeteerFallbackCandidate('Timeout: https://slow.example.org/'); // true
57
+ * isPuppeteerFallbackCandidate('getaddrinfo ENOTFOUND host.invalid'); // false
58
+ * isPuppeteerFallbackCandidate('net::ERR_CERT_DATE_INVALID'); // false
59
+ * ```
60
+ */
61
+ export function isPuppeteerFallbackCandidate(message) {
62
+ return PUPPETEER_FALLBACK_KINDS.has(classifyErrorKind(message));
63
+ }
@@ -45,12 +45,32 @@ export default class LinkList {
45
45
  * @param resource.page - The scraped page data, if the scrape succeeded.
46
46
  * @param resource.error - The error object, if the scrape failed.
47
47
  * @param options - URL parsing options (e.g., `disableQueries`).
48
+ * @param completion - Behaviour overrides for how the redirect chain is folded into
49
+ * the done-set.
50
+ * @param completion.includeRedirectPaths - When `false`, do NOT mark the URLs in
51
+ * `resource.page.redirectPaths` as done. The default `true` preserves the
52
+ * long-standing behaviour where a redirect chain (`/a → /b → /c`) folds every
53
+ * intermediate URL into the done-set in a single sweep — correct for HTTP-layer
54
+ * chains because the browser actually followed each hop, so reaching `/b` later
55
+ * is a no-op. Pass `false` from the JS-redirect rescue: there `redirectPaths`
56
+ * contains a single URL (the JS target Chromium navigated to after `page.goto()`
57
+ * returned null), which the browser has NOT yet rendered. Folding it into the
58
+ * done-set would make a subsequent `linkList.add(destinationUrl)` no-op (the
59
+ * add() guard at line 51-53 refuses keys already in `#done`), so the dest URL
60
+ * never enters `#pending` and the dealer never sees a push to enqueue. The
61
+ * dealer's own `seen` Set in `#runDeal` (the gate `onPush` consults) is a
62
+ * separate registry from `#done` — they are NOT kept in sync — but here it
63
+ * does not matter: the rescue's `add()` is what feeds the eventual `enqueue()`
64
+ * call, so blocking `add()` alone is enough to silently lose the JS target
65
+ * from the archive.
48
66
  * @returns The constructed {@link Link} object, or `null` if the URL was not in the queue.
49
67
  */
50
68
  done(url: ExURL, scope: ReadonlyMap<string, readonly ExURL[]>, resource: {
51
69
  page?: PageData;
52
70
  error?: Error;
53
- }, options: ParseURLOptions): Link | null;
71
+ }, options: ParseURLOptions, completion?: {
72
+ includeRedirectPaths?: boolean;
73
+ }): Link | null;
54
74
  /**
55
75
  * Get the current pending and in-progress URL lists.
56
76
  * @returns An object containing arrays of pending and in-progress URL strings.
@@ -64,9 +64,27 @@ export default class LinkList {
64
64
  * @param resource.page - The scraped page data, if the scrape succeeded.
65
65
  * @param resource.error - The error object, if the scrape failed.
66
66
  * @param options - URL parsing options (e.g., `disableQueries`).
67
+ * @param completion - Behaviour overrides for how the redirect chain is folded into
68
+ * the done-set.
69
+ * @param completion.includeRedirectPaths - When `false`, do NOT mark the URLs in
70
+ * `resource.page.redirectPaths` as done. The default `true` preserves the
71
+ * long-standing behaviour where a redirect chain (`/a → /b → /c`) folds every
72
+ * intermediate URL into the done-set in a single sweep — correct for HTTP-layer
73
+ * chains because the browser actually followed each hop, so reaching `/b` later
74
+ * is a no-op. Pass `false` from the JS-redirect rescue: there `redirectPaths`
75
+ * contains a single URL (the JS target Chromium navigated to after `page.goto()`
76
+ * returned null), which the browser has NOT yet rendered. Folding it into the
77
+ * done-set would make a subsequent `linkList.add(destinationUrl)` no-op (the
78
+ * add() guard at line 51-53 refuses keys already in `#done`), so the dest URL
79
+ * never enters `#pending` and the dealer never sees a push to enqueue. The
80
+ * dealer's own `seen` Set in `#runDeal` (the gate `onPush` consults) is a
81
+ * separate registry from `#done` — they are NOT kept in sync — but here it
82
+ * does not matter: the rescue's `add()` is what feeds the eventual `enqueue()`
83
+ * call, so blocking `add()` alone is enough to silently lose the JS target
84
+ * from the archive.
67
85
  * @returns The constructed {@link Link} object, or `null` if the URL was not in the queue.
68
86
  */
69
- done(url, scope, resource, options) {
87
+ done(url, scope, resource, options, completion) {
70
88
  const key = protocolAgnosticKey(url.withoutHashAndAuth);
71
89
  if (!(this.#pending.has(key) || this.#progress.has(key))) {
72
90
  return null;
@@ -96,8 +114,10 @@ export default class LinkList {
96
114
  responseHeaders: resource.page.responseHeaders,
97
115
  title: resource.page.meta.title,
98
116
  };
99
- for (const path of resource.page.redirectPaths) {
100
- urlList.add(protocolAgnosticKey(path));
117
+ if (completion?.includeRedirectPaths !== false) {
118
+ for (const path of resource.page.redirectPaths) {
119
+ urlList.add(protocolAgnosticKey(path));
120
+ }
101
121
  }
102
122
  }
103
123
  if (resource.error?.message.includes('ERR_NAME_NOT_RESOLVED')) {
@@ -0,0 +1,63 @@
1
+ import type { CrawlerEventTypes } from './types.js';
2
+ import type { PageSource } from '../archive/types.js';
3
+ import type { ResourceEntry } from '@d-zero/beholder';
4
+ /**
5
+ * Planned `response` emit produced by {@link planSubResourceEmits}.
6
+ */
7
+ export interface PlannedResponseEmit {
8
+ /** The resource payload to attach to the `response` event. */
9
+ resource: CrawlerEventTypes['response']['resource'];
10
+ /**
11
+ * The `source` field propagated to the `response` event. Resolved from
12
+ * the parent page's lineage via {@link deriveResourceSource} — pinning
13
+ * this through a planning step (rather than computing it inline in
14
+ * `#handleResources`) lets the wire-up be unit-tested without spinning
15
+ * up the puppeteer mock stack.
16
+ */
17
+ source: PageSource | undefined;
18
+ }
19
+ /**
20
+ * Planned `responseReferrers` emit produced by {@link planSubResourceEmits}.
21
+ * Always emitted, regardless of whether the resource is new — `isNew` only
22
+ * gates the `response` event.
23
+ */
24
+ export interface PlannedReferrerEmit {
25
+ /** The page URL that triggered the sub-resource fetch. */
26
+ url: string;
27
+ /** The resource URL (hash stripped to match the storage key). */
28
+ src: string;
29
+ }
30
+ /**
31
+ * Output of {@link planSubResourceEmits}: the deduped `response` plan and
32
+ * the per-resource `responseReferrers` plan, side-by-side.
33
+ */
34
+ export interface SubResourceEmitPlan {
35
+ /** `response` events to emit (new resources only). */
36
+ responseEmits: PlannedResponseEmit[];
37
+ /** `responseReferrers` events to emit (every resource, even seen ones). */
38
+ referrerEmits: PlannedReferrerEmit[];
39
+ }
40
+ /**
41
+ * Decide which sub-resource `response` / `responseReferrers` events the
42
+ * crawler should emit for a page render, with the parent's source lineage
43
+ * baked into every `response` event's `source` field.
44
+ *
45
+ * Pure function — takes the resources captured during the render plus the
46
+ * seen-resource set and the parent's source, returns the emit plan. The
47
+ * caller (`Crawler.#handleResources`) is responsible for iterating the
48
+ * plan through its event emitter. Splitting "decide what to emit" from
49
+ * "actually emit" is what makes the lineage propagation contract
50
+ * unit-testable: the previous shape inlined `emit('response', { ...
51
+ * source: deriveResourceSource(...) })` and could only be exercised via a
52
+ * full scrape with a mocked puppeteer stack, which left the `source`
53
+ * value half of the contract effectively un-pinned.
54
+ *
55
+ * Mutates `seenResources` as a side effect — every captured resource is
56
+ * recorded as seen so the next call dedupes correctly. This mirrors the
57
+ * `Crawler.#resources` Set semantics that the planner is designed to share.
58
+ * @param resources - Sub-resource entries captured during the page render.
59
+ * @param parentSource - Merged source of the page being rendered, as resolved by `Crawler.#resolveParentSource`.
60
+ * @param seenResources - Mutable set of already-seen resource keys (mutated in place).
61
+ * @returns The plan of `response` + `responseReferrers` emits to dispatch.
62
+ */
63
+ export declare function planSubResourceEmits(resources: ResourceEntry[], parentSource: PageSource | undefined, seenResources: Set<string>): SubResourceEmitPlan;
@@ -0,0 +1,44 @@
1
+ import { deriveResourceSource } from './derive-resource-source.js';
2
+ import { handleResourceResponse } from './handle-resource-response.js';
3
+ /**
4
+ * Decide which sub-resource `response` / `responseReferrers` events the
5
+ * crawler should emit for a page render, with the parent's source lineage
6
+ * baked into every `response` event's `source` field.
7
+ *
8
+ * Pure function — takes the resources captured during the render plus the
9
+ * seen-resource set and the parent's source, returns the emit plan. The
10
+ * caller (`Crawler.#handleResources`) is responsible for iterating the
11
+ * plan through its event emitter. Splitting "decide what to emit" from
12
+ * "actually emit" is what makes the lineage propagation contract
13
+ * unit-testable: the previous shape inlined `emit('response', { ...
14
+ * source: deriveResourceSource(...) })` and could only be exercised via a
15
+ * full scrape with a mocked puppeteer stack, which left the `source`
16
+ * value half of the contract effectively un-pinned.
17
+ *
18
+ * Mutates `seenResources` as a side effect — every captured resource is
19
+ * recorded as seen so the next call dedupes correctly. This mirrors the
20
+ * `Crawler.#resources` Set semantics that the planner is designed to share.
21
+ * @param resources - Sub-resource entries captured during the page render.
22
+ * @param parentSource - Merged source of the page being rendered, as resolved by `Crawler.#resolveParentSource`.
23
+ * @param seenResources - Mutable set of already-seen resource keys (mutated in place).
24
+ * @returns The plan of `response` + `responseReferrers` emits to dispatch.
25
+ */
26
+ export function planSubResourceEmits(resources, parentSource, seenResources) {
27
+ const subResourceSource = deriveResourceSource(parentSource);
28
+ const responseEmits = [];
29
+ const referrerEmits = [];
30
+ for (const { resource, pageUrl } of resources) {
31
+ const { isNew } = handleResourceResponse(resource, seenResources);
32
+ if (isNew) {
33
+ responseEmits.push({
34
+ resource: resource,
35
+ source: subResourceSource,
36
+ });
37
+ }
38
+ referrerEmits.push({
39
+ url: pageUrl,
40
+ src: resource.url.withoutHash,
41
+ });
42
+ }
43
+ return { responseEmits, referrerEmits };
44
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Thrown by `Crawler.#sendHeadRequest` when the target URL's hostname is in
3
+ * `dnsBurnedHostCache` — both session-learned and preload-seeded burns
4
+ * land here.
5
+ *
6
+ * The orchestrator's `crawler.on('error', …)` handler tests `instanceof` and
7
+ * skips writing this error to `crawl_errors` / `error.log`, so the same
8
+ * preload data isn't re-amplified on subsequent crawls. `pages.status = -1`
9
+ * still gets set through the normal scrape-error path.
10
+ *
11
+ * The message embeds the `ENOTFOUND` token so any downstream consumer that
12
+ * runs `classifyErrorKind` over it (e.g. dealer log forwarders) still gets
13
+ * the `'dns'` classification.
14
+ */
15
+ export declare class PreloadShortCircuitError extends Error {
16
+ /** Sniffable flag for callers that prefer duck-typing over instanceof. */
17
+ readonly isPreloadShortCircuit: true;
18
+ /**
19
+ * @param host - The DNS-burned hostname (already lowercased / Punycoded).
20
+ */
21
+ constructor(host: string);
22
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Thrown by `Crawler.#sendHeadRequest` when the target URL's hostname is in
3
+ * `dnsBurnedHostCache` — both session-learned and preload-seeded burns
4
+ * land here.
5
+ *
6
+ * The orchestrator's `crawler.on('error', …)` handler tests `instanceof` and
7
+ * skips writing this error to `crawl_errors` / `error.log`, so the same
8
+ * preload data isn't re-amplified on subsequent crawls. `pages.status = -1`
9
+ * still gets set through the normal scrape-error path.
10
+ *
11
+ * The message embeds the `ENOTFOUND` token so any downstream consumer that
12
+ * runs `classifyErrorKind` over it (e.g. dealer log forwarders) still gets
13
+ * the `'dns'` classification.
14
+ */
15
+ export class PreloadShortCircuitError extends Error {
16
+ /** Sniffable flag for callers that prefer duck-typing over instanceof. */
17
+ isPreloadShortCircuit = true;
18
+ /**
19
+ * @param host - The DNS-burned hostname (already lowercased / Punycoded).
20
+ */
21
+ constructor(host) {
22
+ super(`getaddrinfo ENOTFOUND ${host}`);
23
+ this.name = 'PreloadShortCircuitError';
24
+ }
25
+ }
@@ -0,0 +1,78 @@
1
+ import type { ErrorKind } from '../types.js';
2
+ /**
3
+ * Inputs to {@link shouldBurnHost}.
4
+ */
5
+ export interface ShouldBurnHostParams {
6
+ /**
7
+ * The {@link ErrorKind} classified from the final-attempt error message
8
+ * (i.e. the error that ended the retry loop in `Crawler.#sendHeadRequest`'s
9
+ * `onGiveUp`).
10
+ */
11
+ errorKind: ErrorKind;
12
+ /**
13
+ * Lower-cased hostname whose URL just exhausted retries. Must already be
14
+ * normalised by the caller — `dnsBurnedHostCache` keys are
15
+ * `url.hostname.toLowerCase()`, so this guard reuses that exact form to
16
+ * stay consistent across the two sites.
17
+ */
18
+ host: string;
19
+ /**
20
+ * Set of hostnames that have had at least one successful
21
+ * `fetchDestination` response in this session. `ReadonlySet` because the
22
+ * decision is read-only — populating the set is the caller's job.
23
+ */
24
+ successfulHosts: ReadonlySet<string>;
25
+ }
26
+ /**
27
+ * Burn the host iff the final-attempt kind is `'dns'` AND the host has no
28
+ * session-success record. Pure function — unit-testable in isolation from
29
+ * the `Crawler` instance, the in-memory caches, and the dealer's retry
30
+ * plumbing.
31
+ *
32
+ * **Why the session-success gate exists**: the first worker to exhaust
33
+ * retries with `getaddrinfo ENOTFOUND` would otherwise burn the host and
34
+ * make every subsequent URL on it short-circuit immediately via
35
+ * `PreloadShortCircuitError`, draining the dealer's work queue in seconds
36
+ * and collapsing the crawl into a degenerate `crawlEnd`. When the cause is
37
+ * a local-network blip (operator's WiFi → tethering / VPN flip / ISP DNS
38
+ * hiccup mid-crawl) rather than a dead domain, the host was demonstrably
39
+ * alive moments earlier — earlier successes on it are recorded in
40
+ * `successfulHosts`, so the cascade is suppressed.
41
+ *
42
+ * **Why `'dns-transient'` is excluded**: `EAI_AGAIN` / `EREFUSED` are
43
+ * absorbed by the retry layer within the session; a final-attempt
44
+ * `'dns-transient'` is rare enough that we'd rather pay the per-URL retry
45
+ * cost than wrongly fast-fail a healthy host that flapped briefly. Only the
46
+ * stronger `'dns'` kind is a candidate for burning.
47
+ *
48
+ * **Known limitation — first-URL false positives**: a host whose very first
49
+ * URL of the session hits a real network blip exhausts its retry budget
50
+ * before any URL has succeeded, so `successfulHosts` is still empty and the
51
+ * host IS burned. That URL's siblings on the same host then short-circuit.
52
+ * Acceptable trade-off: the alternative ("never burn anything") regresses
53
+ * the dead-domain fast-fail behavior that the burn cache exists for. The
54
+ * pause-dealer-on-outage layer (separate issue) covers this gap by
55
+ * detecting the outage BEFORE the first retry budget runs out.
56
+ *
57
+ * **Known limitation — preload-seeded burns are not un-burned**: a host
58
+ * added by `#preloadDnsBurnedHostCache` from the archive's `crawl_errors`
59
+ * trips `PreloadShortCircuitError` at the top of `#sendHeadRequest` and
60
+ * never reaches `fetchDestination`, so `successfulHosts` is never
61
+ * populated for it in this session. This is intentional — preload only
62
+ * seeds hosts whose archive evidence is "DNS-failed with no recovery", so
63
+ * un-burning them on a single transient success could re-introduce the
64
+ * cascade we are trying to prevent.
65
+ *
66
+ * **What does NOT contribute to `successfulHosts`**: external pages
67
+ * traversed via `fetchExternal: false` skip the HEAD pre-flight entirely
68
+ * (the crawler stamps a synthetic `PageData` without touching the
69
+ * network), so the host is not recorded as alive even if the same host
70
+ * appears in the crawled-internal scope. Callers must populate the set
71
+ * from real HTTP-response observations only.
72
+ * @param params - See {@link ShouldBurnHostParams}.
73
+ * @param params.errorKind
74
+ * @param params.host
75
+ * @param params.successfulHosts
76
+ * @returns `true` if the burn cache should record this host, `false` otherwise.
77
+ */
78
+ export declare function shouldBurnHost({ errorKind, host, successfulHosts, }: ShouldBurnHostParams): boolean;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Burn the host iff the final-attempt kind is `'dns'` AND the host has no
3
+ * session-success record. Pure function — unit-testable in isolation from
4
+ * the `Crawler` instance, the in-memory caches, and the dealer's retry
5
+ * plumbing.
6
+ *
7
+ * **Why the session-success gate exists**: the first worker to exhaust
8
+ * retries with `getaddrinfo ENOTFOUND` would otherwise burn the host and
9
+ * make every subsequent URL on it short-circuit immediately via
10
+ * `PreloadShortCircuitError`, draining the dealer's work queue in seconds
11
+ * and collapsing the crawl into a degenerate `crawlEnd`. When the cause is
12
+ * a local-network blip (operator's WiFi → tethering / VPN flip / ISP DNS
13
+ * hiccup mid-crawl) rather than a dead domain, the host was demonstrably
14
+ * alive moments earlier — earlier successes on it are recorded in
15
+ * `successfulHosts`, so the cascade is suppressed.
16
+ *
17
+ * **Why `'dns-transient'` is excluded**: `EAI_AGAIN` / `EREFUSED` are
18
+ * absorbed by the retry layer within the session; a final-attempt
19
+ * `'dns-transient'` is rare enough that we'd rather pay the per-URL retry
20
+ * cost than wrongly fast-fail a healthy host that flapped briefly. Only the
21
+ * stronger `'dns'` kind is a candidate for burning.
22
+ *
23
+ * **Known limitation — first-URL false positives**: a host whose very first
24
+ * URL of the session hits a real network blip exhausts its retry budget
25
+ * before any URL has succeeded, so `successfulHosts` is still empty and the
26
+ * host IS burned. That URL's siblings on the same host then short-circuit.
27
+ * Acceptable trade-off: the alternative ("never burn anything") regresses
28
+ * the dead-domain fast-fail behavior that the burn cache exists for. The
29
+ * pause-dealer-on-outage layer (separate issue) covers this gap by
30
+ * detecting the outage BEFORE the first retry budget runs out.
31
+ *
32
+ * **Known limitation — preload-seeded burns are not un-burned**: a host
33
+ * added by `#preloadDnsBurnedHostCache` from the archive's `crawl_errors`
34
+ * trips `PreloadShortCircuitError` at the top of `#sendHeadRequest` and
35
+ * never reaches `fetchDestination`, so `successfulHosts` is never
36
+ * populated for it in this session. This is intentional — preload only
37
+ * seeds hosts whose archive evidence is "DNS-failed with no recovery", so
38
+ * un-burning them on a single transient success could re-introduce the
39
+ * cascade we are trying to prevent.
40
+ *
41
+ * **What does NOT contribute to `successfulHosts`**: external pages
42
+ * traversed via `fetchExternal: false` skip the HEAD pre-flight entirely
43
+ * (the crawler stamps a synthetic `PageData` without touching the
44
+ * network), so the host is not recorded as alive even if the same host
45
+ * appears in the crawled-internal scope. Callers must populate the set
46
+ * from real HTTP-response observations only.
47
+ * @param params - See {@link ShouldBurnHostParams}.
48
+ * @param params.errorKind
49
+ * @param params.host
50
+ * @param params.successfulHosts
51
+ * @returns `true` if the burn cache should record this host, `false` otherwise.
52
+ */
53
+ export function shouldBurnHost({ errorKind, host, successfulHosts, }) {
54
+ if (errorKind !== 'dns') {
55
+ return false;
56
+ }
57
+ if (successfulHosts.has(host)) {
58
+ return false;
59
+ }
60
+ return true;
61
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Decide whether a HEAD pre-flight failure should trigger a GET retry and
3
+ * stay OUT of `destinationCache`.
4
+ *
5
+ * The two contracts are intentionally tied to one helper: anything we'll GET
6
+ * later because the HEAD answer might be wrong is also the same thing we
7
+ * must NOT freeze into the per-session cache (or the second attempt would
8
+ * hit the stale cached failure and skip the live retry that
9
+ * `Crawler.#sendHeadRequest`'s `HEAD_TIMEOUT_ESCALATION_MS` is supposed to
10
+ * pay for).
11
+ *
12
+ * The eligible kinds are:
13
+ *
14
+ * - **`NetTimeoutError`** — the HEAD pre-flight race fired without a
15
+ * server response. The escalating retry can still succeed against a
16
+ * slow-but-reachable host.
17
+ * - **`parse-error`** — `Parse Error` / `Expected HTTP/` / `Unexpected end
18
+ * of stream`. Usually a middlebox rewriting / truncating the HEAD reply
19
+ * the GET path traverses differently.
20
+ * - **`connection-reset`** — `ECONNRESET` / `ERR_CONNECTION_RESET` etc.
21
+ * middlebox dropping the connection mid-response; a retry frequently
22
+ * succeeds.
23
+ *
24
+ * Everything else — DNS, TLS, refused, blocked, plain timeout — is treated
25
+ * as a persistent within-session verdict and IS cached so repeated calls on
26
+ * the same host pay the network cost once.
27
+ * @param error - The `Error` raised by the HEAD attempt.
28
+ * @returns `true` when the error warrants a GET fallback AND a cache skip.
29
+ * @example
30
+ * ```ts
31
+ * shouldGetFallbackOnHeadFailure(new NetTimeoutError(url)); // true
32
+ * shouldGetFallbackOnHeadFailure(new Error('read ECONNRESET')); // true
33
+ * shouldGetFallbackOnHeadFailure(new Error('Parse Error')); // true
34
+ * shouldGetFallbackOnHeadFailure(new Error('getaddrinfo ENOTFOUND host')); // false
35
+ * shouldGetFallbackOnHeadFailure(new Error('ERR_CERT_DATE_INVALID')); // false
36
+ * ```
37
+ */
38
+ export declare function shouldGetFallbackOnHeadFailure(error: Error): boolean;
@@ -0,0 +1,46 @@
1
+ import { classifyErrorKind } from '../classify-error-kind.js';
2
+ import NetTimeoutError from './net-timeout-error.js';
3
+ /**
4
+ * Decide whether a HEAD pre-flight failure should trigger a GET retry and
5
+ * stay OUT of `destinationCache`.
6
+ *
7
+ * The two contracts are intentionally tied to one helper: anything we'll GET
8
+ * later because the HEAD answer might be wrong is also the same thing we
9
+ * must NOT freeze into the per-session cache (or the second attempt would
10
+ * hit the stale cached failure and skip the live retry that
11
+ * `Crawler.#sendHeadRequest`'s `HEAD_TIMEOUT_ESCALATION_MS` is supposed to
12
+ * pay for).
13
+ *
14
+ * The eligible kinds are:
15
+ *
16
+ * - **`NetTimeoutError`** — the HEAD pre-flight race fired without a
17
+ * server response. The escalating retry can still succeed against a
18
+ * slow-but-reachable host.
19
+ * - **`parse-error`** — `Parse Error` / `Expected HTTP/` / `Unexpected end
20
+ * of stream`. Usually a middlebox rewriting / truncating the HEAD reply
21
+ * the GET path traverses differently.
22
+ * - **`connection-reset`** — `ECONNRESET` / `ERR_CONNECTION_RESET` etc.
23
+ * middlebox dropping the connection mid-response; a retry frequently
24
+ * succeeds.
25
+ *
26
+ * Everything else — DNS, TLS, refused, blocked, plain timeout — is treated
27
+ * as a persistent within-session verdict and IS cached so repeated calls on
28
+ * the same host pay the network cost once.
29
+ * @param error - The `Error` raised by the HEAD attempt.
30
+ * @returns `true` when the error warrants a GET fallback AND a cache skip.
31
+ * @example
32
+ * ```ts
33
+ * shouldGetFallbackOnHeadFailure(new NetTimeoutError(url)); // true
34
+ * shouldGetFallbackOnHeadFailure(new Error('read ECONNRESET')); // true
35
+ * shouldGetFallbackOnHeadFailure(new Error('Parse Error')); // true
36
+ * shouldGetFallbackOnHeadFailure(new Error('getaddrinfo ENOTFOUND host')); // false
37
+ * shouldGetFallbackOnHeadFailure(new Error('ERR_CERT_DATE_INVALID')); // false
38
+ * ```
39
+ */
40
+ export function shouldGetFallbackOnHeadFailure(error) {
41
+ if (error instanceof NetTimeoutError) {
42
+ return true;
43
+ }
44
+ const kind = classifyErrorKind(error.message);
45
+ return kind === 'parse-error' || kind === 'connection-reset';
46
+ }