@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
@@ -0,0 +1,32 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Build the absolute path to a single archive's cache directory.
4
+ *
5
+ * The directory name is `<cacheKey>-<safeBasename>`:
6
+ *
7
+ * - `cacheKey` carries the freshness signal (size/mtime/ctime) and is
8
+ * what actually drives cache hit vs miss.
9
+ * - `safeBasename` is appended purely so a human running `ls` on the
10
+ * cache root can recognise which archive an entry belongs to. It is
11
+ * NOT used to disambiguate keys — two archives with identical inode
12
+ * metadata (e.g. an identical copy under a different name) intentionally
13
+ * share an entry under the first basename that landed there.
14
+ *
15
+ * The basename is sanitised: anything outside `[A-Za-z0-9._-]` becomes
16
+ * `_`. This keeps the path portable across filesystems (no spaces,
17
+ * unicode normalisation surprises, Windows-reserved chars) and removes
18
+ * any chance that a crafted archive name could escape the cache root
19
+ * (e.g. via `..` or path separators), independent of the upstream
20
+ * `path.basename` call that already drops directory components.
21
+ * @param cacheRoot - Absolute path returned by `getArchiveCacheRoot()`.
22
+ * @param cacheKey - The freshness key from `computeArchiveCacheKey()`.
23
+ * @param archivePath - Absolute path to the source `.nitpicker` file;
24
+ * only its basename contributes to the cache entry name.
25
+ * @returns Absolute path to the per-archive cache directory.
26
+ */
27
+ export function resolveArchiveCacheDir(cacheRoot, cacheKey, archivePath) {
28
+ const rawBasename = path.basename(archivePath, path.extname(archivePath));
29
+ const safeBasename = rawBasename.replaceAll(/[^\w.-]+/g, '_').slice(0, 80);
30
+ const dirName = safeBasename.length > 0 ? `${cacheKey}-${safeBasename}` : cacheKey;
31
+ return path.resolve(cacheRoot, dirName);
32
+ }
@@ -1,4 +1,5 @@
1
- import type { Config, DatabaseOption, DB_Anchor, DB_Page, DB_Redirect, DB_Referrer, DB_Resource, DatabaseEvent, PageFilter } from './types.js';
1
+ import type { JsonLdRow, TagRow } from './meta/types.js';
2
+ import type { Config, DatabaseOption, DB_Anchor, DB_Page, DB_Redirect, DB_Referrer, DB_Resource, DatabaseEvent, InventoryRunMeta, PageFilter, PageSource } from './types.js';
2
3
  import type { PageData, Resource } from '../utils/types/types.js';
3
4
  import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
4
5
  import type { Knex } from 'knex';
@@ -6,10 +7,19 @@ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-awa
6
7
  /**
7
8
  * Low-level database abstraction layer for the archive's SQLite database.
8
9
  *
9
- * Manages the `pages`, `anchors`, `images`, `resources`, and `resources-referrers`
10
- * tables. All public methods that perform database queries use the `@retryable`
11
- * decorator for automatic retry on transient failures, and `@ErrorEmitter` to
12
- * propagate errors as events.
10
+ * Public methods that perform database queries use the `emitErrorAndRetry`
11
+ * HOF for automatic retry on transient failures combined with error-event
12
+ * propagation, or `emitError` when retry is not appropriate. The set of
13
+ * tables this layer manages is
14
+ * defined by `init-schema.ts` (the source of truth — query that file for
15
+ * the canonical list).
16
+ *
17
+ * **Label sync caveat**: each `emitError` / `emitErrorAndRetry` call passes
18
+ * the method name as a string literal (e.g. `'Database.getAnchorsOnPage'`).
19
+ * TypeScript cannot check that the string matches the enclosing method's
20
+ * real name — the two-way sync is manual. Renaming a method here **must**
21
+ * update the literal string too, otherwise debug logs and `RetryTimeoutError`
22
+ * messages will silently report the old name.
13
23
  *
14
24
  * Use the static {@link Database.connect} factory method to create instances.
15
25
  * The constructor is private.
@@ -30,12 +40,6 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
30
40
  * This ensures the database is fully self-contained in `db.sqlite` before archiving.
31
41
  */
32
42
  checkpoint(): Promise<void>;
33
- /**
34
- * Clears the HTML snapshot path for a page.
35
- * Used to roll back the snapshot reference when the snapshot file write fails.
36
- * @param pageId - The database ID of the page whose HTML path should be cleared.
37
- */
38
- clearHtmlPath(pageId: number): Promise<void>;
39
43
  /**
40
44
  * Destroys the database connection, releasing all pooled resources.
41
45
  */
@@ -62,18 +66,118 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
62
66
  getConfig(): Promise<Config>;
63
67
  /**
64
68
  * Retrieves the current crawling state by listing scraped and pending URLs.
65
- * @returns An object with `scraped` (completed URLs) and `pending` (remaining URLs) arrays.
69
+ *
70
+ * `scraped` is straightforward: every page row whose `scraped` flag is `1`
71
+ * — that is, every URL the crawl reached a terminal state on, including
72
+ * setSkippedPage / setExternalPage / outright setPage success or failure.
73
+ *
74
+ * `pending` is intentionally STRICT — not "every `scraped = 0` row".
75
+ * Three filters apply:
76
+ *
77
+ * 1. `scraped = 0` — work still incomplete.
78
+ * 2. `isExternal = 0` — only in-scope work. External URLs go through a
79
+ * HEAD-only path that always lands on `scraped = 1` (either setPage or
80
+ * setExternalPage). A row with `isExternal = 1 AND scraped = 0` is
81
+ * therefore a data anomaly, and resume / inventory / append have no
82
+ * business retrying it on the next session.
83
+ * 3. `EXISTS (anchor with hrefId = pages.id) OR source != 'crawled'` —
84
+ * the row was either discovered as an anchor destination during a
85
+ * previous scrape OR was explicitly tagged with a non-default
86
+ * source label (`'inventory-seed'`, `'inventory-discovered'`, …).
87
+ * Both halves of the OR represent "deliberately enqueued, expected
88
+ * to be processed", which is exactly what `resume` should pick up.
89
+ *
90
+ * The orphan filter targets the **predicted-discard leak** in
91
+ * `crawler.ts` where `shouldDiscardPredicted` returns true but no
92
+ * `emit('skip')` follows. Such placeholders are inserted with the
93
+ * DB DEFAULT `source = 'crawled'` (no caller explicitly labels
94
+ * them) AND have no anchor referrer (predicted URLs are
95
+ * synthesised from pagination patterns, never anchored from a
96
+ * rendered page) — both halves of the OR are therefore false and
97
+ * the leak is excluded.
98
+ *
99
+ * The `source != 'crawled'` clause specifically saves the
100
+ * `--inventory` × `--retry-failed` interaction: an inventory-seed
101
+ * URL came from the operator's URL list (no anchor referrer) and
102
+ * `resetFailedPages` puts it back at `scraped = 0`. Without this
103
+ * clause those legitimate retries would be dropped on resume.
104
+ *
105
+ * The defensive shape is on purpose: the data source can drift into
106
+ * anomalous states under interruption, but the reader must never throw
107
+ * or feed garbage back into the dealer. A real in-scope URL that was
108
+ * truly interrupted mid-crawl will always have at least one anchor
109
+ * referrer (otherwise the dealer would not have queued it), so the
110
+ * strict filter loses no legitimate pending work.
111
+ *
112
+ * Seeds passed directly to `Crawler.start()` are NOT in the strict
113
+ * pending set when they were never picked by the dealer — they have no
114
+ * DB row at all in that case (`linkList.add` is purely in-memory until
115
+ * `setPage` runs). A Ctrl-C between dealer pick and `setPage` likewise
116
+ * leaves no row to recover. Recovery of un-picked seeds is the
117
+ * responsibility of the caller (e.g. re-running `--inventory ./list.txt`
118
+ * with the same URL list).
119
+ *
120
+ * The query uses an explicit `p` alias on the `pages` table so the
121
+ * correlated `EXISTS` subquery can join via `whereRaw('anchors.hrefId =
122
+ * p.id')`. A future refactor that renames the alias must update both
123
+ * sites — the raw string in the subquery cannot be grep-resolved
124
+ * automatically. Read-only / stub viewer connections never call this
125
+ * method (they do not need to know about pending state), so the EXISTS
126
+ * shape is safe to use without the `migrate*` guards that other writer
127
+ * methods carry.
128
+ * @returns An object with `scraped` (completed URLs) and `pending` (the
129
+ * strict set of in-scope, anchor-referenced, unfinished URLs).
66
130
  */
67
131
  getCrawlingState(): Promise<{
68
132
  scraped: string[];
69
133
  pending: string[];
70
134
  }>;
71
135
  /**
72
- * Retrieves the HTML snapshot file path for a specific page.
136
+ * Return the subset of `urls` that already exist in the `pages` table.
137
+ * Chunked into batches so SQLite's `IN (?, ?, …)` parameter limit
138
+ * (`SQLITE_MAX_VARIABLE_NUMBER`, default 999) cannot be hit even when the
139
+ * inventory list contains tens of thousands of URLs.
140
+ *
141
+ * Read-only — no transaction, no lock contention with the crawler write
142
+ * pipeline (callers run this BEFORE the `<archive>.bak` is taken and the
143
+ * crawl is started).
144
+ * @param urls - URL strings to probe (already in `withoutHashAndAuth` form).
145
+ * @returns URLs found in `pages`. Order is not preserved.
146
+ */
147
+ getExistingPageUrls(urls: readonly string[]): Promise<string[]>;
148
+ /**
149
+ * Return the subset of `urls` that already exist in the `resources` table.
150
+ * See {@link Database.getExistingPageUrls} — same chunking strategy.
151
+ * @param urls - URL strings to probe.
152
+ * @returns URLs found in `resources`.
153
+ */
154
+ getExistingResourceUrls(urls: readonly string[]): Promise<string[]>;
155
+ /**
156
+ * Reads the HTML snapshot stored as a zstd-compressed BLOB for the given page.
157
+ *
158
+ * Joins `page_html_ref` → `page_html_blobs` and decompresses inline. Returns
159
+ * `null` when the page has no stored body (a non-HTML resource, a redirect
160
+ * source, a degraded render). Read works identically on read-only / stub
161
+ * connections — the special-cased "do we have a loose dir vs zip?" branching
162
+ * the previous file-backed layout required is gone.
163
+ *
164
+ * Tables `page_html_ref` and `page_html_blobs` are created by `initSchema`.
165
+ * Older `.nitpicker` archives that predate this migration must be passed
166
+ * through `scripts/migrate-to-0.10.mjs` before they can be read.
73
167
  * @param pageId - The database ID of the page.
74
- * @returns The relative file path to the HTML snapshot, or null if not saved.
168
+ * @returns The decompressed HTML string, or `null` if no snapshot is stored.
75
169
  */
76
- getHtmlPathOnPage(pageId: number): Promise<any>;
170
+ getHtmlOfPageById(pageId: number): Promise<string | null>;
171
+ /**
172
+ * Retrieves all `page_jsonld` rows for the given page id, parsed back into
173
+ * {@link JsonLdRow} shape (with `parsed` deserialised from its JSON column).
174
+ *
175
+ * Read-side counterpart to `#insertJsonLd`. Returns rows in insertion order
176
+ * by `id` so the order observed by `get-page-jsonld` matches the order the
177
+ * scraper saw them.
178
+ * @param pageId
179
+ */
180
+ getJsonLdOfPage(pageId: number): Promise<JsonLdRow[]>;
77
181
  /**
78
182
  * Returns the underlying Knex query builder instance for direct SQL access.
79
183
  * This enables advanced queries (GROUP BY, HAVING, JOINs) at the database
@@ -101,6 +205,27 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
101
205
  * @returns An array of raw {@link DB_Page} rows.
102
206
  */
103
207
  getPages(filter?: PageFilter, offset?: number, limit?: number): Promise<DB_Page[]>;
208
+ /**
209
+ * Look up the `source` column of a single page by its URL key. Used by
210
+ * the orchestrator's `PageSourceLookup` injection so the Crawler can
211
+ * resolve a parent page's lineage on `--resume` / `--retry-failed`
212
+ * sessions, where the in-memory `inventoryMode` is no longer
213
+ * available but the DB still remembers what label was last persisted.
214
+ *
215
+ * Returns `undefined` when the URL has no `pages` row (e.g. a brand-new
216
+ * URL that has not been seen yet) so the caller can fall through to
217
+ * its default behaviour without distinguishing "row absent" from "row
218
+ * present with NULL source" — the schema's `NOT NULL DEFAULT 'crawled'`
219
+ * makes a NULL value impossible in practice.
220
+ *
221
+ * Read-only — no transaction, single PK-equivalent lookup on
222
+ * `pages.url` (a UNIQUE column), so the cost is constant per call. The
223
+ * Crawler calls this at most once per page render, NOT per
224
+ * sub-resource, so the N+1 risk does not apply.
225
+ * @param url - URL key in `url.withoutHashAndAuth` form.
226
+ * @returns The recorded `source`, or `undefined` when no row exists.
227
+ */
228
+ getPageSourceByUrl(url: string): Promise<PageSource | undefined>;
104
229
  /**
105
230
  * Retrieves pages along with their related redirect, anchor, and referrer data.
106
231
  * Results are ordered by the natural URL sort order. Only non-redirected pages are returned.
@@ -122,6 +247,14 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
122
247
  getRedirectsForPages(pageIds: number[]): Promise<DB_Redirect[]>;
123
248
  /**
124
249
  * Retrieves pages that link to a specific page (incoming links / referrers).
250
+ *
251
+ * Incoming links are resolved **through redirects**: an anchor pointing at a
252
+ * redirect source (e.g. `http://x` that 301s to `https://x`) counts as a
253
+ * referrer of the redirect's final destination, not of the source. This keeps
254
+ * backlinks merged on the canonical page instead of splitting them across the
255
+ * `http`/`https` (or any redirect source/dest) pair. The resolution mirrors
256
+ * `redirectTable()` — `redirectDestId` is pre-flattened to the final
257
+ * destination, so `COALESCE(target.redirectDestId, target.id)` is a single hop.
125
258
  * @param pageId - The database ID of the target page.
126
259
  * @returns An array of referrer records with URL, hash, and text content.
127
260
  */
@@ -132,6 +265,22 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
132
265
  * @returns An array of page URL strings that reference the resource.
133
266
  */
134
267
  getReferrersOfResource(id: number): Promise<string[]>;
268
+ /**
269
+ * Retrieves a single sub-resource from the `resources` table by its URL.
270
+ *
271
+ * Accepts multiple URL candidates because the stored key is the resource's
272
+ * `href` while callers may only know the hash-stripped form; the first match
273
+ * wins.
274
+ *
275
+ * Deliberately NOT wrapped with `emitError`/`emitErrorAndRetry`: the only caller (the
276
+ * crawler's resource-reuse hook) has a full fallback (the HEAD pre-flight),
277
+ * so a read failure here must not surface as a database `error` event —
278
+ * the orchestrator aborts the whole crawl on that event, which is the
279
+ * correct reaction to write failures but not to a recoverable read.
280
+ * @param urls - URL candidates to match against the `url` column.
281
+ * @returns The raw {@link DB_Resource} row, or `null` if none match.
282
+ */
283
+ getResourceByUrl(urls: readonly string[]): Promise<DB_Resource | null>;
135
284
  /**
136
285
  * Retrieves all sub-resources from the `resources` table.
137
286
  * @returns An array of raw {@link DB_Resource} rows.
@@ -142,12 +291,125 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
142
291
  * @returns An array of resource URL strings.
143
292
  */
144
293
  getResourceUrlList(): Promise<any[]>;
294
+ /**
295
+ * Counts pages that were scraped as crawl targets (full HTML render).
296
+ *
297
+ * Used by the crawler to seed its `pagesScraped` counter on resume so the
298
+ * progress display reflects all browser-rendered HTML pages across sessions,
299
+ * not just the current one.
300
+ *
301
+ * "HTML page" is guaranteed by `contentType = 'text/html'`, NOT by `isTarget`
302
+ * alone: `isTarget` means "in-scope crawl target" and is set for in-scope
303
+ * non-HTML resources too (e.g. a PDF reached via the HEAD pre-flight is
304
+ * `isTarget = 1`). Counting those would over-report the HTML page total, so
305
+ * page-ness is asserted at the read layer here rather than by trusting
306
+ * `isTarget`.
307
+ * @returns The number of `text/html` rows with `isTarget = 1` and `scraped = 1`.
308
+ */
309
+ getScrapedHtmlPageCount(): Promise<number>;
310
+ /**
311
+ * Retrieves all `page_tags` rows for the given page id, parsed back into
312
+ * {@link TagRow} shape (with `categories` and `sources` JSON columns
313
+ * deserialised).
314
+ *
315
+ * Read-side counterpart to `#insertTags`.
316
+ * @param pageId
317
+ */
318
+ getTagsOfPage(pageId: number): Promise<TagRow[]>;
319
+ /**
320
+ * Records a crawler-level (`error` channel) failure into `crawl_errors`.
321
+ *
322
+ * Unlike {@link insertPageError} this is not tied to a scraped page: `url`
323
+ * may be an external link that never became a page row, or `null` for a
324
+ * process-level error. The cause is intentionally not stored — it is derived
325
+ * on read so that older archives (which only have `error.log`) and freshly
326
+ * captured rows classify identically.
327
+ * @param url - The URL the error is about, or `null` for a process-level error.
328
+ * @param message - The error message (one line is enough for classification).
329
+ * @param isExternal - Whether the URL is external to the crawl scope.
330
+ */
331
+ insertCrawlError(url: string | null, message: string, isExternal?: boolean): Promise<void>;
332
+ /**
333
+ * Pre-insert inventory non-HTML URLs into `resources` as placeholder rows
334
+ * with `source = 'inventory-seed'` and all metadata columns NULL — the
335
+ * non-HTML counterpart of {@link Database.insertInventorySeeds}. Used by
336
+ * `CrawlerOrchestrator.inventory` so the ingestion phase commits all of
337
+ * its non-HTML URLs in one chunked round-trip per 500 instead of N
338
+ * sequential `insertResource` awaits. On a 50k-URL inventory list the
339
+ * old per-URL loop spent minutes inside the `.bak`-protected window;
340
+ * the bulk path finishes in seconds.
341
+ *
342
+ * Idempotent: `onConflict('url').ignore()` leaves existing rows untouched
343
+ * (the orchestrator's `getExistingResourceUrls` filter is what keeps a
344
+ * crawled-lineage `resources` row from being downgraded to the
345
+ * inventory label here).
346
+ *
347
+ * Chunked at 500 to stay well under SQLite's `SQLITE_MAX_VARIABLE_NUMBER`
348
+ * (default 999) — every row binds the URL plus the `responseHeaders`
349
+ * JSON null, so the per-chunk bound budget is well within limits.
350
+ * @param urls - URL strings (already in `withoutHashAndAuth` form).
351
+ */
352
+ insertInventoryResources(urls: readonly string[]): Promise<void>;
353
+ /**
354
+ * Pre-insert inventory HTML seeds into `pages` as `scraped = 0`,
355
+ * `source = 'inventory-seed'` placeholders so the URL's existence in the
356
+ * archive is **durable before the scrape phase starts**.
357
+ *
358
+ * Why this is the linchpin of `--inventory` Ctrl+C tolerance: HTML seeds
359
+ * used to live only in the Crawler's in-memory `LinkList` until the
360
+ * dealer eventually called `setPage`. A Ctrl+C / crash before that point
361
+ * lost the seed without trace, and `--resume` could not recover it
362
+ * because `getCrawlingState`'s strict pending set requires a `pages` row.
363
+ * Pre-inserting fills exactly that gap: the strict pending set picks
364
+ * these rows up via its `OR p.source != 'crawled'` clause, so
365
+ * `--resume` after an interrupted inventory pass picks every seed back
366
+ * up. See {@link Database.getCrawlingState} for the strict-set rationale.
367
+ *
368
+ * Idempotent: `onConflict('url').ignore()` keeps existing rows intact.
369
+ * The {@link Database.#getIdByUrl} crawled-wins downgrade still fires
370
+ * later when a crawled-lineage anchor reaches one of these seeds —
371
+ * that's the right behaviour (a seed that turned out to be reachable
372
+ * is not an orphan and should not retain the inventory label).
373
+ *
374
+ * Chunked into 500-URL batches so SQLite's bound-parameter limit
375
+ * (`SQLITE_MAX_VARIABLE_NUMBER`, default 999) cannot be hit even on a
376
+ * tens-of-thousands inventory list.
377
+ *
378
+ * Called by {@link CrawlerOrchestrator.inventory} during the
379
+ * `.bak`-protected ingestion phase, so any failure here aborts the run
380
+ * and restores from backup — the operator reruns from scratch.
381
+ * @param urls - URL strings already in `withoutHashAndAuth` form.
382
+ */
383
+ insertInventorySeeds(urls: readonly string[]): Promise<void>;
384
+ /**
385
+ * Records a partial scrape failure against the page identified by `url`.
386
+ *
387
+ * The page row is resolved (or inserted as a stub) via
388
+ * {@link Database.#getIdByUrl} so the error can be recorded even before
389
+ * `setPage` has run — useful when the failure fires during scraping
390
+ * (e.g. mid-`scrapeStart`) and the orchestrator enqueues this write
391
+ * before the success write for the same URL.
392
+ *
393
+ * A single page can have multiple `page_errors` rows (e.g. both
394
+ * `desktop-compact` and `mobile-small` viewports failing).
395
+ * @param url - URL of the page being scraped.
396
+ * @param phase - Scrape phase name (typically `'retryExhausted'`).
397
+ * @param message - Human-readable failure message.
398
+ * @param isExternal - Whether the URL is external. Defaults to `false`.
399
+ */
400
+ insertPageError(url: string, phase: string, message: string, isExternal?: boolean): Promise<void>;
145
401
  /**
146
402
  * Inserts a sub-resource into the `resources` table.
147
403
  * Ignores duplicate URLs (uses `ON CONFLICT IGNORE`).
404
+ *
405
+ * The `source` provenance label is written ONLY on insert; an
406
+ * `ON CONFLICT IGNORE` collision leaves an existing row's source untouched
407
+ * (this is what makes a second `crawl --inventory` non-destructive — see
408
+ * the inventory plan).
148
409
  * @param resource - The resource data to insert.
410
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
149
411
  */
150
- insertResource(resource: Resource): Promise<void>;
412
+ insertResource(resource: Resource, source?: PageSource): Promise<void>;
151
413
  /**
152
414
  * Inserts a referrer relationship between a resource and a page into the
153
415
  * `resources-referrers` table. Silently skips if the resource is not found.
@@ -155,6 +417,72 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
155
417
  * @param pageUrl - The URL of the page that references the resource.
156
418
  */
157
419
  insertResourceReferrers(src: string, pageUrl: string): Promise<void>;
420
+ /**
421
+ * Hostnames whose `crawl_errors` history is consistently DNS failures and
422
+ * for which no recent 2xx-3xx page or resource is recorded — i.e. hosts
423
+ * the previous crawl already proved unreachable. Returned in lower-cased
424
+ * form. Used by `CrawlerOrchestrator.#preloadDnsBurnedHostCache` so the
425
+ * next session short-circuits HEAD pre-flight on these hosts.
426
+ *
427
+ * Implementation: a coarse `LIKE` filter over `crawl_errors.message`
428
+ * narrows the row set, then `classifyErrorKind` confirms `'dns'` in JS
429
+ * (the regex is the single truth source — DB-side filters never narrow
430
+ * it). Exclusion bags are built from a single `pages` and a single
431
+ * `resources` scan: any host with a 2xx-3xx page, a 2xx-3xx resource, or
432
+ * a `pages.lastCrawledAt` newer than its latest DNS error is dropped
433
+ * (the host probably recovered between the failure and the last crawl).
434
+ *
435
+ * Returns `[]` on legacy archives that pre-date the `crawl_errors`
436
+ * table — the `hasTable` guard keeps the call non-destructive.
437
+ * @returns Lower-cased hostnames safe to short-circuit.
438
+ */
439
+ listDnsBurnedHostCandidates(): Promise<string[]>;
440
+ /**
441
+ * Appends one row to the `inventory_runs` audit log.
442
+ *
443
+ * Called by {@link CrawlerOrchestrator.inventory} on every successful
444
+ * `--inventory <list>` invocation so the archive carries a durable
445
+ * record of which deploy list was applied when and at what scale —
446
+ * the operational question "did we apply last month's list" the
447
+ * archive itself can answer without consulting external bookkeeping.
448
+ *
449
+ * Append-only at Phase 1. There is intentionally no UPDATE path and
450
+ * no UNIQUE constraint on `source_file_sha256`; two applies of the
451
+ * same list each get their own row, and `Phase 3 --refresh` is where
452
+ * dedupe / pre-flight against the hash will land. Field-level NULL
453
+ * semantics live on {@link InventoryRunMeta}.
454
+ * @param meta - The run metadata to record. Only `ran_at` is required.
455
+ * @returns The autoincremented `id` of the newly-inserted row.
456
+ */
457
+ recordInventoryRun(meta: InventoryRunMeta): Promise<number>;
458
+ /**
459
+ * Records a redirect edge (source → destination) **without** re-storing the
460
+ * destination's content.
461
+ *
462
+ * The crawler renders a many-to-one redirect destination exactly once. For
463
+ * every subsequent source URL that redirects to that already-rendered
464
+ * destination, it calls this instead of {@link updatePage} (#73). Routing a
465
+ * content-less HEAD result through `updatePage` would funnel it into
466
+ * `#insertPage` and overwrite the destination's good title / meta with empty
467
+ * values, so the dedicated edge-only path is required.
468
+ *
469
+ * The destination row is resolved (created on demand if a concurrent in-flight
470
+ * render has not committed it yet) so the edge always points at a valid id;
471
+ * the single render fills in the destination's content under that same id.
472
+ * The destination's existing anchors / images are never touched here.
473
+ * @param page - HEAD-resolved page data carrying the redirect chain. Its
474
+ * `anchorList` / `imageList` are ignored (a redirect source owns no content).
475
+ * @param source - Inventory provenance forwarded by the orchestrator
476
+ * (`Archive.setRedirect` → here) for the redirect-edge fast path. Used
477
+ * as the fallback when the originating URL's row does NOT yet exist in
478
+ * the archive (`#73` convergence on first sight, js-redirect rescue
479
+ * before any prior write). When the originating row already exists
480
+ * (e.g. anchor-lineage INSERT from a prior pass), its stored `source`
481
+ * takes precedence so transitive lineage is preserved across resume /
482
+ * retry-failed sessions. `undefined` keeps the DB DEFAULT `'crawled'`
483
+ * on a brand-new destination row.
484
+ */
485
+ recordRedirect(page: PageData, source?: PageSource): Promise<void>;
158
486
  /**
159
487
  * Promote previously-external pages whose URL falls under any of the new scope
160
488
  * entries back to a "needs scraping" state so that the next crawl picks them up
@@ -174,6 +502,48 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
174
502
  * @returns The URLs of the pages that were promoted.
175
503
  */
176
504
  repromoteExternalPages(scopes: ReadonlyMap<string, readonly ExURL[]>, options?: ParseURLOptions): Promise<string[]>;
505
+ /**
506
+ * Reset previously-attempted pages that ended in a recoverable failure so a
507
+ * follow-up crawl can re-fetch them from scratch.
508
+ *
509
+ * A page qualifies as a recoverable failure when it was already scraped
510
+ * (`scraped = 1`), is not a redirect source (`redirectDestId IS NULL`), was
511
+ * not intentionally skipped (`isSkipped` is not `1`), and one of the
512
+ * following holds:
513
+ *
514
+ * - `status = -1` — the sentinel a hard scrape failure (network error,
515
+ * timeout, browser crash) is recorded with (see `handle-scrape-error.ts`);
516
+ * - `status IS NULL` — no status was ever stored for the row;
517
+ * - `contentType IS NULL` — the content type could not be determined;
518
+ * - `status` is in the `5xx` range — a (frequently transient) server error.
519
+ *
520
+ * Definitive `4xx` responses are intentionally excluded: re-fetching a 404
521
+ * almost always yields the same answer.
522
+ *
523
+ * A second exclusion runs in JS after the SQL candidate scan: any page whose
524
+ * latest recorded `page_errors` / `crawl_errors` message classifies into a
525
+ * permanent {@link PERMANENT_ERROR_KINDS} kind (dns / tls / client-blocked /
526
+ * parse-error / connection-refused) is left as-is rather than reset to
527
+ * pending. Without this filter, `--retry-failed` never converges: NXDOMAIN
528
+ * hosts, expired-cert hosts, and `ERR_BLOCKED_BY_CLIENT` ad pixels would be
529
+ * reset every iteration, re-attempted, fail identically, and rejoin the
530
+ * candidate pool for the next iteration. The exclusion keeps the retry
531
+ * target shrinking across `--retry-failed` passes by leaving deterministic
532
+ * dead-ends alone.
533
+ *
534
+ * Matching rows — internal and external alike — are demoted back to pending
535
+ * (`scraped = 0`) and have their stale scrape metadata cleared. The page row
536
+ * itself is kept (id preserved) so existing `anchors.hrefId` referrers stay
537
+ * valid, and `isExternal` is left untouched so the next pass re-classifies
538
+ * each page from the crawl scope. Related `anchors`, `images`,
539
+ * `resources-referrers`, and `page_errors` rows are deleted so the re-scrape
540
+ * can re-insert fresh data without duplicates.
541
+ *
542
+ * SELECT and UPDATE/DELETE statements are chunked to stay below SQLite's
543
+ * `SQLITE_LIMIT_VARIABLE_NUMBER`.
544
+ * @returns The URLs of the pages that were reset to pending.
545
+ */
546
+ resetFailedPages(): Promise<string[]>;
177
547
  /**
178
548
  * Stores the crawl configuration in the `info` table.
179
549
  * Only fields in {@link INFO_COLUMN_ALLOWLIST} are forwarded — any extra
@@ -212,27 +582,42 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
212
582
  updateConfig(patch: Partial<Config>): Promise<void>;
213
583
  /**
214
584
  * Inserts or updates a crawled page in the database, including its redirect chain,
215
- * anchors, and images. Optionally creates an HTML snapshot file path entry.
585
+ * anchors, images, and (when `writeHtml`) its compressed HTML snapshot BLOB.
216
586
  *
217
587
  * Self-redirects (where the source URL equals the destination URL after normalization)
218
588
  * are skipped to avoid marking a page as redirected to itself — a situation caused by
219
589
  * authentication challenges (e.g. Basic Auth 302) that would otherwise exclude the page
220
590
  * from reports via the `whereNull('redirectDestId')` filter.
221
591
  * @param page - The page data to store.
222
- * @param snapshotDir - The directory for saving HTML snapshots, or null to skip snapshots.
592
+ * @param writeHtml - When `true`, this call is allowed to insert (or clear)
593
+ * the page's HTML blob. `setExternalPage` passes `false` because external
594
+ * metadata-only scrapes never carry HTML and must not perturb an already
595
+ * stored body.
223
596
  * @param isTarget - Whether this page is a crawl target.
224
- * @returns An object with the optional `html` snapshot file path and the page's database `pageId`.
597
+ * @param source - Provenance label written ONLY when the row is freshly
598
+ * inserted. Existing rows keep their original `source` (this is why a
599
+ * second `crawl --inventory` does not "demote" an `'inventory-seed'` row
600
+ * that was discovered earlier).
601
+ * @returns The database `pageId` of the inserted/updated row.
225
602
  */
226
- updatePage(page: PageData, snapshotDir: string | null, isTarget: boolean): Promise<{
227
- html?: string | undefined;
228
- pageId: number;
229
- }>;
603
+ updatePage(page: PageData, writeHtml: boolean, isTarget: boolean, source?: PageSource): Promise<number>;
230
604
  /**
231
605
  * Creates and initializes a new Database instance.
232
- * Creates the parent directory for the database file if needed,
233
- * establishes the connection, and initializes tables if they do not exist.
234
- * @param options - Database connection options (working directory + SQLite file path).
606
+ *
607
+ * **Writer mode (default)**: creates the parent directory for the
608
+ * database file if needed, establishes the connection, and initializes
609
+ * the schema + migrations.
610
+ *
611
+ * **Read-only mode** (`options.readOnly`): refuses to resurrect a
612
+ * missing parent directory or db file — throws if either is absent at
613
+ * the time of the call. Skips schema init and migrations entirely so
614
+ * the user's tmpDir is never modified. Required by viewer / MCP
615
+ * stub-mode opens, where a TOCTOU window between classification and
616
+ * `connect()` could otherwise leave behind a phantom empty tmpDir.
617
+ * @param options - Database connection options.
235
618
  * @returns A fully initialized Database instance.
619
+ * @throws {Error} In read-only mode, if the parent directory or db
620
+ * file does not exist when `connect()` runs.
236
621
  */
237
622
  static connect(options: DatabaseOption): Promise<Database>;
238
623
  }