@nitpicker/crawler 0.11.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.
- package/lib/archive/archive.d.ts +117 -2
- package/lib/archive/archive.js +147 -2
- package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
- package/lib/archive/cache/compute-archive-cache-key.js +95 -0
- package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
- package/lib/archive/cache/extract-archive-to-cache.js +309 -0
- package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
- package/lib/archive/cache/get-archive-cache-root.js +53 -0
- package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
- package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
- package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
- package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
- package/lib/archive/database.d.ts +216 -15
- package/lib/archive/database.js +1459 -938
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- package/lib/archive/get-failed-page-messages.d.ts +43 -0
- package/lib/archive/get-failed-page-messages.js +131 -0
- package/lib/archive/init-schema.js +153 -1
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/migrate-inventory-runs.d.ts +29 -0
- package/lib/archive/migrate-inventory-runs.js +52 -0
- package/lib/archive/types.d.ts +33 -0
- package/lib/classify-error-kind.d.ts +19 -0
- package/lib/classify-error-kind.js +122 -0
- package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
- package/lib/crawler/build-js-redirect-edge.js +57 -0
- package/lib/crawler/build-redirect-event.d.ts +24 -0
- package/lib/crawler/build-redirect-event.js +28 -0
- package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
- package/lib/crawler/crawler.d.ts +3 -1
- package/lib/crawler/crawler.js +655 -107
- package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
- package/lib/crawler/derive-js-redirect-target.js +129 -0
- package/lib/crawler/derive-resource-source.d.ts +25 -15
- package/lib/crawler/derive-resource-source.js +28 -17
- package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
- package/lib/crawler/dns-burned-host-cache.js +25 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
- package/lib/crawler/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +94 -16
- package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
- package/lib/crawler/is-js-redirect-error-shape.js +53 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +23 -3
- package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
- package/lib/crawler/plan-sub-resource-emits.js +44 -0
- package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
- package/lib/crawler/preload-short-circuit-error.js +25 -0
- package/lib/crawler/should-burn-host.d.ts +78 -0
- package/lib/crawler/should-burn-host.js +61 -0
- package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
- package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
- package/lib/crawler/types.d.ts +107 -0
- package/lib/crawler-orchestrator.d.ts +13 -3
- package/lib/crawler-orchestrator.js +292 -69
- package/lib/crawler.d.ts +3 -2
- package/lib/crawler.js +3 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/types.d.ts +84 -0
- package/lib/utils/compute-file-sha256.d.ts +23 -0
- package/lib/utils/compute-file-sha256.js +55 -0
- package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
- package/lib/utils/error/emit-error-with-retry.js +44 -0
- package/lib/utils/error/emit-error.d.ts +39 -0
- package/lib/utils/error/emit-error.js +41 -0
- package/package.json +11 -11
- package/lib/utils/error/error-emitter.d.ts +0 -18
- package/lib/utils/error/error-emitter.js +0 -29
package/lib/archive/archive.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Config, PageSource } from './types.js';
|
|
1
|
+
import type { Config, InventoryRunMeta, PageSource } from './types.js';
|
|
2
2
|
import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
|
|
3
3
|
import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
4
4
|
import { ArchiveAccessor } from './archive-accessor.js';
|
|
@@ -84,6 +84,16 @@ export default class Archive extends ArchiveAccessor {
|
|
|
84
84
|
* @returns URLs already present in `resources`.
|
|
85
85
|
*/
|
|
86
86
|
getExistingResourceUrls(urls: readonly string[]): Promise<string[]>;
|
|
87
|
+
/**
|
|
88
|
+
* Look up the `source` column of a single page row by its URL key. Thin
|
|
89
|
+
* facade over {@link Database.getPageSourceByUrl} — exposes the lookup
|
|
90
|
+
* to the orchestrator so it can inject a `PageSourceLookup` into the
|
|
91
|
+
* Crawler for sub-resource lineage propagation on `--resume` /
|
|
92
|
+
* `--retry-failed` sessions.
|
|
93
|
+
* @param url - URL key in `url.withoutHashAndAuth` form.
|
|
94
|
+
* @returns The recorded `source`, or `undefined` when no row exists.
|
|
95
|
+
*/
|
|
96
|
+
getPageSourceByUrl(url: string): Promise<PageSource | undefined>;
|
|
87
97
|
/**
|
|
88
98
|
* Retrieves a single recorded sub-resource by its URL.
|
|
89
99
|
* @param urls - URL candidates to match against the stored resource URL.
|
|
@@ -103,6 +113,61 @@ export default class Archive extends ArchiveAccessor {
|
|
|
103
113
|
* @returns The base URL string.
|
|
104
114
|
*/
|
|
105
115
|
getUrl(): Promise<any>;
|
|
116
|
+
/**
|
|
117
|
+
* Pre-insert inventory non-HTML URLs as `source='inventory-seed'`
|
|
118
|
+
* placeholders in the `resources` table — the non-HTML counterpart of
|
|
119
|
+
* {@link Archive.insertInventorySeeds}. Replaces the previous per-URL
|
|
120
|
+
* `setResources` loop in `CrawlerOrchestrator.inventory` so the
|
|
121
|
+
* ingestion phase commits all non-HTML rows in one chunked round-trip
|
|
122
|
+
* per 500 (a 50k-URL inventory list dropped from minutes-inside-`.bak`
|
|
123
|
+
* to seconds).
|
|
124
|
+
*
|
|
125
|
+
* Thin facade over {@link Database.insertInventoryResources}.
|
|
126
|
+
* `ExURL.href` is the storage key for `resources.url` (matches what
|
|
127
|
+
* `insertResource` writes for the per-URL path); we normalise here so
|
|
128
|
+
* the orchestrator stays decoupled from the storage form.
|
|
129
|
+
* @param urls - Non-HTML inventory URLs to record. No-op when empty.
|
|
130
|
+
*/
|
|
131
|
+
insertInventoryResources(urls: readonly ExURL[]): Promise<void>;
|
|
132
|
+
/**
|
|
133
|
+
* Pre-insert inventory HTML seeds as `scraped=0`, `source='inventory-seed'`
|
|
134
|
+
* placeholder pages so the URL is durably tracked in the archive **before**
|
|
135
|
+
* the scrape phase starts. Thin facade over
|
|
136
|
+
* {@link Database.insertInventorySeeds} — see that method's JSDoc for the
|
|
137
|
+
* Ctrl+C-tolerance rationale and the `getCrawlingState` interaction.
|
|
138
|
+
*
|
|
139
|
+
* `ExURL` inputs are normalised to `withoutHashAndAuth` here so the storage
|
|
140
|
+
* key matches what `#getIdByUrl` writes for crawled rows, keeping the
|
|
141
|
+
* crawled-wins downgrade and the existing-URL filter (`getExistingPageUrls`)
|
|
142
|
+
* lookups consistent.
|
|
143
|
+
* @param urls - HTML seed URLs to pre-insert. No-op when empty.
|
|
144
|
+
*/
|
|
145
|
+
insertInventorySeeds(urls: readonly ExURL[]): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Hostnames whose `crawl_errors` history is consistently DNS failures and
|
|
148
|
+
* for which no recent 2xx/3xx page or resource is recorded. Consumed by
|
|
149
|
+
* `CrawlerOrchestrator.#preloadDnsBurnedHostCache` to seed the DNS-burned
|
|
150
|
+
* host cache at re-open (append / inventory / retryFailed / resume), so
|
|
151
|
+
* the next crawl skips HEAD pre-flight on hosts the previous crawl
|
|
152
|
+
* already proved unreachable.
|
|
153
|
+
*
|
|
154
|
+
* Deliberately exposed only on `Archive` (writer-side) — read-only
|
|
155
|
+
* `ArchiveAccessor` (stub viewer) does not see this method so the
|
|
156
|
+
* stub's no-migration contract is preserved.
|
|
157
|
+
* @returns Lower-cased hostnames safe to short-circuit.
|
|
158
|
+
*/
|
|
159
|
+
listDnsBurnedHostCandidates(): Promise<string[]>;
|
|
160
|
+
/**
|
|
161
|
+
* Appends one row to the `inventory_runs` audit log.
|
|
162
|
+
*
|
|
163
|
+
* Thin facade over {@link Database.recordInventoryRun} — keeps the
|
|
164
|
+
* orchestrator decoupled from the knex layer and gives a single
|
|
165
|
+
* write entry point that future Archive-level concerns (locking,
|
|
166
|
+
* mirror sync, etc.) can hook into without touching every caller.
|
|
167
|
+
* @param meta - The run metadata. Only `ran_at` is required.
|
|
168
|
+
* @returns The autoincremented `id` of the inserted row.
|
|
169
|
+
*/
|
|
170
|
+
recordInventoryRun(meta: InventoryRunMeta): Promise<number>;
|
|
106
171
|
/**
|
|
107
172
|
* Releases the SQLite handle and the advisory lock **without** writing
|
|
108
173
|
* the archive or removing `tmpDir`.
|
|
@@ -163,8 +228,13 @@ export default class Archive extends ArchiveAccessor {
|
|
|
163
228
|
* destination edge is written, leaving the destination's stored title / meta /
|
|
164
229
|
* anchors / images untouched.
|
|
165
230
|
* @param pageInfo - The HEAD-resolved page data carrying the redirect chain.
|
|
231
|
+
* @param source - Inventory provenance for a brand-new destination row.
|
|
232
|
+
* Forwarded to `recordRedirect` so the destination's `source` (and the
|
|
233
|
+
* chain-intermediate `source` derived from it) lands on the inventory
|
|
234
|
+
* label instead of the DB DEFAULT `'crawled'` when the orchestrator is
|
|
235
|
+
* running an inventory pass. `undefined` keeps the DB DEFAULT.
|
|
166
236
|
*/
|
|
167
|
-
setRedirect(pageInfo: PageData): Promise<void>;
|
|
237
|
+
setRedirect(pageInfo: PageData, source?: PageSource): Promise<void>;
|
|
168
238
|
/**
|
|
169
239
|
* Stores a sub-resource (CSS, JS, image, etc.) in the archive database.
|
|
170
240
|
* @param resource - The resource data to store.
|
|
@@ -234,6 +304,51 @@ export default class Archive extends ArchiveAccessor {
|
|
|
234
304
|
* @returns An ArchiveAccessor instance for querying the archive data.
|
|
235
305
|
*/
|
|
236
306
|
static connect(tmpDir: string, namespace?: string | null): Promise<ArchiveAccessor>;
|
|
307
|
+
/**
|
|
308
|
+
* Open a `.nitpicker` archive through the read-only tar cache.
|
|
309
|
+
*
|
|
310
|
+
* This is the fast path for read-only consumers (viewer, MCP, query
|
|
311
|
+
* CLI). It diverges from {@link Archive.open} in two important ways:
|
|
312
|
+
*
|
|
313
|
+
* 1. The extracted contents land in an OS-temp-scoped cache directory
|
|
314
|
+
* keyed by the archive's `size + mtime_ns + ctime_ns` (see
|
|
315
|
+
* {@link computeArchiveCacheKey}). Subsequent opens of the same
|
|
316
|
+
* unchanged archive skip the untar entirely. A fresh 10 GB archive
|
|
317
|
+
* pays the ~10 s untar cost once; reopens are instant.
|
|
318
|
+
* 2. The returned value is an {@link ArchiveAccessor} (read-only), not
|
|
319
|
+
* an `Archive` (writer). Closing it tears down the DB handle but
|
|
320
|
+
* leaves the cache directory in place for the next reader. The
|
|
321
|
+
* OS's own temp-directory cleanup (macOS reboot, Linux
|
|
322
|
+
* `systemd-tmpfiles`, Windows Disk Cleanup) reclaims stale
|
|
323
|
+
* entries — we do not own eviction.
|
|
324
|
+
*
|
|
325
|
+
* Migrations: the writer-side migration stack
|
|
326
|
+
* (`initSchema` / `migrate*`) runs once at cache-miss extraction, so
|
|
327
|
+
* the cache directory always lands on the current schema before the
|
|
328
|
+
* read-only re-open. Cache hits then skip migrations entirely.
|
|
329
|
+
*
|
|
330
|
+
* Override the cache location with `NITPICKER_TAR_CACHE_DIR`. The
|
|
331
|
+
* disable switch (`NITPICKER_DISABLE_TAR_CACHE=1`) is honoured by
|
|
332
|
+
* the caller (`ArchiveManager.open` falls back to {@link Archive.open}
|
|
333
|
+
* in that case); this function itself always goes through the cache.
|
|
334
|
+
*
|
|
335
|
+
* Writer entry points (`crawl --append`, `crawl --retry-failed`) must
|
|
336
|
+
* NOT use this path — they need the lock + write-back semantics of
|
|
337
|
+
* {@link Archive.open}.
|
|
338
|
+
* @param filePath - Absolute path to the `.nitpicker` file.
|
|
339
|
+
* @param namespace - Optional namespace forwarded to {@link ArchiveAccessor}.
|
|
340
|
+
* @returns A read-only {@link ArchiveAccessor} backed by the cache directory.
|
|
341
|
+
* @example
|
|
342
|
+
* ```ts
|
|
343
|
+
* const accessor = await Archive.openCached('/path/to/site.nitpicker');
|
|
344
|
+
* try {
|
|
345
|
+
* const summary = await getSummary(accessor);
|
|
346
|
+
* } finally {
|
|
347
|
+
* await accessor.close(); // tears down DB handle, cacheDir persists.
|
|
348
|
+
* }
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
static openCached(filePath: string, namespace?: string | null): Promise<ArchiveAccessor>;
|
|
237
352
|
/**
|
|
238
353
|
* Creates a new archive at the specified file path.
|
|
239
354
|
* Initializes a temporary working directory and a fresh SQLite database.
|
package/lib/archive/archive.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { ArchiveAccessor } from './archive-accessor.js';
|
|
3
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';
|
|
4
8
|
import { Database } from './database.js';
|
|
5
9
|
import { dbLog, log, saveLog } from './debug.js';
|
|
6
10
|
import { appendText } from './filesystem/append-text.js';
|
|
@@ -136,6 +140,18 @@ export default class Archive extends ArchiveAccessor {
|
|
|
136
140
|
async getExistingResourceUrls(urls) {
|
|
137
141
|
return this.#db.getExistingResourceUrls(urls);
|
|
138
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
|
+
}
|
|
139
155
|
/**
|
|
140
156
|
* Retrieves a single recorded sub-resource by its URL.
|
|
141
157
|
* @param urls - URL candidates to match against the stored resource URL.
|
|
@@ -161,6 +177,78 @@ export default class Archive extends ArchiveAccessor {
|
|
|
161
177
|
async getUrl() {
|
|
162
178
|
return this.#db.getBaseUrl();
|
|
163
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
|
+
}
|
|
164
252
|
/**
|
|
165
253
|
* Releases the SQLite handle and the advisory lock **without** writing
|
|
166
254
|
* the archive or removing `tmpDir`.
|
|
@@ -242,10 +330,15 @@ export default class Archive extends ArchiveAccessor {
|
|
|
242
330
|
* destination edge is written, leaving the destination's stored title / meta /
|
|
243
331
|
* anchors / images untouched.
|
|
244
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.
|
|
245
338
|
*/
|
|
246
|
-
async setRedirect(pageInfo) {
|
|
339
|
+
async setRedirect(pageInfo, source) {
|
|
247
340
|
dbLog('Set redirect: %s', pageInfo.url.href);
|
|
248
|
-
await this.#db.recordRedirect(pageInfo);
|
|
341
|
+
await this.#db.recordRedirect(pageInfo, source);
|
|
249
342
|
}
|
|
250
343
|
/**
|
|
251
344
|
* Stores a sub-resource (CSS, JS, image, etc.) in the archive database.
|
|
@@ -380,6 +473,58 @@ export default class Archive extends ArchiveAccessor {
|
|
|
380
473
|
const archive = new ArchiveAccessor(tmpDir, db, namespace, { readOnly: true });
|
|
381
474
|
return archive;
|
|
382
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
|
+
}
|
|
383
528
|
/**
|
|
384
529
|
* Creates a new archive at the specified file path.
|
|
385
530
|
* Initializes a temporary working directory and a fresh SQLite database.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a stable cache key from a `.nitpicker` file's inode metadata
|
|
3
|
+
* plus a head+tail content sample.
|
|
4
|
+
*
|
|
5
|
+
* Metadata fields:
|
|
6
|
+
*
|
|
7
|
+
* - `size` covers the bulk of accidental cache collision risk in O(1).
|
|
8
|
+
* - `mtime` (mod time) changes whenever the file's content is rewritten,
|
|
9
|
+
* which is the common case (`crawl --append`, `crawl --retry-failed`,
|
|
10
|
+
* `cp -f`, rsync).
|
|
11
|
+
* - `ctime` (inode change time) closes the `touch -m -t <past>` loophole:
|
|
12
|
+
* even if a user resets `mtime` to fake "unchanged", the act of touching
|
|
13
|
+
* bumps `ctime` on POSIX so the key still rolls.
|
|
14
|
+
*
|
|
15
|
+
* Head + tail digest:
|
|
16
|
+
*
|
|
17
|
+
* - On filesystems with low-resolution timestamps (FAT / exFAT / NFSv3 /
|
|
18
|
+
* some Docker volume mounts where mtime/ctime are second-granular and
|
|
19
|
+
* sometimes don't move on small appends), `size + mtime + ctime`
|
|
20
|
+
* alone can stay identical across an in-place rewrite.
|
|
21
|
+
* - We hash the first {@link HEAD_TAIL_HASH_BYTES} bytes and the last
|
|
22
|
+
* {@link HEAD_TAIL_HASH_BYTES} bytes to detect this. For tar archives
|
|
23
|
+
* the head holds the first entry's header (which moves when the inner
|
|
24
|
+
* directory's name changes) and the tail holds the last entry's data
|
|
25
|
+
* (which always moves on `crawl --append` because the appended pages
|
|
26
|
+
* land near the end of the tar stream).
|
|
27
|
+
* - Full-content sha256 was rejected: ~20-30 s on a 10 GB archive,
|
|
28
|
+
* which is slower than the untar this cache is meant to avoid.
|
|
29
|
+
*
|
|
30
|
+
* The key is stable across symlinks (the caller resolves via
|
|
31
|
+
* `fs.realpath` upstream) but **not** across hardlinks pointing at a
|
|
32
|
+
* mutated inode — that is intentional, hardlinking is a power-user
|
|
33
|
+
* move and the user is expected to know the cache will share an entry.
|
|
34
|
+
* @param archivePath - Absolute path to the `.nitpicker` file.
|
|
35
|
+
* @returns A string of the form
|
|
36
|
+
* `<size>-<mtime_ns>-<ctime_ns>-<headHex>-<tailHex>` suitable for use
|
|
37
|
+
* as a directory-name component.
|
|
38
|
+
*/
|
|
39
|
+
export declare function computeArchiveCacheKey(archivePath: string): Promise<string>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
/**
|
|
5
|
+
* Number of bytes hashed from each end of the file to corner the case
|
|
6
|
+
* where two different archives share `size + mtime + ctime` by accident.
|
|
7
|
+
*
|
|
8
|
+
* 64 KiB is a sweet spot: on a 10 GB archive it reads 0.0006 % of the
|
|
9
|
+
* file (~1 ms on an SSD, ~5 ms on a spinning disk) while reliably
|
|
10
|
+
* sampling enough of both the tar leading header AND the tail (which
|
|
11
|
+
* for tar archives contains the last-written entry's data, so any real
|
|
12
|
+
* mutation moves at least one of the two windows).
|
|
13
|
+
*/
|
|
14
|
+
const HEAD_TAIL_HASH_BYTES = 64 * 1024;
|
|
15
|
+
/**
|
|
16
|
+
* Cache-key segment substituted when a file is smaller than the
|
|
17
|
+
* head+tail sample window. In that case the head segment alone already
|
|
18
|
+
* spans the whole file (size column also rolls), so a separate tail
|
|
19
|
+
* digest is meaningless and we encode that explicitly rather than
|
|
20
|
+
* silently overlapping reads.
|
|
21
|
+
*/
|
|
22
|
+
const TAIL_NOT_NEEDED = 'short';
|
|
23
|
+
/**
|
|
24
|
+
* Derive a stable cache key from a `.nitpicker` file's inode metadata
|
|
25
|
+
* plus a head+tail content sample.
|
|
26
|
+
*
|
|
27
|
+
* Metadata fields:
|
|
28
|
+
*
|
|
29
|
+
* - `size` covers the bulk of accidental cache collision risk in O(1).
|
|
30
|
+
* - `mtime` (mod time) changes whenever the file's content is rewritten,
|
|
31
|
+
* which is the common case (`crawl --append`, `crawl --retry-failed`,
|
|
32
|
+
* `cp -f`, rsync).
|
|
33
|
+
* - `ctime` (inode change time) closes the `touch -m -t <past>` loophole:
|
|
34
|
+
* even if a user resets `mtime` to fake "unchanged", the act of touching
|
|
35
|
+
* bumps `ctime` on POSIX so the key still rolls.
|
|
36
|
+
*
|
|
37
|
+
* Head + tail digest:
|
|
38
|
+
*
|
|
39
|
+
* - On filesystems with low-resolution timestamps (FAT / exFAT / NFSv3 /
|
|
40
|
+
* some Docker volume mounts where mtime/ctime are second-granular and
|
|
41
|
+
* sometimes don't move on small appends), `size + mtime + ctime`
|
|
42
|
+
* alone can stay identical across an in-place rewrite.
|
|
43
|
+
* - We hash the first {@link HEAD_TAIL_HASH_BYTES} bytes and the last
|
|
44
|
+
* {@link HEAD_TAIL_HASH_BYTES} bytes to detect this. For tar archives
|
|
45
|
+
* the head holds the first entry's header (which moves when the inner
|
|
46
|
+
* directory's name changes) and the tail holds the last entry's data
|
|
47
|
+
* (which always moves on `crawl --append` because the appended pages
|
|
48
|
+
* land near the end of the tar stream).
|
|
49
|
+
* - Full-content sha256 was rejected: ~20-30 s on a 10 GB archive,
|
|
50
|
+
* which is slower than the untar this cache is meant to avoid.
|
|
51
|
+
*
|
|
52
|
+
* The key is stable across symlinks (the caller resolves via
|
|
53
|
+
* `fs.realpath` upstream) but **not** across hardlinks pointing at a
|
|
54
|
+
* mutated inode — that is intentional, hardlinking is a power-user
|
|
55
|
+
* move and the user is expected to know the cache will share an entry.
|
|
56
|
+
* @param archivePath - Absolute path to the `.nitpicker` file.
|
|
57
|
+
* @returns A string of the form
|
|
58
|
+
* `<size>-<mtime_ns>-<ctime_ns>-<headHex>-<tailHex>` suitable for use
|
|
59
|
+
* as a directory-name component.
|
|
60
|
+
*/
|
|
61
|
+
export async function computeArchiveCacheKey(archivePath) {
|
|
62
|
+
const stats = await fs.stat(archivePath, { bigint: true });
|
|
63
|
+
const fileSize = Number(stats.size);
|
|
64
|
+
const headHex = await sha256OfRange(archivePath, 0, Math.min(HEAD_TAIL_HASH_BYTES, fileSize));
|
|
65
|
+
const tailHex = fileSize > HEAD_TAIL_HASH_BYTES
|
|
66
|
+
? await sha256OfRange(archivePath, fileSize - HEAD_TAIL_HASH_BYTES, HEAD_TAIL_HASH_BYTES)
|
|
67
|
+
: TAIL_NOT_NEEDED;
|
|
68
|
+
return `${stats.size}-${stats.mtimeNs}-${stats.ctimeNs}-${headHex}-${tailHex}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* SHA-256 the requested byte range of a file via a single read stream.
|
|
72
|
+
* Truncated to 16 hex chars (64 bits) because the digest only needs to
|
|
73
|
+
* disambiguate within a `(size, mtime, ctime)` bucket — full 256-bit
|
|
74
|
+
* fingerprints would only waste filesystem path budget.
|
|
75
|
+
* @param filePath - Absolute path to the file.
|
|
76
|
+
* @param start - Inclusive byte offset to begin reading at.
|
|
77
|
+
* @param length - Number of bytes to read; 0 returns the empty-input digest.
|
|
78
|
+
* @returns A 16-character hex string.
|
|
79
|
+
*/
|
|
80
|
+
async function sha256OfRange(filePath, start, length) {
|
|
81
|
+
const hash = createHash('sha256');
|
|
82
|
+
if (length === 0) {
|
|
83
|
+
return hash.digest('hex').slice(0, 16);
|
|
84
|
+
}
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
const stream = createReadStream(filePath, {
|
|
87
|
+
start,
|
|
88
|
+
end: start + length - 1,
|
|
89
|
+
});
|
|
90
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
91
|
+
stream.on('end', () => resolve());
|
|
92
|
+
stream.on('error', (error) => reject(error));
|
|
93
|
+
});
|
|
94
|
+
return hash.digest('hex').slice(0, 16);
|
|
95
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Untar a `.nitpicker` archive into the given cache directory.
|
|
3
|
+
*
|
|
4
|
+
* Concurrency / re-entry contract:
|
|
5
|
+
*
|
|
6
|
+
* - Two viewers opening the same archive race for the cache lock; the
|
|
7
|
+
* loser waits and recheck-loops until the winner writes the ready
|
|
8
|
+
* marker, then short-circuits.
|
|
9
|
+
* - Same-process concurrent callers dedupe through
|
|
10
|
+
* `inFlightByCacheDir` so they share one extraction promise.
|
|
11
|
+
*
|
|
12
|
+
* Per-archive staging:
|
|
13
|
+
*
|
|
14
|
+
* - The tar's inner directory is extracted into `${cacheDir}.staging/`
|
|
15
|
+
* first, then atomically renamed into `cacheDir`. Crucially, the
|
|
16
|
+
* staging path is keyed off `cacheDir` (not the tar's inner directory
|
|
17
|
+
* name) so two archives that happen to share an inner-dir name never
|
|
18
|
+
* collide on the same staging slot.
|
|
19
|
+
*
|
|
20
|
+
* Integrity guard:
|
|
21
|
+
*
|
|
22
|
+
* - After rename, we run the writer-side `Database.connect` so all
|
|
23
|
+
* migrations apply (column adds, new tables) and the version check
|
|
24
|
+
* fires before the ready marker is written. A corrupt or incompatible
|
|
25
|
+
* archive therefore poisons no cache entry — the cacheDir is removed
|
|
26
|
+
* and the next caller retries from scratch.
|
|
27
|
+
*
|
|
28
|
+
* TOCTOU guard:
|
|
29
|
+
*
|
|
30
|
+
* - The cache key is recomputed from the file's stat AFTER the untar
|
|
31
|
+
* completes. If the file changed mid-flight (a concurrent
|
|
32
|
+
* `crawl --append` rewrote it), the freshly-landed cache contents do
|
|
33
|
+
* not correspond to the original key, so we abort and remove the
|
|
34
|
+
* cacheDir so the caller upstream sees the inconsistency rather than
|
|
35
|
+
* serving misattributed data.
|
|
36
|
+
* @param archivePath - Absolute path to the source `.nitpicker` file.
|
|
37
|
+
* @param cacheRoot - Absolute path to the cache root directory.
|
|
38
|
+
* @param cacheDir - Absolute path the extracted contents should end up at.
|
|
39
|
+
* @param cacheKey - The cache key used to derive `cacheDir`. Recomputed
|
|
40
|
+
* after extraction to detect concurrent writers; must match.
|
|
41
|
+
* @returns Resolves once `cacheDir` is ready to be opened read-only.
|
|
42
|
+
*/
|
|
43
|
+
export declare function extractArchiveToCache(archivePath: string, cacheRoot: string, cacheDir: string, cacheKey: string): Promise<void>;
|