@nitpicker/crawler 0.14.0 → 0.16.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 (155) hide show
  1. package/lib/archive/archive.d.ts +136 -10
  2. package/lib/archive/archive.js +147 -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 +14 -0
  22. package/lib/archive/create-adjunct-tables.js +169 -0
  23. package/lib/archive/create-entity-tables.js +21 -1
  24. package/lib/archive/database.d.ts +67 -2
  25. package/lib/archive/database.js +92 -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/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
  47. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
  48. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
  49. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
  50. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
  51. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
  52. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
  53. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
  54. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  55. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  56. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  57. package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
  58. package/lib/archive/db-ops/lifecycle/init.js +21 -2
  59. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  60. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  61. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  62. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  63. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  64. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  65. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  66. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  67. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  68. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  69. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
  70. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
  71. package/lib/archive/db-ops/pages/write/update-page.js +12 -0
  72. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  73. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  74. package/lib/archive/filesystem/output-binary.js +17 -0
  75. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  76. package/lib/archive/get-failed-page-messages.js +29 -15
  77. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  78. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  79. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  80. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  81. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  82. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  83. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  84. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  85. package/lib/archive/page.d.ts +1 -1
  86. package/lib/archive/types.d.ts +60 -2
  87. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  88. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  89. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  90. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  91. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  92. package/lib/archive/url-alias/format-alias-key.js +14 -0
  93. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  94. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  95. package/lib/archive/url-alias/types.d.ts +7 -0
  96. package/lib/archive/url-alias/types.js +1 -0
  97. package/lib/classify-error-kind.d.ts +1 -0
  98. package/lib/classify-error-kind.js +14 -0
  99. package/lib/crawler/assert-chrome-installed.d.ts +24 -0
  100. package/lib/crawler/assert-chrome-installed.js +43 -0
  101. package/lib/crawler/choose-probe-host.d.ts +24 -0
  102. package/lib/crawler/choose-probe-host.js +38 -0
  103. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  104. package/lib/crawler/crawler.d.ts +12 -0
  105. package/lib/crawler/crawler.js +496 -29
  106. package/lib/crawler/decode-auth-credential.d.ts +29 -0
  107. package/lib/crawler/decode-auth-credential.js +39 -0
  108. package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
  109. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  110. package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
  111. package/lib/crawler/dedupe/compute-shape-key.js +56 -0
  112. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
  113. package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
  114. package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
  115. package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
  116. package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
  117. package/lib/crawler/dedupe/is-shape-capped.js +12 -0
  118. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
  119. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
  120. package/lib/crawler/dedupe/types.d.ts +42 -0
  121. package/lib/crawler/dedupe/types.js +1 -0
  122. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  123. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  124. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  125. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  126. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  127. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  128. package/lib/crawler/fetch-destination.js +14 -2
  129. package/lib/crawler/generate-predicted-urls.d.ts +12 -0
  130. package/lib/crawler/generate-predicted-urls.js +33 -2
  131. package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
  132. package/lib/crawler/network-gate.d.ts +49 -0
  133. package/lib/crawler/network-gate.js +78 -0
  134. package/lib/crawler/network-outage-detector.d.ts +51 -0
  135. package/lib/crawler/network-outage-detector.js +81 -0
  136. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  137. package/lib/crawler/network-outage-summary-counter.js +18 -0
  138. package/lib/crawler/probe-network.d.ts +26 -0
  139. package/lib/crawler/probe-network.js +21 -0
  140. package/lib/crawler/types.d.ts +185 -1
  141. package/lib/crawler-orchestrator.d.ts +74 -18
  142. package/lib/crawler-orchestrator.js +267 -27
  143. package/lib/crawler.d.ts +22 -0
  144. package/lib/crawler.js +18 -0
  145. package/lib/is-within-outage-window.d.ts +49 -0
  146. package/lib/is-within-outage-window.js +33 -0
  147. package/lib/network-related-error-kinds.d.ts +23 -0
  148. package/lib/network-related-error-kinds.js +28 -0
  149. package/lib/permanent-error-kinds.d.ts +9 -4
  150. package/lib/permanent-error-kinds.js +10 -4
  151. package/lib/types.d.ts +11 -2
  152. package/lib/utils/array/each-splitted.d.ts +1 -1
  153. package/lib/utils/compute-file-sha256.d.ts +17 -17
  154. package/lib/utils/compute-file-sha256.js +18 -47
  155. package/package.json +3 -3
@@ -9,17 +9,29 @@ import { retryCall } from '@d-zero/shared/retry';
9
9
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
10
10
  import c from 'ansi-colors';
11
11
  import pkg from '../../package.json' with { type: 'json' };
12
+ import { computeBodyHash } from '../archive/body-hash/compute-body-hash.js';
12
13
  import { classifyErrorKind } from '../classify-error-kind.js';
13
14
  import { crawlerLog } from '../debug.js';
14
15
  import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
15
16
  import { buildRedirectEvent } from './build-redirect-event.js';
16
17
  import { captureImageDomPaths } from './capture-image-dom-paths.js';
18
+ import { chooseProbeHost } from './choose-probe-host.js';
17
19
  import { createChangePhaseHandler } from './create-change-phase-handler.js';
20
+ import { decodeAuthCredential } from './decode-auth-credential.js';
21
+ import { computeMetaSignature } from './dedupe/compute-meta-signature.js';
22
+ import { computeShapeKey } from './dedupe/compute-shape-key.js';
23
+ import DedupeCapTracker from './dedupe/dedupe-cap-tracker.js';
24
+ import { isPredictedContentDuplicate } from './dedupe/is-predicted-content-duplicate.js';
25
+ import { resolveOgUrlMismatch } from './dedupe/resolve-og-url-mismatch.js';
18
26
  import { derivePageSource } from './derive-page-source.js';
27
+ import { destinationCache } from './destination-cache.js';
19
28
  import { detectPaginationPattern } from './detect-pagination-pattern.js';
29
+ import { dnsBurnedHostBurnTimestamps } from './dns-burned-host-burn-timestamps.js';
20
30
  import { dnsBurnedHostCache } from './dns-burned-host-cache.js';
21
31
  import { dnsBurnedHostShortCircuitCounter } from './dns-burned-host-short-circuit-counter.js';
22
32
  import { drainPhaseErrors } from './drain-phase-errors.js';
33
+ import { evictNetworkClassifiedDestinationCacheEntries } from './evict-network-classified-destination-cache-entries.js';
34
+ import { evictOutageTaintedDnsBurns } from './evict-outage-tainted-dns-burns.js';
23
35
  import { fetchDestination } from './fetch-destination.js';
24
36
  import { findScopeEntry } from './find-scope-entry.js';
25
37
  import { formatCrawlProgress } from './format-crawl-progress.js';
@@ -35,9 +47,12 @@ import { isPuppeteerFallbackCandidate } from './is-puppeteer-fallback-candidate.
35
47
  import LinkList from './link-list.js';
36
48
  import { linkToPageData } from './link-to-page-data.js';
37
49
  import { logUndrainedPhaseErrors } from './log-undrained-phase-errors.js';
50
+ import NetworkGate from './network-gate.js';
51
+ import NetworkOutageDetector from './network-outage-detector.js';
38
52
  import { partitionUrlsByHtml } from './partition-urls-by-html.js';
39
53
  import { planSubResourceEmits } from './plan-sub-resource-emits.js';
40
54
  import { PreloadShortCircuitError } from './preload-short-circuit-error.js';
55
+ import { probeNetwork } from './probe-network.js';
41
56
  import { protocolAgnosticKey } from './protocol-agnostic-key.js';
42
57
  import { redirectDestKey } from './redirect-dest-key.js';
43
58
  import { resourceToPageData } from './resource-to-page-data.js';
@@ -56,6 +71,16 @@ import { shouldSkipUrl } from './should-skip-url.js';
56
71
  * stay on the final (longest) timeout for any additional attempts.
57
72
  */
58
73
  const HEAD_TIMEOUT_ESCALATION_MS = [10_000, 30_000, 60_000];
74
+ /** Default {@link CrawlerOptions.networkOutageWindowMs}. */
75
+ const DEFAULT_NETWORK_OUTAGE_WINDOW_MS = 10_000;
76
+ /** Default {@link CrawlerOptions.networkOutageErrorThreshold}. */
77
+ const DEFAULT_NETWORK_OUTAGE_ERROR_THRESHOLD = 5;
78
+ /** Default {@link CrawlerOptions.networkOutageHostThreshold}. */
79
+ const DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD = 2;
80
+ /** Default {@link CrawlerOptions.networkOutageProbeIntervalMs}. */
81
+ const DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS = 10_000;
82
+ /** Default {@link CrawlerOptions.dedupeMapCap}. */
83
+ const DEFAULT_DEDUPE_MAP_CAP = 100_000;
59
84
  /**
60
85
  * The core crawler engine that discovers and scrapes web pages.
61
86
  *
@@ -70,10 +95,59 @@ const HEAD_TIMEOUT_ESCALATION_MS = [10_000, 30_000, 60_000];
70
95
  class Crawler extends EventEmitter {
71
96
  /** Controller used to cancel the deal-based crawl via its AbortSignal. */
72
97
  #abortController = new AbortController();
98
+ /**
99
+ * Per-shape count of anchors rejected by the dedupe-cap enqueue gates
100
+ * after that shape capped. Read by {@link getDedupeCapRejections} at
101
+ * `crawlEnd` so the orchestrator can finalize each
102
+ * `dedupe_cap_events.rejected_count` exactly once (see
103
+ * `Crawler#getDedupeCapRejections`'s JSDoc for why this is not written
104
+ * to the archive incrementally).
105
+ */
106
+ #dedupeCapRejectionCounts = new Map();
107
+ /**
108
+ * Opt-in (`--dedupe-cap`) same-cluster soft cap. Always constructed
109
+ * (Misra-Gries state stays empty when {@link CrawlerOptions.dedupeCap} is
110
+ * `null`), gated on by `#options.dedupeCap !== null` at each call site
111
+ * rather than being conditionally `undefined`, so the two enqueue gates
112
+ * and the observation call in {@link #handleResult} do not need to
113
+ * null-check a class field.
114
+ */
115
+ #dedupeCapTracker;
73
116
  /** Tracks discovered URLs, their scrape status, and deduplication. */
74
117
  #linkList = new LinkList();
118
+ /**
119
+ * Gate every worker callback awaits before doing network work (see the
120
+ * worker body inside {@link #runDeal}). Open by default; closed by
121
+ * {@link #handleOutageSuspect} once a recovery probe confirms a suspect
122
+ * outage, reopened once a later probe succeeds. Re-opened defensively at
123
+ * the start of {@link #runDeal} (a no-op if already open) so a fresh
124
+ * session never inherits a closed gate from a prior anomalous one.
125
+ */
126
+ #networkGate = new NetworkGate();
127
+ /**
128
+ * Sliding-window detector for "the operator's own network, not the
129
+ * target sites, looks like it is down". Fed from {@link #sendHeadRequest}'s
130
+ * `onWait` / `onGiveUp`; a non-null {@link OutageSuspect} triggers
131
+ * {@link #handleOutageSuspect}. Reset at the start of {@link #runDeal}.
132
+ * Assigned in the constructor (not a field initializer) because it
133
+ * needs `this.#options`'s network-outage tunables.
134
+ */
135
+ #networkOutageDetector;
75
136
  /** Merged crawler configuration (user overrides + defaults). */
76
137
  #options;
138
+ /**
139
+ * Synchronous claim flag guarding the async gap between "a suspect
140
+ * outage arrived" and "the confirming probe settled" in
141
+ * {@link #handleOutageSuspect}. Without it, two workers whose HEAD
142
+ * requests both exhaust retries in quick succession could each start
143
+ * their own confirming probe while the gate is still open, and if both
144
+ * probes fail, both would close the gate and emit
145
+ * `networkOutageConfirmed` — creating two simultaneously-open
146
+ * `network_outages` rows for one ongoing outage. Checked and set
147
+ * synchronously (no `await` between the check and the set), which is
148
+ * race-free because JS has no thread-level interleaving.
149
+ */
150
+ #outageHandlingInProgress = false;
77
151
  /**
78
152
  * Phase errors observed during {@link Crawler._launchBrowserAndScrape},
79
153
  * buffered per URL href so they can be emitted as `pageError` events
@@ -82,6 +156,23 @@ class Crawler extends EventEmitter {
82
156
  * `insertPageError`, so the FK resolution via URL always finds the row.
83
157
  */
84
158
  #pendingPhaseErrors = new Map();
159
+ /**
160
+ * Predicted-pagination body-hash tracking (always-on — independent of
161
+ * the opt-in `--dedupe-cap` tracker). Maps a URL shape key
162
+ * ({@link computeShapeKey}) to the {@link computeBodyHash} of the most
163
+ * recently scraped *predicted* page of that shape. Never reset mid-crawl
164
+ * (persists for the whole session, like {@link #scrapedDestinations}).
165
+ */
166
+ #predictedShapeBodyHashes = new Map();
167
+ /**
168
+ * Shapes for which {@link #predictedShapeBodyHashes} detected a
169
+ * content-duplicate predicted page (see {@link isPredictedContentDuplicate}).
170
+ * Once a shape lands here, no further predicted URLs are generated for it
171
+ * (checked in {@link #handleResult}'s pagination-pattern branch) — the
172
+ * cheapest possible way to stop a self-generating trap without needing
173
+ * the opt-in dedupe-cap machinery.
174
+ */
175
+ #predictedShapeStopped = new Set();
85
176
  /** Set of resource URLs (without hash) already captured, for deduplication. */
86
177
  #resources = new Set();
87
178
  /** Number of HTML pages (isTarget=1) scraped in previous sessions, used to seed the progress counter on resume. */
@@ -153,7 +244,21 @@ class Crawler extends EventEmitter {
153
244
  lookupResource: options?.lookupResource ?? null,
154
245
  lookupPageSource: options?.lookupPageSource ?? null,
155
246
  inventoryMode: options?.inventoryMode ?? null,
247
+ networkOutageWindowMs: options?.networkOutageWindowMs ?? DEFAULT_NETWORK_OUTAGE_WINDOW_MS,
248
+ networkOutageErrorThreshold: options?.networkOutageErrorThreshold ?? DEFAULT_NETWORK_OUTAGE_ERROR_THRESHOLD,
249
+ networkOutageHostThreshold: options?.networkOutageHostThreshold ?? DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD,
250
+ networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs ?? DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS,
251
+ networkProbe: options?.networkProbe ?? null,
252
+ dedupeCap: options?.dedupeCap ?? null,
253
+ dedupeMapCap: options?.dedupeMapCap ?? DEFAULT_DEDUPE_MAP_CAP,
254
+ preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
156
255
  };
256
+ this.#networkOutageDetector = new NetworkOutageDetector({
257
+ windowMs: this.#options.networkOutageWindowMs,
258
+ errorThreshold: this.#options.networkOutageErrorThreshold,
259
+ hostThreshold: this.#options.networkOutageHostThreshold,
260
+ });
261
+ this.#dedupeCapTracker = new DedupeCapTracker({ cap: this.#options.dedupeCap ?? 0, mapCap: this.#options.dedupeMapCap }, this.#options.preloadedStickyShapeKeys);
157
262
  this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
158
263
  for (const urlStr of this.#options.roots) {
159
264
  const url = parseUrl(urlStr, this.#options);
@@ -174,6 +279,20 @@ class Crawler extends EventEmitter {
174
279
  abort() {
175
280
  this.#abortController.abort();
176
281
  }
282
+ /**
283
+ * Per-shape count of anchors the dedupe-cap enqueue gates rejected after
284
+ * that shape capped (opt-in `--dedupe-cap`). Read by
285
+ * `CrawlerOrchestrator` at `crawlEnd` to finalize each
286
+ * `dedupe_cap_events.rejected_count` exactly once — rejections are
287
+ * accumulated in memory rather than written to the archive per-rejection
288
+ * to avoid write amplification (a capped trap can generate an unbounded
289
+ * number of rejected anchors).
290
+ * @returns A snapshot of the per-shape rejection counts. Empty when
291
+ * `--dedupe-cap` was not enabled or no shape has capped yet.
292
+ */
293
+ getDedupeCapRejections() {
294
+ return this.#dedupeCapRejectionCounts;
295
+ }
177
296
  /**
178
297
  * Retrieve the list of Chromium process IDs that are still running.
179
298
  *
@@ -324,6 +443,84 @@ class Crawler extends EventEmitter {
324
443
  });
325
444
  }
326
445
  }
446
+ /**
447
+ * Emits captured console messages / page errors for a scrape (issue
448
+ * #228), skipping the emit entirely when `entries` is empty.
449
+ *
450
+ * The empty-skip is deliberate, not an optimization: `replaceConsoleLogs`
451
+ * replaces a page's rows wholesale (Scoped-Replace, like
452
+ * `anchor_edges` / `image_items`), so emitting on an empty capture would
453
+ * wipe out a prior good result on a degraded re-scrape (navigation
454
+ * timeout, partial render) that legitimately produced nothing this
455
+ * time — the same trade-off `updatePage` documents for anchors/images.
456
+ * @param entries - Console log entries captured during the page load.
457
+ * @param url - The originally-requested URL (not necessarily the page
458
+ * that ends up holding the content — see `CrawlerEventTypes.consoleLogs`).
459
+ * @param redirectPaths - The redirect chain hops captured during fetch,
460
+ * in order. Empty when the scrape produced no `pageData` (a
461
+ * `'skipped'` / `'error'` result).
462
+ */
463
+ #handleConsoleLogs(entries, url, redirectPaths) {
464
+ if (entries.length === 0) {
465
+ return;
466
+ }
467
+ void this.emit('consoleLogs', {
468
+ pageUrl: url.withoutHashAndAuth,
469
+ redirectPaths,
470
+ entries,
471
+ });
472
+ }
473
+ /**
474
+ * Confirm a sliding-window suspect via an active probe, and if
475
+ * confirmed, close {@link #networkGate} and start
476
+ * {@link #runRecoveryProbeLoop}.
477
+ *
478
+ * Guarded by {@link #outageHandlingInProgress} (a synchronous
479
+ * check-then-set, race-free under JS's single-threaded execution) AND
480
+ * by `#networkGate.isOpen` — the latter covers the entire duration a
481
+ * recovery loop is running (no new suspect should re-confirm or
482
+ * re-probe while one outage is already open), the former covers only
483
+ * the narrow async gap between "decided to investigate" and "the
484
+ * confirming probe settled", which the gate-open check alone cannot see
485
+ * since the gate has not closed yet at that point.
486
+ * @param suspect - The trigger emitted by {@link NetworkOutageDetector.record}.
487
+ */
488
+ async #handleOutageSuspect(suspect) {
489
+ if (!this.#networkGate.isOpen || this.#outageHandlingInProgress) {
490
+ return;
491
+ }
492
+ this.#outageHandlingInProgress = true;
493
+ try {
494
+ // No usable probe target at all (no session successes yet AND no
495
+ // parseable root URL) — cannot confirm, and cannot ever detect
496
+ // recovery either, so there is nothing safe to do but leave the
497
+ // gate open and treat this as inconclusive.
498
+ const probeHost = chooseProbeHost(this.#successfulHosts, this.#options.roots);
499
+ if (probeHost === null) {
500
+ return;
501
+ }
502
+ const probe = this.#options.networkProbe ?? probeNetwork;
503
+ const initiallyReachable = await probe(probeHost);
504
+ if (initiallyReachable) {
505
+ // False alarm: the sliding window tripped (e.g. several
506
+ // unrelated hosts happened to fail close together) but the
507
+ // probe host answers fine. Leave the gate open.
508
+ return;
509
+ }
510
+ this.#networkGate.close();
511
+ void this.emit('networkOutageConfirmed', {
512
+ startedAt: suspect.startedAt,
513
+ detectedAt: suspect.detectedAt,
514
+ probeHost,
515
+ triggerErrorCount: suspect.triggerErrorCount,
516
+ triggerHostCount: suspect.triggerHostCount,
517
+ });
518
+ void this.#runRecoveryProbeLoop(probeHost, suspect.startedAt);
519
+ }
520
+ finally {
521
+ this.#outageHandlingInProgress = false;
522
+ }
523
+ }
327
524
  /**
328
525
  * Processes captured sub-resources from a page scrape, deduplicates them,
329
526
  * and emits `response` / `responseReferrers` events for new resources.
@@ -358,21 +555,92 @@ class Crawler extends EventEmitter {
358
555
  * @param enqueue - Callback to enqueue newly discovered URLs into the dealer
359
556
  * queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}).
360
557
  * Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order.
361
- * @param paginationState - Mutable state for predicted pagination cascade prevention
362
- * @param paginationState.lastPushedUrl
363
- * @param paginationState.lastPushedWasPredicted
364
558
  * @param concurrency - Current concurrency level, used to determine predicted URL count
559
+ * @param precomputedBodyHash - This page's body hash, if the caller already
560
+ * computed it (the predicted-content-duplicate check, A-3, computes it for
561
+ * every predicted page regardless of `--dedupe-cap`) — reused for the
562
+ * dedupe-cap observation below instead of hashing the same html twice.
365
563
  */
366
- #handleResult(result, url, enqueue, paginationState, concurrency) {
564
+ #handleResult(result, url, enqueue, concurrency, precomputedBodyHash) {
367
565
  switch (result.type) {
368
566
  case 'success': {
369
567
  if (!result.pageData)
370
568
  break;
569
+ // Scoped to this one page's anchor list (fresh per `#handleResult`
570
+ // call, not shared across pages): pagination-pattern detection
571
+ // compares consecutive anchors as they are discovered by
572
+ // `processAnchors`'s single synchronous loop below, so "consecutive"
573
+ // must mean "adjacent in this document", not "adjacent in whatever
574
+ // order the crawl's workers happened to finish". Sharing this state
575
+ // across pages/workers let `step` be computed from two unrelated
576
+ // URLs, compounding across rounds until a `/news/date/{year}/`
577
+ // pager's predicted token overflowed into scientific notation
578
+ // (`1.7715854126052197e+120`, observed in production).
579
+ const paginationState = {
580
+ lastPushedUrl: null,
581
+ lastPushedWasPredicted: false,
582
+ };
583
+ // Feed this page's own signature into the same-cluster tracker
584
+ // (opt-in via `--dedupe-cap`). This is deliberately separate
585
+ // from the enqueue gates below: gating decides whether to
586
+ // admit a not-yet-scraped anchor based on shape alone; this
587
+ // observes the page that was JUST scraped, using its actual
588
+ // meta/body content. External and metadata-only pages carry no
589
+ // useful signal for this feature and are skipped, matching the
590
+ // signature-scope exclusions in `computeMetaSignature`'s design.
591
+ if (this.#options.dedupeCap !== null &&
592
+ !result.pageData.isExternal &&
593
+ !this.#linkList.isMetadataOnly(result.pageData.url.withoutHash) &&
594
+ result.pageData.html.length > 0) {
595
+ const shapeKey = computeShapeKey(result.pageData.url.withoutHashAndAuth);
596
+ const metaSig = computeMetaSignature(result.pageData.meta);
597
+ if (shapeKey && metaSig) {
598
+ const bodyHash = precomputedBodyHash ?? computeBodyHash(result.pageData.html);
599
+ const ogUrlMismatch = resolveOgUrlMismatch(result.pageData.meta, result.pageData.url.href);
600
+ const event = this.#dedupeCapTracker.observe({
601
+ shapeKey,
602
+ metaSig,
603
+ bodyHash,
604
+ ogUrlMismatch,
605
+ url: result.pageData.url.href,
606
+ });
607
+ if (event) {
608
+ void this.emit('dedupeCap', event);
609
+ }
610
+ }
611
+ }
371
612
  handleScrapeEnd(result.pageData, this.#linkList, this.#scope, this.#options, (newUrl, opts) => {
613
+ // Gate 1: blocks real anchors discovered on this page whose
614
+ // shape is already confirmed as a trap. This does NOT cover
615
+ // predicted URLs — `generatePredictedUrls`'s output is
616
+ // pushed directly below (`this.#linkList.add(specUrl, ...)`),
617
+ // bypassing this closure entirely — so the predicted-URL
618
+ // generation site below has its own equivalent check
619
+ // (`shapeIsStopped`, combined with `#predictedShapeStopped`).
620
+ // External anchors are out of scope for the cap (issue #208:
621
+ // "cap 適用は internal only"), enforced by the scope check
622
+ // below. Deliberately NOT also excluding `opts?.metadataOnly`
623
+ // (unlike the tracker's observation side, which does skip
624
+ // metadata-only pages — they carry no reliable signature): with
625
+ // `--recursive=false`, `handle-scrape-end.ts` marks EVERY anchor
626
+ // metadata-only, internal or not, so excluding them here would
627
+ // silently disable `--dedupe-cap` for anchor discovery whenever
628
+ // `--recursive=false` is set — while gate 2 (the JS-redirect
629
+ // direct enqueue below) has no such exclusion and would still
630
+ // cap the very same shape, an inconsistency between the two
631
+ // discovery paths.
632
+ if (this.#options.dedupeCap !== null &&
633
+ findScopeEntry(newUrl, this.#scope, this.#options) !== null) {
634
+ const gateShapeKey = computeShapeKey(newUrl.withoutHashAndAuth);
635
+ if (gateShapeKey && this.#dedupeCapTracker.isCapped(gateShapeKey)) {
636
+ this.#recordDedupeCapRejection(gateShapeKey);
637
+ return;
638
+ }
639
+ }
372
640
  this.#linkList.add(newUrl, opts);
373
641
  void enqueue(newUrl);
374
642
  // Predicted pagination detection
375
- if (!paginationState || !concurrency)
643
+ if (!concurrency)
376
644
  return;
377
645
  // metadataOnly / external: update tracking but skip pattern detection
378
646
  if (opts?.metadataOnly ||
@@ -386,22 +654,37 @@ class Crawler extends EventEmitter {
386
654
  !paginationState.lastPushedWasPredicted) {
387
655
  const pattern = detectPaginationPattern(paginationState.lastPushedUrl, newUrl.withoutHashAndAuth);
388
656
  if (pattern) {
389
- const urls = generatePredictedUrls(pattern, newUrl.withoutHashAndAuth, concurrency);
390
- const specUrls = [];
391
- for (const specUrlStr of urls) {
392
- const specUrl = parseUrl(specUrlStr, this.#options);
393
- if (specUrl) {
394
- this.#linkList.add(specUrl, { predicted: true });
395
- specUrls.push(specUrl);
657
+ // Stop generating further predicted URLs for this shape
658
+ // once EITHER confirmation mechanism has fired — the
659
+ // always-on content-duplication check
660
+ // (`#predictedShapeStopped`), or the opt-in
661
+ // `--dedupe-cap` tracker (`#dedupeCapTracker.isCapped`,
662
+ // only consulted when the flag is set). Falls through to
663
+ // the plain (non-predicted) bookkeeping below instead of
664
+ // returning, since the anchor itself is still real.
665
+ const shapeKey = computeShapeKey(newUrl.withoutHashAndAuth);
666
+ const shapeIsStopped = shapeKey !== null &&
667
+ (this.#predictedShapeStopped.has(shapeKey) ||
668
+ (this.#options.dedupeCap !== null &&
669
+ this.#dedupeCapTracker.isCapped(shapeKey)));
670
+ if (!shapeIsStopped) {
671
+ const urls = generatePredictedUrls(pattern, newUrl.withoutHashAndAuth, concurrency);
672
+ const specUrls = [];
673
+ for (const specUrlStr of urls) {
674
+ const specUrl = parseUrl(specUrlStr, this.#options);
675
+ if (specUrl) {
676
+ this.#linkList.add(specUrl, { predicted: true });
677
+ specUrls.push(specUrl);
678
+ }
396
679
  }
680
+ // Enqueue as one batch so ascending page order is kept
681
+ // at the front of the queue (see enqueue in #runDeal).
682
+ if (specUrls.length > 0)
683
+ void enqueue(...specUrls);
684
+ paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
685
+ paginationState.lastPushedWasPredicted = true;
686
+ return;
397
687
  }
398
- // Enqueue as one batch so ascending page order is kept
399
- // at the front of the queue (see enqueue in #runDeal).
400
- if (specUrls.length > 0)
401
- void enqueue(...specUrls);
402
- paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
403
- paginationState.lastPushedWasPredicted = true;
404
- return;
405
688
  }
406
689
  }
407
690
  paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
@@ -471,6 +754,67 @@ class Crawler extends EventEmitter {
471
754
  }
472
755
  }
473
756
  }
757
+ /**
758
+ * Undo cache damage from the outage window `[startedAt, endedAt]`:
759
+ * evict `destinationCache` entries whose cached error looks
760
+ * network-related (any such entry may be stale evidence about the
761
+ * operator's network, not the target site), and un-burn any
762
+ * `dnsBurnedHostCache` host THIS session burned during that window
763
+ * (preload-seeded burns are structurally immune — see
764
+ * `evict-outage-tainted-dns-burns.ts`).
765
+ *
766
+ * Called on every closed→open gate transition, whether triggered by a
767
+ * successful recovery probe or by an abort — the cached failures are
768
+ * stale either way, and the eviction itself has no failure mode that
769
+ * depends on why the gate reopened.
770
+ * @param startedAt - The outage's `startedAt` (from the triggering `OutageSuspect`).
771
+ * @param endedAt - The moment the gate is reopening.
772
+ */
773
+ #onGateReopened(startedAt, endedAt) {
774
+ evictNetworkClassifiedDestinationCacheEntries(destinationCache);
775
+ evictOutageTaintedDnsBurns({
776
+ cache: dnsBurnedHostCache,
777
+ burnTimestamps: dnsBurnedHostBurnTimestamps,
778
+ window: { startedAt, endedAt },
779
+ });
780
+ }
781
+ /**
782
+ * Increments {@link #dedupeCapRejectionCounts} for one shape. Scoped to
783
+ * the two concrete enqueue-time rejections (a real anchor or a
784
+ * JS-redirect destination that was discovered but blocked) — it does
785
+ * NOT count predicted URLs that were never generated at all because
786
+ * their shape was already stopped (see the `shapeIsStopped` check in
787
+ * {@link #handleResult}), since nothing concrete existed there to
788
+ * reject.
789
+ * @param shapeKey - The capped shape a rejection is being recorded for.
790
+ */
791
+ #recordDedupeCapRejection(shapeKey) {
792
+ this.#dedupeCapRejectionCounts.set(shapeKey, (this.#dedupeCapRejectionCounts.get(shapeKey) ?? 0) + 1);
793
+ }
794
+ /**
795
+ * Feed one observed network-layer error into
796
+ * {@link #networkOutageDetector} and hand off to
797
+ * {@link #handleOutageSuspect} the instant its sliding window trips.
798
+ *
799
+ * Called from BOTH `onWait` (every non-final retry attempt) and
800
+ * `onGiveUp` (the final attempt) inside {@link #sendHeadRequest}, so a
801
+ * single URL's retry storm contributes every attempt's error, not just
802
+ * its terminal one — a real network-wide outage is expected to trip the
803
+ * `hostThreshold` gate from many DIFFERENT hosts' attempts arriving in
804
+ * the same short window, not from one URL retrying against one host.
805
+ * @param message - The raw error message to classify.
806
+ * @param host - Lower-cased hostname the error occurred on.
807
+ */
808
+ #recordNetworkError(message, host) {
809
+ const suspect = this.#networkOutageDetector.record({
810
+ kind: classifyErrorKind(message),
811
+ host,
812
+ at: Date.now(),
813
+ });
814
+ if (suspect) {
815
+ void this.#handleOutageSuspect(suspect);
816
+ }
817
+ }
474
818
  /**
475
819
  * Resolve the source label of the page being scraped so sub-resources
476
820
  * captured during its render can inherit the correct lineage label
@@ -541,6 +885,12 @@ class Crawler extends EventEmitter {
541
885
  // does not inherit "host alive" claims from a prior run that may have
542
886
  // happened on an entirely different network.
543
887
  this.#successfulHosts.clear();
888
+ // Network-outage state is per-crawl too: a sliding window of errors
889
+ // (or a gate left closed) from a prior run on this same `Crawler`
890
+ // instance must not leak into a fresh session. `#networkGate.open()`
891
+ // is a no-op if already open.
892
+ this.#networkOutageDetector.reset();
893
+ this.#networkGate.open();
544
894
  // external URL の追跡(target は deal の total/done から導出)
545
895
  const externalUrls = new Set();
546
896
  const externalDoneUrls = new Set();
@@ -557,11 +907,6 @@ class Crawler extends EventEmitter {
557
907
  const concurrency = this.#options.parallels
558
908
  ? Math.max(this.#options.parallels, 1)
559
909
  : _a.MAX_PROCESS_LENGTH;
560
- // Predicted pagination state
561
- const paginationState = {
562
- lastPushedUrl: null,
563
- lastPushedWasPredicted: false,
564
- };
565
910
  await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
566
911
  const matchedScope = findScopeEntry(url, this.#scope, this.#options);
567
912
  const isExternal = matchedScope === null;
@@ -588,6 +933,12 @@ class Crawler extends EventEmitter {
588
933
  return Promise.all(ops).then(() => { });
589
934
  };
590
935
  return async () => {
936
+ // Pause here, not inside `fetchDestination` or deeper, so a
937
+ // paused worker shows as a long-running dealer task instead
938
+ // of requiring any change to `@d-zero/dealer` itself — a
939
+ // closed gate resolves the instant `#handleOutageSuspect`'s
940
+ // recovery probe succeeds (see `network-gate.ts`).
941
+ await this.#networkGate.wait();
591
942
  // Interval delay is handled here instead of by dealer because
592
943
  // DNS-burned hosts must skip the wait entirely. Spending the
593
944
  // per-URL interval on a host the cache already knows is dead
@@ -610,6 +961,11 @@ class Crawler extends EventEmitter {
610
961
  const markBrowserScrape = () => {
611
962
  renderedInBrowser = true;
612
963
  };
964
+ // Set by the predicted-content-duplicate check below (A-3) when it
965
+ // computes this page's body hash, so `#handleResult`'s dedupe-cap
966
+ // observation (also gated on this page's html) can reuse it instead
967
+ // of hashing the same html a second time.
968
+ let precomputedBodyHash = null;
613
969
  try {
614
970
  const robotsAllowed = await this.#robotsChecker.isAllowed(url);
615
971
  if (!robotsAllowed) {
@@ -687,8 +1043,25 @@ class Crawler extends EventEmitter {
687
1043
  if (destination) {
688
1044
  const destinationUrl = parseUrl(destination, this.#options);
689
1045
  if (destinationUrl) {
690
- this.#linkList.add(destinationUrl);
691
- void enqueue(destinationUrl);
1046
+ // Gate 2: this direct enqueue does not go through
1047
+ // `#handleResult`'s addUrl closure (gate 1), so it needs
1048
+ // its own same-cluster-cap check — a JS-redirect trap
1049
+ // that advances a parameter via `location.replace()`
1050
+ // would otherwise keep re-entering the queue here.
1051
+ const gateShapeKey = computeShapeKey(destinationUrl.withoutHashAndAuth);
1052
+ const isCapped = this.#options.dedupeCap !== null &&
1053
+ gateShapeKey !== null &&
1054
+ findScopeEntry(destinationUrl, this.#scope, this.#options) !==
1055
+ null &&
1056
+ this.#dedupeCapTracker.isCapped(gateShapeKey);
1057
+ if (isCapped) {
1058
+ if (gateShapeKey)
1059
+ this.#recordDedupeCapRejection(gateShapeKey);
1060
+ }
1061
+ else {
1062
+ this.#linkList.add(destinationUrl);
1063
+ void enqueue(destinationUrl);
1064
+ }
692
1065
  }
693
1066
  else {
694
1067
  // `deriveJsRedirectTarget` already canonicalises
@@ -731,6 +1104,32 @@ class Crawler extends EventEmitter {
731
1104
  log(c.dim('Predicted (discarded)'));
732
1105
  return;
733
1106
  }
1107
+ // Discard a predicted URL whose rendered body is a
1108
+ // byte-for-byte duplicate of the previous predicted page of the
1109
+ // same shape, and stop generating further predictions for that
1110
+ // shape (checked above, in the pagination-pattern branch). This
1111
+ // is the always-on backstop against a site that returns 2xx for
1112
+ // any extrapolated token but ignores it entirely (e.g. always
1113
+ // serving the same "no results" template) — `shouldDiscardPredicted`
1114
+ // alone cannot see this, since it only inspects HTTP status.
1115
+ if (isPredicted &&
1116
+ result.type === 'success' &&
1117
+ result.pageData &&
1118
+ result.pageData.html.length > 0) {
1119
+ const shapeKey = computeShapeKey(url.withoutHashAndAuth);
1120
+ if (shapeKey) {
1121
+ const bodyHash = computeBodyHash(result.pageData.html);
1122
+ precomputedBodyHash = bodyHash;
1123
+ const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
1124
+ if (isPredictedContentDuplicate(bodyHash, lastBodyHash)) {
1125
+ this.#predictedShapeStopped.add(shapeKey);
1126
+ handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
1127
+ log(c.dim('Predicted (content duplicate, discarded)'));
1128
+ return;
1129
+ }
1130
+ this.#predictedShapeBodyHashes.set(shapeKey, bodyHash);
1131
+ }
1132
+ }
734
1133
  // Count only after discard check: rendered HTML pages that
735
1134
  // will be persisted to the archive. Launch failures bypass
736
1135
  // this point via the catch block; discarded predicted URLs
@@ -739,9 +1138,10 @@ class Crawler extends EventEmitter {
739
1138
  pagesScraped++;
740
1139
  }
741
1140
  log('Saving results%dots%');
742
- this.#handleResult(result, url, enqueue, paginationState, concurrency);
1141
+ this.#handleResult(result, url, enqueue, concurrency, precomputedBodyHash);
743
1142
  const parentSource = await this.#resolveParentSource(url);
744
1143
  this.#handleResources(result.resources, parentSource);
1144
+ this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
745
1145
  log(formatResultSummary(result));
746
1146
  // Phase errors must be emitted AFTER 'page' / 'externalPage'
747
1147
  // so the orchestrator's WriteQueue sees `setPage` before
@@ -815,6 +1215,52 @@ class Crawler extends EventEmitter {
815
1215
  crawlerLog('Crawl End');
816
1216
  void this.emit('crawlEnd', {});
817
1217
  }
1218
+ /**
1219
+ * While {@link #networkGate} is closed, probe every
1220
+ * `networkOutageProbeIntervalMs` until one succeeds, then reopen the
1221
+ * gate and emit `networkOutageRecovered`.
1222
+ *
1223
+ * If the crawl is aborted while this loop is running, the gate is
1224
+ * opened anyway (so any worker stuck on `#networkGate.wait()` can
1225
+ * unblock and `deal()` can resolve) but `networkOutageRecovered` is NOT
1226
+ * emitted — an abort says nothing about whether the network actually
1227
+ * recovered, so the `network_outages` row is deliberately left open for
1228
+ * the next writer session's boot-time finalizer
1229
+ * (`close-stale-open-network-outages.ts`) to resolve. Either way,
1230
+ * {@link #onGateReopened} still runs — the cached failures are stale
1231
+ * regardless of why the gate reopened.
1232
+ * @param probeHost - The hostname to probe, chosen once by
1233
+ * {@link #handleOutageSuspect} and reused for every attempt in this loop.
1234
+ * @param startedAt - The outage's `startedAt`, forwarded to {@link #onGateReopened}.
1235
+ */
1236
+ async #runRecoveryProbeLoop(probeHost, startedAt) {
1237
+ const probe = this.#options.networkProbe ?? probeNetwork;
1238
+ const bailIfAborted = () => {
1239
+ if (!this.#abortController.signal.aborted) {
1240
+ return false;
1241
+ }
1242
+ this.#networkGate.open();
1243
+ this.#onGateReopened(startedAt, Date.now());
1244
+ return true;
1245
+ };
1246
+ if (bailIfAborted()) {
1247
+ return;
1248
+ }
1249
+ for (;;) {
1250
+ await delay(this.#options.networkOutageProbeIntervalMs);
1251
+ if (bailIfAborted()) {
1252
+ return;
1253
+ }
1254
+ const recovered = await probe(probeHost);
1255
+ if (recovered) {
1256
+ const endedAt = Date.now();
1257
+ this.#networkGate.open();
1258
+ this.#onGateReopened(startedAt, endedAt);
1259
+ void this.emit('networkOutageRecovered', { endedAt });
1260
+ return;
1261
+ }
1262
+ }
1263
+ }
818
1264
  /**
819
1265
  * Orchestrates the full scrape pipeline for a single URL.
820
1266
  *
@@ -875,6 +1321,7 @@ class Crawler extends EventEmitter {
875
1321
  type: 'success',
876
1322
  pageData: metadataOnly ? { ...pageData, isTarget: false } : pageData,
877
1323
  resources: [],
1324
+ consoleLogs: [],
878
1325
  };
879
1326
  }
880
1327
  }
@@ -1002,6 +1449,7 @@ class Crawler extends EventEmitter {
1002
1449
  return {
1003
1450
  type: 'error',
1004
1451
  resources: [],
1452
+ consoleLogs: [],
1005
1453
  error: {
1006
1454
  name: error instanceof Error ? error.name : 'Error',
1007
1455
  message: errorMessage,
@@ -1015,6 +1463,7 @@ class Crawler extends EventEmitter {
1015
1463
  return {
1016
1464
  type: 'error',
1017
1465
  resources: [],
1466
+ consoleLogs: [],
1018
1467
  error: {
1019
1468
  name: error instanceof Error ? error.name : 'Error',
1020
1469
  message: errorMessage,
@@ -1062,6 +1511,7 @@ class Crawler extends EventEmitter {
1062
1511
  type: 'success',
1063
1512
  pageData: { ...titleResult, isTarget: false },
1064
1513
  resources: [],
1514
+ consoleLogs: [],
1065
1515
  };
1066
1516
  }
1067
1517
  catch (error) {
@@ -1072,6 +1522,7 @@ class Crawler extends EventEmitter {
1072
1522
  type: 'success',
1073
1523
  pageData: { ...headCheckResult, isTarget: false },
1074
1524
  resources: [],
1525
+ consoleLogs: [],
1075
1526
  };
1076
1527
  }
1077
1528
  // Non-HTML content — skip browser
@@ -1081,6 +1532,7 @@ class Crawler extends EventEmitter {
1081
1532
  type: 'success',
1082
1533
  pageData: headCheckResult,
1083
1534
  resources: [],
1535
+ consoleLogs: [],
1084
1536
  };
1085
1537
  }
1086
1538
  // HTML or unknown content type — launch browser with preflight result.
@@ -1266,9 +1718,11 @@ class Crawler extends EventEmitter {
1266
1718
  retries: this.#options.retry,
1267
1719
  label: 'HEAD request',
1268
1720
  onWait: (determinedInterval, retryCount, label, error) => {
1721
+ this.#recordNetworkError(error.message, host);
1269
1722
  update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
1270
1723
  },
1271
1724
  onGiveUp: (retryCount, error, label) => {
1725
+ this.#recordNetworkError(error.message, host);
1272
1726
  // Burn the host so subsequent URLs short-circuit — but ONLY
1273
1727
  // when this is the first time we've ever seen the host fail
1274
1728
  // in this session. A host that responded earlier is treated
@@ -1284,6 +1738,12 @@ class Crawler extends EventEmitter {
1284
1738
  successfulHosts: this.#successfulHosts,
1285
1739
  })) {
1286
1740
  dnsBurnedHostCache.set(host, 'dns');
1741
+ // Recorded so a later outage recovery can tell THIS
1742
+ // burn (possibly outage-caused) apart from a
1743
+ // preload-seeded one (a cross-session, confirmed-dead
1744
+ // verdict that must never be undone by an in-session
1745
+ // recovery) — see `evict-outage-tainted-dns-burns.ts`.
1746
+ dnsBurnedHostBurnTimestamps.set(host, Date.now());
1287
1747
  }
1288
1748
  update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
1289
1749
  },
@@ -1369,9 +1829,15 @@ class Crawler extends EventEmitter {
1369
1829
  // Verified by `scope-auth-leak.e2e.ts`: removing either piece
1370
1830
  // causes that test to fail (without auth → main 401 hangs;
1371
1831
  // without strip → scope cred leaks to off-scope sub-resource).
1832
+ //
1833
+ // The ExURL fields keep the WHATWG percent-encoded form, but
1834
+ // `page.authenticate` sends its arguments verbatim — decode
1835
+ // first or a password containing `[`/`]`/`{`/`}`/`=` etc.
1836
+ // authenticates with the wrong literal (see
1837
+ // `decode-auth-credential.ts`).
1372
1838
  await page.authenticate({
1373
- username: url.username ?? '',
1374
- password: url.password ?? '',
1839
+ username: decodeAuthCredential(url.username),
1840
+ password: decodeAuthCredential(url.password),
1375
1841
  });
1376
1842
  // Re-parse from `withoutHashAndAuth` rather than mutating the
1377
1843
  // re-parsed `url.href` object: ExURL pre-computes `href`,
@@ -1479,6 +1945,7 @@ class Crawler extends EventEmitter {
1479
1945
  return {
1480
1946
  type: 'error',
1481
1947
  resources: [],
1948
+ consoleLogs: [],
1482
1949
  error: {
1483
1950
  name: error instanceof Error ? error.name : 'Error',
1484
1951
  message: error instanceof Error ? error.message : String(error),