@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
@@ -1,37 +1,49 @@
1
1
  import path from 'node:path';
2
- import { zip } from '@d-zero/fs/zip';
3
2
  import { ArchiveAccessor } from './archive-accessor.js';
4
3
  import { acquireArchiveLock } from './archive-lock.js';
4
+ import { computeArchiveCacheKey } from './cache/compute-archive-cache-key.js';
5
+ import { extractArchiveToCache } from './cache/extract-archive-to-cache.js';
6
+ import { getArchiveCacheRoot } from './cache/get-archive-cache-root.js';
7
+ import { resolveArchiveCacheDir } from './cache/resolve-archive-cache-dir.js';
5
8
  import { Database } from './database.js';
6
9
  import { dbLog, log, saveLog } from './debug.js';
7
10
  import { appendText } from './filesystem/append-text.js';
8
11
  import { exists } from './filesystem/exists.js';
9
12
  import { isDir } from './filesystem/is-dir.js';
10
- import { outputText } from './filesystem/output-text.js';
13
+ import { peekTarTopDir } from './filesystem/peek-tar-top-dir.js';
11
14
  import { remove } from './filesystem/remove.js';
12
15
  import { rename } from './filesystem/rename.js';
13
16
  import { tar } from './filesystem/tar.js';
14
17
  import { untar } from './filesystem/untar.js';
15
18
  /**
16
- * Main archive class for creating, opening, resuming, and writing Nitpicker archive files (`.nitpicker`).
19
+ * Main archive class for creating, opening, resuming, and writing Nitpicker
20
+ * archive files (`.nitpicker`).
17
21
  *
18
- * An Archive wraps a SQLite database and optional HTML snapshots into a compressed
19
- * tar archive. It extends {@link ArchiveAccessor} to provide read access to stored data.
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
25
+ * {@link ArchiveAccessor} to provide read access to stored data.
20
26
  *
21
27
  * Use the static factory methods ({@link Archive.create}, {@link Archive.open},
22
28
  * {@link Archive.resume}, {@link Archive.connect}) to obtain instances.
23
29
  * The constructor is private.
24
30
  */
25
31
  export default class Archive extends ArchiveAccessor {
32
+ /**
33
+ * Promise tracking an in-progress {@link Archive.close} (or
34
+ * {@link Archive.releaseHandle}). Acts as the override's idempotency
35
+ * guard so a second call — e.g. from a signal handler racing the
36
+ * primary teardown — does not re-enter the destructive prologue
37
+ * (write/remove) on a half-mutated state.
38
+ */
39
+ #closeOnce = null;
26
40
  /** The SQLite database instance for reading and writing crawl data. */
27
41
  #db;
28
42
  /** Absolute path to the output `.nitpicker` archive file. */
29
43
  #filePath;
30
44
  /** Lock release function held while the writer owns the archive. */
31
45
  #releaseLock;
32
- /** Absolute path to the HTML snapshot directory within the temporary working directory. */
33
- #snapshotDir;
34
- /** Absolute path to the temporary working directory containing the SQLite DB and snapshots. */
46
+ /** Absolute path to the temporary working directory containing the SQLite DB. */
35
47
  #tmpDir;
36
48
  /**
37
49
  * The absolute file path of the archive (`.nitpicker` file).
@@ -39,18 +51,26 @@ export default class Archive extends ArchiveAccessor {
39
51
  get filePath() {
40
52
  return this.#filePath;
41
53
  }
54
+ /**
55
+ * The intermediate directory `Archive.write()` produces by renaming
56
+ * `tmpDir` before tarring (`{cwd}/{archiveName}`). Exposed so the
57
+ * manager can include it in its cleanup-on-failure path: if `tar()`
58
+ * fails after the rename, this directory is orphaned and would
59
+ * otherwise be invisible to a `rmSync(tmpDir)` recovery.
60
+ */
61
+ get renamedDir() {
62
+ return path.resolve(path.dirname(this.#filePath), path.basename(this.#filePath, path.extname(this.#filePath)));
63
+ }
42
64
  // eslint-disable-next-line no-restricted-syntax
43
65
  constructor(filePath, tmpDir, db, releaseLock) {
44
66
  super(tmpDir, db, '');
45
67
  this.#filePath = filePath;
46
68
  this.#tmpDir = tmpDir;
47
- this.#snapshotDir = path.resolve(this.#tmpDir, Archive.SNAPSHOT_HTML_DIR);
48
69
  this.#db = db;
49
70
  this.#releaseLock = releaseLock;
50
71
  log('create instance: %O', {
51
72
  filePath,
52
73
  tmpDir,
53
- snapshotDir: this.#snapshotDir,
54
74
  });
55
75
  this.#db.on('error', (e) => {
56
76
  void this.emit('error', e);
@@ -61,35 +81,34 @@ export default class Archive extends ArchiveAccessor {
61
81
  */
62
82
  abort() { }
63
83
  /**
64
- * Appends an error entry to the archive's error log file.
84
+ * Records a crawler-level error to both the human-readable `error.log` (full
85
+ * stack, for debugging) and the structured `crawl_errors` table (queryable,
86
+ * for the `error-kinds` analysis). The cause is not classified here — it is
87
+ * derived on read. `error.log` keeps the full stack while `crawl_errors`
88
+ * stores `error.message`; both normally carry the same cause token (e.g.
89
+ * `ENOTFOUND`), so classification agrees across the two — only an error whose
90
+ * cause lives solely in deeper stack frames could differ.
65
91
  * @param error - The crawler error object containing process and URL information.
66
92
  */
67
93
  async addError(error) {
68
94
  const logFile = path.resolve(this.#tmpDir, 'error.log');
69
95
  await appendText(logFile, `[${error.pid}(${error.isMainProcess ? 'main' : 'sub'})] ${error.url} ${error.error.stack ?? error.error}`);
96
+ await this.#db.insertCrawlError(error.url, error.error.message, error.isExternal);
70
97
  }
71
98
  /**
72
- * Closes the archive. If the archive file does not yet exist on disk,
73
- * it writes the archive first. If the temporary directory still exists,
74
- * it is removed.
99
+ * Records a partial scrape failure against the page identified by `url`.
100
+ *
101
+ * The corresponding `pages` row is created on demand (or matched if it
102
+ * already exists), so the call works even if the page's normal data has
103
+ * not been written yet.
104
+ * @param url - URL of the affected page.
105
+ * @param phase - Scrape phase name (typically `'retryExhausted'`).
106
+ * @param message - Human-readable failure message.
107
+ * @param isExternal - Whether the URL is external. Defaults to `false`.
75
108
  */
76
- async close() {
77
- log('Closing');
78
- try {
79
- if (!exists(this.#filePath)) {
80
- log("Save the file because it doesn't exist");
81
- await this.write();
82
- }
83
- else if (exists(this.#tmpDir)) {
84
- log('Remove temporary dir');
85
- await remove(this.#tmpDir);
86
- }
87
- await this.#db.destroy();
88
- }
89
- finally {
90
- await this.#releaseLock();
91
- }
92
- log('Closing done');
109
+ async addPageError(url, phase, message, isExternal = false) {
110
+ dbLog('Add page error: %s [%s]', url, phase);
111
+ await this.#db.insertPageError(url, phase, message, isExternal);
93
112
  }
94
113
  /**
95
114
  * Retrieves the current crawling state, including lists of scraped and pending URLs.
@@ -98,6 +117,59 @@ export default class Archive extends ArchiveAccessor {
98
117
  async getCrawlingState() {
99
118
  return this.#db.getCrawlingState();
100
119
  }
120
+ /**
121
+ * Return the subset of `urls` that already exist as `pages.url`. Used by
122
+ * `CrawlerOrchestrator.inventory` to filter the user-supplied URL list
123
+ * down to "URLs that are NOT yet in the archive" — only those reach the
124
+ * HEAD / scrape pipeline. Existing URLs are skipped to keep the second
125
+ * (and N-th) `--inventory` pass non-destructive.
126
+ * @param urls - Candidate URLs in `withoutHashAndAuth` form.
127
+ * @returns URLs already present in `pages`.
128
+ */
129
+ async getExistingPageUrls(urls) {
130
+ return this.#db.getExistingPageUrls(urls);
131
+ }
132
+ /**
133
+ * Return the subset of `urls` that already exist as `resources.url`. See
134
+ * {@link Archive.getExistingPageUrls} — the resource-side counterpart used
135
+ * by inventory mode to skip URLs that are already tracked as
136
+ * sub-resources.
137
+ * @param urls - Candidate URLs.
138
+ * @returns URLs already present in `resources`.
139
+ */
140
+ async getExistingResourceUrls(urls) {
141
+ return this.#db.getExistingResourceUrls(urls);
142
+ }
143
+ /**
144
+ * Look up the `source` column of a single page row by its URL key. Thin
145
+ * facade over {@link Database.getPageSourceByUrl} — exposes the lookup
146
+ * to the orchestrator so it can inject a `PageSourceLookup` into the
147
+ * Crawler for sub-resource lineage propagation on `--resume` /
148
+ * `--retry-failed` sessions.
149
+ * @param url - URL key in `url.withoutHashAndAuth` form.
150
+ * @returns The recorded `source`, or `undefined` when no row exists.
151
+ */
152
+ async getPageSourceByUrl(url) {
153
+ return this.#db.getPageSourceByUrl(url);
154
+ }
155
+ /**
156
+ * Retrieves a single recorded sub-resource by its URL.
157
+ * @param urls - URL candidates to match against the stored resource URL.
158
+ * @returns The raw resource row, or `null` if none match.
159
+ */
160
+ async getResourceByUrl(urls) {
161
+ return this.#db.getResourceByUrl(urls);
162
+ }
163
+ /**
164
+ * Counts the number of pages already scraped as crawl targets in the archive.
165
+ *
166
+ * Lets the crawler initialize its session-progress counter on resume so the
167
+ * displayed HTML-page count accounts for previously-rendered pages.
168
+ * @returns The count of pages with `isTarget = 1` and `scraped = 1`.
169
+ */
170
+ async getScrapedHtmlPageCount() {
171
+ return this.#db.getScrapedHtmlPageCount();
172
+ }
101
173
  /**
102
174
  * Retrieves the base URL of the crawl session from the archive database.
103
175
  * @returns The base URL string.
@@ -105,6 +177,96 @@ export default class Archive extends ArchiveAccessor {
105
177
  async getUrl() {
106
178
  return this.#db.getBaseUrl();
107
179
  }
180
+ /**
181
+ * Pre-insert inventory non-HTML URLs as `source='inventory-seed'`
182
+ * placeholders in the `resources` table — the non-HTML counterpart of
183
+ * {@link Archive.insertInventorySeeds}. Replaces the previous per-URL
184
+ * `setResources` loop in `CrawlerOrchestrator.inventory` so the
185
+ * ingestion phase commits all non-HTML rows in one chunked round-trip
186
+ * per 500 (a 50k-URL inventory list dropped from minutes-inside-`.bak`
187
+ * to seconds).
188
+ *
189
+ * Thin facade over {@link Database.insertInventoryResources}.
190
+ * `ExURL.href` is the storage key for `resources.url` (matches what
191
+ * `insertResource` writes for the per-URL path); we normalise here so
192
+ * the orchestrator stays decoupled from the storage form.
193
+ * @param urls - Non-HTML inventory URLs to record. No-op when empty.
194
+ */
195
+ async insertInventoryResources(urls) {
196
+ if (urls.length === 0) {
197
+ return;
198
+ }
199
+ dbLog('Insert inventory resources: %d URL(s)', urls.length);
200
+ await this.#db.insertInventoryResources(urls.map((u) => u.href));
201
+ }
202
+ /**
203
+ * Pre-insert inventory HTML seeds as `scraped=0`, `source='inventory-seed'`
204
+ * placeholder pages so the URL is durably tracked in the archive **before**
205
+ * the scrape phase starts. Thin facade over
206
+ * {@link Database.insertInventorySeeds} — see that method's JSDoc for the
207
+ * Ctrl+C-tolerance rationale and the `getCrawlingState` interaction.
208
+ *
209
+ * `ExURL` inputs are normalised to `withoutHashAndAuth` here so the storage
210
+ * key matches what `#getIdByUrl` writes for crawled rows, keeping the
211
+ * crawled-wins downgrade and the existing-URL filter (`getExistingPageUrls`)
212
+ * lookups consistent.
213
+ * @param urls - HTML seed URLs to pre-insert. No-op when empty.
214
+ */
215
+ async insertInventorySeeds(urls) {
216
+ if (urls.length === 0) {
217
+ return;
218
+ }
219
+ dbLog('Insert inventory seeds: %d URL(s)', urls.length);
220
+ await this.#db.insertInventorySeeds(urls.map((u) => u.withoutHashAndAuth));
221
+ }
222
+ /**
223
+ * Hostnames whose `crawl_errors` history is consistently DNS failures and
224
+ * for which no recent 2xx/3xx page or resource is recorded. Consumed by
225
+ * `CrawlerOrchestrator.#preloadDnsBurnedHostCache` to seed the DNS-burned
226
+ * host cache at re-open (append / inventory / retryFailed / resume), so
227
+ * the next crawl skips HEAD pre-flight on hosts the previous crawl
228
+ * already proved unreachable.
229
+ *
230
+ * Deliberately exposed only on `Archive` (writer-side) — read-only
231
+ * `ArchiveAccessor` (stub viewer) does not see this method so the
232
+ * stub's no-migration contract is preserved.
233
+ * @returns Lower-cased hostnames safe to short-circuit.
234
+ */
235
+ async listDnsBurnedHostCandidates() {
236
+ return this.#db.listDnsBurnedHostCandidates();
237
+ }
238
+ /**
239
+ * Appends one row to the `inventory_runs` audit log.
240
+ *
241
+ * Thin facade over {@link Database.recordInventoryRun} — keeps the
242
+ * orchestrator decoupled from the knex layer and gives a single
243
+ * write entry point that future Archive-level concerns (locking,
244
+ * mirror sync, etc.) can hook into without touching every caller.
245
+ * @param meta - The run metadata. Only `ran_at` is required.
246
+ * @returns The autoincremented `id` of the inserted row.
247
+ */
248
+ async recordInventoryRun(meta) {
249
+ dbLog('Record inventory run: %s', meta.list_label ?? meta.ran_at);
250
+ return await this.#db.recordInventoryRun(meta);
251
+ }
252
+ /**
253
+ * Releases the SQLite handle and the advisory lock **without** writing
254
+ * the archive or removing `tmpDir`.
255
+ *
256
+ * Use this when you need to detach from a freshly-created `Archive`
257
+ * without finalising it — fixtures producing a stub state for tests,
258
+ * tooling that wants to leave the tmpDir alive for `crawl --resume`,
259
+ * or any non-orchestrator caller that owns the lifecycle externally.
260
+ * Shares the same idempotency guard as {@link close}, so the two paths
261
+ * are mutually exclusive (the first one called wins).
262
+ */
263
+ async releaseHandle() {
264
+ if (this.#closeOnce) {
265
+ return this.#closeOnce;
266
+ }
267
+ this.#closeOnce = this.#runReleaseHandle();
268
+ return this.#closeOnce;
269
+ }
108
270
  /**
109
271
  * Promote previously-external pages that now fall under the (possibly extended)
110
272
  * scope back to a pending state so that the crawler re-scrapes them as fully
@@ -117,6 +279,17 @@ export default class Archive extends ArchiveAccessor {
117
279
  dbLog('Repromote external pages with %d hostnames in scope', scopes.size);
118
280
  return this.#db.repromoteExternalPages(scopes, options);
119
281
  }
282
+ /**
283
+ * Reset previously-failed pages back to pending so a follow-up crawl re-fetches them.
284
+ *
285
+ * Delegates to {@link Database.resetFailedPages}. See that method for the
286
+ * exact failure criteria (missing status / content type, or a 5xx status).
287
+ * @returns The URLs of the pages that were reset to pending.
288
+ */
289
+ async resetFailedPages() {
290
+ dbLog('Reset failed pages back to pending');
291
+ return this.#db.resetFailedPages();
292
+ }
120
293
  /**
121
294
  * Stores the crawl configuration into the archive database.
122
295
  * @param config - The configuration object to store.
@@ -126,48 +299,55 @@ export default class Archive extends ArchiveAccessor {
126
299
  return this.#db.setConfig(config);
127
300
  }
128
301
  /**
129
- * Stores an external page's data in the archive database without saving a snapshot.
302
+ * Stores an external page's data in the archive database without storing
303
+ * an HTML snapshot. External-page rows carry only metadata (status, title,
304
+ * content-type), never a rendered body.
130
305
  * @param pageInfo - The page data to store.
306
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
131
307
  */
132
- async setExternalPage(pageInfo) {
308
+ async setExternalPage(pageInfo, source) {
133
309
  dbLog('Set external page: %s', pageInfo.url.href);
134
- await this.#db.updatePage(pageInfo, null, false);
310
+ await this.#db.updatePage(pageInfo, false, false, source);
135
311
  }
136
312
  /**
137
- * Stores a crawled page's data in the archive database and optionally saves an HTML snapshot.
138
- * If the snapshot file write fails, the HTML path in the database is cleared to prevent
139
- * referencing a non-existent file, and the error is re-thrown.
313
+ * Stores a crawled page's data in the archive database, persisting the
314
+ * rendered HTML body as a zstd-compressed BLOB inside the same SQLite
315
+ * transaction. Storage is content-addressable: identical bodies across
316
+ * pages share a single `page_html_blobs` row.
140
317
  * @param pageInfo - The page data to store.
318
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
141
319
  * @returns The database ID of the stored page.
142
- * @throws {Error} Re-throws any error from the snapshot file write after clearing the HTML path.
143
320
  */
144
- async setPage(pageInfo) {
321
+ async setPage(pageInfo, source) {
145
322
  dbLog('Set page: %s', pageInfo.url.href);
146
- const { html, pageId } = await this.#db.updatePage(pageInfo, this.#snapshotDir, pageInfo.isTarget);
147
- if (html) {
148
- try {
149
- await outputText(html, pageInfo.html);
150
- }
151
- catch (error) {
152
- dbLog('Snapshot write failed for page %d, clearing html path: %s', pageId, html);
153
- try {
154
- await this.#db.clearHtmlPath(pageId);
155
- }
156
- catch (clearError) {
157
- dbLog('Failed to clear html path for page %d: %s', pageId, clearError);
158
- }
159
- throw error;
160
- }
161
- }
162
- return pageId;
323
+ return await this.#db.updatePage(pageInfo, true, pageInfo.isTarget, source);
324
+ }
325
+ /**
326
+ * Records a redirect edge without re-storing the destination's content.
327
+ *
328
+ * The crawler calls this (instead of {@link setPage}) when a URL redirects to
329
+ * a destination that has already been rendered (#73): only the source →
330
+ * destination edge is written, leaving the destination's stored title / meta /
331
+ * anchors / images untouched.
332
+ * @param pageInfo - The HEAD-resolved page data carrying the redirect chain.
333
+ * @param source - Inventory provenance for a brand-new destination row.
334
+ * Forwarded to `recordRedirect` so the destination's `source` (and the
335
+ * chain-intermediate `source` derived from it) lands on the inventory
336
+ * label instead of the DB DEFAULT `'crawled'` when the orchestrator is
337
+ * running an inventory pass. `undefined` keeps the DB DEFAULT.
338
+ */
339
+ async setRedirect(pageInfo, source) {
340
+ dbLog('Set redirect: %s', pageInfo.url.href);
341
+ await this.#db.recordRedirect(pageInfo, source);
163
342
  }
164
343
  /**
165
344
  * Stores a sub-resource (CSS, JS, image, etc.) in the archive database.
166
345
  * @param resource - The resource data to store.
346
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
167
347
  */
168
- async setResources(resource) {
348
+ async setResources(resource, source) {
169
349
  dbLog('Set resource: %s', resource.url.href);
170
- await this.#db.insertResource(resource);
350
+ await this.#db.insertResource(resource, source);
171
351
  }
172
352
  /**
173
353
  * Stores the referrer relationship between a resource and the page that references it.
@@ -207,54 +387,144 @@ export default class Archive extends ArchiveAccessor {
207
387
  await this.#db.updateConfig(patch);
208
388
  }
209
389
  /**
210
- * Writes the archive to disk as a compressed `.nitpicker` file.
390
+ * Writes the archive to disk as a `.nitpicker` tar file.
211
391
  *
212
- * This method compresses the HTML snapshot directory into a zip file,
213
- * renames the temporary working directory, and creates the final tar archive.
214
- * The temporary directory is removed after writing.
392
+ * Checkpoints the SQLite WAL so the database is self-contained inside
393
+ * `db.sqlite`, renames the temporary working directory to the archive's
394
+ * basename, and tars it into the final `.nitpicker`. The tar container
395
+ * holds a single `db.sqlite` file (the legacy `snapshot-html.zip` is gone
396
+ * — HTML lives as BLOBs in the DB), so finalisation is effectively a
397
+ * single-file copy with no per-snapshot syscalls.
215
398
  */
216
399
  async write() {
217
400
  saveLog('Starts: %s', this.#filePath);
218
- const snapshotZip = `${this.#snapshotDir}.zip`;
219
- if (exists(this.#snapshotDir)) {
220
- if (!exists(snapshotZip)) {
221
- saveLog('Zips snapshot dir: %s', this.#snapshotDir);
222
- await zip(snapshotZip, this.#snapshotDir);
223
- }
224
- saveLog('Remove snapshot dir: %s', this.#snapshotDir);
225
- await remove(this.#snapshotDir);
226
- }
227
401
  await this.#db.checkpoint();
228
402
  const filePathWithoutExt = path.resolve(path.dirname(this.#filePath), path.basename(this.#filePath, path.extname(this.#filePath)));
229
403
  saveLog('Rename temporary dir: %s to %s', this.#tmpDir, filePathWithoutExt);
230
404
  await rename(this.#tmpDir, filePathWithoutExt, true);
231
- saveLog('Zip temporary dir to file: %s to %s', filePathWithoutExt, this.#filePath);
405
+ saveLog('Tar temporary dir to file: %s to %s', filePathWithoutExt, this.#filePath);
232
406
  await tar(filePathWithoutExt, this.#filePath);
233
407
  saveLog('Remove temporary dir: %s', filePathWithoutExt);
234
408
  await remove(filePathWithoutExt);
235
409
  saveLog('Done: %s', this.#filePath);
236
410
  }
411
+ /**
412
+ * Worker for {@link close}. Performs the destructive prologue
413
+ * (write or remove), drops the DB handle via the base class, then
414
+ * releases the lock in a `finally` so the lock never leaks even on
415
+ * partial failure.
416
+ */
417
+ async #runFullClose() {
418
+ log('Closing');
419
+ try {
420
+ if (!exists(this.#filePath)) {
421
+ log("Save the file because it doesn't exist");
422
+ await this.write();
423
+ }
424
+ else if (exists(this.#tmpDir)) {
425
+ log('Remove temporary dir');
426
+ await remove(this.#tmpDir);
427
+ }
428
+ await super.close();
429
+ }
430
+ finally {
431
+ await this.#releaseLock();
432
+ }
433
+ log('Closing done');
434
+ }
435
+ /**
436
+ * Worker for {@link releaseHandle}. Drops the SQLite handle and the
437
+ * advisory lock with no filesystem mutation.
438
+ */
439
+ async #runReleaseHandle() {
440
+ log('Releasing handle (no write, no remove)');
441
+ try {
442
+ await super.close();
443
+ }
444
+ finally {
445
+ await this.#releaseLock();
446
+ }
447
+ }
237
448
  /** The file extension for Nitpicker archive files (without the leading dot). */
238
449
  static FILE_EXTENSION = 'nitpicker';
239
- /** The directory name used for storing HTML snapshots within the archive. */
240
- static SNAPSHOT_HTML_DIR = 'snapshot-html';
241
450
  /** The filename of the SQLite database within the archive. */
242
451
  static SQLITE_DB_FILE_NAME = 'db.sqlite';
243
452
  /** The prefix used for temporary working directories during archive operations. */
244
453
  static TMP_DIR_PREFIX = '._nitpicker-';
245
454
  /**
246
455
  * Opens a read-only connection to an existing archive's database.
456
+ *
247
457
  * Returns an {@link ArchiveAccessor} that provides query methods
248
- * without the ability to modify or write the archive.
458
+ * without the ability to modify or write the archive. The DB is opened
459
+ * in **read-only mode**: no schema migrations run, and the connection
460
+ * refuses to resurrect a missing parent directory or db file (so a
461
+ * TOCTOU window between source classification and this call cannot
462
+ * silently produce an empty phantom tmpDir).
463
+ *
464
+ * The returned accessor is also marked read-only so consumer-facing
465
+ * helpers (e.g. {@link ArchiveAccessor.getHtmlOfPage}) avoid any
466
+ * filesystem mutation on the user's tmpDir.
249
467
  * @param tmpDir - The path to the temporary directory containing the database.
250
468
  * @param namespace - An optional namespace for scoping data access within the archive.
251
469
  * @returns An ArchiveAccessor instance for querying the archive data.
252
470
  */
253
471
  static async connect(tmpDir, namespace = null) {
254
- const db = await Archive.#connectDB(tmpDir);
255
- const archive = new ArchiveAccessor(tmpDir, db, namespace);
472
+ const db = await Archive.#connectDB(tmpDir, { readOnly: true });
473
+ const archive = new ArchiveAccessor(tmpDir, db, namespace, { readOnly: true });
256
474
  return archive;
257
475
  }
476
+ /**
477
+ * Open a `.nitpicker` archive through the read-only tar cache.
478
+ *
479
+ * This is the fast path for read-only consumers (viewer, MCP, query
480
+ * CLI). It diverges from {@link Archive.open} in two important ways:
481
+ *
482
+ * 1. The extracted contents land in an OS-temp-scoped cache directory
483
+ * keyed by the archive's `size + mtime_ns + ctime_ns` (see
484
+ * {@link computeArchiveCacheKey}). Subsequent opens of the same
485
+ * unchanged archive skip the untar entirely. A fresh 10 GB archive
486
+ * pays the ~10 s untar cost once; reopens are instant.
487
+ * 2. The returned value is an {@link ArchiveAccessor} (read-only), not
488
+ * an `Archive` (writer). Closing it tears down the DB handle but
489
+ * leaves the cache directory in place for the next reader. The
490
+ * OS's own temp-directory cleanup (macOS reboot, Linux
491
+ * `systemd-tmpfiles`, Windows Disk Cleanup) reclaims stale
492
+ * entries — we do not own eviction.
493
+ *
494
+ * Migrations: the writer-side migration stack
495
+ * (`initSchema` / `migrate*`) runs once at cache-miss extraction, so
496
+ * the cache directory always lands on the current schema before the
497
+ * read-only re-open. Cache hits then skip migrations entirely.
498
+ *
499
+ * Override the cache location with `NITPICKER_TAR_CACHE_DIR`. The
500
+ * disable switch (`NITPICKER_DISABLE_TAR_CACHE=1`) is honoured by
501
+ * the caller (`ArchiveManager.open` falls back to {@link Archive.open}
502
+ * in that case); this function itself always goes through the cache.
503
+ *
504
+ * Writer entry points (`crawl --append`, `crawl --retry-failed`) must
505
+ * NOT use this path — they need the lock + write-back semantics of
506
+ * {@link Archive.open}.
507
+ * @param filePath - Absolute path to the `.nitpicker` file.
508
+ * @param namespace - Optional namespace forwarded to {@link ArchiveAccessor}.
509
+ * @returns A read-only {@link ArchiveAccessor} backed by the cache directory.
510
+ * @example
511
+ * ```ts
512
+ * const accessor = await Archive.openCached('/path/to/site.nitpicker');
513
+ * try {
514
+ * const summary = await getSummary(accessor);
515
+ * } finally {
516
+ * await accessor.close(); // tears down DB handle, cacheDir persists.
517
+ * }
518
+ * ```
519
+ */
520
+ static async openCached(filePath, namespace = null) {
521
+ const cacheRoot = getArchiveCacheRoot();
522
+ const cacheKey = await computeArchiveCacheKey(filePath);
523
+ const cacheDir = resolveArchiveCacheDir(cacheRoot, cacheKey, filePath);
524
+ log('Open cached: %s (cacheDir=%s)', filePath, cacheDir);
525
+ await extractArchiveToCache(filePath, cacheRoot, cacheDir, cacheKey);
526
+ return await Archive.connect(cacheDir, namespace);
527
+ }
258
528
  /**
259
529
  * Creates a new archive at the specified file path.
260
530
  * Initializes a temporary working directory and a fresh SQLite database.
@@ -301,22 +571,30 @@ export default class Archive extends ArchiveAccessor {
301
571
  cwd,
302
572
  openPluginData,
303
573
  });
304
- const fileName = path.basename(filePath, path.extname(filePath));
305
- const tmpDir = path.resolve(cwd, Archive.TMP_DIR_PREFIX + fileName);
574
+ // Read the tar's actual top-level directory name instead of deriving
575
+ // it from the outer file's basename. `.nitpicker` files are plain
576
+ // tar archives and users routinely rename them (`mv X.nitpicker
577
+ // Y.nitpicker`) — that operation must not break `open`. The inner
578
+ // directory keeps whatever name `Archive.write()` baked in at write
579
+ // time, and `tmpDir` mirrors the OUTER basename (so concurrent
580
+ // crawls on differently-named copies of the same archive don't
581
+ // collide on the lockfile).
582
+ const outerBasename = path.basename(filePath, path.extname(filePath));
583
+ const innerDirName = await peekTarTopDir(filePath);
584
+ const tmpDir = path.resolve(cwd, Archive.TMP_DIR_PREFIX + outerBasename);
306
585
  const releaseLock = await acquireArchiveLock(tmpDir);
307
586
  try {
308
587
  const openFiles = [];
309
588
  if (!openPluginData) {
310
- const relDdPath = path.join(fileName, Archive.SQLITE_DB_FILE_NAME);
311
- const relSnapshotPath = path.join(fileName, Archive.SNAPSHOT_HTML_DIR + '.zip');
312
- openFiles.push(relDdPath, relSnapshotPath);
589
+ const relDdPath = path.join(innerDirName, Archive.SQLITE_DB_FILE_NAME);
590
+ openFiles.push(relDdPath);
313
591
  }
314
592
  log('Unzip file: %s (%O)', filePath, openFiles);
315
593
  await untar(filePath, {
316
594
  cwd,
317
595
  fileList: openFiles.length > 0 ? openFiles : undefined,
318
596
  });
319
- const extractedDir = path.resolve(cwd, fileName);
597
+ const extractedDir = path.resolve(cwd, innerDirName);
320
598
  log('Move directory: %s to %s', extractedDir, tmpDir);
321
599
  await rename(extractedDir, tmpDir, true);
322
600
  return await Archive.#init(filePath, tmpDir, releaseLock);
@@ -373,13 +651,18 @@ export default class Archive extends ArchiveAccessor {
373
651
  /**
374
652
  * Connects to (or creates) the SQLite database in the given directory.
375
653
  * @param tmpDir - Directory containing `db.sqlite`
654
+ * @param options - Optional connection flags forwarded to
655
+ * {@link Database.connect}. Used by {@link Archive.connect} to pass
656
+ * `readOnly: true` so no migrations run and a missing tmpDir is not
657
+ * resurrected.
658
+ * @param options.readOnly
376
659
  */
377
- static async #connectDB(tmpDir) {
660
+ static async #connectDB(tmpDir, options) {
378
661
  const dbPath = path.resolve(tmpDir, Archive.SQLITE_DB_FILE_NAME);
379
- dbLog('connects database: %s', dbPath);
662
+ dbLog('connects database: %s (readOnly=%s)', dbPath, options?.readOnly ?? false);
380
663
  return await Database.connect({
381
- workingDir: tmpDir,
382
664
  filename: dbPath,
665
+ readOnly: options?.readOnly,
383
666
  });
384
667
  }
385
668
  /**
@@ -397,6 +680,34 @@ export default class Archive extends ArchiveAccessor {
397
680
  const archive = new Archive(filePath, tmpDir, db, releaseLock);
398
681
  return archive;
399
682
  }
683
+ /**
684
+ * Closes the archive. If the archive file does not yet exist on disk,
685
+ * it writes the archive first. If the temporary directory still exists,
686
+ * it is removed. The database connection is then closed via
687
+ * {@link ArchiveAccessor.close} (the base class owns the SQLite handle),
688
+ * and finally the archive's advisory lock is released.
689
+ *
690
+ * **Idempotent**: the first invocation captures the close promise;
691
+ * subsequent invocations (signal handlers, parallel teardowns, retried
692
+ * orchestrator paths) await the same promise instead of re-entering
693
+ * the destructive prologue on a half-mutated state. If the first
694
+ * close fails (e.g. ENOSPC during tar), the rejection propagates to
695
+ * all awaiters and the archive stays latched closed — there is no
696
+ * safe way to retry `write()` once `tmpDir` has been renamed.
697
+ *
698
+ * **Read-only consumers must not reach this override.** Anything that
699
+ * obtains an archive view via {@link Archive.connect} receives an
700
+ * {@link ArchiveAccessor} (not an `Archive`), so `close()` resolves to
701
+ * the safe base implementation — no `write()`, no `remove()`, no lock
702
+ * release — leaving the tmpDir intact for the live crawler.
703
+ */
704
+ async close() {
705
+ if (this.#closeOnce) {
706
+ return this.#closeOnce;
707
+ }
708
+ this.#closeOnce = this.#runFullClose();
709
+ return this.#closeOnce;
710
+ }
400
711
  /**
401
712
  * Retrieves the crawl configuration stored in the archive database.
402
713
  * @returns The configuration object.