@nitpicker/crawler 0.8.0 → 0.11.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 (92) 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 +147 -24
  4. package/lib/archive/archive.js +252 -86
  5. package/lib/archive/database.d.ts +209 -25
  6. package/lib/archive/database.js +928 -108
  7. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  8. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  9. package/lib/archive/init-schema.d.ts +53 -2
  10. package/lib/archive/init-schema.js +247 -15
  11. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  12. package/lib/archive/meta/assert-compatible-version.js +72 -0
  13. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  14. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  15. package/lib/archive/meta/compare-semver.d.ts +23 -0
  16. package/lib/archive/meta/compare-semver.js +51 -0
  17. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  18. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  19. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  20. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  21. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  22. package/lib/archive/meta/derive-meta-extras.js +23 -0
  23. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  24. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  25. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  26. package/lib/archive/meta/summarize-jsonld.js +29 -0
  27. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  28. package/lib/archive/meta/summarize-tags.js +33 -0
  29. package/lib/archive/meta/types.d.ts +207 -0
  30. package/lib/archive/meta/types.js +33 -0
  31. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  32. package/lib/archive/migrate-crawl-errors.js +38 -0
  33. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  34. package/lib/archive/migrate-html-blob-tables.js +53 -0
  35. package/lib/archive/migrate-page-errors.d.ts +16 -0
  36. package/lib/archive/migrate-page-errors.js +35 -0
  37. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  38. package/lib/archive/migrate-pages-resources-source.js +46 -0
  39. package/lib/archive/page.d.ts +187 -49
  40. package/lib/archive/page.js +258 -63
  41. package/lib/archive/peek-archive-lock.d.ts +40 -0
  42. package/lib/archive/peek-archive-lock.js +62 -0
  43. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  44. package/lib/archive/resolve-redirect-chain.js +27 -0
  45. package/lib/archive/types.d.ts +135 -26
  46. package/lib/crawler/close-browser-safely.d.ts +64 -0
  47. package/lib/crawler/close-browser-safely.js +73 -0
  48. package/lib/crawler/crawler.d.ts +4 -1
  49. package/lib/crawler/crawler.js +290 -32
  50. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  51. package/lib/crawler/create-change-phase-handler.js +44 -0
  52. package/lib/crawler/derive-page-source.d.ts +23 -0
  53. package/lib/crawler/derive-page-source.js +28 -0
  54. package/lib/crawler/derive-resource-source.d.ts +23 -0
  55. package/lib/crawler/derive-resource-source.js +26 -0
  56. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  57. package/lib/crawler/drain-phase-errors.js +35 -0
  58. package/lib/crawler/fetch-destination.js +38 -2
  59. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  60. package/lib/crawler/format-crawl-progress.js +14 -6
  61. package/lib/crawler/handle-browser-close.d.ts +29 -0
  62. package/lib/crawler/handle-browser-close.js +28 -0
  63. package/lib/crawler/is-html-content-type.d.ts +17 -0
  64. package/lib/crawler/is-html-content-type.js +19 -0
  65. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  66. package/lib/crawler/is-likely-html-url.js +65 -0
  67. package/lib/crawler/kill-process-tree.d.ts +94 -0
  68. package/lib/crawler/kill-process-tree.js +178 -0
  69. package/lib/crawler/link-list.js +2 -1
  70. package/lib/crawler/link-to-page-data.d.ts +13 -5
  71. package/lib/crawler/link-to-page-data.js +26 -5
  72. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  73. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  74. package/lib/crawler/normalize-content-type.d.ts +14 -0
  75. package/lib/crawler/normalize-content-type.js +20 -0
  76. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  77. package/lib/crawler/partition-urls-by-html.js +23 -0
  78. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  79. package/lib/crawler/redirect-dest-key.js +27 -0
  80. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  81. package/lib/crawler/resource-to-page-data.js +59 -0
  82. package/lib/crawler/types.d.ts +122 -1
  83. package/lib/crawler-orchestrator.d.ts +93 -1
  84. package/lib/crawler-orchestrator.js +389 -12
  85. package/lib/crawler.d.ts +5 -0
  86. package/lib/crawler.js +3 -0
  87. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  88. package/lib/resource-row-to-lookup-result.js +20 -0
  89. package/lib/types.d.ts +11 -1
  90. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  91. package/lib/utils/object/parse-response-headers.js +26 -0
  92. package/package.json +4 -4
@@ -4,15 +4,13 @@ import type { ParseURLOptions } from '@d-zero/shared/parse-url';
4
4
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
5
5
  import Page from './page.js';
6
6
  import Resource from './resource.js';
7
- /**
8
- * Provides read-only access to an archive's database and stored data files.
9
- *
10
- * This class is the base for the `Archive` class and is also returned
11
- * by `Archive.connect` for read-only access to an existing archive.
12
- * It supports querying pages, anchors, referrers, resources, and custom data.
13
- */
14
7
  export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
15
8
  #private;
9
+ /**
10
+ * Whether this accessor was opened in read-only mode (no filesystem
11
+ * mutation on `tmpDir`).
12
+ */
13
+ get readOnly(): boolean;
16
14
  /**
17
15
  * The absolute path to the temporary working directory used by this accessor.
18
16
  */
@@ -23,8 +21,47 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
23
21
  * @param db - The Database instance for querying the SQLite database.
24
22
  * @param namespace - An optional namespace for scoping custom data storage.
25
23
  * When null, `setData` is not available.
24
+ * @param options - Construction options.
25
+ * @param options.readOnly - When `true`, helpers must not mutate the
26
+ * filesystem under `tmpDir` (used for live-crawl / stub-mode opens
27
+ * where any write would race the crawler).
26
28
  */
27
- constructor(tmpDir: string, db: Database, namespace?: string | null);
29
+ constructor(tmpDir: string, db: Database, namespace?: string | null, options?: {
30
+ readOnly?: boolean;
31
+ });
32
+ /**
33
+ * Closes the underlying database connection.
34
+ *
35
+ * This is the **read-only** close path: it releases the SQLite handle and
36
+ * does nothing else. The temporary working directory is left untouched and
37
+ * no `.nitpicker` archive is produced. This makes it safe to call from
38
+ * read-only consumers (e.g. the viewer attached to an in-progress crawl's
39
+ * tmpDir), where touching the filesystem would race with — or destroy —
40
+ * the live crawler's working state.
41
+ *
42
+ * Subclasses that own the archive's lifecycle (notably {@link Archive})
43
+ * override this to add write/cleanup steps.
44
+ *
45
+ * **Idempotent and concurrent-safe**: the first invocation captures the
46
+ * close promise; later invocations (from the same caller, a shutdown
47
+ * signal handler, or a parallel manager teardown) await the same
48
+ * promise and resolve together. If `db.destroy()` rejects, the
49
+ * rejection propagates to *all* awaiters and the accessor stays
50
+ * latched closed — a hung knex pool is not safe to "retry close".
51
+ *
52
+ * **Bounded**: when the optional `timeoutMs` (default {@link
53
+ * DEFAULT_CLOSE_TIMEOUT_MS}) elapses before `db.destroy()` settles, the
54
+ * call resolves with a warning. This prevents a viewer shutdown from
55
+ * being held for the underlying pool's 10-minute `acquireTimeoutMillis`
56
+ * when the live crawler holds the SQLite write lock.
57
+ * @param options - Close options.
58
+ * @param options.timeoutMs - Milliseconds to wait for `db.destroy()`
59
+ * before giving up. Use `Infinity` to wait indefinitely (only
60
+ * advisable in tests and batch jobs that own the DB exclusively).
61
+ */
62
+ close(options?: {
63
+ timeoutMs?: number;
64
+ }): Promise<void>;
28
65
  /**
29
66
  * Retrieves anchor (link) data for a specific page by its database ID.
30
67
  * @param pageId - The database ID of the page whose anchors to retrieve.
@@ -51,13 +88,42 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
51
88
  */
52
89
  getData(name: string, format?: 'txt' | 'html'): Promise<string>;
53
90
  /**
54
- * Reads the HTML content of a page snapshot from the archive.
55
- * Supports reading from both unzipped directories and zipped snapshot archives.
56
- * @param filePath - The relative file path to the HTML snapshot, or null.
57
- * @param openZipped - Whether to attempt unzipping the snapshot archive. Defaults to `true`.
58
- * @returns The HTML content as a string, or null if the snapshot is not found or filePath is null.
91
+ * Reads the HTML snapshot of a page from the archive.
92
+ *
93
+ * HTML is stored as zstd-compressed BLOBs in `page_html_blobs` (keyed by
94
+ * SHA-256 of the raw bytes) with `page_html_ref` linking `page_id hash`.
95
+ * The read is a straight join + decompress; writer- and read-only (stub)
96
+ * accessors take the same code path because nothing here touches the
97
+ * filesystem.
98
+ *
99
+ * Returns `null` when the page row exists but has no stored body — for
100
+ * example a redirect source, a non-HTML resource (PDF), a page that
101
+ * failed to render, or an external page (whose row is metadata-only).
102
+ * Distinguishing "no body stored" from "empty body" is preserved: an
103
+ * empty HTML string returns `''`, not `null`.
104
+ *
105
+ * Throws if the page's referenced blob is missing or the codec marker
106
+ * is unrecognised — both indicate an archive that was truncated or
107
+ * written by a future tool, neither of which we silently paper over.
108
+ * @param pageId - The database id of the page to read.
109
+ * @returns The HTML content as a UTF-8 string, or `null` when no body
110
+ * is stored for `pageId`.
111
+ * @example
112
+ * const html = await accessor.getHtmlOfPage(pageId);
113
+ * if (html === null) {
114
+ * // page has no stored body — redirect source / non-HTML / failed render
115
+ * } else {
116
+ * processHtml(html);
117
+ * }
59
118
  */
60
- getHtmlOfPage(filePath: string | null, openZipped?: boolean): Promise<string | null>;
119
+ getHtmlOfPage(pageId: number): Promise<string | null>;
120
+ /**
121
+ * Retrieves the JSON-LD / SpeculationRules entries for the given page,
122
+ * parsed back from the `page_jsonld` table.
123
+ * @param pageId - The database id of the page.
124
+ * @returns Ordered entries with `kind`, `type`, `raw`, `parsed`, `parseError`.
125
+ */
126
+ getJsonLdOfPage(pageId: number): Promise<import("./meta/types.js").JsonLdRow[]>;
61
127
  /**
62
128
  * Returns the underlying Knex query builder instance for direct SQL access.
63
129
  * Enables advanced queries (GROUP BY, HAVING, JOINs) at the database layer
@@ -106,6 +172,13 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
106
172
  * @returns An array of resource URL strings.
107
173
  */
108
174
  getResourceUrlList(): Promise<any[]>;
175
+ /**
176
+ * Retrieves the Wappalyzer tag entries for the given page, parsed back
177
+ * from the `page_tags` table.
178
+ * @param pageId - The database id of the page.
179
+ * @returns Ordered entries with provider, category, externalId, etc.
180
+ */
181
+ getTagsOfPage(pageId: number): Promise<import("./meta/types.js").TagRow[]>;
109
182
  /**
110
183
  * Stores custom data in the archive under the configured namespace.
111
184
  * Requires a namespace to be set on this accessor; throws if namespace is null.
@@ -1,8 +1,6 @@
1
1
  import path from 'node:path';
2
- import { extractZip, unzip } from '@d-zero/fs/zip';
3
2
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
4
3
  import { log } from './debug.js';
5
- import { exists } from './filesystem/exists.js';
6
4
  import { outputJSON } from './filesystem/output-json.js';
7
5
  import { outputText } from './filesystem/output-text.js';
8
6
  import { readJSON } from './filesystem/read-json.js';
@@ -17,13 +15,45 @@ import { safePath } from './safe-path.js';
17
15
  * by `Archive.connect` for read-only access to an existing archive.
18
16
  * It supports querying pages, anchors, referrers, resources, and custom data.
19
17
  */
18
+ /**
19
+ * Default timeout for {@link ArchiveAccessor.close}'s `db.destroy()` step.
20
+ *
21
+ * `knex.destroy()` will otherwise wait the full `acquireTimeoutMillis`
22
+ * (10 minutes in this repo) for in-flight queries to drain. For a viewer
23
+ * shut down by Ctrl-C while the live crawler holds a long write lock that
24
+ * is an unacceptable user experience, so we bound the wait and treat the
25
+ * accessor as closed after the timeout regardless.
26
+ */
27
+ const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
20
28
  export class ArchiveAccessor extends EventEmitter {
29
+ /**
30
+ * Promise tracking an in-progress (or completed) close. `null` means the
31
+ * accessor is open and idle; a settled promise means we are closed (the
32
+ * accessor stays "closed" even if `db.destroy()` rejected, because there
33
+ * is nothing safe to retry — see {@link close}).
34
+ */
35
+ #closeOnce = null;
21
36
  /** The SQLite database instance for querying archived data. */
22
37
  #db;
23
38
  /** Namespace prefix for custom data storage (e.g. `"analysis/plugin-name"`). `null` disables `setData`. */
24
39
  #namespace = null;
40
+ /**
41
+ * Whether this accessor was opened in read-only mode. With HTML stored
42
+ * as a SQLite BLOB, this no longer toggles any code path — the SELECT
43
+ * is identical for writer- and reader-mode accessors. Kept on the
44
+ * accessor so callers like the viewer can still surface "this archive
45
+ * is being read read-only" UI hints without re-deriving it.
46
+ */
47
+ #readOnly;
25
48
  /** Absolute path to the temporary working directory containing the database and files. */
26
49
  #tmpDir;
50
+ /**
51
+ * Whether this accessor was opened in read-only mode (no filesystem
52
+ * mutation on `tmpDir`).
53
+ */
54
+ get readOnly() {
55
+ return this.#readOnly;
56
+ }
27
57
  /**
28
58
  * The absolute path to the temporary working directory used by this accessor.
29
59
  */
@@ -36,16 +66,59 @@ export class ArchiveAccessor extends EventEmitter {
36
66
  * @param db - The Database instance for querying the SQLite database.
37
67
  * @param namespace - An optional namespace for scoping custom data storage.
38
68
  * When null, `setData` is not available.
69
+ * @param options - Construction options.
70
+ * @param options.readOnly - When `true`, helpers must not mutate the
71
+ * filesystem under `tmpDir` (used for live-crawl / stub-mode opens
72
+ * where any write would race the crawler).
39
73
  */
40
- constructor(tmpDir, db, namespace = null) {
74
+ constructor(tmpDir, db, namespace = null, options = {}) {
41
75
  super();
42
76
  this.#tmpDir = tmpDir;
43
77
  this.#db = db;
44
78
  this.#namespace = namespace;
79
+ this.#readOnly = options.readOnly ?? false;
45
80
  this.#db.on('error', (e) => {
46
81
  void this.emit('error', e);
47
82
  });
48
83
  }
84
+ /**
85
+ * Closes the underlying database connection.
86
+ *
87
+ * This is the **read-only** close path: it releases the SQLite handle and
88
+ * does nothing else. The temporary working directory is left untouched and
89
+ * no `.nitpicker` archive is produced. This makes it safe to call from
90
+ * read-only consumers (e.g. the viewer attached to an in-progress crawl's
91
+ * tmpDir), where touching the filesystem would race with — or destroy —
92
+ * the live crawler's working state.
93
+ *
94
+ * Subclasses that own the archive's lifecycle (notably {@link Archive})
95
+ * override this to add write/cleanup steps.
96
+ *
97
+ * **Idempotent and concurrent-safe**: the first invocation captures the
98
+ * close promise; later invocations (from the same caller, a shutdown
99
+ * signal handler, or a parallel manager teardown) await the same
100
+ * promise and resolve together. If `db.destroy()` rejects, the
101
+ * rejection propagates to *all* awaiters and the accessor stays
102
+ * latched closed — a hung knex pool is not safe to "retry close".
103
+ *
104
+ * **Bounded**: when the optional `timeoutMs` (default {@link
105
+ * DEFAULT_CLOSE_TIMEOUT_MS}) elapses before `db.destroy()` settles, the
106
+ * call resolves with a warning. This prevents a viewer shutdown from
107
+ * being held for the underlying pool's 10-minute `acquireTimeoutMillis`
108
+ * when the live crawler holds the SQLite write lock.
109
+ * @param options - Close options.
110
+ * @param options.timeoutMs - Milliseconds to wait for `db.destroy()`
111
+ * before giving up. Use `Infinity` to wait indefinitely (only
112
+ * advisable in tests and batch jobs that own the DB exclusively).
113
+ */
114
+ async close(options = {}) {
115
+ if (this.#closeOnce) {
116
+ return this.#closeOnce;
117
+ }
118
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
119
+ this.#closeOnce = this.#runClose(timeoutMs);
120
+ return this.#closeOnce;
121
+ }
49
122
  /**
50
123
  * Retrieves anchor (link) data for a specific page by its database ID.
51
124
  * @param pageId - The database ID of the page whose anchors to retrieve.
@@ -71,43 +144,50 @@ export class ArchiveAccessor extends EventEmitter {
71
144
  return await readText(filePath);
72
145
  }
73
146
  /**
74
- * Reads the HTML content of a page snapshot from the archive.
75
- * Supports reading from both unzipped directories and zipped snapshot archives.
76
- * @param filePath - The relative file path to the HTML snapshot, or null.
77
- * @param openZipped - Whether to attempt unzipping the snapshot archive. Defaults to `true`.
78
- * @returns The HTML content as a string, or null if the snapshot is not found or filePath is null.
147
+ * Reads the HTML snapshot of a page from the archive.
148
+ *
149
+ * HTML is stored as zstd-compressed BLOBs in `page_html_blobs` (keyed by
150
+ * SHA-256 of the raw bytes) with `page_html_ref` linking `page_id hash`.
151
+ * The read is a straight join + decompress; writer- and read-only (stub)
152
+ * accessors take the same code path because nothing here touches the
153
+ * filesystem.
154
+ *
155
+ * Returns `null` when the page row exists but has no stored body — for
156
+ * example a redirect source, a non-HTML resource (PDF), a page that
157
+ * failed to render, or an external page (whose row is metadata-only).
158
+ * Distinguishing "no body stored" from "empty body" is preserved: an
159
+ * empty HTML string returns `''`, not `null`.
160
+ *
161
+ * Throws if the page's referenced blob is missing or the codec marker
162
+ * is unrecognised — both indicate an archive that was truncated or
163
+ * written by a future tool, neither of which we silently paper over.
164
+ * @param pageId - The database id of the page to read.
165
+ * @returns The HTML content as a UTF-8 string, or `null` when no body
166
+ * is stored for `pageId`.
167
+ * @example
168
+ * const html = await accessor.getHtmlOfPage(pageId);
169
+ * if (html === null) {
170
+ * // page has no stored body — redirect source / non-HTML / failed render
171
+ * } else {
172
+ * processHtml(html);
173
+ * }
79
174
  */
80
- async getHtmlOfPage(filePath, openZipped = true) {
81
- if (!filePath) {
82
- return null;
83
- }
84
- const snapshotDir = safePath(this.#tmpDir, path.dirname(filePath));
85
- const name = path.basename(filePath);
86
- if (openZipped) {
87
- await unzip(`${snapshotDir}.zip`, snapshotDir);
88
- }
89
- if (exists(snapshotDir)) {
90
- log('Load %s directly because snapshot dir is unzipped', name);
91
- const html = await readText(path.resolve(snapshotDir, name)).catch((error) => error);
92
- if (typeof html === 'string') {
93
- log('Loaded: %s ...', html.split('\n')[0]);
94
- return html;
95
- }
96
- log('Failed Loading: %O', html);
97
- return null;
175
+ async getHtmlOfPage(pageId) {
176
+ const html = await this.#db.getHtmlOfPageById(pageId);
177
+ if (html === null) {
178
+ log('No HTML body stored for page id=%d', pageId);
98
179
  }
99
- log('Extracts %s from zipped snapshots', name);
100
- const zipDir = await extractZip(`${snapshotDir}.zip`);
101
- const file = zipDir.files.find((f) => f.type === 'File' && f.path === name);
102
- if (!file) {
103
- log('Failed: Not found %s from zipped snapshots', name);
104
- return null;
105
- }
106
- const buffer = await file.buffer();
107
- const html = buffer.toString('utf8') || null;
108
- log('Succeeded: Extracts %s from zipped snapshots', name);
109
180
  return html;
110
181
  }
182
+ /**
183
+ * Retrieves the JSON-LD / SpeculationRules entries for the given page,
184
+ * parsed back from the `page_jsonld` table.
185
+ * @param pageId - The database id of the page.
186
+ * @returns Ordered entries with `kind`, `type`, `raw`, `parsed`, `parseError`.
187
+ */
188
+ async getJsonLdOfPage(pageId) {
189
+ return this.#db.getJsonLdOfPage(pageId);
190
+ }
111
191
  /**
112
192
  * Returns the underlying Knex query builder instance for direct SQL access.
113
193
  * Enables advanced queries (GROUP BY, HAVING, JOINs) at the database layer
@@ -200,6 +280,15 @@ export class ArchiveAccessor extends EventEmitter {
200
280
  async getResourceUrlList() {
201
281
  return this.#db.getResourceUrlList();
202
282
  }
283
+ /**
284
+ * Retrieves the Wappalyzer tag entries for the given page, parsed back
285
+ * from the `page_tags` table.
286
+ * @param pageId - The database id of the page.
287
+ * @returns Ordered entries with provider, category, externalId, etc.
288
+ */
289
+ async getTagsOfPage(pageId) {
290
+ return this.#db.getTagsOfPage(pageId);
291
+ }
203
292
  /**
204
293
  * Stores custom data in the archive under the configured namespace.
205
294
  * Requires a namespace to be set on this accessor; throws if namespace is null.
@@ -282,4 +371,41 @@ export class ArchiveAccessor extends EventEmitter {
282
371
  log('Create Page Data: Done');
283
372
  return pPages;
284
373
  }
374
+ /**
375
+ * Actual close worker — invoked exactly once per accessor via
376
+ * {@link close}'s shared promise. Races `db.destroy()` against the
377
+ * caller-supplied timeout; on timeout we log and resolve so the
378
+ * consumer (typically a process shutting down) is not blocked, even
379
+ * though the underlying knex pool may still be draining in the
380
+ * background.
381
+ * @param timeoutMs - Maximum time to wait for `db.destroy()`.
382
+ */
383
+ async #runClose(timeoutMs) {
384
+ if (!Number.isFinite(timeoutMs)) {
385
+ await this.#db.destroy();
386
+ return;
387
+ }
388
+ let timer = null;
389
+ const timeout = new Promise((resolve) => {
390
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
391
+ });
392
+ // Track destroy() so we can attach an error-suppressing handler if we
393
+ // give up waiting — otherwise a late rejection becomes an unhandled
394
+ // promise rejection on the process.
395
+ const destroy = this.#db.destroy().then(() => 'done');
396
+ try {
397
+ const result = await Promise.race([destroy, timeout]);
398
+ if (result === 'timeout') {
399
+ log('ArchiveAccessor.close: db.destroy() did not settle within %dms — giving up', timeoutMs);
400
+ destroy.catch((error) => {
401
+ log('ArchiveAccessor.close: late db.destroy() rejection (post-timeout): %O', error);
402
+ });
403
+ }
404
+ }
405
+ finally {
406
+ if (timer) {
407
+ clearTimeout(timer);
408
+ }
409
+ }
410
+ }
285
411
  }
@@ -1,12 +1,15 @@
1
- import type { Config } from './types.js';
1
+ import type { Config, 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';
5
5
  /**
6
- * Main archive class for creating, opening, resuming, and writing Nitpicker archive files (`.nitpicker`).
6
+ * Main archive class for creating, opening, resuming, and writing Nitpicker
7
+ * archive files (`.nitpicker`).
7
8
  *
8
- * An Archive wraps a SQLite database and optional HTML snapshots into a compressed
9
- * tar archive. It extends {@link ArchiveAccessor} to provide read access to stored data.
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
+ * {@link ArchiveAccessor} to provide read access to stored data.
10
13
  *
11
14
  * Use the static factory methods ({@link Archive.create}, {@link Archive.open},
12
15
  * {@link Archive.resume}, {@link Archive.connect}) to obtain instances.
@@ -18,22 +21,42 @@ export default class Archive extends ArchiveAccessor {
18
21
  * The absolute file path of the archive (`.nitpicker` file).
19
22
  */
20
23
  get filePath(): string;
24
+ /**
25
+ * The intermediate directory `Archive.write()` produces by renaming
26
+ * `tmpDir` before tarring (`{cwd}/{archiveName}`). Exposed so the
27
+ * manager can include it in its cleanup-on-failure path: if `tar()`
28
+ * fails after the rename, this directory is orphaned and would
29
+ * otherwise be invisible to a `rmSync(tmpDir)` recovery.
30
+ */
31
+ get renamedDir(): string;
21
32
  private constructor();
22
33
  /**
23
34
  * @deprecated This method is no longer functional.
24
35
  */
25
36
  abort(): void;
26
37
  /**
27
- * Appends an error entry to the archive's error log file.
38
+ * Records a crawler-level error to both the human-readable `error.log` (full
39
+ * stack, for debugging) and the structured `crawl_errors` table (queryable,
40
+ * for the `error-kinds` analysis). The cause is not classified here — it is
41
+ * derived on read. `error.log` keeps the full stack while `crawl_errors`
42
+ * stores `error.message`; both normally carry the same cause token (e.g.
43
+ * `ENOTFOUND`), so classification agrees across the two — only an error whose
44
+ * cause lives solely in deeper stack frames could differ.
28
45
  * @param error - The crawler error object containing process and URL information.
29
46
  */
30
47
  addError(error: CrawlerError): Promise<void>;
31
48
  /**
32
- * Closes the archive. If the archive file does not yet exist on disk,
33
- * it writes the archive first. If the temporary directory still exists,
34
- * it is removed.
49
+ * Records a partial scrape failure against the page identified by `url`.
50
+ *
51
+ * The corresponding `pages` row is created on demand (or matched if it
52
+ * already exists), so the call works even if the page's normal data has
53
+ * not been written yet.
54
+ * @param url - URL of the affected page.
55
+ * @param phase - Scrape phase name (typically `'retryExhausted'`).
56
+ * @param message - Human-readable failure message.
57
+ * @param isExternal - Whether the URL is external. Defaults to `false`.
35
58
  */
36
- close(): Promise<void>;
59
+ addPageError(url: string, phase: string, message: string, isExternal?: boolean): Promise<void>;
37
60
  /**
38
61
  * Retrieves the current crawling state, including lists of scraped and pending URLs.
39
62
  * @returns An object with `scraped` and `pending` URL arrays.
@@ -42,11 +65,56 @@ export default class Archive extends ArchiveAccessor {
42
65
  scraped: string[];
43
66
  pending: string[];
44
67
  }>;
68
+ /**
69
+ * Return the subset of `urls` that already exist as `pages.url`. Used by
70
+ * `CrawlerOrchestrator.inventory` to filter the user-supplied URL list
71
+ * down to "URLs that are NOT yet in the archive" — only those reach the
72
+ * HEAD / scrape pipeline. Existing URLs are skipped to keep the second
73
+ * (and N-th) `--inventory` pass non-destructive.
74
+ * @param urls - Candidate URLs in `withoutHashAndAuth` form.
75
+ * @returns URLs already present in `pages`.
76
+ */
77
+ getExistingPageUrls(urls: readonly string[]): Promise<string[]>;
78
+ /**
79
+ * Return the subset of `urls` that already exist as `resources.url`. See
80
+ * {@link Archive.getExistingPageUrls} — the resource-side counterpart used
81
+ * by inventory mode to skip URLs that are already tracked as
82
+ * sub-resources.
83
+ * @param urls - Candidate URLs.
84
+ * @returns URLs already present in `resources`.
85
+ */
86
+ getExistingResourceUrls(urls: readonly string[]): Promise<string[]>;
87
+ /**
88
+ * Retrieves a single recorded sub-resource by its URL.
89
+ * @param urls - URL candidates to match against the stored resource URL.
90
+ * @returns The raw resource row, or `null` if none match.
91
+ */
92
+ getResourceByUrl(urls: readonly string[]): Promise<import("./types.js").DB_Resource | null>;
93
+ /**
94
+ * Counts the number of pages already scraped as crawl targets in the archive.
95
+ *
96
+ * Lets the crawler initialize its session-progress counter on resume so the
97
+ * displayed HTML-page count accounts for previously-rendered pages.
98
+ * @returns The count of pages with `isTarget = 1` and `scraped = 1`.
99
+ */
100
+ getScrapedHtmlPageCount(): Promise<number>;
45
101
  /**
46
102
  * Retrieves the base URL of the crawl session from the archive database.
47
103
  * @returns The base URL string.
48
104
  */
49
105
  getUrl(): Promise<any>;
106
+ /**
107
+ * Releases the SQLite handle and the advisory lock **without** writing
108
+ * the archive or removing `tmpDir`.
109
+ *
110
+ * Use this when you need to detach from a freshly-created `Archive`
111
+ * without finalising it — fixtures producing a stub state for tests,
112
+ * tooling that wants to leave the tmpDir alive for `crawl --resume`,
113
+ * or any non-orchestrator caller that owns the lifecycle externally.
114
+ * Shares the same idempotency guard as {@link close}, so the two paths
115
+ * are mutually exclusive (the first one called wins).
116
+ */
117
+ releaseHandle(): Promise<void>;
50
118
  /**
51
119
  * Promote previously-external pages that now fall under the (possibly extended)
52
120
  * scope back to a pending state so that the crawler re-scrapes them as fully
@@ -56,30 +124,53 @@ export default class Archive extends ArchiveAccessor {
56
124
  * @returns The URLs that were repromoted.
57
125
  */
58
126
  repromoteExternalPages(scopes: ReadonlyMap<string, readonly ExURL[]>, options?: ParseURLOptions): Promise<string[]>;
127
+ /**
128
+ * Reset previously-failed pages back to pending so a follow-up crawl re-fetches them.
129
+ *
130
+ * Delegates to {@link Database.resetFailedPages}. See that method for the
131
+ * exact failure criteria (missing status / content type, or a 5xx status).
132
+ * @returns The URLs of the pages that were reset to pending.
133
+ */
134
+ resetFailedPages(): Promise<string[]>;
59
135
  /**
60
136
  * Stores the crawl configuration into the archive database.
61
137
  * @param config - The configuration object to store.
62
138
  */
63
139
  setConfig(config: Config): Promise<number[]>;
64
140
  /**
65
- * Stores an external page's data in the archive database without saving a snapshot.
141
+ * Stores an external page's data in the archive database without storing
142
+ * an HTML snapshot. External-page rows carry only metadata (status, title,
143
+ * content-type), never a rendered body.
66
144
  * @param pageInfo - The page data to store.
145
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
67
146
  */
68
- setExternalPage(pageInfo: PageData): Promise<void>;
147
+ setExternalPage(pageInfo: PageData, source?: PageSource): Promise<void>;
69
148
  /**
70
- * Stores a crawled page's data in the archive database and optionally saves an HTML snapshot.
71
- * If the snapshot file write fails, the HTML path in the database is cleared to prevent
72
- * referencing a non-existent file, and the error is re-thrown.
149
+ * Stores a crawled page's data in the archive database, persisting the
150
+ * rendered HTML body as a zstd-compressed BLOB inside the same SQLite
151
+ * transaction. Storage is content-addressable: identical bodies across
152
+ * pages share a single `page_html_blobs` row.
73
153
  * @param pageInfo - The page data to store.
154
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
74
155
  * @returns The database ID of the stored page.
75
- * @throws {Error} Re-throws any error from the snapshot file write after clearing the HTML path.
76
156
  */
77
- setPage(pageInfo: PageData): Promise<number>;
157
+ setPage(pageInfo: PageData, source?: PageSource): Promise<number>;
158
+ /**
159
+ * Records a redirect edge without re-storing the destination's content.
160
+ *
161
+ * The crawler calls this (instead of {@link setPage}) when a URL redirects to
162
+ * a destination that has already been rendered (#73): only the source →
163
+ * destination edge is written, leaving the destination's stored title / meta /
164
+ * anchors / images untouched.
165
+ * @param pageInfo - The HEAD-resolved page data carrying the redirect chain.
166
+ */
167
+ setRedirect(pageInfo: PageData): Promise<void>;
78
168
  /**
79
169
  * Stores a sub-resource (CSS, JS, image, etc.) in the archive database.
80
170
  * @param resource - The resource data to store.
171
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
81
172
  */
82
- setResources(resource: Resource): Promise<void>;
173
+ setResources(resource: Resource, source?: PageSource): Promise<void>;
83
174
  /**
84
175
  * Stores the referrer relationship between a resource and the page that references it.
85
176
  * @param params - An object containing `url` (the page URL) and `src` (the resource URL).
@@ -109,25 +200,35 @@ export default class Archive extends ArchiveAccessor {
109
200
  */
110
201
  updateConfig(patch: Partial<Config>): Promise<void>;
111
202
  /**
112
- * Writes the archive to disk as a compressed `.nitpicker` file.
203
+ * Writes the archive to disk as a `.nitpicker` tar file.
113
204
  *
114
- * This method compresses the HTML snapshot directory into a zip file,
115
- * renames the temporary working directory, and creates the final tar archive.
116
- * The temporary directory is removed after writing.
205
+ * Checkpoints the SQLite WAL so the database is self-contained inside
206
+ * `db.sqlite`, renames the temporary working directory to the archive's
207
+ * basename, and tars it into the final `.nitpicker`. The tar container
208
+ * holds a single `db.sqlite` file (the legacy `snapshot-html.zip` is gone
209
+ * — HTML lives as BLOBs in the DB), so finalisation is effectively a
210
+ * single-file copy with no per-snapshot syscalls.
117
211
  */
118
212
  write(): Promise<void>;
119
213
  /** The file extension for Nitpicker archive files (without the leading dot). */
120
214
  static FILE_EXTENSION: string;
121
- /** The directory name used for storing HTML snapshots within the archive. */
122
- static readonly SNAPSHOT_HTML_DIR = "snapshot-html";
123
215
  /** The filename of the SQLite database within the archive. */
124
216
  static readonly SQLITE_DB_FILE_NAME = "db.sqlite";
125
217
  /** The prefix used for temporary working directories during archive operations. */
126
218
  static TMP_DIR_PREFIX: string;
127
219
  /**
128
220
  * Opens a read-only connection to an existing archive's database.
221
+ *
129
222
  * Returns an {@link ArchiveAccessor} that provides query methods
130
- * without the ability to modify or write the archive.
223
+ * without the ability to modify or write the archive. The DB is opened
224
+ * in **read-only mode**: no schema migrations run, and the connection
225
+ * refuses to resurrect a missing parent directory or db file (so a
226
+ * TOCTOU window between source classification and this call cannot
227
+ * silently produce an empty phantom tmpDir).
228
+ *
229
+ * The returned accessor is also marked read-only so consumer-facing
230
+ * helpers (e.g. {@link ArchiveAccessor.getHtmlOfPage}) avoid any
231
+ * filesystem mutation on the user's tmpDir.
131
232
  * @param tmpDir - The path to the temporary directory containing the database.
132
233
  * @param namespace - An optional namespace for scoping data access within the archive.
133
234
  * @returns An ArchiveAccessor instance for querying the archive data.
@@ -167,6 +268,28 @@ export default class Archive extends ArchiveAccessor {
167
268
  * @returns A formatted timestamp string.
168
269
  */
169
270
  static timestamp(): string;
271
+ /**
272
+ * Closes the archive. If the archive file does not yet exist on disk,
273
+ * it writes the archive first. If the temporary directory still exists,
274
+ * it is removed. The database connection is then closed via
275
+ * {@link ArchiveAccessor.close} (the base class owns the SQLite handle),
276
+ * and finally the archive's advisory lock is released.
277
+ *
278
+ * **Idempotent**: the first invocation captures the close promise;
279
+ * subsequent invocations (signal handlers, parallel teardowns, retried
280
+ * orchestrator paths) await the same promise instead of re-entering
281
+ * the destructive prologue on a half-mutated state. If the first
282
+ * close fails (e.g. ENOSPC during tar), the rejection propagates to
283
+ * all awaiters and the archive stays latched closed — there is no
284
+ * safe way to retry `write()` once `tmpDir` has been renamed.
285
+ *
286
+ * **Read-only consumers must not reach this override.** Anything that
287
+ * obtains an archive view via {@link Archive.connect} receives an
288
+ * {@link ArchiveAccessor} (not an `Archive`), so `close()` resolves to
289
+ * the safe base implementation — no `write()`, no `remove()`, no lock
290
+ * release — leaving the tmpDir intact for the live crawler.
291
+ */
292
+ close(): Promise<void>;
170
293
  /**
171
294
  * Retrieves the crawl configuration stored in the archive database.
172
295
  * @returns The configuration object.