@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,14 +1,20 @@
1
- import type { Config, InventoryRunMeta, PageSource } from './types.js';
1
+ import type { TemplateClusterReason } from './db-ops/analysis/types.js';
2
+ import type { Config, InsertNetworkOutageParams, InventoryRunMeta, PageSource } from './types.js';
3
+ import type { OutageWindow } from '../is-within-outage-window.js';
2
4
  import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
5
+ import type { ConsoleLogEntry } from '@d-zero/beholder';
3
6
  import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
4
7
  import { ArchiveAccessor } from './archive-accessor.js';
5
8
  /**
6
9
  * Main archive class for creating, opening, resuming, and writing Nitpicker
7
10
  * archive files (`.nitpicker`).
8
11
  *
9
- * An Archive wraps a single SQLite database into a tar archive. HTML
10
- * bodies live inside the same DB as zstd-compressed BLOBs (see #75) the
11
- * tar payload is effectively just `db.sqlite`. It extends
12
+ * An Archive wraps a SQLite database into a tar archive. HTML bodies live
13
+ * inside the same DB as zstd-compressed BLOBs (see #75), so `db.sqlite` is
14
+ * normally the tar's only entry — but it is not the only entry the format
15
+ * allows: {@link ArchiveAccessor.setData} (namespace-scoped analyze output)
16
+ * and {@link Archive.saveInventorySourceList} (a saved `--inventory`
17
+ * source list) add plain files alongside it. It extends
12
18
  * {@link ArchiveAccessor} to provide read access to stored data.
13
19
  *
14
20
  * Use the static factory methods ({@link Archive.create}, {@link Archive.open},
@@ -66,6 +72,14 @@ export default class Archive extends ArchiveAccessor {
66
72
  * @param isExternal - Whether the URL is external. Defaults to `false`.
67
73
  */
68
74
  addPageError(url: string, phase: string, message: string, isExternal?: boolean): Promise<void>;
75
+ /**
76
+ * Closes an outage row by stamping `ended_at` — a no-op if already closed.
77
+ *
78
+ * Thin facade over {@link Database.closeNetworkOutage}.
79
+ * @param id - The `network_outages.id` to close.
80
+ * @param endedAt - Epoch ms the outage is considered to have ended.
81
+ */
82
+ closeNetworkOutage(id: number, endedAt: number): Promise<void>;
69
83
  /**
70
84
  * Retrieves the current crawling state, including lists of scraped and pending URLs.
71
85
  * @returns An object with `scraped` and `pending` URL arrays.
@@ -151,6 +165,15 @@ export default class Archive extends ArchiveAccessor {
151
165
  * @param urls - HTML seed URLs to pre-insert. No-op when empty.
152
166
  */
153
167
  insertInventorySeeds(urls: readonly ExURL[]): Promise<void>;
168
+ /**
169
+ * Appends one open row to the `network_outages` journal.
170
+ *
171
+ * Thin facade over {@link Database.insertNetworkOutage} — see
172
+ * {@link recordInventoryRun}'s docstring for why this indirection exists.
173
+ * @param params - The confirmed-outage fields to record.
174
+ * @returns The autoincremented `id` of the inserted row.
175
+ */
176
+ insertNetworkOutage(params: InsertNetworkOutageParams): Promise<number>;
154
177
  /**
155
178
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
156
179
  * for which no recent 2xx/3xx page or resource is recorded. Consumed by
@@ -165,6 +188,13 @@ export default class Archive extends ArchiveAccessor {
165
188
  * @returns Lower-cased hostnames safe to short-circuit.
166
189
  */
167
190
  listDnsBurnedHostCandidates(): Promise<string[]>;
191
+ /**
192
+ * Lists every recorded outage as a resolved {@link OutageWindow}.
193
+ *
194
+ * Thin facade over {@link Database.listNetworkOutages}.
195
+ * @returns Resolved outage windows, or `[]` if none have been recorded.
196
+ */
197
+ listNetworkOutages(): Promise<OutageWindow[]>;
168
198
  /**
169
199
  * Appends one row to the `inventory_runs` audit log.
170
200
  *
@@ -215,8 +245,12 @@ export default class Archive extends ArchiveAccessor {
215
245
  * reaching into the low-level database class directly.
216
246
  * @param templateKeysByUrl - Page URL → template key, as produced by
217
247
  * `@nitpicker/core`'s `classifyPageTemplates`.
248
+ * @param clusterReasonsByTemplateKey - Template key → cluster-selection
249
+ * evidence, if the caller captured it. Omitting this always clears the
250
+ * previously-stored reasons too — "no reason" means "not captured for
251
+ * this run", never "carry over the previous run's reasons".
218
252
  */
219
- replacePageTemplates(templateKeysByUrl: ReadonlyMap<string, string>): Promise<void>;
253
+ replacePageTemplates(templateKeysByUrl: ReadonlyMap<string, string>, clusterReasonsByTemplateKey?: ReadonlyMap<string, TemplateClusterReason>): Promise<void>;
220
254
  /**
221
255
  * Promote previously-external pages that now fall under the (possibly extended)
222
256
  * scope back to a pending state so that the crawler re-scrapes them as fully
@@ -234,11 +268,46 @@ export default class Archive extends ArchiveAccessor {
234
268
  * @returns The URLs of the pages that were reset to pending.
235
269
  */
236
270
  resetFailedPages(): Promise<string[]>;
271
+ /**
272
+ * Persists the raw bytes of an `--inventory` source URL list into the
273
+ * archive's tar payload, at `inventory/<sha256>.txt`.
274
+ *
275
+ * The file name is the content hash rather than the original file name:
276
+ * re-applying the same list is then a no-op write (`fs.writeFile`
277
+ * overwrites identical bytes), and the original name — which may embed a
278
+ * client/project identifier — is never retained (the archive already
279
+ * omits the source file's absolute path for the same reason; see
280
+ * `CrawlerOrchestrator.inventory`'s `source` param).
281
+ *
282
+ * This bypasses the namespace-scoped {@link ArchiveAccessor.setData} API
283
+ * (that one is reserved for analyze plugins and requires a namespace) —
284
+ * this always lands under the fixed `inventory/` prefix regardless of
285
+ * how this accessor was constructed. Callers that need to read the
286
+ * saved list back can use the inherited `getData(`inventory/${sha256}`,
287
+ * 'txt')`, since it resolves to the same path when no namespace is set.
288
+ *
289
+ * No entry is ever removed here — same accepted gap as `page_html_blobs`
290
+ * (a future #23 GC pass will sweep unreachable hashes across both). A
291
+ * source list that differs byte-for-byte on every run (e.g. a
292
+ * regenerated doc-root export with fresh timestamps) adds one entry per
293
+ * run with no pruning of superseded ones.
294
+ * @param sha256 - Lower-case hex SHA-256 digest of `bytes` (used as the file name).
295
+ * @param bytes - The exact bytes of the source list file, written verbatim.
296
+ */
297
+ saveInventorySourceList(sha256: string, bytes: Buffer): Promise<void>;
237
298
  /**
238
299
  * Stores the crawl configuration into the archive database.
239
300
  * @param config - The configuration object to store.
240
301
  */
241
302
  setConfig(config: Config): Promise<number[]>;
303
+ /**
304
+ * Replaces one page's captured console messages / page errors in the
305
+ * archive database.
306
+ * @param pageUrl - The originally-requested URL, normalised (`withoutHashAndAuth` form).
307
+ * @param redirectPaths - The redirect chain hops captured during fetch, in order.
308
+ * @param entries - The console log entries to persist.
309
+ */
310
+ setConsoleLogs(pageUrl: string, redirectPaths: readonly string[], entries: readonly ConsoleLogEntry[]): Promise<void>;
242
311
  /**
243
312
  * Stores an external page's data in the archive database without storing
244
313
  * an HTML snapshot. External-page rows carry only metadata (status, title,
@@ -311,10 +380,17 @@ export default class Archive extends ArchiveAccessor {
311
380
  *
312
381
  * Checkpoints the SQLite WAL so the database is self-contained inside
313
382
  * `db.sqlite`, renames the temporary working directory to the archive's
314
- * basename, and tars it into the final `.nitpicker`. The tar container
315
- * holds a single `db.sqlite` file (the legacy `snapshot-html.zip` is gone
316
- * HTML lives as BLOBs in the DB), so finalisation is effectively a
317
- * single-file copy with no per-snapshot syscalls.
383
+ * basename, and tars the **entire tmpDir**. `db.sqlite` is normally the
384
+ * only entry (HTML lives as BLOBs in the DB, not a `snapshot-html.zip`),
385
+ * but a namespace-scoped `setData` write (analyze output) or
386
+ * `saveInventorySourceList` (a saved `--inventory` source list) adds
387
+ * extra files under tmpDir that get tarred right alongside it.
388
+ *
389
+ * This is why every writer path that reaches `write()` must open with
390
+ * `openPluginData: true` — `Archive.open`'s default extracts only
391
+ * `db.sqlite`, so a re-crawl (`append` / `inventory` / `retryFailed`)
392
+ * opened without it would tar back a tmpDir missing those extra files,
393
+ * silently dropping them from the rewritten archive.
318
394
  */
319
395
  write(): Promise<void>;
320
396
  /** The file extension for Nitpicker archive files (without the leading dot). */
@@ -482,7 +558,19 @@ type ArchiveOptions = {
482
558
  * Additional options for opening an existing archive.
483
559
  */
484
560
  type ArchiveOpenOptions = {
485
- /** When true, extracts all files including plugin data. When false, only extracts the database and snapshots. */
561
+ /**
562
+ * When `false` (the default), only `db.sqlite` is extracted into tmpDir.
563
+ * When `true`, every tar entry is extracted, including non-namespace
564
+ * files written via {@link ArchiveAccessor.setData} (analyze output) or
565
+ * {@link Archive.saveInventorySourceList} (a saved `--inventory` source
566
+ * list).
567
+ *
568
+ * Every writer path that later calls {@link Archive.write} MUST pass
569
+ * `true`: `write()` re-tars whatever is currently in tmpDir, so a
570
+ * re-crawl (`append` / `inventory` / `retryFailed`) opened with the
571
+ * default would tar back a tmpDir missing those extra files, silently
572
+ * dropping them from the rewritten archive.
573
+ */
486
574
  openPluginData?: boolean;
487
575
  };
488
576
  export {};
@@ -10,18 +10,23 @@ import { dbLog, log, saveLog } from './debug.js';
10
10
  import { appendText } from './filesystem/append-text.js';
11
11
  import { exists } from './filesystem/exists.js';
12
12
  import { isDir } from './filesystem/is-dir.js';
13
+ import { outputBinary } from './filesystem/output-binary.js';
13
14
  import { peekTarTopDir } from './filesystem/peek-tar-top-dir.js';
14
15
  import { remove } from './filesystem/remove.js';
15
16
  import { rename } from './filesystem/rename.js';
16
17
  import { tar } from './filesystem/tar.js';
17
18
  import { untar } from './filesystem/untar.js';
19
+ import { safePath } from './safe-path.js';
18
20
  /**
19
21
  * Main archive class for creating, opening, resuming, and writing Nitpicker
20
22
  * archive files (`.nitpicker`).
21
23
  *
22
- * An Archive wraps a single SQLite database into a tar archive. HTML
23
- * bodies live inside the same DB as zstd-compressed BLOBs (see #75) the
24
- * tar payload is effectively just `db.sqlite`. It extends
24
+ * An Archive wraps a SQLite database into a tar archive. HTML bodies live
25
+ * inside the same DB as zstd-compressed BLOBs (see #75), so `db.sqlite` is
26
+ * normally the tar's only entry — but it is not the only entry the format
27
+ * allows: {@link ArchiveAccessor.setData} (namespace-scoped analyze output)
28
+ * and {@link Archive.saveInventorySourceList} (a saved `--inventory`
29
+ * source list) add plain files alongside it. It extends
25
30
  * {@link ArchiveAccessor} to provide read access to stored data.
26
31
  *
27
32
  * Use the static factory methods ({@link Archive.create}, {@link Archive.open},
@@ -119,6 +124,17 @@ export default class Archive extends ArchiveAccessor {
119
124
  dbLog('Add page error: %s [%s]', url, phase);
120
125
  await this.#db.insertPageError(url, phase, message, isExternal);
121
126
  }
127
+ /**
128
+ * Closes an outage row by stamping `ended_at` — a no-op if already closed.
129
+ *
130
+ * Thin facade over {@link Database.closeNetworkOutage}.
131
+ * @param id - The `network_outages.id` to close.
132
+ * @param endedAt - Epoch ms the outage is considered to have ended.
133
+ */
134
+ async closeNetworkOutage(id, endedAt) {
135
+ dbLog('Close network outage id=%d endedAt=%d', id, endedAt);
136
+ return await this.#db.closeNetworkOutage(id, endedAt);
137
+ }
122
138
  /**
123
139
  * Retrieves the current crawling state, including lists of scraped and pending URLs.
124
140
  * @returns An object with `scraped` and `pending` URL arrays.
@@ -227,6 +243,18 @@ export default class Archive extends ArchiveAccessor {
227
243
  dbLog('Insert inventory seeds: %d URL(s)', urls.length);
228
244
  await this.#db.insertInventorySeeds(urls.map((u) => u.withoutHashAndAuth));
229
245
  }
246
+ /**
247
+ * Appends one open row to the `network_outages` journal.
248
+ *
249
+ * Thin facade over {@link Database.insertNetworkOutage} — see
250
+ * {@link recordInventoryRun}'s docstring for why this indirection exists.
251
+ * @param params - The confirmed-outage fields to record.
252
+ * @returns The autoincremented `id` of the inserted row.
253
+ */
254
+ async insertNetworkOutage(params) {
255
+ dbLog('Insert network outage: startedAt=%d probeHost=%s', params.startedAt, params.probeHost);
256
+ return await this.#db.insertNetworkOutage(params);
257
+ }
230
258
  /**
231
259
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
232
260
  * for which no recent 2xx/3xx page or resource is recorded. Consumed by
@@ -243,6 +271,15 @@ export default class Archive extends ArchiveAccessor {
243
271
  async listDnsBurnedHostCandidates() {
244
272
  return this.#db.listDnsBurnedHostCandidates();
245
273
  }
274
+ /**
275
+ * Lists every recorded outage as a resolved {@link OutageWindow}.
276
+ *
277
+ * Thin facade over {@link Database.listNetworkOutages}.
278
+ * @returns Resolved outage windows, or `[]` if none have been recorded.
279
+ */
280
+ async listNetworkOutages() {
281
+ return await this.#db.listNetworkOutages();
282
+ }
246
283
  /**
247
284
  * Appends one row to the `inventory_runs` audit log.
248
285
  *
@@ -295,9 +332,13 @@ export default class Archive extends ArchiveAccessor {
295
332
  * reaching into the low-level database class directly.
296
333
  * @param templateKeysByUrl - Page URL → template key, as produced by
297
334
  * `@nitpicker/core`'s `classifyPageTemplates`.
335
+ * @param clusterReasonsByTemplateKey - Template key → cluster-selection
336
+ * evidence, if the caller captured it. Omitting this always clears the
337
+ * previously-stored reasons too — "no reason" means "not captured for
338
+ * this run", never "carry over the previous run's reasons".
298
339
  */
299
- async replacePageTemplates(templateKeysByUrl) {
300
- await this.#db.replacePageTemplates(templateKeysByUrl);
340
+ async replacePageTemplates(templateKeysByUrl, clusterReasonsByTemplateKey) {
341
+ await this.#db.replacePageTemplates(templateKeysByUrl, clusterReasonsByTemplateKey);
301
342
  }
302
343
  /**
303
344
  * Promote previously-external pages that now fall under the (possibly extended)
@@ -322,6 +363,36 @@ export default class Archive extends ArchiveAccessor {
322
363
  dbLog('Reset failed pages back to pending');
323
364
  return this.#db.resetFailedPages();
324
365
  }
366
+ /**
367
+ * Persists the raw bytes of an `--inventory` source URL list into the
368
+ * archive's tar payload, at `inventory/<sha256>.txt`.
369
+ *
370
+ * The file name is the content hash rather than the original file name:
371
+ * re-applying the same list is then a no-op write (`fs.writeFile`
372
+ * overwrites identical bytes), and the original name — which may embed a
373
+ * client/project identifier — is never retained (the archive already
374
+ * omits the source file's absolute path for the same reason; see
375
+ * `CrawlerOrchestrator.inventory`'s `source` param).
376
+ *
377
+ * This bypasses the namespace-scoped {@link ArchiveAccessor.setData} API
378
+ * (that one is reserved for analyze plugins and requires a namespace) —
379
+ * this always lands under the fixed `inventory/` prefix regardless of
380
+ * how this accessor was constructed. Callers that need to read the
381
+ * saved list back can use the inherited `getData(`inventory/${sha256}`,
382
+ * 'txt')`, since it resolves to the same path when no namespace is set.
383
+ *
384
+ * No entry is ever removed here — same accepted gap as `page_html_blobs`
385
+ * (a future #23 GC pass will sweep unreachable hashes across both). A
386
+ * source list that differs byte-for-byte on every run (e.g. a
387
+ * regenerated doc-root export with fresh timestamps) adds one entry per
388
+ * run with no pruning of superseded ones.
389
+ * @param sha256 - Lower-case hex SHA-256 digest of `bytes` (used as the file name).
390
+ * @param bytes - The exact bytes of the source list file, written verbatim.
391
+ */
392
+ async saveInventorySourceList(sha256, bytes) {
393
+ const filePath = safePath(this.tmpDir, 'inventory', `${sha256}.txt`);
394
+ await outputBinary(filePath, bytes);
395
+ }
325
396
  /**
326
397
  * Stores the crawl configuration into the archive database.
327
398
  * @param config - The configuration object to store.
@@ -330,6 +401,17 @@ export default class Archive extends ArchiveAccessor {
330
401
  dbLog('Set config: %O', config);
331
402
  return this.#db.setConfig(config);
332
403
  }
404
+ /**
405
+ * Replaces one page's captured console messages / page errors in the
406
+ * archive database.
407
+ * @param pageUrl - The originally-requested URL, normalised (`withoutHashAndAuth` form).
408
+ * @param redirectPaths - The redirect chain hops captured during fetch, in order.
409
+ * @param entries - The console log entries to persist.
410
+ */
411
+ async setConsoleLogs(pageUrl, redirectPaths, entries) {
412
+ dbLog('Set console logs: %d entries on %s', entries.length, pageUrl);
413
+ await this.#db.replaceConsoleLogs(pageUrl, redirectPaths, entries);
414
+ }
333
415
  /**
334
416
  * Stores an external page's data in the archive database without storing
335
417
  * an HTML snapshot. External-page rows carry only metadata (status, title,
@@ -423,10 +505,17 @@ export default class Archive extends ArchiveAccessor {
423
505
  *
424
506
  * Checkpoints the SQLite WAL so the database is self-contained inside
425
507
  * `db.sqlite`, renames the temporary working directory to the archive's
426
- * basename, and tars it into the final `.nitpicker`. The tar container
427
- * holds a single `db.sqlite` file (the legacy `snapshot-html.zip` is gone
428
- * HTML lives as BLOBs in the DB), so finalisation is effectively a
429
- * single-file copy with no per-snapshot syscalls.
508
+ * basename, and tars the **entire tmpDir**. `db.sqlite` is normally the
509
+ * only entry (HTML lives as BLOBs in the DB, not a `snapshot-html.zip`),
510
+ * but a namespace-scoped `setData` write (analyze output) or
511
+ * `saveInventorySourceList` (a saved `--inventory` source list) adds
512
+ * extra files under tmpDir that get tarred right alongside it.
513
+ *
514
+ * This is why every writer path that reaches `write()` must open with
515
+ * `openPluginData: true` — `Archive.open`'s default extracts only
516
+ * `db.sqlite`, so a re-crawl (`append` / `inventory` / `retryFailed`)
517
+ * opened without it would tar back a tmpDir missing those extra files,
518
+ * silently dropping them from the rewritten archive.
430
519
  */
431
520
  async write() {
432
521
  saveLog('Starts: %s', this.#filePath);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Computes a content hash of a page's `<body>`, after normalizing away the
3
+ * kinds of incidental variance that would otherwise make two structurally
4
+ * identical pages hash differently: `/index.{ext}` URL-suffix forms and
5
+ * embedded dynamic tokens (cache-busting hashes, session/order ids, per-build
6
+ * CSS-module suffixes).
7
+ *
8
+ * Only the resulting hash is persisted (`page_meta.body_hash`) — the masked
9
+ * intermediate string is never stored. The unmasked original HTML remains
10
+ * fully recoverable from `page_html_blobs`, so nothing is lost by discarding
11
+ * it here.
12
+ * @param html - A full HTML document string (or fragment).
13
+ * @returns 32-byte SHA-256 hash of the masked `<body>` content, ready to
14
+ * insert into a `BLOB` column.
15
+ * @example
16
+ * ```ts
17
+ * const hashA = computeBodyHash('<body><a href="/p/a1b2c3d4">x</a></body>');
18
+ * const hashB = computeBodyHash('<body><a href="/p/z9y8x7w6">x</a></body>');
19
+ * hashA.equals(hashB); // true — the differing token is masked before hashing
20
+ * ```
21
+ */
22
+ export declare function computeBodyHash(html: string): Buffer;
@@ -0,0 +1,31 @@
1
+ import { computeContentHash } from '../populate-ref-tables/compute-content-hash.js';
2
+ import { extractBody } from './extract-body.js';
3
+ import { maskDynamicIds } from './mask-dynamic-ids.js';
4
+ import { normalizeUrlLikeStrings } from './normalize-url-like-strings.js';
5
+ /**
6
+ * Computes a content hash of a page's `<body>`, after normalizing away the
7
+ * kinds of incidental variance that would otherwise make two structurally
8
+ * identical pages hash differently: `/index.{ext}` URL-suffix forms and
9
+ * embedded dynamic tokens (cache-busting hashes, session/order ids, per-build
10
+ * CSS-module suffixes).
11
+ *
12
+ * Only the resulting hash is persisted (`page_meta.body_hash`) — the masked
13
+ * intermediate string is never stored. The unmasked original HTML remains
14
+ * fully recoverable from `page_html_blobs`, so nothing is lost by discarding
15
+ * it here.
16
+ * @param html - A full HTML document string (or fragment).
17
+ * @returns 32-byte SHA-256 hash of the masked `<body>` content, ready to
18
+ * insert into a `BLOB` column.
19
+ * @example
20
+ * ```ts
21
+ * const hashA = computeBodyHash('<body><a href="/p/a1b2c3d4">x</a></body>');
22
+ * const hashB = computeBodyHash('<body><a href="/p/z9y8x7w6">x</a></body>');
23
+ * hashA.equals(hashB); // true — the differing token is masked before hashing
24
+ * ```
25
+ */
26
+ export function computeBodyHash(html) {
27
+ const body = extractBody(html);
28
+ const normalized = normalizeUrlLikeStrings(body);
29
+ const masked = maskDynamicIds(normalized);
30
+ return computeContentHash(masked);
31
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Extracts the inner HTML of the first `<body>` element from a full HTML
3
+ * document string.
4
+ *
5
+ * Uses a greedy match (`[\s\S]*`, not `[\s\S]*?`) so a literal `<body>`
6
+ * substring appearing inside the real body (e.g. an inline code sample) does
7
+ * not truncate the extracted content at that inner occurrence — the match
8
+ * always extends to the last `</body>` in the document.
9
+ *
10
+ * Falls back to returning the full input unchanged when no `<body>` tag is
11
+ * found (fragment HTML, a page that failed to render, or a snapshot cut off
12
+ * mid-render) rather than throwing, so callers never need a separate
13
+ * not-found branch.
14
+ * @param html - A full HTML document string, or a fragment.
15
+ * @returns The content between `<body...>` and `</body>`, or `html` unchanged
16
+ * if no `<body>` tag is present.
17
+ */
18
+ export declare function extractBody(html: string): string;
@@ -0,0 +1,29 @@
1
+ // The opening tag's attribute span is matched attribute-aware
2
+ // (`(?:"[^"]*"|'[^']*'|[^"'>])*`, not a plain `[^>]*`) so a literal `>`
3
+ // inside a quoted attribute value (e.g. `<body data-x="a>b">`) — legal HTML,
4
+ // since only the delimiting quote character itself must not appear
5
+ // unescaped inside the value — does not end the match early. A plain
6
+ // `[^>]*` would stop at that inner `>`, so the captured body would start
7
+ // mid-attribute instead of at the real content.
8
+ const BODY_PATTERN = /<body(?:"[^"]*"|'[^']*'|[^"'>])*>([\s\S]*)<\/body>/i;
9
+ /**
10
+ * Extracts the inner HTML of the first `<body>` element from a full HTML
11
+ * document string.
12
+ *
13
+ * Uses a greedy match (`[\s\S]*`, not `[\s\S]*?`) so a literal `<body>`
14
+ * substring appearing inside the real body (e.g. an inline code sample) does
15
+ * not truncate the extracted content at that inner occurrence — the match
16
+ * always extends to the last `</body>` in the document.
17
+ *
18
+ * Falls back to returning the full input unchanged when no `<body>` tag is
19
+ * found (fragment HTML, a page that failed to render, or a snapshot cut off
20
+ * mid-render) rather than throwing, so callers never need a separate
21
+ * not-found branch.
22
+ * @param html - A full HTML document string, or a fragment.
23
+ * @returns The content between `<body...>` and `</body>`, or `html` unchanged
24
+ * if no `<body>` tag is present.
25
+ */
26
+ export function extractBody(html) {
27
+ const match = BODY_PATTERN.exec(html);
28
+ return match?.[1] ?? html;
29
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Replaces mixed-alphanumeric tokens of 8+ characters with a fixed
3
+ * placeholder, so that two bodies differing only in an embedded dynamic
4
+ * value (a cache-busting hash, a per-build CSS-module suffix, a session or
5
+ * order id) hash the same.
6
+ *
7
+ * A token is masked only when it mixes letters and digits — a pure-digit
8
+ * match (phone numbers, product codes, dates) or a pure-alphabetic match (an
9
+ * ordinary English word) is left untouched, since those are far more likely
10
+ * to be stable content than a dynamic identifier.
11
+ *
12
+ * This is a heuristic, not a semantic classifier, and it is accepted as such:
13
+ * a mixed-alphanumeric SKU/order/product code that is itself the only
14
+ * distinguishing content between two otherwise-identical pages (e.g.
15
+ * `ABC12345` vs `XYZ98765`) also gets masked, collapsing two genuinely
16
+ * different pages into one `computeBodyHash` duplicate-detection group — a
17
+ * false positive. Narrowing the pattern to reduce that risk would also
18
+ * narrow what it catches (the whole point of this mask is to absorb
19
+ * cache-busting hashes and CSS-module suffixes whose shape is
20
+ * indistinguishable from such a code), so this trade-off is deliberate
21
+ * rather than something a stricter regex would cleanly resolve.
22
+ *
23
+ * The placeholder is a fixed constant rather than a per-match incrementing
24
+ * id: nothing needs to reverse the substitution back to the original value —
25
+ * the full original HTML is already preserved verbatim in `page_html_blobs`,
26
+ * so recovering it here would be redundant. All that matters for duplicate
27
+ * detection is that every masked token collapses to the same value.
28
+ * @param text - Text to mask (already URL-normalized by
29
+ * `normalizeUrlLikeStrings`).
30
+ * @returns `text` with every mixed-alphanumeric 8+ character token replaced
31
+ * by a fixed placeholder.
32
+ */
33
+ export declare function maskDynamicIds(text: string): string;
@@ -0,0 +1,44 @@
1
+ const DYNAMIC_ID_PATTERN = /[a-z0-9]{8,}/gi;
2
+ const DIGITS_ONLY_PATTERN = /^\d+$/;
3
+ const ALPHA_ONLY_PATTERN = /^[a-z]+$/i;
4
+ const MASK_PLACEHOLDER = '__MASKED_ID__';
5
+ /**
6
+ * Replaces mixed-alphanumeric tokens of 8+ characters with a fixed
7
+ * placeholder, so that two bodies differing only in an embedded dynamic
8
+ * value (a cache-busting hash, a per-build CSS-module suffix, a session or
9
+ * order id) hash the same.
10
+ *
11
+ * A token is masked only when it mixes letters and digits — a pure-digit
12
+ * match (phone numbers, product codes, dates) or a pure-alphabetic match (an
13
+ * ordinary English word) is left untouched, since those are far more likely
14
+ * to be stable content than a dynamic identifier.
15
+ *
16
+ * This is a heuristic, not a semantic classifier, and it is accepted as such:
17
+ * a mixed-alphanumeric SKU/order/product code that is itself the only
18
+ * distinguishing content between two otherwise-identical pages (e.g.
19
+ * `ABC12345` vs `XYZ98765`) also gets masked, collapsing two genuinely
20
+ * different pages into one `computeBodyHash` duplicate-detection group — a
21
+ * false positive. Narrowing the pattern to reduce that risk would also
22
+ * narrow what it catches (the whole point of this mask is to absorb
23
+ * cache-busting hashes and CSS-module suffixes whose shape is
24
+ * indistinguishable from such a code), so this trade-off is deliberate
25
+ * rather than something a stricter regex would cleanly resolve.
26
+ *
27
+ * The placeholder is a fixed constant rather than a per-match incrementing
28
+ * id: nothing needs to reverse the substitution back to the original value —
29
+ * the full original HTML is already preserved verbatim in `page_html_blobs`,
30
+ * so recovering it here would be redundant. All that matters for duplicate
31
+ * detection is that every masked token collapses to the same value.
32
+ * @param text - Text to mask (already URL-normalized by
33
+ * `normalizeUrlLikeStrings`).
34
+ * @returns `text` with every mixed-alphanumeric 8+ character token replaced
35
+ * by a fixed placeholder.
36
+ */
37
+ export function maskDynamicIds(text) {
38
+ return text.replaceAll(DYNAMIC_ID_PATTERN, (match) => {
39
+ if (DIGITS_ONLY_PATTERN.test(match) || ALPHA_ONLY_PATTERN.test(match)) {
40
+ return match;
41
+ }
42
+ return MASK_PLACEHOLDER;
43
+ });
44
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Normalizes `/index.{ext}` path suffixes (`/index.html`, `/index.php`, ...)
3
+ * to a bare trailing `/` throughout the given text, so that two otherwise
4
+ * identical bodies that differ only in which equivalent URL form a template
5
+ * happened to render (`/about/` vs `/about/index.html`) hash the same.
6
+ *
7
+ * Applied as a blanket string sweep over the whole body — not scoped to
8
+ * `href`/`src` attribute values — because the same trailing-suffix variance
9
+ * can appear anywhere a URL-shaped string is rendered as text (breadcrumbs,
10
+ * "print this page" widgets, JSON embedded in an inline `<script>`), and the
11
+ * body is treated as an opaque string for masking purposes (see
12
+ * `computeBodyHash`).
13
+ * @param body - The `<body>` inner HTML (or any text) to normalize.
14
+ * @returns `body` with every `/index.{ext}` suffix collapsed to `/`.
15
+ */
16
+ export declare function normalizeUrlLikeStrings(body: string): string;
@@ -0,0 +1,19 @@
1
+ const INDEX_SUFFIX_PATTERN = /\/index\.\w+/gi;
2
+ /**
3
+ * Normalizes `/index.{ext}` path suffixes (`/index.html`, `/index.php`, ...)
4
+ * to a bare trailing `/` throughout the given text, so that two otherwise
5
+ * identical bodies that differ only in which equivalent URL form a template
6
+ * happened to render (`/about/` vs `/about/index.html`) hash the same.
7
+ *
8
+ * Applied as a blanket string sweep over the whole body — not scoped to
9
+ * `href`/`src` attribute values — because the same trailing-suffix variance
10
+ * can appear anywhere a URL-shaped string is rendered as text (breadcrumbs,
11
+ * "print this page" widgets, JSON embedded in an inline `<script>`), and the
12
+ * body is treated as an opaque string for masking purposes (see
13
+ * `computeBodyHash`).
14
+ * @param body - The `<body>` inner HTML (or any text) to normalize.
15
+ * @returns `body` with every `/index.{ext}` suffix collapsed to `/`.
16
+ */
17
+ export function normalizeUrlLikeStrings(body) {
18
+ return body.replaceAll(INDEX_SUFFIX_PATTERN, '/');
19
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Remove a single archive cache entry (one directory previously resolved by
3
+ * {@link import('./resolve-archive-cache-dir.js').resolveArchiveCacheDir}).
4
+ *
5
+ * Deliberately scoped to exactly `cacheDir` — it never touches sibling
6
+ * entries under the same cache root (in particular, the `@nitpicker/core`
7
+ * analyze `table` cache is never archive-scoped and must survive a
8
+ * per-archive clear).
9
+ * @param cacheDir - Absolute path to the single cache entry to remove.
10
+ * @returns `true` if `cacheDir` existed and was removed, `false` if it was
11
+ * already absent.
12
+ * @example
13
+ * ```ts
14
+ * const cacheKey = await computeArchiveCacheKey(archivePath);
15
+ * const cacheDir = resolveArchiveCacheDir(cacheRoot, cacheKey, archivePath);
16
+ * const removed = await clearArchiveCacheEntry(cacheDir);
17
+ * ```
18
+ */
19
+ export declare function clearArchiveCacheEntry(cacheDir: string): Promise<boolean>;
@@ -0,0 +1,25 @@
1
+ import fs from 'node:fs/promises';
2
+ import { pathExists } from './path-exists.js';
3
+ /**
4
+ * Remove a single archive cache entry (one directory previously resolved by
5
+ * {@link import('./resolve-archive-cache-dir.js').resolveArchiveCacheDir}).
6
+ *
7
+ * Deliberately scoped to exactly `cacheDir` — it never touches sibling
8
+ * entries under the same cache root (in particular, the `@nitpicker/core`
9
+ * analyze `table` cache is never archive-scoped and must survive a
10
+ * per-archive clear).
11
+ * @param cacheDir - Absolute path to the single cache entry to remove.
12
+ * @returns `true` if `cacheDir` existed and was removed, `false` if it was
13
+ * already absent.
14
+ * @example
15
+ * ```ts
16
+ * const cacheKey = await computeArchiveCacheKey(archivePath);
17
+ * const cacheDir = resolveArchiveCacheDir(cacheRoot, cacheKey, archivePath);
18
+ * const removed = await clearArchiveCacheEntry(cacheDir);
19
+ * ```
20
+ */
21
+ export async function clearArchiveCacheEntry(cacheDir) {
22
+ const existedBefore = await pathExists(cacheDir);
23
+ await fs.rm(cacheDir, { recursive: true, force: true });
24
+ return existedBefore;
25
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Remove an entire archive cache root, including every tar-cache entry and
3
+ * any sibling directory living under it (e.g. the `@nitpicker/core` analyze
4
+ * `table` cache, which is resolved as a child of the same root).
5
+ *
6
+ * Pure: takes `cacheRoot` as a parameter and never resolves it itself, so
7
+ * tests can point it at a throwaway directory instead of the real OS temp
8
+ * cache. Mirrors the "manual `rm -rf` is safe" contract already documented
9
+ * for the tar cache (ARCHITECTURE.md) — no confirmation, no lock check.
10
+ *
11
+ * Why not lock-aware: a concurrent `extractArchiveToCache` elsewhere may be
12
+ * holding `<cacheDir>.lock` / writing `<cacheDir>.staging` inside this root
13
+ * when it is removed, which can surface as an ENOENT in that extraction (or,
14
+ * rarely, a second extractor racing into a freshly recreated `.staging`
15
+ * path). This is the same exposure a manual `rm -rf` already has today —
16
+ * this function does not add new risk, it just makes that pre-existing,
17
+ * accepted risk reachable via a single explicit command.
18
+ * @param cacheRoot - Absolute path to the cache root to remove.
19
+ * @returns `true` if `cacheRoot` existed and was removed, `false` if it was
20
+ * already absent.
21
+ * @example
22
+ * ```ts
23
+ * const removed = await clearArchiveCacheRoot(getArchiveCacheRoot());
24
+ * ```
25
+ */
26
+ export declare function clearArchiveCacheRoot(cacheRoot: string): Promise<boolean>;