@nitpicker/crawler 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/lib/archive/archive.d.ts +98 -10
  2. package/lib/archive/archive.js +98 -9
  3. package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
  4. package/lib/archive/body-hash/compute-body-hash.js +31 -0
  5. package/lib/archive/body-hash/extract-body.d.ts +18 -0
  6. package/lib/archive/body-hash/extract-body.js +29 -0
  7. package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
  8. package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
  9. package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
  10. package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
  11. package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
  12. package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
  13. package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
  14. package/lib/archive/cache/clear-archive-cache-root.js +32 -0
  15. package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
  16. package/lib/archive/cache/list-archive-cache-entries.js +125 -0
  17. package/lib/archive/cache/path-exists.d.ts +9 -0
  18. package/lib/archive/cache/path-exists.js +18 -0
  19. package/lib/archive/cache/types.d.ts +33 -0
  20. package/lib/archive/cache/types.js +1 -0
  21. package/lib/archive/create-adjunct-tables.d.ts +11 -0
  22. package/lib/archive/create-adjunct-tables.js +127 -0
  23. package/lib/archive/create-entity-tables.js +21 -1
  24. package/lib/archive/database.d.ts +37 -2
  25. package/lib/archive/database.js +50 -3
  26. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
  27. package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
  28. package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
  29. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
  30. package/lib/archive/db-ops/_shared/types.d.ts +2 -0
  31. package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
  32. package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
  33. package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
  34. package/lib/archive/db-ops/analysis/types.d.ts +61 -0
  35. package/lib/archive/db-ops/analysis/types.js +1 -0
  36. package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
  37. package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
  38. package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
  39. package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
  40. package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
  41. package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
  42. package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
  43. package/lib/archive/db-ops/console-logs/types.js +1 -0
  44. package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
  45. package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
  46. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  47. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  48. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  49. package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
  50. package/lib/archive/db-ops/lifecycle/init.js +21 -2
  51. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  52. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  53. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  54. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  55. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  56. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  57. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  58. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  59. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  60. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  61. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
  62. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
  63. package/lib/archive/db-ops/pages/write/update-page.js +12 -0
  64. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  65. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  66. package/lib/archive/filesystem/output-binary.js +17 -0
  67. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  68. package/lib/archive/get-failed-page-messages.js +29 -15
  69. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  70. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  71. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  72. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  73. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  74. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  75. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  76. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  77. package/lib/archive/page.d.ts +1 -1
  78. package/lib/archive/types.d.ts +47 -2
  79. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  80. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  81. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  82. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  83. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  84. package/lib/archive/url-alias/format-alias-key.js +14 -0
  85. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  86. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  87. package/lib/archive/url-alias/types.d.ts +7 -0
  88. package/lib/archive/url-alias/types.js +1 -0
  89. package/lib/crawler/choose-probe-host.d.ts +24 -0
  90. package/lib/crawler/choose-probe-host.js +38 -0
  91. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  92. package/lib/crawler/crawler.js +257 -0
  93. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  94. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  95. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  96. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  97. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  98. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  99. package/lib/crawler/network-gate.d.ts +49 -0
  100. package/lib/crawler/network-gate.js +78 -0
  101. package/lib/crawler/network-outage-detector.d.ts +51 -0
  102. package/lib/crawler/network-outage-detector.js +81 -0
  103. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  104. package/lib/crawler/network-outage-summary-counter.js +18 -0
  105. package/lib/crawler/probe-network.d.ts +26 -0
  106. package/lib/crawler/probe-network.js +21 -0
  107. package/lib/crawler/types.d.ts +147 -1
  108. package/lib/crawler-orchestrator.d.ts +62 -18
  109. package/lib/crawler-orchestrator.js +161 -26
  110. package/lib/crawler.d.ts +21 -0
  111. package/lib/crawler.js +17 -0
  112. package/lib/is-within-outage-window.d.ts +49 -0
  113. package/lib/is-within-outage-window.js +33 -0
  114. package/lib/network-related-error-kinds.d.ts +23 -0
  115. package/lib/network-related-error-kinds.js +28 -0
  116. package/lib/types.d.ts +9 -1
  117. package/lib/utils/array/each-splitted.d.ts +1 -1
  118. package/lib/utils/compute-file-sha256.d.ts +17 -17
  119. package/lib/utils/compute-file-sha256.js +18 -47
  120. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
1
  import type { Config } from './archive/types.js';
2
+ import type { NetworkProbe } from './crawler/probe-network.js';
2
3
  import type { InventoryMode } from './crawler/types.js';
3
4
  import type { CrawlEvent } from './types.js';
4
5
  import type { ExURL } from '@d-zero/shared/parse-url';
@@ -48,6 +49,26 @@ interface CrawlConfig extends Config {
48
49
  * this `null` so new rows are labelled `'crawled'` by the DB DEFAULT.
49
50
  */
50
51
  inventoryMode: InventoryMode | null;
52
+ /**
53
+ * See {@link CrawlerOptions.networkOutageWindowMs}. Omitted (`undefined`
54
+ * on the `Partial<CrawlConfig>` callers actually pass) falls through to
55
+ * `Crawler`'s own default — this field exists so tests can shrink the
56
+ * window for a fast, deterministic outage-detection cycle.
57
+ */
58
+ networkOutageWindowMs: number;
59
+ /** See {@link CrawlerOptions.networkOutageErrorThreshold}. */
60
+ networkOutageErrorThreshold: number;
61
+ /** See {@link CrawlerOptions.networkOutageHostThreshold}. */
62
+ networkOutageHostThreshold: number;
63
+ /** See {@link CrawlerOptions.networkOutageProbeIntervalMs}. */
64
+ networkOutageProbeIntervalMs: number;
65
+ /**
66
+ * See {@link CrawlerOptions.networkProbe}. The seam tests use to simulate
67
+ * confirmed outages and recoveries deterministically without touching
68
+ * the real network — plumbed through from `CrawlerOrchestrator.crawling`'s
69
+ * `options` so an E2E test can inject it via the public API.
70
+ */
71
+ networkProbe: NetworkProbe | null;
51
72
  }
52
73
  /**
53
74
  * Callback invoked after the CrawlerOrchestrator instance is fully initialized
@@ -56,6 +77,20 @@ interface CrawlConfig extends Config {
56
77
  * @param config - The resolved archive configuration.
57
78
  */
58
79
  type CrawlInitializedCallback = (orchestrator: CrawlerOrchestrator, config: Config) => void | Promise<void>;
80
+ /**
81
+ * The CLI's already-read `--inventory` source list, passed to
82
+ * {@link CrawlerOrchestrator.inventory} instead of a file path — see that
83
+ * method's `source` param for why the path itself never crosses this
84
+ * boundary.
85
+ */
86
+ interface InventorySource {
87
+ /** Lower-case hex SHA-256 digest of `bytes` (`computeFileSha256(bytes)`). */
88
+ sha256: string;
89
+ /** The exact bytes of the source list file, archived verbatim. */
90
+ bytes: Buffer;
91
+ /** Number of source-file lines the CLI warned-and-dropped for failing URL validation, before `inventoryUrls` was ever built. Recorded on the audit row as `inventory_runs.invalid_skipped`. */
92
+ invalidLineCount: number;
93
+ }
59
94
  /**
60
95
  * The main entry point for Nitpicker web crawling and archiving.
61
96
  *
@@ -184,22 +219,29 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
184
219
  * 3. Reject archives with unfinished `pending` URLs — those would inherit
185
220
  * the inventory `source` label by mistake. Operator must resume /
186
221
  * retry-failed first.
187
- * 4. Parse the URL list. Anything outside the archived scope is warned
188
- * and skipped (inventory is per-server by design).
189
- * 5. Subtract URLs that already exist in `pages` or `resources` so the
222
+ * 4. If `source` is given, archive its exact bytes under
223
+ * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList}).
224
+ * Done before scope classification so even a run that discards every
225
+ * URL (out of scope or already known) still leaves a recoverable
226
+ * copy of what was fed in.
227
+ * 5. Parse the URL list — the CLI has already warned-and-dropped
228
+ * unparseable-URL lines before calling this method, so every
229
+ * remaining entry parses. Anything outside the archived scope is
230
+ * warned and skipped (inventory is per-server by design).
231
+ * 6. Subtract URLs that already exist in `pages` or `resources` so the
190
232
  * second (and N-th) inventory pass is a no-op for known rows — keeps
191
233
  * `'inventory-seed'` rows from being silently demoted.
192
- * 6. Make `<archive>.bak`. Anything thrown beyond this point restores
234
+ * 7. Make `<archive>.bak`. Anything thrown beyond this point restores
193
235
  * from the backup.
194
- * 7. HEAD-probe each novel URL. Responses classified as HTML are queued
236
+ * 8. HEAD-probe each novel URL. Responses classified as HTML are queued
195
237
  * as Crawler seeds (`'inventory-seed'`); everything else is recorded
196
238
  * in `resources` directly as `'inventory-seed'` (no browser launch).
197
- * 8. If any HTML seeds exist, start a Crawler with
239
+ * 9. If any HTML seeds exist, start a Crawler with
198
240
  * `inventoryMode = { seedUrls }` so the rendered page and every newly
199
241
  * discovered downstream link is labelled correctly. `resume` is fed
200
242
  * the existing `scraped` / `resources` sets so links into already-
201
243
  * crawled pages stop at the seen-gate without re-rendering.
202
- * 9. Drop the backup on success; restore it on any throw.
244
+ * 10. Drop the backup on success; restore it on any throw.
203
245
  *
204
246
  * Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
205
247
  * `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
@@ -209,20 +251,22 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
209
251
  * @param inventoryUrls - Pre-read URL list (one URL per element).
210
252
  * @param options - Optional config overrides — most callers leave this blank and let the archived config flow through.
211
253
  * @param initializedCallback - Hook invoked once the orchestrator is constructed but before `crawling` runs (the CLI uses it to attach progress reporting).
212
- * @param sourceFileSha256 - **Pre-computed** SHA-256 hex digest of the
213
- * source URL list. The orchestrator deliberately does NOT receive
214
- * the file path: the path is privacy-sensitive (leaks user-home /
215
- * OS structure when archives are shared) and we want it lifted off
216
- * this boundary so no future log line / breadcrumb / error message
217
- * inside the orchestrator can accidentally re-leak it. The CLI
218
- * computes the digest via `computeFileSha256(resolvedListFile)`
219
- * and passes it through here. Pass `null` for programmatic
220
- * callers that built `inventoryUrls` in-memory; the audit row's
221
- * `source_file_sha256` column will be `NULL`.
254
+ * @param source - The CLI's already-read source list, as `{ sha256, bytes }`.
255
+ * The orchestrator deliberately does NOT receive the file path: the
256
+ * path is privacy-sensitive (leaks user-home / OS structure when
257
+ * archives are shared) and we want it lifted off this boundary so no
258
+ * future log line / breadcrumb / error message inside the orchestrator
259
+ * can accidentally re-leak it. `bytes` is archived verbatim under
260
+ * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList})
261
+ * before scope classification, so a later `--inventory` run against
262
+ * the same list is an audit no-op even when it discards zero new
263
+ * URLs. Pass `null` for programmatic callers that built
264
+ * `inventoryUrls` in-memory; the audit row's `source_file_sha256`
265
+ * column will be `NULL` and no source list is archived.
222
266
  * @returns The orchestrator instance after a successful inventory pass.
223
267
  * @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
224
268
  */
225
- static inventory(archivePath: string, inventoryUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, sourceFileSha256?: string | null): Promise<CrawlerOrchestrator>;
269
+ static inventory(archivePath: string, inventoryUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, source?: InventorySource | null): Promise<CrawlerOrchestrator>;
226
270
  /**
227
271
  * Re-fetch previously-failed pages in an existing `.nitpicker` archive.
228
272
  *
@@ -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';
@@ -65,6 +66,17 @@ export class CrawlerOrchestrator extends EventEmitter {
65
66
  #crawler;
66
67
  /** Whether the crawl was started from a pre-defined URL list (non-recursive mode). */
67
68
  #fromList;
69
+ /**
70
+ * The `network_outages` row id for the currently-open outage, or `null`
71
+ * when none is open. Set by the `networkOutageConfirmed` handler (once
72
+ * the INSERT resolves) and consumed by `networkOutageRecovered` — the
73
+ * `Crawler` class never touches the archive itself and has no way to
74
+ * know the row's id, so the orchestrator is the only place that can
75
+ * bridge the two events for the same outage.
76
+ */
77
+ #openNetworkOutageId = null;
78
+ /** `startedAt` of the currently-open outage, tracked alongside {@link #openNetworkOutageId} so `networkOutageRecovered` can compute a duration for {@link networkOutageSummaryCounter}. */
79
+ #openNetworkOutageStartedAt = null;
68
80
  /** Serializes archive writes from crawler event handlers (FIFO). */
69
81
  #writeQueue = new WriteQueue();
70
82
  /**
@@ -138,6 +150,14 @@ export class CrawlerOrchestrator extends EventEmitter {
138
150
  // rows continue to land in pages/resources with the DB DEFAULT
139
151
  // `'crawled'` provenance label.
140
152
  inventoryMode: options?.inventoryMode ?? null,
153
+ // Forwarded as-is (including `undefined`) — `Crawler`'s own
154
+ // constructor merges each against its `DEFAULT_NETWORK_OUTAGE_*`
155
+ // constant, so omitting them here is exactly "use the default".
156
+ networkOutageWindowMs: options?.networkOutageWindowMs,
157
+ networkOutageErrorThreshold: options?.networkOutageErrorThreshold,
158
+ networkOutageHostThreshold: options?.networkOutageHostThreshold,
159
+ networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs,
160
+ networkProbe: options?.networkProbe ?? null,
141
161
  });
142
162
  }
143
163
  /**
@@ -166,6 +186,11 @@ export class CrawlerOrchestrator extends EventEmitter {
166
186
  */
167
187
  async crawling(list, opts) {
168
188
  const writeQueue = this.#writeQueue;
189
+ // Per-session state, like `Crawler`'s own `#successfulHosts.clear()` /
190
+ // `#networkGate.open()` reset at the start of `#runDeal` — a fresh
191
+ // session must not inherit a dangling outage id from a prior one.
192
+ this.#openNetworkOutageId = null;
193
+ this.#openNetworkOutageStartedAt = null;
169
194
  return new Promise((resolve, reject) => {
170
195
  this.#crawler.on('error', (error) => {
171
196
  if (error.error instanceof PreloadShortCircuitError) {
@@ -212,6 +237,67 @@ export class CrawlerOrchestrator extends EventEmitter {
212
237
  .catch((error) => reject(error));
213
238
  void this.emit('redirect', { result });
214
239
  });
240
+ this.#crawler.on('networkOutageConfirmed', ({ startedAt, detectedAt, probeHost, triggerErrorCount, triggerHostCount }) => {
241
+ crawlerLog('Network outage confirmed: probeHost=%s triggerErrorCount=%d triggerHostCount=%d', probeHost, triggerErrorCount, triggerHostCount);
242
+ // event notice; mirrors `#finalizeCrawlSession`'s unconditional
243
+ // `console.error` for the DNS-burn short-circuit summary.
244
+ console.error(`[network] outage suspected — pausing workers (probe host: ${probeHost ?? 'none'})`);
245
+ writeQueue
246
+ .enqueue(async () => {
247
+ // Both fields are set together, inside this single
248
+ // closure, so the pair can never fall out of sync
249
+ // (e.g. one set synchronously above while the other
250
+ // waits on the INSERT) — `networkOutageRecovered`'s
251
+ // queued closure always sees either both set or
252
+ // neither.
253
+ const id = await this.#archive.insertNetworkOutage({
254
+ startedAt,
255
+ detectedAt,
256
+ probeHost,
257
+ triggerErrorCount,
258
+ triggerHostCount,
259
+ });
260
+ this.#openNetworkOutageId = id;
261
+ this.#openNetworkOutageStartedAt = startedAt;
262
+ })
263
+ .catch((error) => reject(error));
264
+ });
265
+ this.#crawler.on('networkOutageRecovered', ({ endedAt }) => {
266
+ // The `id` read is deferred to INSIDE the queued closure, not
267
+ // read synchronously here, because `networkOutageConfirmed`'s
268
+ // INSERT is itself only queued (not awaited) when that event
269
+ // fires — `#openNetworkOutageId` is not guaranteed to be set
270
+ // yet at the instant `networkOutageRecovered` fires (the two
271
+ // events can arrive in quick succession, e.g. in tests that
272
+ // drive them back-to-back with no real probe-interval delay
273
+ // between them). `WriteQueue` runs enqueued operations in
274
+ // submission order, so by the time THIS closure actually
275
+ // executes, the confirm's INSERT closure (enqueued first) has
276
+ // already completed and `#openNetworkOutageId` is reliably set.
277
+ writeQueue
278
+ .enqueue(() => {
279
+ const id = this.#openNetworkOutageId;
280
+ const startedAt = this.#openNetworkOutageStartedAt;
281
+ if (id === null) {
282
+ // Defensive: `networkOutageConfirmed` always
283
+ // precedes `networkOutageRecovered` on the same
284
+ // `Crawler` instance. If this fires anyway, there
285
+ // is no row to close.
286
+ crawlerLog('Network outage recovered but no open outage id was tracked');
287
+ return Promise.resolve();
288
+ }
289
+ this.#openNetworkOutageId = null;
290
+ this.#openNetworkOutageStartedAt = null;
291
+ const durationMs = endedAt - (startedAt ?? endedAt);
292
+ networkOutageSummaryCounter.confirmedCount++;
293
+ networkOutageSummaryCounter.totalDurationMs += durationMs;
294
+ crawlerLog('Network outage recovered: id=%d endedAt=%d', id, endedAt);
295
+ // eslint-disable-next-line no-console -- see the confirmed handler above
296
+ console.error(`[network] recovered after ${Math.round(durationMs / 1000)}s`);
297
+ return this.#archive.closeNetworkOutage(id, endedAt);
298
+ })
299
+ .catch((error) => reject(error));
300
+ });
215
301
  this.#crawler.on('response', ({ resource, source }) => {
216
302
  writeQueue
217
303
  .enqueue(() => this.#archive.setResources(resource, source))
@@ -222,6 +308,11 @@ export class CrawlerOrchestrator extends EventEmitter {
222
308
  .enqueue(() => this.#archive.setResourcesReferrers(resource))
223
309
  .catch((error) => reject(error));
224
310
  });
311
+ this.#crawler.on('consoleLogs', ({ pageUrl, redirectPaths, entries }) => {
312
+ writeQueue
313
+ .enqueue(() => this.#archive.setConsoleLogs(pageUrl, redirectPaths, entries))
314
+ .catch((error) => reject(error));
315
+ });
225
316
  this.#crawler.on('crawlEnd', () => {
226
317
  writeQueue
227
318
  .drain()
@@ -387,7 +478,13 @@ export class CrawlerOrchestrator extends EventEmitter {
387
478
  const absFilePath = path.isAbsolute(archivePath)
388
479
  ? archivePath
389
480
  : path.resolve(cwd, archivePath);
390
- const archive = await Archive.open({ filePath: absFilePath, cwd });
481
+ // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
482
+ // on every writer path that calls `write()`.
483
+ const archive = await Archive.open({
484
+ filePath: absFilePath,
485
+ cwd,
486
+ openPluginData: true,
487
+ });
391
488
  // Any throw between here and the successful return must release the
392
489
  // archive lock and clean up tmpDir; the caller's `close()` only runs on
393
490
  // the happy path. Errors from `close()` itself are intentionally
@@ -481,22 +578,29 @@ export class CrawlerOrchestrator extends EventEmitter {
481
578
  * 3. Reject archives with unfinished `pending` URLs — those would inherit
482
579
  * the inventory `source` label by mistake. Operator must resume /
483
580
  * 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
581
+ * 4. If `source` is given, archive its exact bytes under
582
+ * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList}).
583
+ * Done before scope classification so even a run that discards every
584
+ * URL (out of scope or already known) still leaves a recoverable
585
+ * copy of what was fed in.
586
+ * 5. Parse the URL list — the CLI has already warned-and-dropped
587
+ * unparseable-URL lines before calling this method, so every
588
+ * remaining entry parses. Anything outside the archived scope is
589
+ * warned and skipped (inventory is per-server by design).
590
+ * 6. Subtract URLs that already exist in `pages` or `resources` so the
487
591
  * second (and N-th) inventory pass is a no-op for known rows — keeps
488
592
  * `'inventory-seed'` rows from being silently demoted.
489
- * 6. Make `<archive>.bak`. Anything thrown beyond this point restores
593
+ * 7. Make `<archive>.bak`. Anything thrown beyond this point restores
490
594
  * from the backup.
491
- * 7. HEAD-probe each novel URL. Responses classified as HTML are queued
595
+ * 8. HEAD-probe each novel URL. Responses classified as HTML are queued
492
596
  * as Crawler seeds (`'inventory-seed'`); everything else is recorded
493
597
  * in `resources` directly as `'inventory-seed'` (no browser launch).
494
- * 8. If any HTML seeds exist, start a Crawler with
598
+ * 9. If any HTML seeds exist, start a Crawler with
495
599
  * `inventoryMode = { seedUrls }` so the rendered page and every newly
496
600
  * discovered downstream link is labelled correctly. `resume` is fed
497
601
  * the existing `scraped` / `resources` sets so links into already-
498
602
  * crawled pages stop at the seen-gate without re-rendering.
499
- * 9. Drop the backup on success; restore it on any throw.
603
+ * 10. Drop the backup on success; restore it on any throw.
500
604
  *
501
605
  * Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
502
606
  * `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
@@ -506,20 +610,22 @@ export class CrawlerOrchestrator extends EventEmitter {
506
610
  * @param inventoryUrls - Pre-read URL list (one URL per element).
507
611
  * @param options - Optional config overrides — most callers leave this blank and let the archived config flow through.
508
612
  * @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`.
613
+ * @param source - The CLI's already-read source list, as `{ sha256, bytes }`.
614
+ * The orchestrator deliberately does NOT receive the file path: the
615
+ * path is privacy-sensitive (leaks user-home / OS structure when
616
+ * archives are shared) and we want it lifted off this boundary so no
617
+ * future log line / breadcrumb / error message inside the orchestrator
618
+ * can accidentally re-leak it. `bytes` is archived verbatim under
619
+ * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList})
620
+ * before scope classification, so a later `--inventory` run against
621
+ * the same list is an audit no-op even when it discards zero new
622
+ * URLs. Pass `null` for programmatic callers that built
623
+ * `inventoryUrls` in-memory; the audit row's `source_file_sha256`
624
+ * column will be `NULL` and no source list is archived.
519
625
  * @returns The orchestrator instance after a successful inventory pass.
520
626
  * @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
521
627
  */
522
- static async inventory(archivePath, inventoryUrls, options, initializedCallback, sourceFileSha256 = null) {
628
+ static async inventory(archivePath, inventoryUrls, options, initializedCallback, source = null) {
523
629
  if (inventoryUrls.length === 0) {
524
630
  throw new Error('inventory: URL list is empty');
525
631
  }
@@ -527,7 +633,13 @@ export class CrawlerOrchestrator extends EventEmitter {
527
633
  const absFilePath = path.isAbsolute(archivePath)
528
634
  ? archivePath
529
635
  : path.resolve(cwd, archivePath);
530
- const archive = await Archive.open({ filePath: absFilePath, cwd });
636
+ // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
637
+ // on every writer path that calls `write()`.
638
+ const archive = await Archive.open({
639
+ filePath: absFilePath,
640
+ cwd,
641
+ openPluginData: true,
642
+ });
531
643
  try {
532
644
  const archived = await archive.getConfig();
533
645
  if (archived.fromList) {
@@ -549,6 +661,14 @@ export class CrawlerOrchestrator extends EventEmitter {
549
661
  // eslint-disable-next-line no-console -- operator-facing warning, must be visible regardless of DEBUG filters
550
662
  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
663
  }
664
+ // Archive the exact source bytes before scope classification, so
665
+ // even a run that discards every URL (out-of-scope or already
666
+ // known — see `novelUrls.length === 0` below) still leaves a
667
+ // recoverable copy of what was fed in. Content-hash-named, so a
668
+ // second `--inventory` pass with the same list is a no-op write.
669
+ if (source) {
670
+ await archive.saveInventorySourceList(source.sha256, source.bytes);
671
+ }
552
672
  // Parse + scope-classify the candidate URLs. sortUrl drops
553
673
  // unparseable strings; findScopeEntry separates in-scope from
554
674
  // out-of-scope.
@@ -703,7 +823,8 @@ export class CrawlerOrchestrator extends EventEmitter {
703
823
  htmlSeedsCount: htmlSeeds.length,
704
824
  nonHtmlCount: nonHtmlSeeds.length,
705
825
  outOfScope,
706
- sourceFileSha256,
826
+ sourceFileSha256: source?.sha256 ?? null,
827
+ invalidSkipped: source?.invalidLineCount ?? null,
707
828
  });
708
829
  // Ingestion's DB writes are now committed. From here on a
709
830
  // throw must NOT trigger the `.bak` restore (it would wipe
@@ -853,7 +974,13 @@ export class CrawlerOrchestrator extends EventEmitter {
853
974
  const absFilePath = path.isAbsolute(archivePath)
854
975
  ? archivePath
855
976
  : path.resolve(cwd, archivePath);
856
- const archive = await Archive.open({ filePath: absFilePath, cwd });
977
+ // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
978
+ // on every writer path that calls `write()`.
979
+ const archive = await Archive.open({
980
+ filePath: absFilePath,
981
+ cwd,
982
+ openPluginData: true,
983
+ });
857
984
  // Any throw between here and the successful return must release the
858
985
  // archive lock and clean up tmpDir; the caller's `close()` only runs on
859
986
  // the happy path.
@@ -990,10 +1117,10 @@ export class CrawlerOrchestrator extends EventEmitter {
990
1117
  * no `--label` flag, so this is always the auto form.
991
1118
  * `source_file_sha256` arrives pre-computed via
992
1119
  * `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.
1120
+ * `computeFileSha256` against the bytes it read from the input txt,
1121
+ * before the orchestrator was even invoked). The orchestrator boundary
1122
+ * deliberately never sees the absolute path — see
1123
+ * {@link InventoryRunAggregates} for the privacy rationale.
997
1124
  *
998
1125
  * **Audit-write failures abort the ingestion phase.** Swallowing them
999
1126
  * would only be justified if the audit were the last write after the
@@ -1020,6 +1147,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1020
1147
  new_pages: aggregates.htmlSeedsCount,
1021
1148
  new_resources: aggregates.nonHtmlCount,
1022
1149
  scope_skipped: aggregates.outOfScope,
1150
+ invalid_skipped: aggregates.invalidSkipped,
1023
1151
  });
1024
1152
  }
1025
1153
  /**
@@ -1034,6 +1162,13 @@ export class CrawlerOrchestrator extends EventEmitter {
1034
1162
  // eslint-disable-next-line no-console
1035
1163
  console.error(`[preload] Short-circuited ${skipped} URL(s) on DNS-burned hosts`);
1036
1164
  }
1165
+ const { confirmedCount, totalDurationMs } = networkOutageSummaryCounter;
1166
+ if (confirmedCount > 0) {
1167
+ // eslint-disable-next-line no-console
1168
+ console.error(`[network] ${confirmedCount} outage(s), ${Math.round(totalDurationMs / 1000)}s total`);
1169
+ }
1170
+ networkOutageSummaryCounter.confirmedCount = 0;
1171
+ networkOutageSummaryCounter.totalDurationMs = 0;
1037
1172
  clearDestinationCache();
1038
1173
  clearDnsBurnedHostCache();
1039
1174
  }
package/lib/crawler.d.ts CHANGED
@@ -16,16 +16,36 @@ 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';
29
49
  export { computeFileSha256 } from './utils/compute-file-sha256.js';
30
50
  export { populateEntityTables } from './archive/populate-entity-tables/populate-entities.js';
31
51
  export type { PageDomPathResolver } from './archive/populate-entity-tables/populate-image-items.js';
@@ -38,3 +58,4 @@ export { populateBlobRefs } from './archive/populate-ref-tables/populate-blob-re
38
58
  export { populateHeaderTables } from './archive/populate-ref-tables/populate-header-tables.js';
39
59
  export { loadResponseHeadersBySetIds } from './archive/db-ops/_shared/load-response-headers-by-set-ids.js';
40
60
  export { decodeJsonRef } from './archive/db-ops/_shared/decode-json-ref.js';
61
+ export type { TemplateClusterBlockingEvidence, TemplateClusterBlockingReason, TemplateClusterLandmarkProfile, TemplateClusterLandmarkType, TemplateClusterReason, } from './archive/db-ops/analysis/types.js';
package/lib/crawler.js CHANGED
@@ -17,15 +17,32 @@ 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';
29
46
  export { computeFileSha256 } from './utils/compute-file-sha256.js';
30
47
  // 0.13 ref-table population (issue #191, epic #103). Exposed as the
31
48
  // 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>;