@nitpicker/crawler 0.13.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 (171) hide show
  1. package/lib/archive/archive-accessor.d.ts +56 -0
  2. package/lib/archive/archive-accessor.js +72 -0
  3. package/lib/archive/archive.d.ts +108 -9
  4. package/lib/archive/archive.js +109 -7
  5. package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
  6. package/lib/archive/body-hash/compute-body-hash.js +31 -0
  7. package/lib/archive/body-hash/extract-body.d.ts +18 -0
  8. package/lib/archive/body-hash/extract-body.js +29 -0
  9. package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
  10. package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
  11. package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
  12. package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
  13. package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
  14. package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
  15. package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
  16. package/lib/archive/cache/clear-archive-cache-root.js +32 -0
  17. package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
  18. package/lib/archive/cache/list-archive-cache-entries.js +125 -0
  19. package/lib/archive/cache/path-exists.d.ts +9 -0
  20. package/lib/archive/cache/path-exists.js +18 -0
  21. package/lib/archive/cache/types.d.ts +33 -0
  22. package/lib/archive/cache/types.js +1 -0
  23. package/lib/archive/create-adjunct-tables.d.ts +18 -0
  24. package/lib/archive/create-adjunct-tables.js +277 -0
  25. package/lib/archive/create-entity-tables.d.ts +14 -0
  26. package/lib/archive/create-entity-tables.js +52 -1
  27. package/lib/archive/database.d.ts +91 -2
  28. package/lib/archive/database.js +128 -0
  29. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
  30. package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
  31. package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
  32. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
  33. package/lib/archive/db-ops/_shared/types.d.ts +2 -0
  34. package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
  35. package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +29 -0
  36. package/lib/archive/db-ops/analysis/replace-page-templates.js +84 -0
  37. package/lib/archive/db-ops/analysis/types.d.ts +61 -0
  38. package/lib/archive/db-ops/analysis/types.js +1 -0
  39. package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
  40. package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
  41. package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
  42. package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
  43. package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
  44. package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
  45. package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
  46. package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
  47. package/lib/archive/db-ops/console-logs/types.js +1 -0
  48. package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
  49. package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
  50. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  51. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  52. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  53. package/lib/archive/db-ops/lifecycle/init.d.ts +25 -10
  54. package/lib/archive/db-ops/lifecycle/init.js +39 -10
  55. package/lib/archive/db-ops/meta/get-audios-of-page.d.ts +11 -0
  56. package/lib/archive/db-ops/meta/get-audios-of-page.js +15 -0
  57. package/lib/archive/db-ops/meta/get-buttons-of-page.d.ts +11 -0
  58. package/lib/archive/db-ops/meta/get-buttons-of-page.js +15 -0
  59. package/lib/archive/db-ops/meta/get-canvases-of-page.d.ts +11 -0
  60. package/lib/archive/db-ops/meta/get-canvases-of-page.js +15 -0
  61. package/lib/archive/db-ops/meta/get-headings-of-page.d.ts +11 -0
  62. package/lib/archive/db-ops/meta/get-headings-of-page.js +15 -0
  63. package/lib/archive/db-ops/meta/get-iframes-of-page.d.ts +11 -0
  64. package/lib/archive/db-ops/meta/get-iframes-of-page.js +15 -0
  65. package/lib/archive/db-ops/meta/get-main-content-images-of-page.d.ts +11 -0
  66. package/lib/archive/db-ops/meta/get-main-content-images-of-page.js +15 -0
  67. package/lib/archive/db-ops/meta/get-main-content-tables-of-page.d.ts +11 -0
  68. package/lib/archive/db-ops/meta/get-main-content-tables-of-page.js +15 -0
  69. package/lib/archive/db-ops/meta/get-videos-of-page.d.ts +11 -0
  70. package/lib/archive/db-ops/meta/get-videos-of-page.js +15 -0
  71. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  72. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  73. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  74. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  75. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  76. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  77. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  78. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  79. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  80. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  81. package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
  82. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +19 -10
  83. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +18 -3
  84. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +48 -10
  85. package/lib/archive/db-ops/pages/write/insert-audios.d.ts +14 -0
  86. package/lib/archive/db-ops/pages/write/insert-audios.js +25 -0
  87. package/lib/archive/db-ops/pages/write/insert-buttons.d.ts +14 -0
  88. package/lib/archive/db-ops/pages/write/insert-buttons.js +29 -0
  89. package/lib/archive/db-ops/pages/write/insert-canvases.d.ts +14 -0
  90. package/lib/archive/db-ops/pages/write/insert-canvases.js +26 -0
  91. package/lib/archive/db-ops/pages/write/insert-headings.d.ts +15 -0
  92. package/lib/archive/db-ops/pages/write/insert-headings.js +27 -0
  93. package/lib/archive/db-ops/pages/write/insert-iframes.d.ts +14 -0
  94. package/lib/archive/db-ops/pages/write/insert-iframes.js +28 -0
  95. package/lib/archive/db-ops/pages/write/insert-main-content-images.d.ts +20 -0
  96. package/lib/archive/db-ops/pages/write/insert-main-content-images.js +32 -0
  97. package/lib/archive/db-ops/pages/write/insert-main-content-tables.d.ts +14 -0
  98. package/lib/archive/db-ops/pages/write/insert-main-content-tables.js +29 -0
  99. package/lib/archive/db-ops/pages/write/insert-page.js +15 -2
  100. package/lib/archive/db-ops/pages/write/insert-videos.d.ts +14 -0
  101. package/lib/archive/db-ops/pages/write/insert-videos.js +28 -0
  102. package/lib/archive/db-ops/pages/write/update-page.js +33 -0
  103. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  104. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  105. package/lib/archive/filesystem/output-binary.js +17 -0
  106. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  107. package/lib/archive/get-failed-page-messages.js +29 -15
  108. package/lib/archive/init-schema.js +2 -1
  109. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +22 -0
  110. package/lib/archive/meta/compute-main-contents-denormalized.js +63 -0
  111. package/lib/archive/meta/types.d.ts +263 -0
  112. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  113. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  114. package/lib/archive/migrate-info-main-content-selector.d.ts +11 -0
  115. package/lib/archive/migrate-info-main-content-selector.js +24 -0
  116. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  117. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  118. package/lib/archive/migrate-main-contents-columns.d.ts +21 -0
  119. package/lib/archive/migrate-main-contents-columns.js +50 -0
  120. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  121. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  122. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  123. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  124. package/lib/archive/page.d.ts +145 -3
  125. package/lib/archive/page.js +195 -0
  126. package/lib/archive/types.d.ts +86 -2
  127. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  128. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  129. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  130. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  131. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  132. package/lib/archive/url-alias/format-alias-key.js +14 -0
  133. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  134. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  135. package/lib/archive/url-alias/types.d.ts +7 -0
  136. package/lib/archive/url-alias/types.js +1 -0
  137. package/lib/crawler/choose-probe-host.d.ts +24 -0
  138. package/lib/crawler/choose-probe-host.js +38 -0
  139. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  140. package/lib/crawler/crawler.js +261 -2
  141. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  142. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  143. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  144. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  145. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  146. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  147. package/lib/crawler/fetch-destination.js +2 -0
  148. package/lib/crawler/link-to-page-data.js +2 -0
  149. package/lib/crawler/network-gate.d.ts +49 -0
  150. package/lib/crawler/network-gate.js +78 -0
  151. package/lib/crawler/network-outage-detector.d.ts +51 -0
  152. package/lib/crawler/network-outage-detector.js +81 -0
  153. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  154. package/lib/crawler/network-outage-summary-counter.js +18 -0
  155. package/lib/crawler/probe-network.d.ts +26 -0
  156. package/lib/crawler/probe-network.js +21 -0
  157. package/lib/crawler/resource-to-page-data.js +2 -0
  158. package/lib/crawler/types.d.ts +152 -1
  159. package/lib/crawler-orchestrator.d.ts +62 -18
  160. package/lib/crawler-orchestrator.js +163 -26
  161. package/lib/crawler.d.ts +21 -0
  162. package/lib/crawler.js +17 -0
  163. package/lib/is-within-outage-window.d.ts +49 -0
  164. package/lib/is-within-outage-window.js +33 -0
  165. package/lib/network-related-error-kinds.d.ts +23 -0
  166. package/lib/network-related-error-kinds.js +28 -0
  167. package/lib/types.d.ts +9 -1
  168. package/lib/utils/array/each-splitted.d.ts +1 -1
  169. package/lib/utils/compute-file-sha256.d.ts +17 -17
  170. package/lib/utils/compute-file-sha256.js +18 -47
  171. package/package.json +3 -3
@@ -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
@@ -149,10 +196,21 @@ class Crawler extends EventEmitter {
149
196
  verbose: options?.verbose ?? false,
150
197
  userAgent: options?.userAgent || `Nitpicker/${pkg.version}`,
151
198
  ignoreRobots: options?.ignoreRobots ?? false,
199
+ mainContentSelector: options?.mainContentSelector ?? null,
152
200
  lookupResource: options?.lookupResource ?? null,
153
201
  lookupPageSource: options?.lookupPageSource ?? null,
154
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,
155
208
  };
209
+ this.#networkOutageDetector = new NetworkOutageDetector({
210
+ windowMs: this.#options.networkOutageWindowMs,
211
+ errorThreshold: this.#options.networkOutageErrorThreshold,
212
+ hostThreshold: this.#options.networkOutageHostThreshold,
213
+ });
156
214
  this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
157
215
  for (const urlStr of this.#options.roots) {
158
216
  const url = parseUrl(urlStr, this.#options);
@@ -323,6 +381,84 @@ class Crawler extends EventEmitter {
323
381
  });
324
382
  }
325
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
+ }
326
462
  /**
327
463
  * Processes captured sub-resources from a page scrape, deduplicates them,
328
464
  * and emits `response` / `responseReferrers` events for new resources.
@@ -470,6 +606,54 @@ class Crawler extends EventEmitter {
470
606
  }
471
607
  }
472
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
+ }
473
657
  /**
474
658
  * Resolve the source label of the page being scraped so sub-resources
475
659
  * captured during its render can inherit the correct lineage label
@@ -540,6 +724,12 @@ class Crawler extends EventEmitter {
540
724
  // does not inherit "host alive" claims from a prior run that may have
541
725
  // happened on an entirely different network.
542
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();
543
733
  // external URL の追跡(target は deal の total/done から導出)
544
734
  const externalUrls = new Set();
545
735
  const externalDoneUrls = new Set();
@@ -587,6 +777,12 @@ class Crawler extends EventEmitter {
587
777
  return Promise.all(ops).then(() => { });
588
778
  };
589
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();
590
786
  // Interval delay is handled here instead of by dealer because
591
787
  // DNS-burned hosts must skip the wait entirely. Spending the
592
788
  // per-URL interval on a host the cache already knows is dead
@@ -741,6 +937,7 @@ class Crawler extends EventEmitter {
741
937
  this.#handleResult(result, url, enqueue, paginationState, concurrency);
742
938
  const parentSource = await this.#resolveParentSource(url);
743
939
  this.#handleResources(result.resources, parentSource);
940
+ this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
744
941
  log(formatResultSummary(result));
745
942
  // Phase errors must be emitted AFTER 'page' / 'externalPage'
746
943
  // so the orchestrator's WriteQueue sees `setPage` before
@@ -814,6 +1011,52 @@ class Crawler extends EventEmitter {
814
1011
  crawlerLog('Crawl End');
815
1012
  void this.emit('crawlEnd', {});
816
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
+ }
817
1060
  /**
818
1061
  * Orchestrates the full scrape pipeline for a single URL.
819
1062
  *
@@ -874,6 +1117,7 @@ class Crawler extends EventEmitter {
874
1117
  type: 'success',
875
1118
  pageData: metadataOnly ? { ...pageData, isTarget: false } : pageData,
876
1119
  resources: [],
1120
+ consoleLogs: [],
877
1121
  };
878
1122
  }
879
1123
  }
@@ -1001,6 +1245,7 @@ class Crawler extends EventEmitter {
1001
1245
  return {
1002
1246
  type: 'error',
1003
1247
  resources: [],
1248
+ consoleLogs: [],
1004
1249
  error: {
1005
1250
  name: error instanceof Error ? error.name : 'Error',
1006
1251
  message: errorMessage,
@@ -1014,6 +1259,7 @@ class Crawler extends EventEmitter {
1014
1259
  return {
1015
1260
  type: 'error',
1016
1261
  resources: [],
1262
+ consoleLogs: [],
1017
1263
  error: {
1018
1264
  name: error instanceof Error ? error.name : 'Error',
1019
1265
  message: errorMessage,
@@ -1061,6 +1307,7 @@ class Crawler extends EventEmitter {
1061
1307
  type: 'success',
1062
1308
  pageData: { ...titleResult, isTarget: false },
1063
1309
  resources: [],
1310
+ consoleLogs: [],
1064
1311
  };
1065
1312
  }
1066
1313
  catch (error) {
@@ -1071,6 +1318,7 @@ class Crawler extends EventEmitter {
1071
1318
  type: 'success',
1072
1319
  pageData: { ...headCheckResult, isTarget: false },
1073
1320
  resources: [],
1321
+ consoleLogs: [],
1074
1322
  };
1075
1323
  }
1076
1324
  // Non-HTML content — skip browser
@@ -1080,6 +1328,7 @@ class Crawler extends EventEmitter {
1080
1328
  type: 'success',
1081
1329
  pageData: headCheckResult,
1082
1330
  resources: [],
1331
+ consoleLogs: [],
1083
1332
  };
1084
1333
  }
1085
1334
  // HTML or unknown content type — launch browser with preflight result.
@@ -1265,9 +1514,11 @@ class Crawler extends EventEmitter {
1265
1514
  retries: this.#options.retry,
1266
1515
  label: 'HEAD request',
1267
1516
  onWait: (determinedInterval, retryCount, label, error) => {
1517
+ this.#recordNetworkError(error.message, host);
1268
1518
  update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
1269
1519
  },
1270
1520
  onGiveUp: (retryCount, error, label) => {
1521
+ this.#recordNetworkError(error.message, host);
1271
1522
  // Burn the host so subsequent URLs short-circuit — but ONLY
1272
1523
  // when this is the first time we've ever seen the host fail
1273
1524
  // in this session. A host that responded earlier is treated
@@ -1283,6 +1534,12 @@ class Crawler extends EventEmitter {
1283
1534
  successfulHosts: this.#successfulHosts,
1284
1535
  })) {
1285
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());
1286
1543
  }
1287
1544
  update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
1288
1545
  },
@@ -1355,8 +1612,8 @@ class Crawler extends EventEmitter {
1355
1612
  // sub-resource requests issued from the same page —
1356
1613
  // including cross-origin requests to a different hostname
1357
1614
  // sharing the same IP / port (e.g. an embedded
1358
- // `<img src="http://127.0.0.1:8010/…">` loaded from a
1359
- // `localhost:8010` page) — get the cached `Authorization`
1615
+ // `<img src="http://127.0.0.1:PORT/…">` loaded from a
1616
+ // `localhost:PORT` page) — get the cached `Authorization`
1360
1617
  // header re-attached by the network stack. The
1361
1618
  // `Fetch.authRequired` event never fires for these
1362
1619
  // pre-emptive attachments, so neither `page.authenticate`
@@ -1399,6 +1656,7 @@ class Crawler extends EventEmitter {
1399
1656
  metadataOnly,
1400
1657
  retries: this.#options.retry,
1401
1658
  headCheckResult,
1659
+ mainContentSelector: this.#options.mainContentSelector,
1402
1660
  });
1403
1661
  // Image dom-path capture runs here — after the scrape completed but
1404
1662
  // while `page` is still alive — because beholder's image metadata
@@ -1477,6 +1735,7 @@ class Crawler extends EventEmitter {
1477
1735
  return {
1478
1736
  type: 'error',
1479
1737
  resources: [],
1738
+ consoleLogs: [],
1480
1739
  error: {
1481
1740
  name: error instanceof Error ? error.name : 'Error',
1482
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>;
@@ -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 const dnsBurnedHostBurnTimestamps = new Map();
@@ -0,0 +1,26 @@
1
+ import type { PageData } from '@d-zero/beholder';
2
+ /**
3
+ * Delete every `destinationCache` entry whose cached value is an `Error`
4
+ * that classifies as a {@link NETWORK_RELATED_ERROR_KINDS network-related}
5
+ * kind, leaving successes and site-specific failures (`tls`,
6
+ * `client-blocked`, `connection-refused`, …) untouched.
7
+ *
8
+ * Called by `Crawler` whenever its network gate transitions from closed to
9
+ * open (whether via a successful recovery probe or an abort), so a URL
10
+ * that failed with `getaddrinfo ENOTFOUND` only because the operator's own
11
+ * network was down does not stay wrongly cached as "this host is dead" for
12
+ * the rest of the session.
13
+ *
14
+ * Deliberately does NOT distinguish "cached during THIS specific outage"
15
+ * from "cached during an earlier blip this session" — any cached error
16
+ * whose KIND looks network-related is, by definition, potentially stale
17
+ * evidence about the operator's network rather than the target site, so it
18
+ * is always safe to re-test after any recovery. The trade-off is a few
19
+ * redundant HEAD attempts for hosts that were already dead independent of
20
+ * the outage; the alternative (a stale "outage-tainted" failure verdict
21
+ * surviving the rest of the session) is strictly worse.
22
+ * @param cache - The cache to sweep. Takes the `Map` explicitly (rather
23
+ * than importing the `destinationCache` singleton directly) so this stays
24
+ * unit-testable without touching module-level state.
25
+ */
26
+ export declare function evictNetworkClassifiedDestinationCacheEntries(cache: Map<string, PageData | Error>): void;
@@ -0,0 +1,34 @@
1
+ import { classifyErrorKind } from '../classify-error-kind.js';
2
+ import { NETWORK_RELATED_ERROR_KINDS } from '../network-related-error-kinds.js';
3
+ /**
4
+ * Delete every `destinationCache` entry whose cached value is an `Error`
5
+ * that classifies as a {@link NETWORK_RELATED_ERROR_KINDS network-related}
6
+ * kind, leaving successes and site-specific failures (`tls`,
7
+ * `client-blocked`, `connection-refused`, …) untouched.
8
+ *
9
+ * Called by `Crawler` whenever its network gate transitions from closed to
10
+ * open (whether via a successful recovery probe or an abort), so a URL
11
+ * that failed with `getaddrinfo ENOTFOUND` only because the operator's own
12
+ * network was down does not stay wrongly cached as "this host is dead" for
13
+ * the rest of the session.
14
+ *
15
+ * Deliberately does NOT distinguish "cached during THIS specific outage"
16
+ * from "cached during an earlier blip this session" — any cached error
17
+ * whose KIND looks network-related is, by definition, potentially stale
18
+ * evidence about the operator's network rather than the target site, so it
19
+ * is always safe to re-test after any recovery. The trade-off is a few
20
+ * redundant HEAD attempts for hosts that were already dead independent of
21
+ * the outage; the alternative (a stale "outage-tainted" failure verdict
22
+ * surviving the rest of the session) is strictly worse.
23
+ * @param cache - The cache to sweep. Takes the `Map` explicitly (rather
24
+ * than importing the `destinationCache` singleton directly) so this stays
25
+ * unit-testable without touching module-level state.
26
+ */
27
+ export function evictNetworkClassifiedDestinationCacheEntries(cache) {
28
+ for (const [key, value] of cache) {
29
+ if (value instanceof Error &&
30
+ NETWORK_RELATED_ERROR_KINDS.has(classifyErrorKind(value.message))) {
31
+ cache.delete(key);
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,23 @@
1
+ import type { OutageWindow } from '../is-within-outage-window.js';
2
+ import type { ErrorKind } from '../types.js';
3
+ /**
4
+ * Undo `dnsBurnedHostCache` burns whose recorded timestamp (see
5
+ * `dns-burned-host-burn-timestamps.ts`) falls inside `window` — i.e. hosts
6
+ * THIS session burned because a HEAD request failed with a
7
+ * `dns`-classified error while (or just before) the operator's own network
8
+ * was down, not because the host is actually dead.
9
+ *
10
+ * Preload-seeded burns are structurally immune: they never appear in
11
+ * `burnTimestamps` (see that module's docstring), so this function can
12
+ * never touch them regardless of the window.
13
+ * @param options - Named parameters.
14
+ * @param options.cache - `dnsBurnedHostCache` (or a test double with the same shape).
15
+ * @param options.burnTimestamps - `dnsBurnedHostBurnTimestamps` (or a test double).
16
+ * @param options.window - The just-resolved outage window (`{ startedAt, endedAt }`)
17
+ * to test each burn's timestamp against.
18
+ */
19
+ export declare function evictOutageTaintedDnsBurns(options: {
20
+ cache: Map<string, ErrorKind>;
21
+ burnTimestamps: Map<string, number>;
22
+ window: OutageWindow;
23
+ }): void;
@@ -0,0 +1,26 @@
1
+ import { isWithinOutageWindow } from '../is-within-outage-window.js';
2
+ /**
3
+ * Undo `dnsBurnedHostCache` burns whose recorded timestamp (see
4
+ * `dns-burned-host-burn-timestamps.ts`) falls inside `window` — i.e. hosts
5
+ * THIS session burned because a HEAD request failed with a
6
+ * `dns`-classified error while (or just before) the operator's own network
7
+ * was down, not because the host is actually dead.
8
+ *
9
+ * Preload-seeded burns are structurally immune: they never appear in
10
+ * `burnTimestamps` (see that module's docstring), so this function can
11
+ * never touch them regardless of the window.
12
+ * @param options - Named parameters.
13
+ * @param options.cache - `dnsBurnedHostCache` (or a test double with the same shape).
14
+ * @param options.burnTimestamps - `dnsBurnedHostBurnTimestamps` (or a test double).
15
+ * @param options.window - The just-resolved outage window (`{ startedAt, endedAt }`)
16
+ * to test each burn's timestamp against.
17
+ */
18
+ export function evictOutageTaintedDnsBurns(options) {
19
+ const { cache, burnTimestamps, window } = options;
20
+ for (const [host, burnedAt] of burnTimestamps) {
21
+ if (isWithinOutageWindow(burnedAt, [window])) {
22
+ cache.delete(host);
23
+ burnTimestamps.delete(host);
24
+ }
25
+ }
26
+ }
@@ -196,6 +196,8 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent, t
196
196
  imageList: [],
197
197
  anchorList: [],
198
198
  html: '',
199
+ mainContents: null,
200
+ scrollHeight: null,
199
201
  isSkipped: false,
200
202
  };
201
203
  };
@@ -48,6 +48,8 @@ export function linkToPageData(link) {
48
48
  anchorList: [],
49
49
  imageList: [],
50
50
  html: '',
51
+ mainContents: null,
52
+ scrollHeight: null,
51
53
  isSkipped: false,
52
54
  };
53
55
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Level-triggered gate that dealer worker callbacks await before doing any
3
+ * network work. Achieves "pause the crawl until the network recovers"
4
+ * without touching `@d-zero/dealer` internals — a closed gate just makes
5
+ * the worker's task body a long-running `await`, which is all dealer ever
6
+ * sees.
7
+ *
8
+ * **`epoch`** counts outage generations: it increments on every `close()`
9
+ * transition (open → closed) and holds that value for the remainder of the
10
+ * closed period AND through the following open period, until the next
11
+ * `close()`. This is the hook `destinationCache` / `dnsBurnedHostCache`
12
+ * eviction uses: an entry written while the gate was closed is tagged with
13
+ * `gate.epoch` at write time; after `open()`, the caller purges entries
14
+ * tagged with that same epoch value. Because `epoch` only advances on
15
+ * `close()` (never on `open()`), entries written during ordinary (open)
16
+ * operation are never mistakenly tagged with a soon-to-be-purged epoch —
17
+ * only writes that happen while `isOpen` is `false` get tagged at all.
18
+ */
19
+ export default class NetworkGate {
20
+ #private;
21
+ /** `true` when the gate is open (the default). */
22
+ get isOpen(): boolean;
23
+ /**
24
+ * Current outage-generation counter. See the class docstring for how
25
+ * cache-eviction callers are expected to use this value.
26
+ */
27
+ get epoch(): number;
28
+ /**
29
+ * Close the gate, blocking future {@link wait} callers until the next
30
+ * {@link open}. Idempotent — calling `close()` while already closed does
31
+ * nothing (in particular, it does NOT bump {@link epoch} a second time,
32
+ * which would otherwise let a still-open outage's cache entries escape
33
+ * eviction under a stale epoch number).
34
+ */
35
+ close(): void;
36
+ /**
37
+ * Open the gate, resolving every {@link wait} caller currently pending —
38
+ * including ones that subscribed after `close()` but before this call.
39
+ * Idempotent — calling `open()` while already open does nothing.
40
+ */
41
+ open(): void;
42
+ /**
43
+ * Resolve immediately if the gate is open; otherwise resolve on the next
44
+ * {@link open}. Multiple concurrent callers all resolve on the same
45
+ * `open()` call — no awaiter is dropped regardless of when it subscribed
46
+ * relative to others.
47
+ */
48
+ wait(): Promise<void>;
49
+ }