@nitpicker/crawler 0.9.0 → 0.12.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 (148) hide show
  1. package/lib/archive/archive-accessor.d.ts +87 -14
  2. package/lib/archive/archive-accessor.js +162 -36
  3. package/lib/archive/archive.d.ts +262 -24
  4. package/lib/archive/archive.js +397 -86
  5. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  6. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  7. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  8. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  9. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  10. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  11. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  12. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  13. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  14. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  15. package/lib/archive/database.d.ts +411 -26
  16. package/lib/archive/database.js +1881 -540
  17. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  18. package/lib/archive/derive-lineage-from-parent.js +42 -0
  19. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  20. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  21. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  22. package/lib/archive/get-failed-page-messages.js +131 -0
  23. package/lib/archive/init-schema.d.ts +53 -2
  24. package/lib/archive/init-schema.js +399 -15
  25. package/lib/archive/is-inventory-source.d.ts +21 -0
  26. package/lib/archive/is-inventory-source.js +22 -0
  27. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  28. package/lib/archive/meta/assert-compatible-version.js +72 -0
  29. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  30. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  31. package/lib/archive/meta/compare-semver.d.ts +23 -0
  32. package/lib/archive/meta/compare-semver.js +51 -0
  33. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  34. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  35. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  36. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  37. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  38. package/lib/archive/meta/derive-meta-extras.js +23 -0
  39. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  40. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  41. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  42. package/lib/archive/meta/summarize-jsonld.js +29 -0
  43. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  44. package/lib/archive/meta/summarize-tags.js +33 -0
  45. package/lib/archive/meta/types.d.ts +207 -0
  46. package/lib/archive/meta/types.js +33 -0
  47. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  48. package/lib/archive/migrate-crawl-errors.js +38 -0
  49. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  50. package/lib/archive/migrate-html-blob-tables.js +53 -0
  51. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  52. package/lib/archive/migrate-inventory-runs.js +52 -0
  53. package/lib/archive/migrate-page-errors.d.ts +16 -0
  54. package/lib/archive/migrate-page-errors.js +35 -0
  55. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  56. package/lib/archive/migrate-pages-resources-source.js +46 -0
  57. package/lib/archive/page.d.ts +187 -49
  58. package/lib/archive/page.js +258 -63
  59. package/lib/archive/peek-archive-lock.d.ts +40 -0
  60. package/lib/archive/peek-archive-lock.js +62 -0
  61. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  62. package/lib/archive/resolve-redirect-chain.js +27 -0
  63. package/lib/archive/types.d.ts +168 -26
  64. package/lib/classify-error-kind.d.ts +19 -0
  65. package/lib/classify-error-kind.js +122 -0
  66. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  67. package/lib/crawler/build-js-redirect-edge.js +57 -0
  68. package/lib/crawler/build-redirect-event.d.ts +24 -0
  69. package/lib/crawler/build-redirect-event.js +28 -0
  70. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  71. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  72. package/lib/crawler/close-browser-safely.d.ts +64 -0
  73. package/lib/crawler/close-browser-safely.js +73 -0
  74. package/lib/crawler/crawler.d.ts +7 -2
  75. package/lib/crawler/crawler.js +919 -113
  76. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  77. package/lib/crawler/create-change-phase-handler.js +44 -0
  78. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  79. package/lib/crawler/derive-js-redirect-target.js +129 -0
  80. package/lib/crawler/derive-page-source.d.ts +23 -0
  81. package/lib/crawler/derive-page-source.js +28 -0
  82. package/lib/crawler/derive-resource-source.d.ts +33 -0
  83. package/lib/crawler/derive-resource-source.js +37 -0
  84. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  85. package/lib/crawler/dns-burned-host-cache.js +25 -0
  86. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  87. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  88. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  89. package/lib/crawler/drain-phase-errors.js +35 -0
  90. package/lib/crawler/fetch-destination.d.ts +12 -4
  91. package/lib/crawler/fetch-destination.js +132 -18
  92. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  93. package/lib/crawler/format-crawl-progress.js +14 -6
  94. package/lib/crawler/handle-browser-close.d.ts +29 -0
  95. package/lib/crawler/handle-browser-close.js +28 -0
  96. package/lib/crawler/is-html-content-type.d.ts +17 -0
  97. package/lib/crawler/is-html-content-type.js +19 -0
  98. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  99. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  100. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  101. package/lib/crawler/is-likely-html-url.js +65 -0
  102. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  103. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  104. package/lib/crawler/kill-process-tree.d.ts +94 -0
  105. package/lib/crawler/kill-process-tree.js +178 -0
  106. package/lib/crawler/link-list.d.ts +21 -1
  107. package/lib/crawler/link-list.js +25 -4
  108. package/lib/crawler/link-to-page-data.d.ts +13 -5
  109. package/lib/crawler/link-to-page-data.js +26 -5
  110. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  111. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  112. package/lib/crawler/normalize-content-type.d.ts +14 -0
  113. package/lib/crawler/normalize-content-type.js +20 -0
  114. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  115. package/lib/crawler/partition-urls-by-html.js +23 -0
  116. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  117. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  118. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  119. package/lib/crawler/preload-short-circuit-error.js +25 -0
  120. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  121. package/lib/crawler/redirect-dest-key.js +27 -0
  122. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  123. package/lib/crawler/resource-to-page-data.js +59 -0
  124. package/lib/crawler/should-burn-host.d.ts +78 -0
  125. package/lib/crawler/should-burn-host.js +61 -0
  126. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  127. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  128. package/lib/crawler/types.d.ts +229 -1
  129. package/lib/crawler-orchestrator.d.ts +105 -3
  130. package/lib/crawler-orchestrator.js +620 -20
  131. package/lib/crawler.d.ts +8 -2
  132. package/lib/crawler.js +6 -1
  133. package/lib/permanent-error-kinds.d.ts +43 -0
  134. package/lib/permanent-error-kinds.js +48 -0
  135. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  136. package/lib/resource-row-to-lookup-result.js +20 -0
  137. package/lib/types.d.ts +95 -1
  138. package/lib/utils/compute-file-sha256.d.ts +23 -0
  139. package/lib/utils/compute-file-sha256.js +55 -0
  140. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  141. package/lib/utils/error/emit-error-with-retry.js +44 -0
  142. package/lib/utils/error/emit-error.d.ts +39 -0
  143. package/lib/utils/error/emit-error.js +41 -0
  144. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  145. package/lib/utils/object/parse-response-headers.js +26 -0
  146. package/package.json +11 -11
  147. package/lib/utils/error/error-emitter.d.ts +0 -18
  148. package/lib/utils/error/error-emitter.js +0 -29
@@ -6,10 +6,18 @@ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-awa
6
6
  import pkg from '../package.json' with { type: 'json' };
7
7
  import Archive from './archive/archive.js';
8
8
  import { clearDestinationCache } from './crawler/clear-destination-cache.js';
9
+ import { clearDnsBurnedHostCache } from './crawler/clear-dns-burned-host-cache.js';
9
10
  import Crawler from './crawler/crawler.js';
11
+ import { dnsBurnedHostCache } from './crawler/dns-burned-host-cache.js';
12
+ import { dnsBurnedHostShortCircuitCounter } from './crawler/dns-burned-host-short-circuit-counter.js';
13
+ import { findScopeEntry } from './crawler/find-scope-entry.js';
14
+ import { isLikelyHtmlUrl } from './crawler/is-likely-html-url.js';
15
+ import { PreloadShortCircuitError } from './crawler/preload-short-circuit-error.js';
16
+ import { protocolAgnosticKey } from './crawler/protocol-agnostic-key.js';
10
17
  import { crawlerLog, log } from './debug.js';
11
18
  import { normalizeToArray } from './normalize-to-array.js';
12
19
  import { resolveOutputPath } from './resolve-output-path.js';
20
+ import { resourceRowToLookupResult } from './resource-row-to-lookup-result.js';
13
21
  import { cleanObject } from './utils/object/clean-object.js';
14
22
  import { WriteQueue } from './write-queue.js';
15
23
  /**
@@ -53,6 +61,8 @@ export class CrawlerOrchestrator extends EventEmitter {
53
61
  #crawler;
54
62
  /** Whether the crawl was started from a pre-defined URL list (non-recursive mode). */
55
63
  #fromList;
64
+ /** Serializes archive writes from crawler event handlers (FIFO). */
65
+ #writeQueue = new WriteQueue();
56
66
  /**
57
67
  * The underlying archive instance used for storing crawl results.
58
68
  */
@@ -95,6 +105,34 @@ export class CrawlerOrchestrator extends EventEmitter {
95
105
  verbose: options?.verbose ?? false,
96
106
  userAgent: options?.userAgent || defaultUserAgent,
97
107
  ignoreRobots: options?.ignoreRobots ?? false,
108
+ // Let the crawler reuse sub-resource data captured during page
109
+ // rendering instead of issuing a redundant HEAD pre-flight.
110
+ lookupResource: async (urls) => {
111
+ // Fast path: read directly — the row is usually flushed long
112
+ // before the queued URL is dequeued, and a direct read does not
113
+ // block behind pending writes.
114
+ const direct = await this.#archive.getResourceByUrl(urls);
115
+ if (direct) {
116
+ return resourceRowToLookupResult(direct);
117
+ }
118
+ // A miss may be an insert still queued — re-read serialized
119
+ // behind the write queue so hit/miss is deterministic.
120
+ const row = await this.#writeQueue.enqueue(() => this.#archive.getResourceByUrl(urls));
121
+ return row ? resourceRowToLookupResult(row) : null;
122
+ },
123
+ // Let the crawler propagate the parent's source lineage to
124
+ // sub-resources on `--resume` / `--retry-failed` sessions, where
125
+ // `inventoryMode` is not in memory but the DB still remembers
126
+ // the parent's `source`. Without this, sub-resources captured
127
+ // during a re-render of an inventory-labelled page would fall
128
+ // back to the DB DEFAULT `'crawled'` and lose their
129
+ // `'inventory-discovered'` provenance.
130
+ lookupPageSource: async (url) => this.#archive.getPageSourceByUrl(url),
131
+ // Inventory mode is opted into by `CrawlerOrchestrator.inventory`
132
+ // (see T3); the default crawl path stays in normal mode so new
133
+ // rows continue to land in pages/resources with the DB DEFAULT
134
+ // `'crawled'` provenance label.
135
+ inventoryMode: options?.inventoryMode ?? null,
98
136
  });
99
137
  }
100
138
  /**
@@ -113,30 +151,44 @@ export class CrawlerOrchestrator extends EventEmitter {
113
151
  * Sets up event listeners on the crawler, starts crawling, and resolves
114
152
  * when the crawl completes. Discovered pages, external pages, skipped pages,
115
153
  * and resources are forwarded to the archive for storage.
116
- * @param list - The list of parsed URLs to crawl. The first URL is used as the root.
154
+ * @param list - The list of parsed URLs to crawl. May be empty when a resumed
155
+ * session already has pending pages queued (for example `--retry-failed`).
156
+ * @param opts - Optional crawl overrides.
157
+ * @param opts.recursive - Whether discovered URLs are followed. Defaults to
158
+ * `!fromList` (recursive unless the archive was created from a URL list), so
159
+ * existing callers keep their behaviour; the retry flow passes it explicitly.
117
160
  * @returns A promise that resolves when crawling is complete.
118
- * @throws {Error} If the URL list is empty.
119
161
  */
120
- async crawling(list) {
121
- const root = list[0];
122
- if (!root) {
123
- throw new Error('URL is empty');
124
- }
125
- const writeQueue = new WriteQueue();
162
+ async crawling(list, opts) {
163
+ const writeQueue = this.#writeQueue;
126
164
  return new Promise((resolve, reject) => {
127
165
  this.#crawler.on('error', (error) => {
166
+ if (error.error instanceof PreloadShortCircuitError) {
167
+ // DNS-burned host short-circuit: the underlying cause already
168
+ // lives in `crawl_errors` from the original DNS failure.
169
+ // Writing it again on every subsequent URL would amplify the
170
+ // row count on each `--retry-failed` re-run and could even
171
+ // inflate the preload selection on the next open. Drop it
172
+ // here; `pages.status = -1` still gets set via the normal
173
+ // scrape-error path (handleScrapeError → addPageError) so the
174
+ // page record itself is unchanged.
175
+ crawlerLog('Skipping addError for preload short-circuit: %s', error.url);
176
+ return;
177
+ }
128
178
  crawlerLog('On error: %O', error);
129
- void writeQueue.enqueue(() => this.#archive.addError(error));
179
+ writeQueue
180
+ .enqueue(() => this.#archive.addError(error))
181
+ .catch((writeError) => reject(writeError));
130
182
  void this.emit('error', error);
131
183
  });
132
- this.#crawler.on('page', ({ result }) => {
184
+ this.#crawler.on('page', ({ result, source }) => {
133
185
  writeQueue
134
- .enqueue(() => this.#archive.setPage(result))
186
+ .enqueue(() => this.#archive.setPage(result, source))
135
187
  .catch((error) => reject(error));
136
188
  });
137
- this.#crawler.on('externalPage', ({ result }) => {
189
+ this.#crawler.on('externalPage', ({ result, source }) => {
138
190
  writeQueue
139
- .enqueue(() => this.#archive.setExternalPage(result))
191
+ .enqueue(() => this.#archive.setExternalPage(result, source))
140
192
  .catch((error) => reject(error));
141
193
  });
142
194
  this.#crawler.on('skip', ({ url, reason, isExternal }) => {
@@ -144,9 +196,20 @@ export class CrawlerOrchestrator extends EventEmitter {
144
196
  .enqueue(() => this.#archive.setSkippedPage(url, reason, isExternal))
145
197
  .catch((error) => reject(error));
146
198
  });
147
- this.#crawler.on('response', ({ resource }) => {
199
+ this.#crawler.on('pageError', ({ url, phase, message, isExternal }) => {
200
+ writeQueue
201
+ .enqueue(() => this.#archive.addPageError(url, phase, message, isExternal))
202
+ .catch((error) => reject(error));
203
+ });
204
+ this.#crawler.on('redirect', ({ result, source }) => {
205
+ writeQueue
206
+ .enqueue(() => this.#archive.setRedirect(result, source))
207
+ .catch((error) => reject(error));
208
+ void this.emit('redirect', { result });
209
+ });
210
+ this.#crawler.on('response', ({ resource, source }) => {
148
211
  writeQueue
149
- .enqueue(() => this.#archive.setResources(resource))
212
+ .enqueue(() => this.#archive.setResources(resource, source))
150
213
  .catch((error) => reject(error));
151
214
  });
152
215
  this.#crawler.on('responseReferrers', (resource) => {
@@ -160,7 +223,7 @@ export class CrawlerOrchestrator extends EventEmitter {
160
223
  .then(() => resolve())
161
224
  .catch((error) => reject(error));
162
225
  });
163
- this.#crawler.start(list, { recursive: !this.#fromList });
226
+ this.#crawler.start(list, { recursive: opts?.recursive ?? !this.#fromList });
164
227
  });
165
228
  }
166
229
  /**
@@ -271,7 +334,7 @@ export class CrawlerOrchestrator extends EventEmitter {
271
334
  log('Config %O', config);
272
335
  await orchestrator.crawling(list);
273
336
  log('Crawling completed');
274
- clearDestinationCache();
337
+ CrawlerOrchestrator.#finalizeCrawlSession();
275
338
  log('Set order natural URL sort');
276
339
  await archive.setUrlOrder();
277
340
  log('Sorting done');
@@ -348,7 +411,8 @@ export class CrawlerOrchestrator extends EventEmitter {
348
411
  });
349
412
  const { scraped, pending } = await archive.getCrawlingState();
350
413
  const resources = await archive.getResourceUrlList();
351
- orchestrator.#crawler.resume(pending, scraped, resources);
414
+ const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
415
+ orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
352
416
  if (initializedCallback) {
353
417
  await initializedCallback(orchestrator, mergedConfig);
354
418
  }
@@ -356,8 +420,9 @@ export class CrawlerOrchestrator extends EventEmitter {
356
420
  log('Archive %s', absFilePath);
357
421
  log('New roots %O', newRoots);
358
422
  log('Merged roots %O', mergedRoots);
423
+ await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
359
424
  await orchestrator.crawling(newParsed);
360
- clearDestinationCache();
425
+ CrawlerOrchestrator.#finalizeCrawlSession();
361
426
  await archive.setUrlOrder();
362
427
  await ignoreEnoent(unlinkFile(backupPath));
363
428
  return orchestrator;
@@ -381,6 +446,454 @@ export class CrawlerOrchestrator extends EventEmitter {
381
446
  throw error;
382
447
  }
383
448
  }
449
+ /**
450
+ * Inventory mode: cross-reference a user-supplied URL list against an
451
+ * existing `.nitpicker` archive and import ONLY the URLs that are not yet
452
+ * tracked there. Designed to surface "orphan" landing pages that link
453
+ * graph traversal could not reach, and "unused" server-side files that
454
+ * no crawled page references — both of which the
455
+ * `listIsolatedPages` / `listUnusedResources` queries can then list.
456
+ *
457
+ * Flow:
458
+ *
459
+ * 1. Open the archive (writer mode, takes the archive lock).
460
+ * 2. Reject list-mode archives — they hold metadata-only rows that
461
+ * inventory has no business touching.
462
+ * 3. Reject archives with unfinished `pending` URLs — those would inherit
463
+ * the inventory `source` label by mistake. Operator must resume /
464
+ * retry-failed first.
465
+ * 4. Parse the URL list. Anything outside the archived scope is warned
466
+ * and skipped (inventory is per-server by design).
467
+ * 5. Subtract URLs that already exist in `pages` or `resources` so the
468
+ * second (and N-th) inventory pass is a no-op for known rows — keeps
469
+ * `'inventory-seed'` rows from being silently demoted.
470
+ * 6. Make `<archive>.bak`. Anything thrown beyond this point restores
471
+ * from the backup.
472
+ * 7. HEAD-probe each novel URL. Responses classified as HTML are queued
473
+ * as Crawler seeds (`'inventory-seed'`); everything else is recorded
474
+ * in `resources` directly as `'inventory-seed'` (no browser launch).
475
+ * 8. If any HTML seeds exist, start a Crawler with
476
+ * `inventoryMode = { seedUrls }` so the rendered page and every newly
477
+ * discovered downstream link is labelled correctly. `resume` is fed
478
+ * the existing `scraped` / `resources` sets so links into already-
479
+ * crawled pages stop at the seen-gate without re-rendering.
480
+ * 9. Drop the backup on success; restore it on any throw.
481
+ *
482
+ * Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
483
+ * `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
484
+ * CLI dispatch enforces this; this method assumes the caller honoured
485
+ * the contract.
486
+ * @param archivePath - Absolute or cwd-relative path to the `.nitpicker` archive.
487
+ * @param inventoryUrls - Pre-read URL list (one URL per element).
488
+ * @param options - Optional config overrides — most callers leave this blank and let the archived config flow through.
489
+ * @param initializedCallback - Hook invoked once the orchestrator is constructed but before `crawling` runs (the CLI uses it to attach progress reporting).
490
+ * @param sourceFileSha256 - **Pre-computed** SHA-256 hex digest of the
491
+ * source URL list. The orchestrator deliberately does NOT receive
492
+ * the file path: the path is privacy-sensitive (leaks user-home /
493
+ * OS structure when archives are shared) and we want it lifted off
494
+ * this boundary so no future log line / breadcrumb / error message
495
+ * inside the orchestrator can accidentally re-leak it. The CLI
496
+ * computes the digest via `computeFileSha256(resolvedListFile)`
497
+ * and passes it through here. Pass `null` for programmatic
498
+ * callers that built `inventoryUrls` in-memory; the audit row's
499
+ * `source_file_sha256` column will be `NULL`.
500
+ * @returns The orchestrator instance after a successful inventory pass.
501
+ * @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
502
+ */
503
+ static async inventory(archivePath, inventoryUrls, options, initializedCallback, sourceFileSha256 = null) {
504
+ if (inventoryUrls.length === 0) {
505
+ throw new Error('inventory: URL list is empty');
506
+ }
507
+ const cwd = options?.cwd ?? process.cwd();
508
+ const absFilePath = path.isAbsolute(archivePath)
509
+ ? archivePath
510
+ : path.resolve(cwd, archivePath);
511
+ const archive = await Archive.open({ filePath: absFilePath, cwd });
512
+ try {
513
+ const archived = await archive.getConfig();
514
+ if (archived.fromList) {
515
+ throw new Error('Cannot run inventory on a list-mode archive: this archive was created with --list/--list-file and contains metadata-only pages. Create a fresh archive instead.');
516
+ }
517
+ const { pending } = await archive.getCrawlingState();
518
+ if (pending.length > 0) {
519
+ // `getCrawlingState` returns the STRICT pending set — in-scope,
520
+ // anchor-referenced, `scraped=0` rows. Predicted-discard leaks
521
+ // and external anomalies are filtered out at the reader, so a
522
+ // non-empty pending here means the previous session genuinely
523
+ // stopped with interrupted in-scope work. The original hard
524
+ // rejection blocked legitimate inventory runs in practice
525
+ // because leak rows polluted the count; with the strict
526
+ // reader those false positives are gone, so a warning is
527
+ // enough — the inventory pass continues and the crawled-wins
528
+ // source priority keeps stale labels stable even if some of
529
+ // the strict-pending rows happen to land on inventory seeds.
530
+ 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.`);
531
+ }
532
+ // Parse + scope-classify the candidate URLs. sortUrl drops
533
+ // unparseable strings; findScopeEntry separates in-scope from
534
+ // out-of-scope.
535
+ const parsedAll = sortUrl(inventoryUrls, archived);
536
+ const scopeMap = new Map();
537
+ for (const raw of archived.roots) {
538
+ const parsed = parseUrl(raw, archived);
539
+ if (!parsed)
540
+ continue;
541
+ const existing = scopeMap.get(parsed.hostname) ?? [];
542
+ scopeMap.set(parsed.hostname, [...existing, parsed]);
543
+ }
544
+ const inScope = [];
545
+ let outOfScope = 0;
546
+ for (const url of parsedAll) {
547
+ if (findScopeEntry(url, scopeMap, archived) === null) {
548
+ outOfScope++;
549
+ }
550
+ else {
551
+ inScope.push(url);
552
+ }
553
+ }
554
+ if (outOfScope > 0) {
555
+ log('[inventory] %d URL(s) skipped (outside archived scope: %O)', outOfScope, archived.roots);
556
+ }
557
+ // Drop URLs that are already represented in the archive (either
558
+ // as pages or resources). Comparison key is `withoutHashAndAuth`
559
+ // to mirror what `#getIdByUrl` / `insertResource` actually store.
560
+ // Two independent reads — Promise.all halves the wait on large
561
+ // archives where each `WHERE url IN (?)` chunk costs real I/O.
562
+ const candidateUrls = inScope.map((u) => u.withoutHashAndAuth);
563
+ const [existingPageUrlList, existingResourceUrlList] = await Promise.all([
564
+ archive.getExistingPageUrls(candidateUrls),
565
+ archive.getExistingResourceUrls(candidateUrls),
566
+ ]);
567
+ const existingPageUrls = new Set(existingPageUrlList);
568
+ const existingResourceUrls = new Set(existingResourceUrlList);
569
+ const novelUrls = inScope.filter((u) => {
570
+ const key = u.withoutHashAndAuth;
571
+ return !existingPageUrls.has(key) && !existingResourceUrls.has(key);
572
+ });
573
+ const knownCount = existingPageUrls.size + existingResourceUrls.size;
574
+ log('[inventory] %d in-scope, %d already in archive, %d new', inScope.length, knownCount, novelUrls.length);
575
+ if (novelUrls.length === 0) {
576
+ // Nothing to do — release the archive cleanly without taking a
577
+ // backup. The orchestrator returned here is empty; the caller
578
+ // should only invoke `close` on it.
579
+ const noopConfig = {
580
+ ...archived,
581
+ ...cleanObject(options),
582
+ };
583
+ const orchestrator = new CrawlerOrchestrator(archive, noopConfig);
584
+ if (initializedCallback) {
585
+ await initializedCallback(orchestrator, noopConfig);
586
+ }
587
+ return orchestrator;
588
+ }
589
+ const backupPath = absFilePath + '.bak';
590
+ await copyFile(absFilePath, backupPath);
591
+ // Ingestion (pre-insert + audit) is `.bak`-protected — a failure
592
+ // there restores the archive and the operator reruns. Once
593
+ // ingestion completes and the `.bak` is released, the scrape
594
+ // phase runs without `.bak` protection: a Ctrl+C / crash leaves
595
+ // the pre-inserted `inventory-seed` rows in `pages` so
596
+ // `crawl --resume` recovers them via the strict-pending set
597
+ // (see {@link Database.getCrawlingState}'s `OR p.source != 'crawled'`
598
+ // clause). This flag steers the catch below.
599
+ let ingestionComplete = false;
600
+ try {
601
+ // Classify novel URLs by URL-extension heuristic (no I/O).
602
+ // Source file lists come from `ls` on the doc-root, so the
603
+ // extension reflects the real file type — a HEAD pre-flight
604
+ // here would be pure wasted I/O. Edge cases:
605
+ //
606
+ // - `.html` returning 404 / 200: the normal crawler HEAD/GET
607
+ // path absorbs this because every HTML-classified URL is
608
+ // fed through the dealer and gets its real HEAD/GET there.
609
+ //
610
+ // - Extensionless API endpoints (e.g. `/api/foo`) that the
611
+ // server returns as `text/html`: `isLikelyHtmlUrl` accepts
612
+ // them as HTML so the dealer's render path runs — the
613
+ // real content-type wins downstream.
614
+ //
615
+ // - `.aspx` / `.do` / `.jsp` / other server-handler
616
+ // extensions that the heuristic does NOT recognise as
617
+ // HTML: these are classified as non-HTML here, recorded
618
+ // as `resources` rows with all-null metadata, and never
619
+ // get a HEAD/GET probe. The accepted trade-off for
620
+ // `--inventory`'s "list of static-looking server files"
621
+ // contract; sites that mix server-handlers into the
622
+ // inventory list will need a follow-up `--retry-failed`
623
+ // pass (or a re-`--inventory` with the corrected list)
624
+ // to populate metadata.
625
+ //
626
+ // non-HTML rows are recorded with null status/content-type
627
+ // which is sufficient for `listUnusedResources` (referrer
628
+ // count = 0) but means downstream consumers must treat
629
+ // null as "not probed" rather than "failed".
630
+ const rawHtmlSeeds = [];
631
+ const nonHtmlSeeds = [];
632
+ for (const url of novelUrls) {
633
+ if (isLikelyHtmlUrl(url)) {
634
+ rawHtmlSeeds.push(url);
635
+ }
636
+ else {
637
+ nonHtmlSeeds.push(url);
638
+ }
639
+ }
640
+ // Dedup HTML seeds by `protocolAgnosticKey` so an inventory
641
+ // list that mixes `http://` and `https://` for the same
642
+ // origin does not produce two `pages` rows that the dealer
643
+ // later collapses to one — the loser would otherwise stay
644
+ // `scraped=0, source='inventory-seed'` forever and look like
645
+ // a real recovery candidate on `--resume`. `getExistingPageUrls`
646
+ // keys on the full URL (with protocol), so it cannot catch
647
+ // the cross-scheme duplicate; this is the dedup boundary.
648
+ const seenKeys = new Set();
649
+ const htmlSeeds = [];
650
+ for (const url of rawHtmlSeeds) {
651
+ const key = protocolAgnosticKey(url.withoutHashAndAuth);
652
+ if (seenKeys.has(key)) {
653
+ continue;
654
+ }
655
+ seenKeys.add(key);
656
+ htmlSeeds.push(url);
657
+ }
658
+ // Bulk-record non-HTML novel URLs in `resources` as
659
+ // `source='inventory-seed'` placeholders. The previous
660
+ // per-URL `await setResources(...)` loop spent minutes
661
+ // inside the `.bak`-protected window on large inventory
662
+ // lists; the chunked bulk path collapses N round-trips
663
+ // to N/500.
664
+ await archive.insertInventoryResources(nonHtmlSeeds);
665
+ // Pre-insert HTML seeds as `scraped = 0`,
666
+ // `source = 'inventory-seed'` placeholders *before* the
667
+ // scrape phase, so a Ctrl+C between here and `setPage`
668
+ // no longer loses the URL. The strict-pending set picks
669
+ // these rows up on the next `--resume` via the
670
+ // `OR p.source != 'crawled'` clause.
671
+ await archive.insertInventorySeeds(htmlSeeds);
672
+ log('[inventory] %d HTML seed(s), %d non-HTML resource(s) recorded', htmlSeeds.length, nonHtmlSeeds.length);
673
+ // Audit row is written *inside* the `.bak` window: a libsql
674
+ // hiccup or transient lock on the INSERT aborts the ingestion
675
+ // and the `.bak` restore wipes the pre-inserted seeds too,
676
+ // so "either the whole run took or none of it did" holds at
677
+ // the ingestion boundary. Past behaviour swallowed the
678
+ // failure post-scrape; the new boundary makes restore safe
679
+ // and useful, so the swallow is gone (see
680
+ // {@link CrawlerOrchestrator.#writeInventoryRunRow}).
681
+ await CrawlerOrchestrator.#writeInventoryRunRow(archive, {
682
+ inventoryUrlsCount: inventoryUrls.length,
683
+ htmlSeedsCount: htmlSeeds.length,
684
+ nonHtmlCount: nonHtmlSeeds.length,
685
+ outOfScope,
686
+ sourceFileSha256,
687
+ });
688
+ // Ingestion's DB writes are now committed. From here on a
689
+ // throw must NOT trigger the `.bak` restore (it would wipe
690
+ // the durable seeds + audit row). Setting the flag *before*
691
+ // the `.bak` unlink covers the rare Windows / antivirus
692
+ // path where `unlinkFile` itself fails with EBUSY/EPERM —
693
+ // the `.bak` may leak on disk for the operator to delete
694
+ // manually, but the archive state stays intact.
695
+ ingestionComplete = true;
696
+ // Release `.bak` — ingestion succeeded. Beyond this point a
697
+ // throw is the scrape phase's problem; the archive stays
698
+ // intact and the operator runs `--resume` to recover.
699
+ await ignoreEnoent(unlinkFile(backupPath));
700
+ // Config sent to the user-facing `initializedCallback`
701
+ // (matches the rest of the orchestrator's public surface —
702
+ // no inventory bookkeeping leaks out).
703
+ const baseConfig = {
704
+ ...archived,
705
+ ...cleanObject(options),
706
+ recursive: true,
707
+ fromList: false,
708
+ };
709
+ const seedSet = new Set(htmlSeeds.map((u) => u.withoutHashAndAuth));
710
+ // CrawlConfig overlay handed to the orchestrator constructor —
711
+ // carries the runtime-only `inventoryMode` that drives source
712
+ // labelling. Not persisted to the archive.
713
+ const orchestratorOptions = {
714
+ ...baseConfig,
715
+ inventoryMode: { seedUrls: seedSet },
716
+ };
717
+ if (htmlSeeds.length > 0) {
718
+ const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
719
+ // Re-read pending *after* the pre-insert so the strict-
720
+ // pending set includes the freshly inserted
721
+ // `inventory-seed` rows; feed that into `crawler.resume`
722
+ // and start a seedless `crawling([])` — the same pattern
723
+ // `retryFailed` uses to drive the dealer from the
724
+ // pending set alone (see retryFailed's
725
+ // `crawling([], { recursive })` invocation).
726
+ const { scraped: scrapedAfter, pending: pendingAfter } = await archive.getCrawlingState();
727
+ const resources = await archive.getResourceUrlList();
728
+ // Pre-existing rendered HTML page count seeds the
729
+ // session-spanning `pagesScraped` counter so the progress
730
+ // header reads `internalDone(cumulative pagesScraped)`
731
+ // rather than session-only — matches the `append` /
732
+ // `retryFailed` / `resume` paths and avoids users reading
733
+ // the parenthesised number as "inner pages dropped to N".
734
+ const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
735
+ orchestrator.#crawler.resume(pendingAfter, scrapedAfter, resources, pagesScrapedOffset);
736
+ if (initializedCallback) {
737
+ await initializedCallback(orchestrator, baseConfig);
738
+ }
739
+ log('Start inventory');
740
+ log('Archive %s', absFilePath);
741
+ log('HTML seeds %O', htmlSeeds.map((u) => u.href));
742
+ await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
743
+ await orchestrator.crawling([], { recursive: true });
744
+ CrawlerOrchestrator.#finalizeCrawlSession();
745
+ await archive.setUrlOrder();
746
+ return orchestrator;
747
+ }
748
+ // Only non-HTML URLs were imported — nothing left to render,
749
+ // but still update sort order and finalize.
750
+ const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
751
+ if (initializedCallback) {
752
+ await initializedCallback(orchestrator, baseConfig);
753
+ }
754
+ await archive.setUrlOrder();
755
+ return orchestrator;
756
+ }
757
+ catch (error) {
758
+ if (ingestionComplete) {
759
+ // Scrape phase failed; the pre-inserted seeds + audit
760
+ // row are durable inside `tmpDir/db.sqlite` but not yet
761
+ // on disk as a `.nitpicker` tar. The outer catch below
762
+ // runs `archive.close()`, which sees the original
763
+ // (pre-inventory) `.nitpicker` already on disk and
764
+ // would just `remove(tmpDir)` — silently wiping every
765
+ // `inventory-seed` row and the audit row.
766
+ //
767
+ // Persist the ingested state ourselves before letting
768
+ // the outer catch unwind, then re-throw so the operator
769
+ // learns about the scrape failure (and can recover via
770
+ // `crawl --resume <archive>`). `releaseHandle` shares
771
+ // the orchestrator's `#closeOnce` guard, so the outer
772
+ // catch's `close()` becomes a no-op for the destructive
773
+ // step and only runs `releaseLock` cleanup.
774
+ try {
775
+ await archive.write();
776
+ await archive.releaseHandle();
777
+ }
778
+ catch (persistError) {
779
+ throw new AggregateError([error, persistError], 'inventory scrape phase failed AND persisting the ingested state to disk also failed. The archive may be in an inconsistent state — check tmpDir.');
780
+ }
781
+ throw error;
782
+ }
783
+ try {
784
+ await copyFile(backupPath, absFilePath);
785
+ await ignoreEnoent(unlinkFile(backupPath));
786
+ }
787
+ catch (restoreError) {
788
+ throw new AggregateError([error, restoreError], `inventory failed AND restore from backup failed. Original archive backup is left at: ${backupPath}`);
789
+ }
790
+ throw error;
791
+ }
792
+ }
793
+ catch (error) {
794
+ await archive.close().catch(() => { });
795
+ throw error;
796
+ }
797
+ }
798
+ /**
799
+ * Re-fetch previously-failed pages in an existing `.nitpicker` archive.
800
+ *
801
+ * Opens the archive, resets every page whose previous attempt ended in a
802
+ * recoverable failure (missing status / content type, or a 5xx status — see
803
+ * {@link Archive.resetFailedPages}) back to pending, and resumes crawling.
804
+ * The archived crawl configuration is reused — scopes, excludes, keywords,
805
+ * user agent, etc. — so the retry honours the original crawl boundaries
806
+ * unless a field is explicitly overridden via `options`. The exception is
807
+ * `recursive`: it is taken from `options` (the CLI flag defaults it to
808
+ * `true`) rather than inherited from the archive, so a retry decides afresh
809
+ * whether to follow newly-discovered URLs regardless of how the original
810
+ * crawl was run.
811
+ *
812
+ * When `recursive` is enabled (the default), newly-discovered URLs from the
813
+ * re-fetched pages are followed and crawled from scratch; when disabled, only
814
+ * the failed pages themselves are re-fetched. The archived roots seed the
815
+ * crawl scope while the reset pages are picked up through the resumed pending
816
+ * set, so failed external pages stay external (metadata-only) instead of being
817
+ * promoted into scope, and a failed root is re-fetched in place.
818
+ *
819
+ * A `<archive>.bak` is created before any DB mutation and removed on success;
820
+ * if the crawl throws, the backup is restored to keep the original archive
821
+ * intact.
822
+ *
823
+ * List-mode archives (`info.fromList === true`) are rejected for the same
824
+ * reason as {@link CrawlerOrchestrator.append}: their pages are metadata-only.
825
+ * @param archivePath - Absolute or relative path to the existing `.nitpicker`.
826
+ * @param options - Optional config overrides applied on top of the archived config.
827
+ * @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
828
+ * @returns The orchestrator instance after the retry crawl completes.
829
+ * @throws {Error} When the archive is in list mode or has no parseable roots.
830
+ */
831
+ static async retryFailed(archivePath, options, initializedCallback) {
832
+ const cwd = options?.cwd ?? process.cwd();
833
+ const absFilePath = path.isAbsolute(archivePath)
834
+ ? archivePath
835
+ : path.resolve(cwd, archivePath);
836
+ const archive = await Archive.open({ filePath: absFilePath, cwd });
837
+ // Any throw between here and the successful return must release the
838
+ // archive lock and clean up tmpDir; the caller's `close()` only runs on
839
+ // the happy path.
840
+ try {
841
+ const archived = await archive.getConfig();
842
+ if (archived.fromList) {
843
+ throw new Error('Cannot retry a list-mode archive: this archive was created with --list/--list-file and contains metadata-only pages. Create a fresh archive instead.');
844
+ }
845
+ const rootsParsed = sortUrl(archived.roots, archived);
846
+ if (rootsParsed.length === 0) {
847
+ throw new Error('retry: archive has no parseable root URLs');
848
+ }
849
+ const config = {
850
+ ...archived,
851
+ ...cleanObject(options),
852
+ roots: archived.roots,
853
+ fromList: false,
854
+ baseUrl: archived.baseUrl,
855
+ };
856
+ const backupPath = absFilePath + '.bak';
857
+ await copyFile(absFilePath, backupPath);
858
+ try {
859
+ const resetUrls = await archive.resetFailedPages();
860
+ log('Start retrying failed pages');
861
+ log('Archive %s', absFilePath);
862
+ log('Reset %d failed page(s)', resetUrls.length);
863
+ const orchestrator = new CrawlerOrchestrator(archive, config);
864
+ const { scraped, pending } = await archive.getCrawlingState();
865
+ const resources = await archive.getResourceUrlList();
866
+ const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
867
+ orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
868
+ if (initializedCallback) {
869
+ await initializedCallback(orchestrator, config);
870
+ }
871
+ await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
872
+ await orchestrator.crawling([], { recursive: config.recursive });
873
+ CrawlerOrchestrator.#finalizeCrawlSession();
874
+ await archive.setUrlOrder();
875
+ await ignoreEnoent(unlinkFile(backupPath));
876
+ return orchestrator;
877
+ }
878
+ catch (error) {
879
+ try {
880
+ await copyFile(backupPath, absFilePath);
881
+ await ignoreEnoent(unlinkFile(backupPath));
882
+ }
883
+ catch (restoreError) {
884
+ // Restore itself failed — surface both so the operator knows
885
+ // the .bak still exists and the original archive may be
886
+ // corrupt. The outer `catch` still releases the lock.
887
+ throw new AggregateError([error, restoreError], `retry failed AND restore from backup failed. Original archive backup is left at: ${backupPath}`);
888
+ }
889
+ throw error;
890
+ }
891
+ }
892
+ catch (error) {
893
+ await archive.close().catch(() => { });
894
+ throw error;
895
+ }
896
+ }
384
897
  /**
385
898
  * Resume a previously interrupted crawl from an existing archive file.
386
899
  *
@@ -408,7 +921,8 @@ export class CrawlerOrchestrator extends EventEmitter {
408
921
  }
409
922
  const { scraped, pending } = await archive.getCrawlingState();
410
923
  const resources = await archive.getResourceUrlList();
411
- orchestrator.#crawler.resume(pending, scraped, resources);
924
+ const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
925
+ orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
412
926
  if (initializedCallback) {
413
927
  await initializedCallback(orchestrator, config);
414
928
  }
@@ -416,9 +930,95 @@ export class CrawlerOrchestrator extends EventEmitter {
416
930
  log('Data %s', stubPath);
417
931
  log('URL %s', url.href);
418
932
  log('Config %O', config);
933
+ await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
419
934
  await orchestrator.crawling([url]);
935
+ CrawlerOrchestrator.#finalizeCrawlSession();
420
936
  return orchestrator;
421
937
  }
938
+ /**
939
+ * Seeds {@link dnsBurnedHostCache} from `crawl_errors` history at re-open
940
+ * (append / inventory / retryFailed / resume). Called after Archive.open
941
+ * succeeds and before crawling starts, so the first URL on a burned host
942
+ * already short-circuits — no retry budget is spent on a dead host that
943
+ * the previous crawl already proved was dead.
944
+ *
945
+ * Fresh `crawling()` skips this — there is no archive history to seed
946
+ * from. Within-session learning still kicks in via the `onGiveUp` mark.
947
+ * @param archive - The opened archive whose `crawl_errors` is read.
948
+ */
949
+ static async #preloadDnsBurnedHostCache(archive) {
950
+ const hosts = await archive.listDnsBurnedHostCandidates();
951
+ for (const host of hosts) {
952
+ dnsBurnedHostCache.set(host, 'dns');
953
+ }
954
+ if (hosts.length > 0) {
955
+ // eslint-disable-next-line no-console
956
+ console.error(`[preload] DNS-burned hosts: ${hosts.length} (will short-circuit subsequent URLs)`);
957
+ }
958
+ }
959
+ /**
960
+ * Persist one `inventory_runs` audit row inside the ingestion phase of a
961
+ * `--inventory` invocation, before the `.bak` is released. Lives as a
962
+ * static helper because the audit-row shape (timestamp stamping + label
963
+ * auto-gen + the privacy-driven path elision documented below) is a
964
+ * cohesive concern that benefits from staying outside the long
965
+ * `inventory()` body even though only one caller remains after the
966
+ * ingestion-phase consolidation.
967
+ *
968
+ * `ran_at` is stamped now (ingestion-completion timestamp; the scrape
969
+ * phase that may follow is treated as separate). `list_label` is
970
+ * auto-generated from `ran_at` when the CLI did not pass one — Phase 1
971
+ * has no `--label` flag, so this is always the auto form.
972
+ * `source_file_sha256` arrives pre-computed via
973
+ * `aggregates.sourceFileSha256` (the CLI's `inventoryCrawl` ran
974
+ * `computeFileSha256` against the input txt before the orchestrator
975
+ * was even invoked). The orchestrator boundary deliberately never sees
976
+ * the absolute path — see {@link InventoryRunAggregates} for the
977
+ * privacy rationale.
978
+ *
979
+ * **Audit-write failures abort the ingestion phase.** The earlier
980
+ * implementation swallowed them because the audit was the last write
981
+ * after* the scrape, so re-throwing would have wiped a completed crawl;
982
+ * with audit now lifted into the `.bak`-protected ingestion phase the
983
+ * trade-off flips. A failed audit row is restorable: the outer catch
984
+ * copies `.bak` back over the archive and the operator reruns the
985
+ * (short) ingestion from scratch. That keeps `inventory_runs` honest
986
+ * (no "ran but unrecorded" rows) at the cost of one rerun.
987
+ *
988
+ * Forward-compat: when Phase 2 introduces an explicit `--label` flag,
989
+ * thread `labelOverride` through {@link inventory} into the `aggregates`
990
+ * shape so the auto-name can be overridden.
991
+ * @param archive - The opened archive to write the audit row into.
992
+ * @param aggregates - The counts captured during the inventory pass; see {@link InventoryRunAggregates}.
993
+ */
994
+ static async #writeInventoryRunRow(archive, aggregates) {
995
+ const ranAt = new Date().toISOString();
996
+ await archive.recordInventoryRun({
997
+ ran_at: ranAt,
998
+ list_label: `inventory-${ranAt}`,
999
+ source_file_sha256: aggregates.sourceFileSha256,
1000
+ total_lines: aggregates.inventoryUrlsCount,
1001
+ new_pages: aggregates.htmlSeedsCount,
1002
+ new_resources: aggregates.nonHtmlCount,
1003
+ scope_skipped: aggregates.outOfScope,
1004
+ });
1005
+ }
1006
+ /**
1007
+ * Tears down session-scoped crawler caches and prints a short-circuit
1008
+ * summary if any URL fetches were skipped. Invoked at the four
1009
+ * crawl-session boundaries (`crawling` / `append` / `inventory` /
1010
+ * `retryFailed` / `resume`) where the previous `clearDestinationCache`
1011
+ * call already lived.
1012
+ */
1013
+ static #finalizeCrawlSession() {
1014
+ const skipped = dnsBurnedHostShortCircuitCounter.count;
1015
+ if (skipped > 0) {
1016
+ // eslint-disable-next-line no-console
1017
+ console.error(`[preload] Short-circuited ${skipped} URL(s) on DNS-burned hosts`);
1018
+ }
1019
+ clearDestinationCache();
1020
+ clearDnsBurnedHostCache();
1021
+ }
422
1022
  }
423
1023
  /**
424
1024
  * Await a filesystem promise but silently swallow only `ENOENT` errors. Any