@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
@@ -13,6 +13,7 @@ import { dnsBurnedHostCache } from './crawler/dns-burned-host-cache.js';
13
13
  import { dnsBurnedHostShortCircuitCounter } from './crawler/dns-burned-host-short-circuit-counter.js';
14
14
  import { findScopeEntry } from './crawler/find-scope-entry.js';
15
15
  import { isLikelyHtmlUrl } from './crawler/is-likely-html-url.js';
16
+ import { networkOutageSummaryCounter } from './crawler/network-outage-summary-counter.js';
16
17
  import { PreloadShortCircuitError } from './crawler/preload-short-circuit-error.js';
17
18
  import { protocolAgnosticKey } from './crawler/protocol-agnostic-key.js';
18
19
  import { crawlerLog, log } from './debug.js';
@@ -63,8 +64,28 @@ export class CrawlerOrchestrator extends EventEmitter {
63
64
  #archive;
64
65
  /** The crawler engine that discovers and scrapes pages. */
65
66
  #crawler;
67
+ /**
68
+ * `dedupe_cap_events.id` for each shape confirmed capped this session, so
69
+ * `crawlEnd` can look up the right row to finalize with
70
+ * `Crawler#getDedupeCapRejections`'s counts. A `Map` (not a single
71
+ * scalar like {@link #openNetworkOutageId}) because, unlike a network
72
+ * outage, more than one shape can be capped simultaneously within one
73
+ * crawl.
74
+ */
75
+ #dedupeCapEventIds = new Map();
66
76
  /** Whether the crawl was started from a pre-defined URL list (non-recursive mode). */
67
77
  #fromList;
78
+ /**
79
+ * The `network_outages` row id for the currently-open outage, or `null`
80
+ * when none is open. Set by the `networkOutageConfirmed` handler (once
81
+ * the INSERT resolves) and consumed by `networkOutageRecovered` — the
82
+ * `Crawler` class never touches the archive itself and has no way to
83
+ * know the row's id, so the orchestrator is the only place that can
84
+ * bridge the two events for the same outage.
85
+ */
86
+ #openNetworkOutageId = null;
87
+ /** `startedAt` of the currently-open outage, tracked alongside {@link #openNetworkOutageId} so `networkOutageRecovered` can compute a duration for {@link networkOutageSummaryCounter}. */
88
+ #openNetworkOutageStartedAt = null;
68
89
  /** Serializes archive writes from crawler event handlers (FIFO). */
69
90
  #writeQueue = new WriteQueue();
70
91
  /**
@@ -138,6 +159,21 @@ export class CrawlerOrchestrator extends EventEmitter {
138
159
  // rows continue to land in pages/resources with the DB DEFAULT
139
160
  // `'crawled'` provenance label.
140
161
  inventoryMode: options?.inventoryMode ?? null,
162
+ // Forwarded as-is (including `undefined`) — `Crawler`'s own
163
+ // constructor merges each against its `DEFAULT_NETWORK_OUTAGE_*`
164
+ // constant, so omitting them here is exactly "use the default".
165
+ networkOutageWindowMs: options?.networkOutageWindowMs,
166
+ networkOutageErrorThreshold: options?.networkOutageErrorThreshold,
167
+ networkOutageHostThreshold: options?.networkOutageHostThreshold,
168
+ networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs,
169
+ networkProbe: options?.networkProbe ?? null,
170
+ dedupeCap: options?.dedupeCap ?? null,
171
+ dedupeMapCap: options?.dedupeMapCap,
172
+ // Only the four resuming-session static methods
173
+ // (`append`/`inventory`/`retryFailed`/`resume`) pass this — a
174
+ // fresh `crawling()` has no archive history to seed from (see
175
+ // `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc).
176
+ preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
141
177
  });
142
178
  }
143
179
  /**
@@ -166,6 +202,11 @@ export class CrawlerOrchestrator extends EventEmitter {
166
202
  */
167
203
  async crawling(list, opts) {
168
204
  const writeQueue = this.#writeQueue;
205
+ // Per-session state, like `Crawler`'s own `#successfulHosts.clear()` /
206
+ // `#networkGate.open()` reset at the start of `#runDeal` — a fresh
207
+ // session must not inherit a dangling outage id from a prior one.
208
+ this.#openNetworkOutageId = null;
209
+ this.#openNetworkOutageStartedAt = null;
169
210
  return new Promise((resolve, reject) => {
170
211
  this.#crawler.on('error', (error) => {
171
212
  if (error.error instanceof PreloadShortCircuitError) {
@@ -212,6 +253,84 @@ export class CrawlerOrchestrator extends EventEmitter {
212
253
  .catch((error) => reject(error));
213
254
  void this.emit('redirect', { result });
214
255
  });
256
+ this.#crawler.on('networkOutageConfirmed', ({ startedAt, detectedAt, probeHost, triggerErrorCount, triggerHostCount }) => {
257
+ crawlerLog('Network outage confirmed: probeHost=%s triggerErrorCount=%d triggerHostCount=%d', probeHost, triggerErrorCount, triggerHostCount);
258
+ // event notice; mirrors `#finalizeCrawlSession`'s unconditional
259
+ // `console.error` for the DNS-burn short-circuit summary.
260
+ console.error(`[network] outage suspected — pausing workers (probe host: ${probeHost ?? 'none'})`);
261
+ writeQueue
262
+ .enqueue(async () => {
263
+ // Both fields are set together, inside this single
264
+ // closure, so the pair can never fall out of sync
265
+ // (e.g. one set synchronously above while the other
266
+ // waits on the INSERT) — `networkOutageRecovered`'s
267
+ // queued closure always sees either both set or
268
+ // neither.
269
+ const id = await this.#archive.insertNetworkOutage({
270
+ startedAt,
271
+ detectedAt,
272
+ probeHost,
273
+ triggerErrorCount,
274
+ triggerHostCount,
275
+ });
276
+ this.#openNetworkOutageId = id;
277
+ this.#openNetworkOutageStartedAt = startedAt;
278
+ })
279
+ .catch((error) => reject(error));
280
+ });
281
+ this.#crawler.on('networkOutageRecovered', ({ endedAt }) => {
282
+ // The `id` read is deferred to INSIDE the queued closure, not
283
+ // read synchronously here, because `networkOutageConfirmed`'s
284
+ // INSERT is itself only queued (not awaited) when that event
285
+ // fires — `#openNetworkOutageId` is not guaranteed to be set
286
+ // yet at the instant `networkOutageRecovered` fires (the two
287
+ // events can arrive in quick succession, e.g. in tests that
288
+ // drive them back-to-back with no real probe-interval delay
289
+ // between them). `WriteQueue` runs enqueued operations in
290
+ // submission order, so by the time THIS closure actually
291
+ // executes, the confirm's INSERT closure (enqueued first) has
292
+ // already completed and `#openNetworkOutageId` is reliably set.
293
+ writeQueue
294
+ .enqueue(() => {
295
+ const id = this.#openNetworkOutageId;
296
+ const startedAt = this.#openNetworkOutageStartedAt;
297
+ if (id === null) {
298
+ // Defensive: `networkOutageConfirmed` always
299
+ // precedes `networkOutageRecovered` on the same
300
+ // `Crawler` instance. If this fires anyway, there
301
+ // is no row to close.
302
+ crawlerLog('Network outage recovered but no open outage id was tracked');
303
+ return Promise.resolve();
304
+ }
305
+ this.#openNetworkOutageId = null;
306
+ this.#openNetworkOutageStartedAt = null;
307
+ const durationMs = endedAt - (startedAt ?? endedAt);
308
+ networkOutageSummaryCounter.confirmedCount++;
309
+ networkOutageSummaryCounter.totalDurationMs += durationMs;
310
+ crawlerLog('Network outage recovered: id=%d endedAt=%d', id, endedAt);
311
+ // eslint-disable-next-line no-console -- see the confirmed handler above
312
+ console.error(`[network] recovered after ${Math.round(durationMs / 1000)}s`);
313
+ return this.#archive.closeNetworkOutage(id, endedAt);
314
+ })
315
+ .catch((error) => reject(error));
316
+ });
317
+ this.#crawler.on('dedupeCap', ({ shapeKey, sampleUrl, bodyHash, effectiveThreshold, observedCount }) => {
318
+ crawlerLog('Dedupe cap reached: shapeKey=%s effectiveThreshold=%d observedCount=%d', shapeKey, effectiveThreshold, observedCount);
319
+ console.error(`[dedupe-cap] same-cluster trap confirmed: ${shapeKey} (sample: ${sampleUrl})`);
320
+ writeQueue
321
+ .enqueue(async () => {
322
+ const id = await this.#archive.insertDedupeCapEvent({
323
+ shapeKey,
324
+ sampleUrl,
325
+ bodyHash,
326
+ effectiveThreshold,
327
+ observedCount,
328
+ detectedAt: Date.now(),
329
+ });
330
+ this.#dedupeCapEventIds.set(shapeKey, id);
331
+ })
332
+ .catch((error) => reject(error));
333
+ });
215
334
  this.#crawler.on('response', ({ resource, source }) => {
216
335
  writeQueue
217
336
  .enqueue(() => this.#archive.setResources(resource, source))
@@ -222,7 +341,57 @@ export class CrawlerOrchestrator extends EventEmitter {
222
341
  .enqueue(() => this.#archive.setResourcesReferrers(resource))
223
342
  .catch((error) => reject(error));
224
343
  });
344
+ this.#crawler.on('consoleLogs', ({ pageUrl, redirectPaths, entries }) => {
345
+ writeQueue
346
+ .enqueue(() => this.#archive.setConsoleLogs(pageUrl, redirectPaths, entries))
347
+ .catch((error) => reject(error));
348
+ });
225
349
  this.#crawler.on('crawlEnd', () => {
350
+ // Deferred to INSIDE a queued closure, not read synchronously
351
+ // here, for the same reason `networkOutageRecovered`'s handler
352
+ // defers reading `#openNetworkOutageId`: a `dedupeCap` event's
353
+ // INSERT closure may still be queued (not yet executed) at the
354
+ // instant `crawlEnd` fires. `WriteQueue` runs enqueued
355
+ // operations in submission order, so by the time THIS closure
356
+ // executes, every earlier-queued `dedupeCap` INSERT has
357
+ // already completed and `#dedupeCapEventIds` is reliably
358
+ // populated.
359
+ writeQueue
360
+ .enqueue(async () => {
361
+ const rejections = this.#crawler.getDedupeCapRejections();
362
+ // Finalize every shape capped THIS session (has an id in
363
+ // `#dedupeCapEventIds`), not just the ones with a nonzero
364
+ // rejection count — a shape that capped near the end of the
365
+ // crawl (or whose remaining anchors all happened to be
366
+ // discovered before it capped) never enters `rejections` at
367
+ // all, and would otherwise stay `rejected_count: NULL` forever
368
+ // despite the crawl completing normally, corrupting the "NULL
369
+ // means the crawl never reached crawlEnd" contract
370
+ // `list-dedupe-cap-events.ts` documents.
371
+ const shapeKeysToFinalize = new Set([
372
+ ...this.#dedupeCapEventIds.keys(),
373
+ ...rejections.keys(),
374
+ ]);
375
+ await Promise.all([...shapeKeysToFinalize].map((shapeKey) => {
376
+ const rejectedCount = rejections.get(shapeKey) ?? 0;
377
+ const id = this.#dedupeCapEventIds.get(shapeKey);
378
+ // A shape capped THIS session has an id here (the
379
+ // `dedupeCap` event always enqueues an INSERT before any
380
+ // rejection for that shape can be counted) and is
381
+ // finalized once via its row id. A shape with no id was
382
+ // never observed this session at all — it was preloaded
383
+ // into `DedupeCapTracker`'s sticky set from an EARLIER
384
+ // session's `dedupe_cap_events` row (see
385
+ // `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc), so gate
386
+ // rejections still accumulate for it but no new row (and
387
+ // thus no id) is ever created. That earlier row's count is
388
+ // accumulated onto by shape_key instead of overwritten.
389
+ return id === undefined
390
+ ? this.#archive.accumulateDedupeCapRejectedCount(shapeKey, rejectedCount)
391
+ : this.#archive.finalizeDedupeCapEvent(id, rejectedCount);
392
+ }));
393
+ })
394
+ .catch((error) => reject(error));
226
395
  writeQueue
227
396
  .drain()
228
397
  .then(() => resolve())
@@ -387,7 +556,13 @@ export class CrawlerOrchestrator extends EventEmitter {
387
556
  const absFilePath = path.isAbsolute(archivePath)
388
557
  ? archivePath
389
558
  : path.resolve(cwd, archivePath);
390
- const archive = await Archive.open({ filePath: absFilePath, cwd });
559
+ // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
560
+ // on every writer path that calls `write()`.
561
+ const archive = await Archive.open({
562
+ filePath: absFilePath,
563
+ cwd,
564
+ openPluginData: true,
565
+ });
391
566
  // Any throw between here and the successful return must release the
392
567
  // archive lock and clean up tmpDir; the caller's `close()` only runs on
393
568
  // the happy path. Errors from `close()` itself are intentionally
@@ -424,9 +599,14 @@ export class CrawlerOrchestrator extends EventEmitter {
424
599
  scopeMap.set(parsed.hostname, [...existing, parsed]);
425
600
  }
426
601
  await archive.repromoteExternalPages(scopeMap, archived);
602
+ // Seed the sticky set from prior sessions' confirmed traps so
603
+ // `--append` does not pay the cost of re-discovering them (see
604
+ // `DedupeCapTracker`'s constructor JSDoc).
605
+ const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
427
606
  const orchestrator = new CrawlerOrchestrator(archive, {
428
607
  ...mergedConfig,
429
608
  roots: mergedRoots,
609
+ preloadedStickyShapeKeys,
430
610
  });
431
611
  const { scraped, pending } = await archive.getCrawlingState();
432
612
  const resources = await archive.getResourceUrlList();
@@ -481,22 +661,29 @@ export class CrawlerOrchestrator extends EventEmitter {
481
661
  * 3. Reject archives with unfinished `pending` URLs — those would inherit
482
662
  * the inventory `source` label by mistake. Operator must resume /
483
663
  * retry-failed first.
484
- * 4. Parse the URL list. Anything outside the archived scope is warned
485
- * and skipped (inventory is per-server by design).
486
- * 5. Subtract URLs that already exist in `pages` or `resources` so the
664
+ * 4. If `source` is given, archive its exact bytes under
665
+ * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList}).
666
+ * Done before scope classification so even a run that discards every
667
+ * URL (out of scope or already known) still leaves a recoverable
668
+ * copy of what was fed in.
669
+ * 5. Parse the URL list — the CLI has already warned-and-dropped
670
+ * unparseable-URL lines before calling this method, so every
671
+ * remaining entry parses. Anything outside the archived scope is
672
+ * warned and skipped (inventory is per-server by design).
673
+ * 6. Subtract URLs that already exist in `pages` or `resources` so the
487
674
  * second (and N-th) inventory pass is a no-op for known rows — keeps
488
675
  * `'inventory-seed'` rows from being silently demoted.
489
- * 6. Make `<archive>.bak`. Anything thrown beyond this point restores
676
+ * 7. Make `<archive>.bak`. Anything thrown beyond this point restores
490
677
  * from the backup.
491
- * 7. HEAD-probe each novel URL. Responses classified as HTML are queued
678
+ * 8. HEAD-probe each novel URL. Responses classified as HTML are queued
492
679
  * as Crawler seeds (`'inventory-seed'`); everything else is recorded
493
680
  * in `resources` directly as `'inventory-seed'` (no browser launch).
494
- * 8. If any HTML seeds exist, start a Crawler with
681
+ * 9. If any HTML seeds exist, start a Crawler with
495
682
  * `inventoryMode = { seedUrls }` so the rendered page and every newly
496
683
  * discovered downstream link is labelled correctly. `resume` is fed
497
684
  * the existing `scraped` / `resources` sets so links into already-
498
685
  * crawled pages stop at the seen-gate without re-rendering.
499
- * 9. Drop the backup on success; restore it on any throw.
686
+ * 10. Drop the backup on success; restore it on any throw.
500
687
  *
501
688
  * Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
502
689
  * `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
@@ -506,20 +693,22 @@ export class CrawlerOrchestrator extends EventEmitter {
506
693
  * @param inventoryUrls - Pre-read URL list (one URL per element).
507
694
  * @param options - Optional config overrides — most callers leave this blank and let the archived config flow through.
508
695
  * @param initializedCallback - Hook invoked once the orchestrator is constructed but before `crawling` runs (the CLI uses it to attach progress reporting).
509
- * @param sourceFileSha256 - **Pre-computed** SHA-256 hex digest of the
510
- * source URL list. The orchestrator deliberately does NOT receive
511
- * the file path: the path is privacy-sensitive (leaks user-home /
512
- * OS structure when archives are shared) and we want it lifted off
513
- * this boundary so no future log line / breadcrumb / error message
514
- * inside the orchestrator can accidentally re-leak it. The CLI
515
- * computes the digest via `computeFileSha256(resolvedListFile)`
516
- * and passes it through here. Pass `null` for programmatic
517
- * callers that built `inventoryUrls` in-memory; the audit row's
518
- * `source_file_sha256` column will be `NULL`.
696
+ * @param source - The CLI's already-read source list, as `{ sha256, bytes }`.
697
+ * The orchestrator deliberately does NOT receive the file path: the
698
+ * path is privacy-sensitive (leaks user-home / OS structure when
699
+ * archives are shared) and we want it lifted off this boundary so no
700
+ * future log line / breadcrumb / error message inside the orchestrator
701
+ * can accidentally re-leak it. `bytes` is archived verbatim under
702
+ * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList})
703
+ * before scope classification, so a later `--inventory` run against
704
+ * the same list is an audit no-op even when it discards zero new
705
+ * URLs. Pass `null` for programmatic callers that built
706
+ * `inventoryUrls` in-memory; the audit row's `source_file_sha256`
707
+ * column will be `NULL` and no source list is archived.
519
708
  * @returns The orchestrator instance after a successful inventory pass.
520
709
  * @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
521
710
  */
522
- static async inventory(archivePath, inventoryUrls, options, initializedCallback, sourceFileSha256 = null) {
711
+ static async inventory(archivePath, inventoryUrls, options, initializedCallback, source = null) {
523
712
  if (inventoryUrls.length === 0) {
524
713
  throw new Error('inventory: URL list is empty');
525
714
  }
@@ -527,7 +716,13 @@ export class CrawlerOrchestrator extends EventEmitter {
527
716
  const absFilePath = path.isAbsolute(archivePath)
528
717
  ? archivePath
529
718
  : path.resolve(cwd, archivePath);
530
- const archive = await Archive.open({ filePath: absFilePath, cwd });
719
+ // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
720
+ // on every writer path that calls `write()`.
721
+ const archive = await Archive.open({
722
+ filePath: absFilePath,
723
+ cwd,
724
+ openPluginData: true,
725
+ });
531
726
  try {
532
727
  const archived = await archive.getConfig();
533
728
  if (archived.fromList) {
@@ -549,6 +744,14 @@ export class CrawlerOrchestrator extends EventEmitter {
549
744
  // eslint-disable-next-line no-console -- operator-facing warning, must be visible regardless of DEBUG filters
550
745
  console.warn(`inventory: archive has ${pending.length} pending URLs from a previous crawl. Proceeding — crawled-wins priority keeps their labels stable. Consider \`--resume\` first if you want the prior work finalized.`);
551
746
  }
747
+ // Archive the exact source bytes before scope classification, so
748
+ // even a run that discards every URL (out-of-scope or already
749
+ // known — see `novelUrls.length === 0` below) still leaves a
750
+ // recoverable copy of what was fed in. Content-hash-named, so a
751
+ // second `--inventory` pass with the same list is a no-op write.
752
+ if (source) {
753
+ await archive.saveInventorySourceList(source.sha256, source.bytes);
754
+ }
552
755
  // Parse + scope-classify the candidate URLs. sortUrl drops
553
756
  // unparseable strings; findScopeEntry separates in-scope from
554
757
  // out-of-scope.
@@ -703,7 +906,8 @@ export class CrawlerOrchestrator extends EventEmitter {
703
906
  htmlSeedsCount: htmlSeeds.length,
704
907
  nonHtmlCount: nonHtmlSeeds.length,
705
908
  outOfScope,
706
- sourceFileSha256,
909
+ sourceFileSha256: source?.sha256 ?? null,
910
+ invalidSkipped: source?.invalidLineCount ?? null,
707
911
  });
708
912
  // Ingestion's DB writes are now committed. From here on a
709
913
  // throw must NOT trigger the `.bak` restore (it would wipe
@@ -735,6 +939,16 @@ export class CrawlerOrchestrator extends EventEmitter {
735
939
  inventoryMode: { seedUrls: seedSet },
736
940
  };
737
941
  if (htmlSeeds.length > 0) {
942
+ // Seed the sticky set from prior sessions' confirmed traps
943
+ // so `--inventory` does not pay the cost of
944
+ // re-discovering them (see `DedupeCapTracker`'s
945
+ // constructor JSDoc). Scoped to this branch only,
946
+ // matching `#preloadDnsBurnedHostCache`'s scoping below —
947
+ // the fallback (non-HTML-only) branch never calls
948
+ // `orchestrator.crawling(...)`, so the tracker is never
949
+ // consulted there.
950
+ orchestratorOptions.preloadedStickyShapeKeys =
951
+ await archive.listDedupeCapShapeKeys();
738
952
  const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
739
953
  // Re-read pending *after* the pre-insert so the strict-
740
954
  // pending set includes the freshly inserted
@@ -853,7 +1067,13 @@ export class CrawlerOrchestrator extends EventEmitter {
853
1067
  const absFilePath = path.isAbsolute(archivePath)
854
1068
  ? archivePath
855
1069
  : path.resolve(cwd, archivePath);
856
- const archive = await Archive.open({ filePath: absFilePath, cwd });
1070
+ // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
1071
+ // on every writer path that calls `write()`.
1072
+ const archive = await Archive.open({
1073
+ filePath: absFilePath,
1074
+ cwd,
1075
+ openPluginData: true,
1076
+ });
857
1077
  // Any throw between here and the successful return must release the
858
1078
  // archive lock and clean up tmpDir; the caller's `close()` only runs on
859
1079
  // the happy path.
@@ -880,7 +1100,14 @@ export class CrawlerOrchestrator extends EventEmitter {
880
1100
  log('Start retrying failed pages');
881
1101
  log('Archive %s', absFilePath);
882
1102
  log('Reset %d failed page(s)', resetUrls.length);
883
- const orchestrator = new CrawlerOrchestrator(archive, config);
1103
+ // Seed the sticky set from prior sessions' confirmed traps so
1104
+ // `--retry-failed` does not pay the cost of re-discovering
1105
+ // them (see `DedupeCapTracker`'s constructor JSDoc).
1106
+ const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
1107
+ const orchestrator = new CrawlerOrchestrator(archive, {
1108
+ ...config,
1109
+ preloadedStickyShapeKeys,
1110
+ });
884
1111
  const { scraped, pending } = await archive.getCrawlingState();
885
1112
  const resources = await archive.getResourceUrlList();
886
1113
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
@@ -929,9 +1156,14 @@ export class CrawlerOrchestrator extends EventEmitter {
929
1156
  static async resume(stubPath, options, initializedCallback) {
930
1157
  const archive = await Archive.resume(stubPath);
931
1158
  const archivedConfig = await archive.getConfig();
1159
+ // Seed the sticky set from prior sessions' confirmed traps so
1160
+ // `--resume` does not pay the cost of re-discovering them (see
1161
+ // `DedupeCapTracker`'s constructor JSDoc).
1162
+ const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
932
1163
  const config = {
933
1164
  ...archivedConfig,
934
1165
  ...cleanObject(options),
1166
+ preloadedStickyShapeKeys,
935
1167
  };
936
1168
  const orchestrator = new CrawlerOrchestrator(archive, config);
937
1169
  const _url = await archive.getUrl();
@@ -990,10 +1222,10 @@ export class CrawlerOrchestrator extends EventEmitter {
990
1222
  * no `--label` flag, so this is always the auto form.
991
1223
  * `source_file_sha256` arrives pre-computed via
992
1224
  * `aggregates.sourceFileSha256` (the CLI's `inventoryCrawl` ran
993
- * `computeFileSha256` against the input txt before the orchestrator
994
- * was even invoked). The orchestrator boundary deliberately never sees
995
- * the absolute path — see {@link InventoryRunAggregates} for the
996
- * privacy rationale.
1225
+ * `computeFileSha256` against the bytes it read from the input txt,
1226
+ * before the orchestrator was even invoked). The orchestrator boundary
1227
+ * deliberately never sees the absolute path — see
1228
+ * {@link InventoryRunAggregates} for the privacy rationale.
997
1229
  *
998
1230
  * **Audit-write failures abort the ingestion phase.** Swallowing them
999
1231
  * would only be justified if the audit were the last write after the
@@ -1020,6 +1252,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1020
1252
  new_pages: aggregates.htmlSeedsCount,
1021
1253
  new_resources: aggregates.nonHtmlCount,
1022
1254
  scope_skipped: aggregates.outOfScope,
1255
+ invalid_skipped: aggregates.invalidSkipped,
1023
1256
  });
1024
1257
  }
1025
1258
  /**
@@ -1034,6 +1267,13 @@ export class CrawlerOrchestrator extends EventEmitter {
1034
1267
  // eslint-disable-next-line no-console
1035
1268
  console.error(`[preload] Short-circuited ${skipped} URL(s) on DNS-burned hosts`);
1036
1269
  }
1270
+ const { confirmedCount, totalDurationMs } = networkOutageSummaryCounter;
1271
+ if (confirmedCount > 0) {
1272
+ // eslint-disable-next-line no-console
1273
+ console.error(`[network] ${confirmedCount} outage(s), ${Math.round(totalDurationMs / 1000)}s total`);
1274
+ }
1275
+ networkOutageSummaryCounter.confirmedCount = 0;
1276
+ networkOutageSummaryCounter.totalDurationMs = 0;
1037
1277
  clearDestinationCache();
1038
1278
  clearDnsBurnedHostCache();
1039
1279
  }
package/lib/crawler.d.ts CHANGED
@@ -16,16 +16,37 @@ export { default as ArchiveResource } from './archive/resource.js';
16
16
  export * from './archive/types.js';
17
17
  export { default as Archive } from './archive/archive.js';
18
18
  export { isArchiveCacheDisabled } from './archive/cache/is-archive-cache-disabled.js';
19
+ export { getArchiveCacheRoot } from './archive/cache/get-archive-cache-root.js';
20
+ export { computeArchiveCacheKey } from './archive/cache/compute-archive-cache-key.js';
21
+ export { resolveArchiveCacheDir } from './archive/cache/resolve-archive-cache-dir.js';
22
+ export { listArchiveCacheEntries } from './archive/cache/list-archive-cache-entries.js';
23
+ export { clearArchiveCacheRoot } from './archive/cache/clear-archive-cache-root.js';
24
+ export { clearArchiveCacheEntry } from './archive/cache/clear-archive-cache-entry.js';
25
+ export type { ArchiveCacheEntry, ArchiveCacheEntryKind } from './archive/cache/types.js';
19
26
  export { acquireArchiveLock, ArchiveLockError } from './archive/archive-lock.js';
20
27
  export { peekArchiveLockHolder } from './archive/peek-archive-lock.js';
21
28
  export type { ArchiveLockHolder } from './archive/peek-archive-lock.js';
22
29
  export type { FlatPageMetaColumns, JsonLdRow, JsonLdRowForInsert, TagRow, TagRowForInsert, JsonLdSummary, TagsSummary, TagInventoryEntry, PageDenormalizedColumns, } from './archive/meta/types.js';
23
30
  export { IncompatibleArchiveError } from './archive/meta/types.js';
24
31
  export { REQUIRED_FORMAT_VERSION } from './archive/meta/assert-compatible-version.js';
32
+ export { computeBodyHash } from './archive/body-hash/compute-body-hash.js';
33
+ export { decodeStoredBlob } from './archive/decode-html-blob.js';
34
+ export { computeTierAAliasKey } from './archive/url-alias/compute-tier-a-alias-key.js';
35
+ export { computeTierBAliasKey } from './archive/url-alias/compute-tier-b-alias-key.js';
25
36
  export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
26
37
  export * from './types.js';
27
38
  export * from './crawler/types.js';
28
39
  export { classifyErrorKind } from './classify-error-kind.js';
40
+ export { NETWORK_RELATED_ERROR_KINDS } from './network-related-error-kinds.js';
41
+ export type { OutageWindow } from './is-within-outage-window.js';
42
+ export { isWithinOutageWindow } from './is-within-outage-window.js';
43
+ export { default as NetworkOutageDetector } from './crawler/network-outage-detector.js';
44
+ export { default as NetworkGate } from './crawler/network-gate.js';
45
+ export type { NetworkProbe } from './crawler/probe-network.js';
46
+ export { probeNetwork } from './crawler/probe-network.js';
47
+ export { computeOutageClampTimestamp } from './archive/db-ops/outages/compute-outage-clamp-timestamp.js';
48
+ export { chooseProbeHost } from './crawler/choose-probe-host.js';
49
+ export { assertChromeIsInstalled } from './crawler/assert-chrome-installed.js';
29
50
  export { computeFileSha256 } from './utils/compute-file-sha256.js';
30
51
  export { populateEntityTables } from './archive/populate-entity-tables/populate-entities.js';
31
52
  export type { PageDomPathResolver } from './archive/populate-entity-tables/populate-image-items.js';
@@ -38,3 +59,4 @@ export { populateBlobRefs } from './archive/populate-ref-tables/populate-blob-re
38
59
  export { populateHeaderTables } from './archive/populate-ref-tables/populate-header-tables.js';
39
60
  export { loadResponseHeadersBySetIds } from './archive/db-ops/_shared/load-response-headers-by-set-ids.js';
40
61
  export { decodeJsonRef } from './archive/db-ops/_shared/decode-json-ref.js';
62
+ export type { TemplateClusterBlockingEvidence, TemplateClusterBlockingReason, TemplateClusterLandmarkProfile, TemplateClusterLandmarkType, TemplateClusterReason, } from './archive/db-ops/analysis/types.js';
package/lib/crawler.js CHANGED
@@ -17,15 +17,33 @@ export { default as ArchiveResource } from './archive/resource.js';
17
17
  export * from './archive/types.js';
18
18
  export { default as Archive } from './archive/archive.js';
19
19
  export { isArchiveCacheDisabled } from './archive/cache/is-archive-cache-disabled.js';
20
+ export { getArchiveCacheRoot } from './archive/cache/get-archive-cache-root.js';
21
+ export { computeArchiveCacheKey } from './archive/cache/compute-archive-cache-key.js';
22
+ export { resolveArchiveCacheDir } from './archive/cache/resolve-archive-cache-dir.js';
23
+ export { listArchiveCacheEntries } from './archive/cache/list-archive-cache-entries.js';
24
+ export { clearArchiveCacheRoot } from './archive/cache/clear-archive-cache-root.js';
25
+ export { clearArchiveCacheEntry } from './archive/cache/clear-archive-cache-entry.js';
20
26
  export { acquireArchiveLock, ArchiveLockError } from './archive/archive-lock.js';
21
27
  export { peekArchiveLockHolder } from './archive/peek-archive-lock.js';
22
28
  export { IncompatibleArchiveError } from './archive/meta/types.js';
23
29
  export { REQUIRED_FORMAT_VERSION } from './archive/meta/assert-compatible-version.js';
30
+ export { computeBodyHash } from './archive/body-hash/compute-body-hash.js';
31
+ export { decodeStoredBlob } from './archive/decode-html-blob.js';
32
+ export { computeTierAAliasKey } from './archive/url-alias/compute-tier-a-alias-key.js';
33
+ export { computeTierBAliasKey } from './archive/url-alias/compute-tier-b-alias-key.js';
24
34
  // Core
25
35
  export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
26
36
  export * from './types.js';
27
37
  export * from './crawler/types.js';
28
38
  export { classifyErrorKind } from './classify-error-kind.js';
39
+ export { NETWORK_RELATED_ERROR_KINDS } from './network-related-error-kinds.js';
40
+ export { isWithinOutageWindow } from './is-within-outage-window.js';
41
+ export { default as NetworkOutageDetector } from './crawler/network-outage-detector.js';
42
+ export { default as NetworkGate } from './crawler/network-gate.js';
43
+ export { probeNetwork } from './crawler/probe-network.js';
44
+ export { computeOutageClampTimestamp } from './archive/db-ops/outages/compute-outage-clamp-timestamp.js';
45
+ export { chooseProbeHost } from './crawler/choose-probe-host.js';
46
+ export { assertChromeIsInstalled } from './crawler/assert-chrome-installed.js';
29
47
  export { computeFileSha256 } from './utils/compute-file-sha256.js';
30
48
  // 0.13 ref-table population (issue #191, epic #103). Exposed as the
31
49
  // public seam that the migration script (`scripts/migrate-to-0.13.mjs`)
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A resolved (closed) network-outage interval, as consumed by
3
+ * {@link isWithinOutageWindow}.
4
+ *
5
+ * This is deliberately NOT the raw `network_outages` row shape: a row whose
6
+ * `ended_at` is still `NULL` (the crawl session crashed before recovery was
7
+ * observed) must be resolved to a concrete `endedAt` by the caller — e.g.
8
+ * `listNetworkOutages` clamping it to the latest observed timestamp in the
9
+ * archive — before it reaches this function. Accepting a nullable `endedAt`
10
+ * here would let a crashed session's outage silently swallow every
11
+ * subsequent timestamp as "network-caused" forever.
12
+ */
13
+ export interface OutageWindow {
14
+ /** Epoch ms the outage is considered to have started (already backdated to the earliest triggering error). */
15
+ readonly startedAt: number;
16
+ /** Epoch ms the outage is considered to have ended. Never `NULL` — see the interface docstring. */
17
+ readonly endedAt: number;
18
+ }
19
+ /**
20
+ * Decide whether a timestamp falls inside any recorded network-outage
21
+ * window — i.e. whether an error observed at that instant is more likely
22
+ * attributable to the operator's own network than to the target site.
23
+ *
24
+ * Pure and dependency-free by design: no DB handle, no `Crawler` instance.
25
+ * This is the single predicate every consumer (`resetFailedPages`,
26
+ * `listDnsBurnedHostCandidates`, `getSummary`'s attribution split) is
27
+ * expected to call, so the inclusive/exclusive boundary decision below is
28
+ * made exactly once.
29
+ *
30
+ * **Boundaries are inclusive on both ends.** A timestamp equal to
31
+ * `startedAt` or `endedAt` counts as inside the window. This errs toward
32
+ * attributing borderline errors to the outage rather than to the site:
33
+ * classifying a genuinely network-caused failure as "unknown/site kills it
34
+ * forever" (a false negative) is worse than the reverse (a false positive
35
+ * merely costs one extra retry pass before the kind classifier sorts it out
36
+ * again).
37
+ * @param timestamp - Epoch ms to test (typically an error's `createdAt`).
38
+ * @param windows - Resolved outage windows to test against. An empty array
39
+ * always yields `false` — this is what makes an archive with no
40
+ * `network_outages` rows (every archive created before this feature, or
41
+ * any crawl with no detected outage) behave identically to today.
42
+ * @returns `true` if `timestamp` falls within any window.
43
+ * @example
44
+ * ```ts
45
+ * isWithinOutageWindow(1_000, [{ startedAt: 500, endedAt: 1_500 }]); // true
46
+ * isWithinOutageWindow(1_000, []); // false — no recorded outages
47
+ * ```
48
+ */
49
+ export declare function isWithinOutageWindow(timestamp: number, windows: readonly OutageWindow[]): boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Decide whether a timestamp falls inside any recorded network-outage
3
+ * window — i.e. whether an error observed at that instant is more likely
4
+ * attributable to the operator's own network than to the target site.
5
+ *
6
+ * Pure and dependency-free by design: no DB handle, no `Crawler` instance.
7
+ * This is the single predicate every consumer (`resetFailedPages`,
8
+ * `listDnsBurnedHostCandidates`, `getSummary`'s attribution split) is
9
+ * expected to call, so the inclusive/exclusive boundary decision below is
10
+ * made exactly once.
11
+ *
12
+ * **Boundaries are inclusive on both ends.** A timestamp equal to
13
+ * `startedAt` or `endedAt` counts as inside the window. This errs toward
14
+ * attributing borderline errors to the outage rather than to the site:
15
+ * classifying a genuinely network-caused failure as "unknown/site kills it
16
+ * forever" (a false negative) is worse than the reverse (a false positive
17
+ * merely costs one extra retry pass before the kind classifier sorts it out
18
+ * again).
19
+ * @param timestamp - Epoch ms to test (typically an error's `createdAt`).
20
+ * @param windows - Resolved outage windows to test against. An empty array
21
+ * always yields `false` — this is what makes an archive with no
22
+ * `network_outages` rows (every archive created before this feature, or
23
+ * any crawl with no detected outage) behave identically to today.
24
+ * @returns `true` if `timestamp` falls within any window.
25
+ * @example
26
+ * ```ts
27
+ * isWithinOutageWindow(1_000, [{ startedAt: 500, endedAt: 1_500 }]); // true
28
+ * isWithinOutageWindow(1_000, []); // false — no recorded outages
29
+ * ```
30
+ */
31
+ export function isWithinOutageWindow(timestamp, windows) {
32
+ return windows.some((window) => timestamp >= window.startedAt && timestamp <= window.endedAt);
33
+ }
@@ -0,0 +1,23 @@
1
+ import type { ErrorKind } from './types.js';
2
+ /**
3
+ * `ErrorKind`s that plausibly indicate trouble with the operator's own
4
+ * network path rather than a genuinely dead or misconfigured target site.
5
+ *
6
+ * Single source of truth shared by two consumers that must agree on
7
+ * exactly the same set:
8
+ *
9
+ * - `NetworkOutageDetector` — only these kinds contribute to the sliding
10
+ * window that detects a suspect outage.
11
+ * - `evict-network-classified-destination-cache-entries.ts` — on recovery,
12
+ * only `destinationCache` entries whose cached `Error` classifies into
13
+ * one of these kinds are evicted (a cached `tls` or `client-blocked`
14
+ * failure is a site-specific fact and must survive an outage recovery
15
+ * unrelated to it).
16
+ *
17
+ * `dns` is included despite `permanent-error-kinds.ts` treating it as a
18
+ * permanent, site-specific verdict in isolation — that classification is
19
+ * exactly what outage detection exists to override when the surrounding
20
+ * evidence (many hosts, tight time window) points to the local network
21
+ * instead.
22
+ */
23
+ export declare const NETWORK_RELATED_ERROR_KINDS: ReadonlySet<ErrorKind>;