@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
@@ -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
  }