@nitpicker/crawler 0.14.0 → 0.15.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 (120) hide show
  1. package/lib/archive/archive.d.ts +98 -10
  2. package/lib/archive/archive.js +98 -9
  3. package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
  4. package/lib/archive/body-hash/compute-body-hash.js +31 -0
  5. package/lib/archive/body-hash/extract-body.d.ts +18 -0
  6. package/lib/archive/body-hash/extract-body.js +29 -0
  7. package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
  8. package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
  9. package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
  10. package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
  11. package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
  12. package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
  13. package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
  14. package/lib/archive/cache/clear-archive-cache-root.js +32 -0
  15. package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
  16. package/lib/archive/cache/list-archive-cache-entries.js +125 -0
  17. package/lib/archive/cache/path-exists.d.ts +9 -0
  18. package/lib/archive/cache/path-exists.js +18 -0
  19. package/lib/archive/cache/types.d.ts +33 -0
  20. package/lib/archive/cache/types.js +1 -0
  21. package/lib/archive/create-adjunct-tables.d.ts +11 -0
  22. package/lib/archive/create-adjunct-tables.js +127 -0
  23. package/lib/archive/create-entity-tables.js +21 -1
  24. package/lib/archive/database.d.ts +37 -2
  25. package/lib/archive/database.js +50 -3
  26. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
  27. package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
  28. package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
  29. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
  30. package/lib/archive/db-ops/_shared/types.d.ts +2 -0
  31. package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
  32. package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
  33. package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
  34. package/lib/archive/db-ops/analysis/types.d.ts +61 -0
  35. package/lib/archive/db-ops/analysis/types.js +1 -0
  36. package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
  37. package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
  38. package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
  39. package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
  40. package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
  41. package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
  42. package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
  43. package/lib/archive/db-ops/console-logs/types.js +1 -0
  44. package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
  45. package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
  46. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  47. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  48. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  49. package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
  50. package/lib/archive/db-ops/lifecycle/init.js +21 -2
  51. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  52. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  53. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  54. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  55. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  56. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  57. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  58. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  59. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  60. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  61. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
  62. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
  63. package/lib/archive/db-ops/pages/write/update-page.js +12 -0
  64. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  65. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  66. package/lib/archive/filesystem/output-binary.js +17 -0
  67. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  68. package/lib/archive/get-failed-page-messages.js +29 -15
  69. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  70. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  71. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  72. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  73. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  74. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  75. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  76. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  77. package/lib/archive/page.d.ts +1 -1
  78. package/lib/archive/types.d.ts +47 -2
  79. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  80. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  81. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  82. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  83. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  84. package/lib/archive/url-alias/format-alias-key.js +14 -0
  85. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  86. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  87. package/lib/archive/url-alias/types.d.ts +7 -0
  88. package/lib/archive/url-alias/types.js +1 -0
  89. package/lib/crawler/choose-probe-host.d.ts +24 -0
  90. package/lib/crawler/choose-probe-host.js +38 -0
  91. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  92. package/lib/crawler/crawler.js +257 -0
  93. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  94. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  95. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  96. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  97. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  98. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  99. package/lib/crawler/network-gate.d.ts +49 -0
  100. package/lib/crawler/network-gate.js +78 -0
  101. package/lib/crawler/network-outage-detector.d.ts +51 -0
  102. package/lib/crawler/network-outage-detector.js +81 -0
  103. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  104. package/lib/crawler/network-outage-summary-counter.js +18 -0
  105. package/lib/crawler/probe-network.d.ts +26 -0
  106. package/lib/crawler/probe-network.js +21 -0
  107. package/lib/crawler/types.d.ts +147 -1
  108. package/lib/crawler-orchestrator.d.ts +62 -18
  109. package/lib/crawler-orchestrator.js +161 -26
  110. package/lib/crawler.d.ts +21 -0
  111. package/lib/crawler.js +17 -0
  112. package/lib/is-within-outage-window.d.ts +49 -0
  113. package/lib/is-within-outage-window.js +33 -0
  114. package/lib/network-related-error-kinds.d.ts +23 -0
  115. package/lib/network-related-error-kinds.js +28 -0
  116. package/lib/types.d.ts +9 -1
  117. package/lib/utils/array/each-splitted.d.ts +1 -1
  118. package/lib/utils/compute-file-sha256.d.ts +17 -17
  119. package/lib/utils/compute-file-sha256.js +18 -47
  120. package/package.json +3 -3
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Computes the Tier B alias key for `url`: like `computeTierAAliasKey`,
3
+ * but additionally strips one trailing `/` from the path (the bare root
4
+ * path `/` is left alone). Two URLs sharing a Tier B key differ only by
5
+ * trailing-slash presence — a resource-identity signal considerably weaker
6
+ * than Tier A's (a web server can legitimately serve different content at
7
+ * `/foo` and `/foo/`), so callers must additionally require a matching
8
+ * `page_meta.body_hash` before treating a Tier B match as the same page.
9
+ * @param url - The URL string to compute a key for.
10
+ * @returns The Tier B key, or `null` if `url` is not a parseable http(s) URL.
11
+ * @example
12
+ * ```ts
13
+ * computeTierBAliasKey('https://example.com/foo');
14
+ * computeTierBAliasKey('https://example.com/foo/');
15
+ * // both: 'example.com/foo' -- same key, but the caller must still confirm
16
+ * // a matching body_hash before merging these as the same page.
17
+ * ```
18
+ */
19
+ export declare function computeTierBAliasKey(url: string): string | null;
@@ -0,0 +1,30 @@
1
+ import { formatAliasKey } from './format-alias-key.js';
2
+ import { parseAliasKeyParts } from './parse-alias-key-parts.js';
3
+ /**
4
+ * Computes the Tier B alias key for `url`: like `computeTierAAliasKey`,
5
+ * but additionally strips one trailing `/` from the path (the bare root
6
+ * path `/` is left alone). Two URLs sharing a Tier B key differ only by
7
+ * trailing-slash presence — a resource-identity signal considerably weaker
8
+ * than Tier A's (a web server can legitimately serve different content at
9
+ * `/foo` and `/foo/`), so callers must additionally require a matching
10
+ * `page_meta.body_hash` before treating a Tier B match as the same page.
11
+ * @param url - The URL string to compute a key for.
12
+ * @returns The Tier B key, or `null` if `url` is not a parseable http(s) URL.
13
+ * @example
14
+ * ```ts
15
+ * computeTierBAliasKey('https://example.com/foo');
16
+ * computeTierBAliasKey('https://example.com/foo/');
17
+ * // both: 'example.com/foo' -- same key, but the caller must still confirm
18
+ * // a matching body_hash before merging these as the same page.
19
+ * ```
20
+ */
21
+ export function computeTierBAliasKey(url) {
22
+ const parts = parseAliasKeyParts(url);
23
+ if (!parts) {
24
+ return null;
25
+ }
26
+ const path = parts.path.length > 1 && parts.path.endsWith('/')
27
+ ? parts.path.slice(0, -1)
28
+ : parts.path;
29
+ return formatAliasKey({ ...parts, path });
30
+ }
@@ -0,0 +1,13 @@
1
+ import type { AliasKeyParts } from './types.js';
2
+ /**
3
+ * Formats parsed alias-key parts into a single string key. `host`/`port`
4
+ * never contain `/` or `?`, and `path`/`search` always start with those
5
+ * characters respectively (or `search` is empty), so concatenation without
6
+ * an explicit delimiter is unambiguous.
7
+ * @param parts - The parts to format.
8
+ * @returns The formatted key string.
9
+ * @example
10
+ * formatAliasKey({ host: 'example.com', port: '', path: '/about/', search: '' });
11
+ * // 'example.com/about/'
12
+ */
13
+ export declare function formatAliasKey(parts: AliasKeyParts): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Formats parsed alias-key parts into a single string key. `host`/`port`
3
+ * never contain `/` or `?`, and `path`/`search` always start with those
4
+ * characters respectively (or `search` is empty), so concatenation without
5
+ * an explicit delimiter is unambiguous.
6
+ * @param parts - The parts to format.
7
+ * @returns The formatted key string.
8
+ * @example
9
+ * formatAliasKey({ host: 'example.com', port: '', path: '/about/', search: '' });
10
+ * // 'example.com/about/'
11
+ */
12
+ export function formatAliasKey(parts) {
13
+ return `${parts.host}${parts.port ? `:${parts.port}` : ''}${parts.path}${parts.search}`;
14
+ }
@@ -0,0 +1,15 @@
1
+ import type { AliasKeyParts } from './types.js';
2
+ /**
3
+ * Parses `url` and extracts the components Tier A / Tier B keys are built
4
+ * from. Returns `null` for anything that isn't a parseable `http`/`https`
5
+ * URL — such a row is defensively excluded from alias candidacy entirely
6
+ * (a non-http(s) URL should not occur for anything the crawler itself
7
+ * stored, since every `url_refs.url` was already parsed once during
8
+ * crawling).
9
+ * @param url - The URL string to parse.
10
+ * @returns The extracted parts, or `null` if unparseable / not http(s).
11
+ * @example
12
+ * parseAliasKeyParts('https://Example.com/about/index.html');
13
+ * // { host: 'example.com', port: '', path: '/about/', search: '' }
14
+ */
15
+ export declare function parseAliasKeyParts(url: string): AliasKeyParts | null;
@@ -0,0 +1,40 @@
1
+ const INDEX_SUFFIX_PATTERN = /\/index\.\w+$/i;
2
+ /**
3
+ * Parses `url` and extracts the components Tier A / Tier B keys are built
4
+ * from. Returns `null` for anything that isn't a parseable `http`/`https`
5
+ * URL — such a row is defensively excluded from alias candidacy entirely
6
+ * (a non-http(s) URL should not occur for anything the crawler itself
7
+ * stored, since every `url_refs.url` was already parsed once during
8
+ * crawling).
9
+ * @param url - The URL string to parse.
10
+ * @returns The extracted parts, or `null` if unparseable / not http(s).
11
+ * @example
12
+ * parseAliasKeyParts('https://Example.com/about/index.html');
13
+ * // { host: 'example.com', port: '', path: '/about/', search: '' }
14
+ */
15
+ export function parseAliasKeyParts(url) {
16
+ let parsed;
17
+ try {
18
+ parsed = new URL(url);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
24
+ return null;
25
+ }
26
+ return {
27
+ // WHATWG URL already lowercases `hostname` during parsing; the
28
+ // explicit `.toLowerCase()` documents that intent rather than
29
+ // relying on it silently.
30
+ host: parsed.hostname.toLowerCase(),
31
+ // Empty string when the port is the scheme's default (or
32
+ // unspecified) — this is why `http://example.com/` and
33
+ // `https://example.com/` fold to the same key below (both have
34
+ // `port === ''`) while an explicit non-default port on either
35
+ // scheme is preserved and kept distinct.
36
+ port: parsed.port,
37
+ path: parsed.pathname.replace(INDEX_SUFFIX_PATTERN, '/'),
38
+ search: parsed.search,
39
+ };
40
+ }
@@ -0,0 +1,7 @@
1
+ /** Components a Tier A / Tier B alias key is built from. */
2
+ export interface AliasKeyParts {
3
+ host: string;
4
+ port: string;
5
+ path: string;
6
+ search: string;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Pick the hostname a network-outage probe should target.
3
+ *
4
+ * Prefers a host that has already answered successfully during this crawl
5
+ * session (`Crawler.#successfulHosts` — the same "proven alive" evidence
6
+ * `shouldBurnHost` uses) over a root URL's hostname, and never falls back to
7
+ * a hardcoded external address (e.g. `1.1.1.1`): the probe should depend
8
+ * only on infrastructure the crawl is already touching. `ReadonlySet`
9
+ * iteration order in JS is insertion order, so this deterministically picks
10
+ * the first host to have succeeded this session.
11
+ * @param successfulHosts - Hostnames observed to respond in this session.
12
+ * @param roots - The crawl's configured root URLs (`CrawlerOptions.roots`),
13
+ * used as a fallback before any host has succeeded yet.
14
+ * @returns A probe target hostname, or `null` if neither source yields one
15
+ * (e.g. a fresh session with a malformed/empty roots list and no
16
+ * successes yet — the caller should treat this as "cannot probe").
17
+ * @example
18
+ * ```ts
19
+ * chooseProbeHost(new Set(['a.example']), ['https://b.example/']); // 'a.example'
20
+ * chooseProbeHost(new Set(), ['https://b.example/']); // 'b.example'
21
+ * chooseProbeHost(new Set(), []); // null
22
+ * ```
23
+ */
24
+ export declare function chooseProbeHost(successfulHosts: ReadonlySet<string>, roots: readonly string[]): string | null;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pick the hostname a network-outage probe should target.
3
+ *
4
+ * Prefers a host that has already answered successfully during this crawl
5
+ * session (`Crawler.#successfulHosts` — the same "proven alive" evidence
6
+ * `shouldBurnHost` uses) over a root URL's hostname, and never falls back to
7
+ * a hardcoded external address (e.g. `1.1.1.1`): the probe should depend
8
+ * only on infrastructure the crawl is already touching. `ReadonlySet`
9
+ * iteration order in JS is insertion order, so this deterministically picks
10
+ * the first host to have succeeded this session.
11
+ * @param successfulHosts - Hostnames observed to respond in this session.
12
+ * @param roots - The crawl's configured root URLs (`CrawlerOptions.roots`),
13
+ * used as a fallback before any host has succeeded yet.
14
+ * @returns A probe target hostname, or `null` if neither source yields one
15
+ * (e.g. a fresh session with a malformed/empty roots list and no
16
+ * successes yet — the caller should treat this as "cannot probe").
17
+ * @example
18
+ * ```ts
19
+ * chooseProbeHost(new Set(['a.example']), ['https://b.example/']); // 'a.example'
20
+ * chooseProbeHost(new Set(), ['https://b.example/']); // 'b.example'
21
+ * chooseProbeHost(new Set(), []); // null
22
+ * ```
23
+ */
24
+ export function chooseProbeHost(successfulHosts, roots) {
25
+ const [firstSuccessfulHost] = successfulHosts;
26
+ if (firstSuccessfulHost !== undefined) {
27
+ return firstSuccessfulHost;
28
+ }
29
+ for (const root of roots) {
30
+ try {
31
+ return new URL(root).hostname;
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ }
37
+ return null;
38
+ }
@@ -1,3 +1,4 @@
1
+ import { dnsBurnedHostBurnTimestamps } from './dns-burned-host-burn-timestamps.js';
1
2
  import { dnsBurnedHostCache } from './dns-burned-host-cache.js';
2
3
  import { dnsBurnedHostShortCircuitCounter } from './dns-burned-host-short-circuit-counter.js';
3
4
  /**
@@ -8,4 +9,5 @@ import { dnsBurnedHostShortCircuitCounter } from './dns-burned-host-short-circui
8
9
  export function clearDnsBurnedHostCache() {
9
10
  dnsBurnedHostCache.clear();
10
11
  dnsBurnedHostShortCircuitCounter.count = 0;
12
+ dnsBurnedHostBurnTimestamps.clear();
11
13
  }
@@ -14,12 +14,17 @@ import { crawlerLog } from '../debug.js';
14
14
  import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
15
15
  import { buildRedirectEvent } from './build-redirect-event.js';
16
16
  import { captureImageDomPaths } from './capture-image-dom-paths.js';
17
+ import { chooseProbeHost } from './choose-probe-host.js';
17
18
  import { createChangePhaseHandler } from './create-change-phase-handler.js';
18
19
  import { derivePageSource } from './derive-page-source.js';
20
+ import { destinationCache } from './destination-cache.js';
19
21
  import { detectPaginationPattern } from './detect-pagination-pattern.js';
22
+ import { dnsBurnedHostBurnTimestamps } from './dns-burned-host-burn-timestamps.js';
20
23
  import { dnsBurnedHostCache } from './dns-burned-host-cache.js';
21
24
  import { dnsBurnedHostShortCircuitCounter } from './dns-burned-host-short-circuit-counter.js';
22
25
  import { drainPhaseErrors } from './drain-phase-errors.js';
26
+ import { evictNetworkClassifiedDestinationCacheEntries } from './evict-network-classified-destination-cache-entries.js';
27
+ import { evictOutageTaintedDnsBurns } from './evict-outage-tainted-dns-burns.js';
23
28
  import { fetchDestination } from './fetch-destination.js';
24
29
  import { findScopeEntry } from './find-scope-entry.js';
25
30
  import { formatCrawlProgress } from './format-crawl-progress.js';
@@ -35,9 +40,12 @@ import { isPuppeteerFallbackCandidate } from './is-puppeteer-fallback-candidate.
35
40
  import LinkList from './link-list.js';
36
41
  import { linkToPageData } from './link-to-page-data.js';
37
42
  import { logUndrainedPhaseErrors } from './log-undrained-phase-errors.js';
43
+ import NetworkGate from './network-gate.js';
44
+ import NetworkOutageDetector from './network-outage-detector.js';
38
45
  import { partitionUrlsByHtml } from './partition-urls-by-html.js';
39
46
  import { planSubResourceEmits } from './plan-sub-resource-emits.js';
40
47
  import { PreloadShortCircuitError } from './preload-short-circuit-error.js';
48
+ import { probeNetwork } from './probe-network.js';
41
49
  import { protocolAgnosticKey } from './protocol-agnostic-key.js';
42
50
  import { redirectDestKey } from './redirect-dest-key.js';
43
51
  import { resourceToPageData } from './resource-to-page-data.js';
@@ -56,6 +64,14 @@ import { shouldSkipUrl } from './should-skip-url.js';
56
64
  * stay on the final (longest) timeout for any additional attempts.
57
65
  */
58
66
  const HEAD_TIMEOUT_ESCALATION_MS = [10_000, 30_000, 60_000];
67
+ /** Default {@link CrawlerOptions.networkOutageWindowMs}. */
68
+ const DEFAULT_NETWORK_OUTAGE_WINDOW_MS = 10_000;
69
+ /** Default {@link CrawlerOptions.networkOutageErrorThreshold}. */
70
+ const DEFAULT_NETWORK_OUTAGE_ERROR_THRESHOLD = 5;
71
+ /** Default {@link CrawlerOptions.networkOutageHostThreshold}. */
72
+ const DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD = 2;
73
+ /** Default {@link CrawlerOptions.networkOutageProbeIntervalMs}. */
74
+ const DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS = 10_000;
59
75
  /**
60
76
  * The core crawler engine that discovers and scrapes web pages.
61
77
  *
@@ -72,8 +88,39 @@ class Crawler extends EventEmitter {
72
88
  #abortController = new AbortController();
73
89
  /** Tracks discovered URLs, their scrape status, and deduplication. */
74
90
  #linkList = new LinkList();
91
+ /**
92
+ * Gate every worker callback awaits before doing network work (see the
93
+ * worker body inside {@link #runDeal}). Open by default; closed by
94
+ * {@link #handleOutageSuspect} once a recovery probe confirms a suspect
95
+ * outage, reopened once a later probe succeeds. Re-opened defensively at
96
+ * the start of {@link #runDeal} (a no-op if already open) so a fresh
97
+ * session never inherits a closed gate from a prior anomalous one.
98
+ */
99
+ #networkGate = new NetworkGate();
100
+ /**
101
+ * Sliding-window detector for "the operator's own network, not the
102
+ * target sites, looks like it is down". Fed from {@link #sendHeadRequest}'s
103
+ * `onWait` / `onGiveUp`; a non-null {@link OutageSuspect} triggers
104
+ * {@link #handleOutageSuspect}. Reset at the start of {@link #runDeal}.
105
+ * Assigned in the constructor (not a field initializer) because it
106
+ * needs `this.#options`'s network-outage tunables.
107
+ */
108
+ #networkOutageDetector;
75
109
  /** Merged crawler configuration (user overrides + defaults). */
76
110
  #options;
111
+ /**
112
+ * Synchronous claim flag guarding the async gap between "a suspect
113
+ * outage arrived" and "the confirming probe settled" in
114
+ * {@link #handleOutageSuspect}. Without it, two workers whose HEAD
115
+ * requests both exhaust retries in quick succession could each start
116
+ * their own confirming probe while the gate is still open, and if both
117
+ * probes fail, both would close the gate and emit
118
+ * `networkOutageConfirmed` — creating two simultaneously-open
119
+ * `network_outages` rows for one ongoing outage. Checked and set
120
+ * synchronously (no `await` between the check and the set), which is
121
+ * race-free because JS has no thread-level interleaving.
122
+ */
123
+ #outageHandlingInProgress = false;
77
124
  /**
78
125
  * Phase errors observed during {@link Crawler._launchBrowserAndScrape},
79
126
  * buffered per URL href so they can be emitted as `pageError` events
@@ -153,7 +200,17 @@ class Crawler extends EventEmitter {
153
200
  lookupResource: options?.lookupResource ?? null,
154
201
  lookupPageSource: options?.lookupPageSource ?? null,
155
202
  inventoryMode: options?.inventoryMode ?? null,
203
+ networkOutageWindowMs: options?.networkOutageWindowMs ?? DEFAULT_NETWORK_OUTAGE_WINDOW_MS,
204
+ networkOutageErrorThreshold: options?.networkOutageErrorThreshold ?? DEFAULT_NETWORK_OUTAGE_ERROR_THRESHOLD,
205
+ networkOutageHostThreshold: options?.networkOutageHostThreshold ?? DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD,
206
+ networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs ?? DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS,
207
+ networkProbe: options?.networkProbe ?? null,
156
208
  };
209
+ this.#networkOutageDetector = new NetworkOutageDetector({
210
+ windowMs: this.#options.networkOutageWindowMs,
211
+ errorThreshold: this.#options.networkOutageErrorThreshold,
212
+ hostThreshold: this.#options.networkOutageHostThreshold,
213
+ });
157
214
  this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
158
215
  for (const urlStr of this.#options.roots) {
159
216
  const url = parseUrl(urlStr, this.#options);
@@ -324,6 +381,84 @@ class Crawler extends EventEmitter {
324
381
  });
325
382
  }
326
383
  }
384
+ /**
385
+ * Emits captured console messages / page errors for a scrape (issue
386
+ * #228), skipping the emit entirely when `entries` is empty.
387
+ *
388
+ * The empty-skip is deliberate, not an optimization: `replaceConsoleLogs`
389
+ * replaces a page's rows wholesale (Scoped-Replace, like
390
+ * `anchor_edges` / `image_items`), so emitting on an empty capture would
391
+ * wipe out a prior good result on a degraded re-scrape (navigation
392
+ * timeout, partial render) that legitimately produced nothing this
393
+ * time — the same trade-off `updatePage` documents for anchors/images.
394
+ * @param entries - Console log entries captured during the page load.
395
+ * @param url - The originally-requested URL (not necessarily the page
396
+ * that ends up holding the content — see `CrawlerEventTypes.consoleLogs`).
397
+ * @param redirectPaths - The redirect chain hops captured during fetch,
398
+ * in order. Empty when the scrape produced no `pageData` (a
399
+ * `'skipped'` / `'error'` result).
400
+ */
401
+ #handleConsoleLogs(entries, url, redirectPaths) {
402
+ if (entries.length === 0) {
403
+ return;
404
+ }
405
+ void this.emit('consoleLogs', {
406
+ pageUrl: url.withoutHashAndAuth,
407
+ redirectPaths,
408
+ entries,
409
+ });
410
+ }
411
+ /**
412
+ * Confirm a sliding-window suspect via an active probe, and if
413
+ * confirmed, close {@link #networkGate} and start
414
+ * {@link #runRecoveryProbeLoop}.
415
+ *
416
+ * Guarded by {@link #outageHandlingInProgress} (a synchronous
417
+ * check-then-set, race-free under JS's single-threaded execution) AND
418
+ * by `#networkGate.isOpen` — the latter covers the entire duration a
419
+ * recovery loop is running (no new suspect should re-confirm or
420
+ * re-probe while one outage is already open), the former covers only
421
+ * the narrow async gap between "decided to investigate" and "the
422
+ * confirming probe settled", which the gate-open check alone cannot see
423
+ * since the gate has not closed yet at that point.
424
+ * @param suspect - The trigger emitted by {@link NetworkOutageDetector.record}.
425
+ */
426
+ async #handleOutageSuspect(suspect) {
427
+ if (!this.#networkGate.isOpen || this.#outageHandlingInProgress) {
428
+ return;
429
+ }
430
+ this.#outageHandlingInProgress = true;
431
+ try {
432
+ // No usable probe target at all (no session successes yet AND no
433
+ // parseable root URL) — cannot confirm, and cannot ever detect
434
+ // recovery either, so there is nothing safe to do but leave the
435
+ // gate open and treat this as inconclusive.
436
+ const probeHost = chooseProbeHost(this.#successfulHosts, this.#options.roots);
437
+ if (probeHost === null) {
438
+ return;
439
+ }
440
+ const probe = this.#options.networkProbe ?? probeNetwork;
441
+ const initiallyReachable = await probe(probeHost);
442
+ if (initiallyReachable) {
443
+ // False alarm: the sliding window tripped (e.g. several
444
+ // unrelated hosts happened to fail close together) but the
445
+ // probe host answers fine. Leave the gate open.
446
+ return;
447
+ }
448
+ this.#networkGate.close();
449
+ void this.emit('networkOutageConfirmed', {
450
+ startedAt: suspect.startedAt,
451
+ detectedAt: suspect.detectedAt,
452
+ probeHost,
453
+ triggerErrorCount: suspect.triggerErrorCount,
454
+ triggerHostCount: suspect.triggerHostCount,
455
+ });
456
+ void this.#runRecoveryProbeLoop(probeHost, suspect.startedAt);
457
+ }
458
+ finally {
459
+ this.#outageHandlingInProgress = false;
460
+ }
461
+ }
327
462
  /**
328
463
  * Processes captured sub-resources from a page scrape, deduplicates them,
329
464
  * and emits `response` / `responseReferrers` events for new resources.
@@ -471,6 +606,54 @@ class Crawler extends EventEmitter {
471
606
  }
472
607
  }
473
608
  }
609
+ /**
610
+ * Undo cache damage from the outage window `[startedAt, endedAt]`:
611
+ * evict `destinationCache` entries whose cached error looks
612
+ * network-related (any such entry may be stale evidence about the
613
+ * operator's network, not the target site), and un-burn any
614
+ * `dnsBurnedHostCache` host THIS session burned during that window
615
+ * (preload-seeded burns are structurally immune — see
616
+ * `evict-outage-tainted-dns-burns.ts`).
617
+ *
618
+ * Called on every closed→open gate transition, whether triggered by a
619
+ * successful recovery probe or by an abort — the cached failures are
620
+ * stale either way, and the eviction itself has no failure mode that
621
+ * depends on why the gate reopened.
622
+ * @param startedAt - The outage's `startedAt` (from the triggering `OutageSuspect`).
623
+ * @param endedAt - The moment the gate is reopening.
624
+ */
625
+ #onGateReopened(startedAt, endedAt) {
626
+ evictNetworkClassifiedDestinationCacheEntries(destinationCache);
627
+ evictOutageTaintedDnsBurns({
628
+ cache: dnsBurnedHostCache,
629
+ burnTimestamps: dnsBurnedHostBurnTimestamps,
630
+ window: { startedAt, endedAt },
631
+ });
632
+ }
633
+ /**
634
+ * Feed one observed network-layer error into
635
+ * {@link #networkOutageDetector} and hand off to
636
+ * {@link #handleOutageSuspect} the instant its sliding window trips.
637
+ *
638
+ * Called from BOTH `onWait` (every non-final retry attempt) and
639
+ * `onGiveUp` (the final attempt) inside {@link #sendHeadRequest}, so a
640
+ * single URL's retry storm contributes every attempt's error, not just
641
+ * its terminal one — a real network-wide outage is expected to trip the
642
+ * `hostThreshold` gate from many DIFFERENT hosts' attempts arriving in
643
+ * the same short window, not from one URL retrying against one host.
644
+ * @param message - The raw error message to classify.
645
+ * @param host - Lower-cased hostname the error occurred on.
646
+ */
647
+ #recordNetworkError(message, host) {
648
+ const suspect = this.#networkOutageDetector.record({
649
+ kind: classifyErrorKind(message),
650
+ host,
651
+ at: Date.now(),
652
+ });
653
+ if (suspect) {
654
+ void this.#handleOutageSuspect(suspect);
655
+ }
656
+ }
474
657
  /**
475
658
  * Resolve the source label of the page being scraped so sub-resources
476
659
  * captured during its render can inherit the correct lineage label
@@ -541,6 +724,12 @@ class Crawler extends EventEmitter {
541
724
  // does not inherit "host alive" claims from a prior run that may have
542
725
  // happened on an entirely different network.
543
726
  this.#successfulHosts.clear();
727
+ // Network-outage state is per-crawl too: a sliding window of errors
728
+ // (or a gate left closed) from a prior run on this same `Crawler`
729
+ // instance must not leak into a fresh session. `#networkGate.open()`
730
+ // is a no-op if already open.
731
+ this.#networkOutageDetector.reset();
732
+ this.#networkGate.open();
544
733
  // external URL の追跡(target は deal の total/done から導出)
545
734
  const externalUrls = new Set();
546
735
  const externalDoneUrls = new Set();
@@ -588,6 +777,12 @@ class Crawler extends EventEmitter {
588
777
  return Promise.all(ops).then(() => { });
589
778
  };
590
779
  return async () => {
780
+ // Pause here, not inside `fetchDestination` or deeper, so a
781
+ // paused worker shows as a long-running dealer task instead
782
+ // of requiring any change to `@d-zero/dealer` itself — a
783
+ // closed gate resolves the instant `#handleOutageSuspect`'s
784
+ // recovery probe succeeds (see `network-gate.ts`).
785
+ await this.#networkGate.wait();
591
786
  // Interval delay is handled here instead of by dealer because
592
787
  // DNS-burned hosts must skip the wait entirely. Spending the
593
788
  // per-URL interval on a host the cache already knows is dead
@@ -742,6 +937,7 @@ class Crawler extends EventEmitter {
742
937
  this.#handleResult(result, url, enqueue, paginationState, concurrency);
743
938
  const parentSource = await this.#resolveParentSource(url);
744
939
  this.#handleResources(result.resources, parentSource);
940
+ this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
745
941
  log(formatResultSummary(result));
746
942
  // Phase errors must be emitted AFTER 'page' / 'externalPage'
747
943
  // so the orchestrator's WriteQueue sees `setPage` before
@@ -815,6 +1011,52 @@ class Crawler extends EventEmitter {
815
1011
  crawlerLog('Crawl End');
816
1012
  void this.emit('crawlEnd', {});
817
1013
  }
1014
+ /**
1015
+ * While {@link #networkGate} is closed, probe every
1016
+ * `networkOutageProbeIntervalMs` until one succeeds, then reopen the
1017
+ * gate and emit `networkOutageRecovered`.
1018
+ *
1019
+ * If the crawl is aborted while this loop is running, the gate is
1020
+ * opened anyway (so any worker stuck on `#networkGate.wait()` can
1021
+ * unblock and `deal()` can resolve) but `networkOutageRecovered` is NOT
1022
+ * emitted — an abort says nothing about whether the network actually
1023
+ * recovered, so the `network_outages` row is deliberately left open for
1024
+ * the next writer session's boot-time finalizer
1025
+ * (`close-stale-open-network-outages.ts`) to resolve. Either way,
1026
+ * {@link #onGateReopened} still runs — the cached failures are stale
1027
+ * regardless of why the gate reopened.
1028
+ * @param probeHost - The hostname to probe, chosen once by
1029
+ * {@link #handleOutageSuspect} and reused for every attempt in this loop.
1030
+ * @param startedAt - The outage's `startedAt`, forwarded to {@link #onGateReopened}.
1031
+ */
1032
+ async #runRecoveryProbeLoop(probeHost, startedAt) {
1033
+ const probe = this.#options.networkProbe ?? probeNetwork;
1034
+ const bailIfAborted = () => {
1035
+ if (!this.#abortController.signal.aborted) {
1036
+ return false;
1037
+ }
1038
+ this.#networkGate.open();
1039
+ this.#onGateReopened(startedAt, Date.now());
1040
+ return true;
1041
+ };
1042
+ if (bailIfAborted()) {
1043
+ return;
1044
+ }
1045
+ for (;;) {
1046
+ await delay(this.#options.networkOutageProbeIntervalMs);
1047
+ if (bailIfAborted()) {
1048
+ return;
1049
+ }
1050
+ const recovered = await probe(probeHost);
1051
+ if (recovered) {
1052
+ const endedAt = Date.now();
1053
+ this.#networkGate.open();
1054
+ this.#onGateReopened(startedAt, endedAt);
1055
+ void this.emit('networkOutageRecovered', { endedAt });
1056
+ return;
1057
+ }
1058
+ }
1059
+ }
818
1060
  /**
819
1061
  * Orchestrates the full scrape pipeline for a single URL.
820
1062
  *
@@ -875,6 +1117,7 @@ class Crawler extends EventEmitter {
875
1117
  type: 'success',
876
1118
  pageData: metadataOnly ? { ...pageData, isTarget: false } : pageData,
877
1119
  resources: [],
1120
+ consoleLogs: [],
878
1121
  };
879
1122
  }
880
1123
  }
@@ -1002,6 +1245,7 @@ class Crawler extends EventEmitter {
1002
1245
  return {
1003
1246
  type: 'error',
1004
1247
  resources: [],
1248
+ consoleLogs: [],
1005
1249
  error: {
1006
1250
  name: error instanceof Error ? error.name : 'Error',
1007
1251
  message: errorMessage,
@@ -1015,6 +1259,7 @@ class Crawler extends EventEmitter {
1015
1259
  return {
1016
1260
  type: 'error',
1017
1261
  resources: [],
1262
+ consoleLogs: [],
1018
1263
  error: {
1019
1264
  name: error instanceof Error ? error.name : 'Error',
1020
1265
  message: errorMessage,
@@ -1062,6 +1307,7 @@ class Crawler extends EventEmitter {
1062
1307
  type: 'success',
1063
1308
  pageData: { ...titleResult, isTarget: false },
1064
1309
  resources: [],
1310
+ consoleLogs: [],
1065
1311
  };
1066
1312
  }
1067
1313
  catch (error) {
@@ -1072,6 +1318,7 @@ class Crawler extends EventEmitter {
1072
1318
  type: 'success',
1073
1319
  pageData: { ...headCheckResult, isTarget: false },
1074
1320
  resources: [],
1321
+ consoleLogs: [],
1075
1322
  };
1076
1323
  }
1077
1324
  // Non-HTML content — skip browser
@@ -1081,6 +1328,7 @@ class Crawler extends EventEmitter {
1081
1328
  type: 'success',
1082
1329
  pageData: headCheckResult,
1083
1330
  resources: [],
1331
+ consoleLogs: [],
1084
1332
  };
1085
1333
  }
1086
1334
  // HTML or unknown content type — launch browser with preflight result.
@@ -1266,9 +1514,11 @@ class Crawler extends EventEmitter {
1266
1514
  retries: this.#options.retry,
1267
1515
  label: 'HEAD request',
1268
1516
  onWait: (determinedInterval, retryCount, label, error) => {
1517
+ this.#recordNetworkError(error.message, host);
1269
1518
  update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
1270
1519
  },
1271
1520
  onGiveUp: (retryCount, error, label) => {
1521
+ this.#recordNetworkError(error.message, host);
1272
1522
  // Burn the host so subsequent URLs short-circuit — but ONLY
1273
1523
  // when this is the first time we've ever seen the host fail
1274
1524
  // in this session. A host that responded earlier is treated
@@ -1284,6 +1534,12 @@ class Crawler extends EventEmitter {
1284
1534
  successfulHosts: this.#successfulHosts,
1285
1535
  })) {
1286
1536
  dnsBurnedHostCache.set(host, 'dns');
1537
+ // Recorded so a later outage recovery can tell THIS
1538
+ // burn (possibly outage-caused) apart from a
1539
+ // preload-seeded one (a cross-session, confirmed-dead
1540
+ // verdict that must never be undone by an in-session
1541
+ // recovery) — see `evict-outage-tainted-dns-burns.ts`.
1542
+ dnsBurnedHostBurnTimestamps.set(host, Date.now());
1287
1543
  }
1288
1544
  update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
1289
1545
  },
@@ -1479,6 +1735,7 @@ class Crawler extends EventEmitter {
1479
1735
  return {
1480
1736
  type: 'error',
1481
1737
  resources: [],
1738
+ consoleLogs: [],
1482
1739
  error: {
1483
1740
  name: error instanceof Error ? error.name : 'Error',
1484
1741
  message: error instanceof Error ? error.message : String(error),
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Companion to `dnsBurnedHostCache`: records the epoch-ms timestamp of each
3
+ * host burned by THIS session's `#sendHeadRequest` `onGiveUp` path (the
4
+ * `shouldBurnHost`-gated write in `crawler.ts`).
5
+ *
6
+ * Deliberately NOT populated by `#preloadDnsBurnedHostCache`'s seeding from
7
+ * a previous session's `crawl_errors` — those entries represent hosts
8
+ * already proven dead across sessions and must never be evicted just
9
+ * because THIS session's network happened to recover from an outage. Since
10
+ * `dnsBurnedHostCache`'s value (`ErrorKind`) carries no provenance of its
11
+ * own (preload-seeded and session-learned burns are indistinguishable by
12
+ * value alone), this separate timestamp map is what lets
13
+ * `evict-outage-tainted-dns-burns.ts` tell them apart: only hosts present
14
+ * HERE are eligible for eviction, and only if their burn timestamp falls
15
+ * inside the just-recovered outage's window.
16
+ */
17
+ export declare const dnsBurnedHostBurnTimestamps: Map<string, number>;