@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.
- package/lib/archive/archive-accessor.d.ts +87 -14
- package/lib/archive/archive-accessor.js +162 -36
- package/lib/archive/archive.d.ts +147 -24
- package/lib/archive/archive.js +252 -86
- package/lib/archive/database.d.ts +209 -25
- package/lib/archive/database.js +928 -108
- package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
- package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
- package/lib/archive/init-schema.d.ts +53 -2
- package/lib/archive/init-schema.js +247 -15
- package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
- package/lib/archive/meta/assert-compatible-version.js +72 -0
- package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
- package/lib/archive/meta/classify-jsonld-type.js +43 -0
- package/lib/archive/meta/compare-semver.d.ts +23 -0
- package/lib/archive/meta/compare-semver.js +51 -0
- package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
- package/lib/archive/meta/compute-page-denormalized.js +35 -0
- package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
- package/lib/archive/meta/derive-flat-from-meta.js +158 -0
- package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
- package/lib/archive/meta/derive-meta-extras.js +23 -0
- package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
- package/lib/archive/meta/extract-tags-for-archive.js +36 -0
- package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
- package/lib/archive/meta/summarize-jsonld.js +29 -0
- package/lib/archive/meta/summarize-tags.d.ts +16 -0
- package/lib/archive/meta/summarize-tags.js +33 -0
- package/lib/archive/meta/types.d.ts +207 -0
- package/lib/archive/meta/types.js +33 -0
- package/lib/archive/migrate-crawl-errors.d.ts +20 -0
- package/lib/archive/migrate-crawl-errors.js +38 -0
- package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
- package/lib/archive/migrate-html-blob-tables.js +53 -0
- package/lib/archive/migrate-page-errors.d.ts +16 -0
- package/lib/archive/migrate-page-errors.js +35 -0
- package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
- package/lib/archive/migrate-pages-resources-source.js +46 -0
- package/lib/archive/page.d.ts +187 -49
- package/lib/archive/page.js +258 -63
- package/lib/archive/peek-archive-lock.d.ts +40 -0
- package/lib/archive/peek-archive-lock.js +62 -0
- package/lib/archive/resolve-redirect-chain.d.ts +33 -0
- package/lib/archive/resolve-redirect-chain.js +27 -0
- package/lib/archive/types.d.ts +135 -26
- package/lib/crawler/close-browser-safely.d.ts +64 -0
- package/lib/crawler/close-browser-safely.js +73 -0
- package/lib/crawler/crawler.d.ts +4 -1
- package/lib/crawler/crawler.js +290 -32
- package/lib/crawler/create-change-phase-handler.d.ts +54 -0
- package/lib/crawler/create-change-phase-handler.js +44 -0
- package/lib/crawler/derive-page-source.d.ts +23 -0
- package/lib/crawler/derive-page-source.js +28 -0
- package/lib/crawler/derive-resource-source.d.ts +23 -0
- package/lib/crawler/derive-resource-source.js +26 -0
- package/lib/crawler/drain-phase-errors.d.ts +48 -0
- package/lib/crawler/drain-phase-errors.js +35 -0
- package/lib/crawler/fetch-destination.js +38 -2
- package/lib/crawler/format-crawl-progress.d.ts +12 -3
- package/lib/crawler/format-crawl-progress.js +14 -6
- package/lib/crawler/handle-browser-close.d.ts +29 -0
- package/lib/crawler/handle-browser-close.js +28 -0
- package/lib/crawler/is-html-content-type.d.ts +17 -0
- package/lib/crawler/is-html-content-type.js +19 -0
- package/lib/crawler/is-likely-html-url.d.ts +22 -0
- package/lib/crawler/is-likely-html-url.js +65 -0
- package/lib/crawler/kill-process-tree.d.ts +94 -0
- package/lib/crawler/kill-process-tree.js +178 -0
- package/lib/crawler/link-list.js +2 -1
- package/lib/crawler/link-to-page-data.d.ts +13 -5
- package/lib/crawler/link-to-page-data.js +26 -5
- package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
- package/lib/crawler/log-undrained-phase-errors.js +34 -0
- package/lib/crawler/normalize-content-type.d.ts +14 -0
- package/lib/crawler/normalize-content-type.js +20 -0
- package/lib/crawler/partition-urls-by-html.d.ts +16 -0
- package/lib/crawler/partition-urls-by-html.js +23 -0
- package/lib/crawler/redirect-dest-key.d.ts +19 -0
- package/lib/crawler/redirect-dest-key.js +27 -0
- package/lib/crawler/resource-to-page-data.d.ts +28 -0
- package/lib/crawler/resource-to-page-data.js +59 -0
- package/lib/crawler/types.d.ts +122 -1
- package/lib/crawler-orchestrator.d.ts +93 -1
- package/lib/crawler-orchestrator.js +389 -12
- package/lib/crawler.d.ts +5 -0
- package/lib/crawler.js +3 -0
- package/lib/resource-row-to-lookup-result.d.ts +13 -0
- package/lib/resource-row-to-lookup-result.js +20 -0
- package/lib/types.d.ts +11 -1
- package/lib/utils/object/parse-response-headers.d.ts +12 -0
- package/lib/utils/object/parse-response-headers.js +26 -0
- package/package.json +4 -4
package/lib/archive/archive.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { zip } from '@d-zero/fs/zip';
|
|
3
2
|
import { ArchiveAccessor } from './archive-accessor.js';
|
|
4
3
|
import { acquireArchiveLock } from './archive-lock.js';
|
|
5
4
|
import { Database } from './database.js';
|
|
@@ -7,31 +6,40 @@ import { dbLog, log, saveLog } from './debug.js';
|
|
|
7
6
|
import { appendText } from './filesystem/append-text.js';
|
|
8
7
|
import { exists } from './filesystem/exists.js';
|
|
9
8
|
import { isDir } from './filesystem/is-dir.js';
|
|
10
|
-
import {
|
|
9
|
+
import { peekTarTopDir } from './filesystem/peek-tar-top-dir.js';
|
|
11
10
|
import { remove } from './filesystem/remove.js';
|
|
12
11
|
import { rename } from './filesystem/rename.js';
|
|
13
12
|
import { tar } from './filesystem/tar.js';
|
|
14
13
|
import { untar } from './filesystem/untar.js';
|
|
15
14
|
/**
|
|
16
|
-
* Main archive class for creating, opening, resuming, and writing Nitpicker
|
|
15
|
+
* Main archive class for creating, opening, resuming, and writing Nitpicker
|
|
16
|
+
* archive files (`.nitpicker`).
|
|
17
17
|
*
|
|
18
|
-
* An Archive wraps a SQLite database
|
|
19
|
-
*
|
|
18
|
+
* An Archive wraps a single SQLite database into a tar archive. HTML
|
|
19
|
+
* bodies live inside the same DB as zstd-compressed BLOBs (see #75) — the
|
|
20
|
+
* tar payload is effectively just `db.sqlite`. It extends
|
|
21
|
+
* {@link ArchiveAccessor} to provide read access to stored data.
|
|
20
22
|
*
|
|
21
23
|
* Use the static factory methods ({@link Archive.create}, {@link Archive.open},
|
|
22
24
|
* {@link Archive.resume}, {@link Archive.connect}) to obtain instances.
|
|
23
25
|
* The constructor is private.
|
|
24
26
|
*/
|
|
25
27
|
export default class Archive extends ArchiveAccessor {
|
|
28
|
+
/**
|
|
29
|
+
* Promise tracking an in-progress {@link Archive.close} (or
|
|
30
|
+
* {@link Archive.releaseHandle}). Acts as the override's idempotency
|
|
31
|
+
* guard so a second call — e.g. from a signal handler racing the
|
|
32
|
+
* primary teardown — does not re-enter the destructive prologue
|
|
33
|
+
* (write/remove) on a half-mutated state.
|
|
34
|
+
*/
|
|
35
|
+
#closeOnce = null;
|
|
26
36
|
/** The SQLite database instance for reading and writing crawl data. */
|
|
27
37
|
#db;
|
|
28
38
|
/** Absolute path to the output `.nitpicker` archive file. */
|
|
29
39
|
#filePath;
|
|
30
40
|
/** Lock release function held while the writer owns the archive. */
|
|
31
41
|
#releaseLock;
|
|
32
|
-
/** Absolute path to the
|
|
33
|
-
#snapshotDir;
|
|
34
|
-
/** Absolute path to the temporary working directory containing the SQLite DB and snapshots. */
|
|
42
|
+
/** Absolute path to the temporary working directory containing the SQLite DB. */
|
|
35
43
|
#tmpDir;
|
|
36
44
|
/**
|
|
37
45
|
* The absolute file path of the archive (`.nitpicker` file).
|
|
@@ -39,18 +47,26 @@ export default class Archive extends ArchiveAccessor {
|
|
|
39
47
|
get filePath() {
|
|
40
48
|
return this.#filePath;
|
|
41
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The intermediate directory `Archive.write()` produces by renaming
|
|
52
|
+
* `tmpDir` before tarring (`{cwd}/{archiveName}`). Exposed so the
|
|
53
|
+
* manager can include it in its cleanup-on-failure path: if `tar()`
|
|
54
|
+
* fails after the rename, this directory is orphaned and would
|
|
55
|
+
* otherwise be invisible to a `rmSync(tmpDir)` recovery.
|
|
56
|
+
*/
|
|
57
|
+
get renamedDir() {
|
|
58
|
+
return path.resolve(path.dirname(this.#filePath), path.basename(this.#filePath, path.extname(this.#filePath)));
|
|
59
|
+
}
|
|
42
60
|
// eslint-disable-next-line no-restricted-syntax
|
|
43
61
|
constructor(filePath, tmpDir, db, releaseLock) {
|
|
44
62
|
super(tmpDir, db, '');
|
|
45
63
|
this.#filePath = filePath;
|
|
46
64
|
this.#tmpDir = tmpDir;
|
|
47
|
-
this.#snapshotDir = path.resolve(this.#tmpDir, Archive.SNAPSHOT_HTML_DIR);
|
|
48
65
|
this.#db = db;
|
|
49
66
|
this.#releaseLock = releaseLock;
|
|
50
67
|
log('create instance: %O', {
|
|
51
68
|
filePath,
|
|
52
69
|
tmpDir,
|
|
53
|
-
snapshotDir: this.#snapshotDir,
|
|
54
70
|
});
|
|
55
71
|
this.#db.on('error', (e) => {
|
|
56
72
|
void this.emit('error', e);
|
|
@@ -61,35 +77,34 @@ export default class Archive extends ArchiveAccessor {
|
|
|
61
77
|
*/
|
|
62
78
|
abort() { }
|
|
63
79
|
/**
|
|
64
|
-
*
|
|
80
|
+
* Records a crawler-level error to both the human-readable `error.log` (full
|
|
81
|
+
* stack, for debugging) and the structured `crawl_errors` table (queryable,
|
|
82
|
+
* for the `error-kinds` analysis). The cause is not classified here — it is
|
|
83
|
+
* derived on read. `error.log` keeps the full stack while `crawl_errors`
|
|
84
|
+
* stores `error.message`; both normally carry the same cause token (e.g.
|
|
85
|
+
* `ENOTFOUND`), so classification agrees across the two — only an error whose
|
|
86
|
+
* cause lives solely in deeper stack frames could differ.
|
|
65
87
|
* @param error - The crawler error object containing process and URL information.
|
|
66
88
|
*/
|
|
67
89
|
async addError(error) {
|
|
68
90
|
const logFile = path.resolve(this.#tmpDir, 'error.log');
|
|
69
91
|
await appendText(logFile, `[${error.pid}(${error.isMainProcess ? 'main' : 'sub'})] ${error.url} ${error.error.stack ?? error.error}`);
|
|
92
|
+
await this.#db.insertCrawlError(error.url, error.error.message, error.isExternal);
|
|
70
93
|
}
|
|
71
94
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
95
|
+
* Records a partial scrape failure against the page identified by `url`.
|
|
96
|
+
*
|
|
97
|
+
* The corresponding `pages` row is created on demand (or matched if it
|
|
98
|
+
* already exists), so the call works even if the page's normal data has
|
|
99
|
+
* not been written yet.
|
|
100
|
+
* @param url - URL of the affected page.
|
|
101
|
+
* @param phase - Scrape phase name (typically `'retryExhausted'`).
|
|
102
|
+
* @param message - Human-readable failure message.
|
|
103
|
+
* @param isExternal - Whether the URL is external. Defaults to `false`.
|
|
75
104
|
*/
|
|
76
|
-
async
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (!exists(this.#filePath)) {
|
|
80
|
-
log("Save the file because it doesn't exist");
|
|
81
|
-
await this.write();
|
|
82
|
-
}
|
|
83
|
-
else if (exists(this.#tmpDir)) {
|
|
84
|
-
log('Remove temporary dir');
|
|
85
|
-
await remove(this.#tmpDir);
|
|
86
|
-
}
|
|
87
|
-
await this.#db.destroy();
|
|
88
|
-
}
|
|
89
|
-
finally {
|
|
90
|
-
await this.#releaseLock();
|
|
91
|
-
}
|
|
92
|
-
log('Closing done');
|
|
105
|
+
async addPageError(url, phase, message, isExternal = false) {
|
|
106
|
+
dbLog('Add page error: %s [%s]', url, phase);
|
|
107
|
+
await this.#db.insertPageError(url, phase, message, isExternal);
|
|
93
108
|
}
|
|
94
109
|
/**
|
|
95
110
|
* Retrieves the current crawling state, including lists of scraped and pending URLs.
|
|
@@ -98,6 +113,47 @@ export default class Archive extends ArchiveAccessor {
|
|
|
98
113
|
async getCrawlingState() {
|
|
99
114
|
return this.#db.getCrawlingState();
|
|
100
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Return the subset of `urls` that already exist as `pages.url`. Used by
|
|
118
|
+
* `CrawlerOrchestrator.inventory` to filter the user-supplied URL list
|
|
119
|
+
* down to "URLs that are NOT yet in the archive" — only those reach the
|
|
120
|
+
* HEAD / scrape pipeline. Existing URLs are skipped to keep the second
|
|
121
|
+
* (and N-th) `--inventory` pass non-destructive.
|
|
122
|
+
* @param urls - Candidate URLs in `withoutHashAndAuth` form.
|
|
123
|
+
* @returns URLs already present in `pages`.
|
|
124
|
+
*/
|
|
125
|
+
async getExistingPageUrls(urls) {
|
|
126
|
+
return this.#db.getExistingPageUrls(urls);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Return the subset of `urls` that already exist as `resources.url`. See
|
|
130
|
+
* {@link Archive.getExistingPageUrls} — the resource-side counterpart used
|
|
131
|
+
* by inventory mode to skip URLs that are already tracked as
|
|
132
|
+
* sub-resources.
|
|
133
|
+
* @param urls - Candidate URLs.
|
|
134
|
+
* @returns URLs already present in `resources`.
|
|
135
|
+
*/
|
|
136
|
+
async getExistingResourceUrls(urls) {
|
|
137
|
+
return this.#db.getExistingResourceUrls(urls);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Retrieves a single recorded sub-resource by its URL.
|
|
141
|
+
* @param urls - URL candidates to match against the stored resource URL.
|
|
142
|
+
* @returns The raw resource row, or `null` if none match.
|
|
143
|
+
*/
|
|
144
|
+
async getResourceByUrl(urls) {
|
|
145
|
+
return this.#db.getResourceByUrl(urls);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Counts the number of pages already scraped as crawl targets in the archive.
|
|
149
|
+
*
|
|
150
|
+
* Lets the crawler initialize its session-progress counter on resume so the
|
|
151
|
+
* displayed HTML-page count accounts for previously-rendered pages.
|
|
152
|
+
* @returns The count of pages with `isTarget = 1` and `scraped = 1`.
|
|
153
|
+
*/
|
|
154
|
+
async getScrapedHtmlPageCount() {
|
|
155
|
+
return this.#db.getScrapedHtmlPageCount();
|
|
156
|
+
}
|
|
101
157
|
/**
|
|
102
158
|
* Retrieves the base URL of the crawl session from the archive database.
|
|
103
159
|
* @returns The base URL string.
|
|
@@ -105,6 +161,24 @@ export default class Archive extends ArchiveAccessor {
|
|
|
105
161
|
async getUrl() {
|
|
106
162
|
return this.#db.getBaseUrl();
|
|
107
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Releases the SQLite handle and the advisory lock **without** writing
|
|
166
|
+
* the archive or removing `tmpDir`.
|
|
167
|
+
*
|
|
168
|
+
* Use this when you need to detach from a freshly-created `Archive`
|
|
169
|
+
* without finalising it — fixtures producing a stub state for tests,
|
|
170
|
+
* tooling that wants to leave the tmpDir alive for `crawl --resume`,
|
|
171
|
+
* or any non-orchestrator caller that owns the lifecycle externally.
|
|
172
|
+
* Shares the same idempotency guard as {@link close}, so the two paths
|
|
173
|
+
* are mutually exclusive (the first one called wins).
|
|
174
|
+
*/
|
|
175
|
+
async releaseHandle() {
|
|
176
|
+
if (this.#closeOnce) {
|
|
177
|
+
return this.#closeOnce;
|
|
178
|
+
}
|
|
179
|
+
this.#closeOnce = this.#runReleaseHandle();
|
|
180
|
+
return this.#closeOnce;
|
|
181
|
+
}
|
|
108
182
|
/**
|
|
109
183
|
* Promote previously-external pages that now fall under the (possibly extended)
|
|
110
184
|
* scope back to a pending state so that the crawler re-scrapes them as fully
|
|
@@ -117,6 +191,17 @@ export default class Archive extends ArchiveAccessor {
|
|
|
117
191
|
dbLog('Repromote external pages with %d hostnames in scope', scopes.size);
|
|
118
192
|
return this.#db.repromoteExternalPages(scopes, options);
|
|
119
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Reset previously-failed pages back to pending so a follow-up crawl re-fetches them.
|
|
196
|
+
*
|
|
197
|
+
* Delegates to {@link Database.resetFailedPages}. See that method for the
|
|
198
|
+
* exact failure criteria (missing status / content type, or a 5xx status).
|
|
199
|
+
* @returns The URLs of the pages that were reset to pending.
|
|
200
|
+
*/
|
|
201
|
+
async resetFailedPages() {
|
|
202
|
+
dbLog('Reset failed pages back to pending');
|
|
203
|
+
return this.#db.resetFailedPages();
|
|
204
|
+
}
|
|
120
205
|
/**
|
|
121
206
|
* Stores the crawl configuration into the archive database.
|
|
122
207
|
* @param config - The configuration object to store.
|
|
@@ -126,48 +211,50 @@ export default class Archive extends ArchiveAccessor {
|
|
|
126
211
|
return this.#db.setConfig(config);
|
|
127
212
|
}
|
|
128
213
|
/**
|
|
129
|
-
* Stores an external page's data in the archive database without
|
|
214
|
+
* Stores an external page's data in the archive database without storing
|
|
215
|
+
* an HTML snapshot. External-page rows carry only metadata (status, title,
|
|
216
|
+
* content-type), never a rendered body.
|
|
130
217
|
* @param pageInfo - The page data to store.
|
|
218
|
+
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
131
219
|
*/
|
|
132
|
-
async setExternalPage(pageInfo) {
|
|
220
|
+
async setExternalPage(pageInfo, source) {
|
|
133
221
|
dbLog('Set external page: %s', pageInfo.url.href);
|
|
134
|
-
await this.#db.updatePage(pageInfo,
|
|
222
|
+
await this.#db.updatePage(pageInfo, false, false, source);
|
|
135
223
|
}
|
|
136
224
|
/**
|
|
137
|
-
* Stores a crawled page's data in the archive database
|
|
138
|
-
*
|
|
139
|
-
*
|
|
225
|
+
* Stores a crawled page's data in the archive database, persisting the
|
|
226
|
+
* rendered HTML body as a zstd-compressed BLOB inside the same SQLite
|
|
227
|
+
* transaction. Storage is content-addressable: identical bodies across
|
|
228
|
+
* pages share a single `page_html_blobs` row.
|
|
140
229
|
* @param pageInfo - The page data to store.
|
|
230
|
+
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
141
231
|
* @returns The database ID of the stored page.
|
|
142
|
-
* @throws {Error} Re-throws any error from the snapshot file write after clearing the HTML path.
|
|
143
232
|
*/
|
|
144
|
-
async setPage(pageInfo) {
|
|
233
|
+
async setPage(pageInfo, source) {
|
|
145
234
|
dbLog('Set page: %s', pageInfo.url.href);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return pageId;
|
|
235
|
+
return await this.#db.updatePage(pageInfo, true, pageInfo.isTarget, source);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Records a redirect edge without re-storing the destination's content.
|
|
239
|
+
*
|
|
240
|
+
* The crawler calls this (instead of {@link setPage}) when a URL redirects to
|
|
241
|
+
* a destination that has already been rendered (#73): only the source →
|
|
242
|
+
* destination edge is written, leaving the destination's stored title / meta /
|
|
243
|
+
* anchors / images untouched.
|
|
244
|
+
* @param pageInfo - The HEAD-resolved page data carrying the redirect chain.
|
|
245
|
+
*/
|
|
246
|
+
async setRedirect(pageInfo) {
|
|
247
|
+
dbLog('Set redirect: %s', pageInfo.url.href);
|
|
248
|
+
await this.#db.recordRedirect(pageInfo);
|
|
163
249
|
}
|
|
164
250
|
/**
|
|
165
251
|
* Stores a sub-resource (CSS, JS, image, etc.) in the archive database.
|
|
166
252
|
* @param resource - The resource data to store.
|
|
253
|
+
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
167
254
|
*/
|
|
168
|
-
async setResources(resource) {
|
|
255
|
+
async setResources(resource, source) {
|
|
169
256
|
dbLog('Set resource: %s', resource.url.href);
|
|
170
|
-
await this.#db.insertResource(resource);
|
|
257
|
+
await this.#db.insertResource(resource, source);
|
|
171
258
|
}
|
|
172
259
|
/**
|
|
173
260
|
* Stores the referrer relationship between a resource and the page that references it.
|
|
@@ -207,52 +294,90 @@ export default class Archive extends ArchiveAccessor {
|
|
|
207
294
|
await this.#db.updateConfig(patch);
|
|
208
295
|
}
|
|
209
296
|
/**
|
|
210
|
-
* Writes the archive to disk as a
|
|
297
|
+
* Writes the archive to disk as a `.nitpicker` tar file.
|
|
211
298
|
*
|
|
212
|
-
*
|
|
213
|
-
* renames the temporary working directory
|
|
214
|
-
*
|
|
299
|
+
* Checkpoints the SQLite WAL so the database is self-contained inside
|
|
300
|
+
* `db.sqlite`, renames the temporary working directory to the archive's
|
|
301
|
+
* basename, and tars it into the final `.nitpicker`. The tar container
|
|
302
|
+
* holds a single `db.sqlite` file (the legacy `snapshot-html.zip` is gone
|
|
303
|
+
* — HTML lives as BLOBs in the DB), so finalisation is effectively a
|
|
304
|
+
* single-file copy with no per-snapshot syscalls.
|
|
215
305
|
*/
|
|
216
306
|
async write() {
|
|
217
307
|
saveLog('Starts: %s', this.#filePath);
|
|
218
|
-
const snapshotZip = `${this.#snapshotDir}.zip`;
|
|
219
|
-
if (exists(this.#snapshotDir)) {
|
|
220
|
-
if (!exists(snapshotZip)) {
|
|
221
|
-
saveLog('Zips snapshot dir: %s', this.#snapshotDir);
|
|
222
|
-
await zip(snapshotZip, this.#snapshotDir);
|
|
223
|
-
}
|
|
224
|
-
saveLog('Remove snapshot dir: %s', this.#snapshotDir);
|
|
225
|
-
await remove(this.#snapshotDir);
|
|
226
|
-
}
|
|
227
308
|
await this.#db.checkpoint();
|
|
228
309
|
const filePathWithoutExt = path.resolve(path.dirname(this.#filePath), path.basename(this.#filePath, path.extname(this.#filePath)));
|
|
229
310
|
saveLog('Rename temporary dir: %s to %s', this.#tmpDir, filePathWithoutExt);
|
|
230
311
|
await rename(this.#tmpDir, filePathWithoutExt, true);
|
|
231
|
-
saveLog('
|
|
312
|
+
saveLog('Tar temporary dir to file: %s to %s', filePathWithoutExt, this.#filePath);
|
|
232
313
|
await tar(filePathWithoutExt, this.#filePath);
|
|
233
314
|
saveLog('Remove temporary dir: %s', filePathWithoutExt);
|
|
234
315
|
await remove(filePathWithoutExt);
|
|
235
316
|
saveLog('Done: %s', this.#filePath);
|
|
236
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Worker for {@link close}. Performs the destructive prologue
|
|
320
|
+
* (write or remove), drops the DB handle via the base class, then
|
|
321
|
+
* releases the lock in a `finally` so the lock never leaks even on
|
|
322
|
+
* partial failure.
|
|
323
|
+
*/
|
|
324
|
+
async #runFullClose() {
|
|
325
|
+
log('Closing');
|
|
326
|
+
try {
|
|
327
|
+
if (!exists(this.#filePath)) {
|
|
328
|
+
log("Save the file because it doesn't exist");
|
|
329
|
+
await this.write();
|
|
330
|
+
}
|
|
331
|
+
else if (exists(this.#tmpDir)) {
|
|
332
|
+
log('Remove temporary dir');
|
|
333
|
+
await remove(this.#tmpDir);
|
|
334
|
+
}
|
|
335
|
+
await super.close();
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
await this.#releaseLock();
|
|
339
|
+
}
|
|
340
|
+
log('Closing done');
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Worker for {@link releaseHandle}. Drops the SQLite handle and the
|
|
344
|
+
* advisory lock with no filesystem mutation.
|
|
345
|
+
*/
|
|
346
|
+
async #runReleaseHandle() {
|
|
347
|
+
log('Releasing handle (no write, no remove)');
|
|
348
|
+
try {
|
|
349
|
+
await super.close();
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
await this.#releaseLock();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
237
355
|
/** The file extension for Nitpicker archive files (without the leading dot). */
|
|
238
356
|
static FILE_EXTENSION = 'nitpicker';
|
|
239
|
-
/** The directory name used for storing HTML snapshots within the archive. */
|
|
240
|
-
static SNAPSHOT_HTML_DIR = 'snapshot-html';
|
|
241
357
|
/** The filename of the SQLite database within the archive. */
|
|
242
358
|
static SQLITE_DB_FILE_NAME = 'db.sqlite';
|
|
243
359
|
/** The prefix used for temporary working directories during archive operations. */
|
|
244
360
|
static TMP_DIR_PREFIX = '._nitpicker-';
|
|
245
361
|
/**
|
|
246
362
|
* Opens a read-only connection to an existing archive's database.
|
|
363
|
+
*
|
|
247
364
|
* Returns an {@link ArchiveAccessor} that provides query methods
|
|
248
|
-
* without the ability to modify or write the archive.
|
|
365
|
+
* without the ability to modify or write the archive. The DB is opened
|
|
366
|
+
* in **read-only mode**: no schema migrations run, and the connection
|
|
367
|
+
* refuses to resurrect a missing parent directory or db file (so a
|
|
368
|
+
* TOCTOU window between source classification and this call cannot
|
|
369
|
+
* silently produce an empty phantom tmpDir).
|
|
370
|
+
*
|
|
371
|
+
* The returned accessor is also marked read-only so consumer-facing
|
|
372
|
+
* helpers (e.g. {@link ArchiveAccessor.getHtmlOfPage}) avoid any
|
|
373
|
+
* filesystem mutation on the user's tmpDir.
|
|
249
374
|
* @param tmpDir - The path to the temporary directory containing the database.
|
|
250
375
|
* @param namespace - An optional namespace for scoping data access within the archive.
|
|
251
376
|
* @returns An ArchiveAccessor instance for querying the archive data.
|
|
252
377
|
*/
|
|
253
378
|
static async connect(tmpDir, namespace = null) {
|
|
254
|
-
const db = await Archive.#connectDB(tmpDir);
|
|
255
|
-
const archive = new ArchiveAccessor(tmpDir, db, namespace);
|
|
379
|
+
const db = await Archive.#connectDB(tmpDir, { readOnly: true });
|
|
380
|
+
const archive = new ArchiveAccessor(tmpDir, db, namespace, { readOnly: true });
|
|
256
381
|
return archive;
|
|
257
382
|
}
|
|
258
383
|
/**
|
|
@@ -301,22 +426,30 @@ export default class Archive extends ArchiveAccessor {
|
|
|
301
426
|
cwd,
|
|
302
427
|
openPluginData,
|
|
303
428
|
});
|
|
304
|
-
|
|
305
|
-
|
|
429
|
+
// Read the tar's actual top-level directory name instead of deriving
|
|
430
|
+
// it from the outer file's basename. `.nitpicker` files are plain
|
|
431
|
+
// tar archives and users routinely rename them (`mv X.nitpicker
|
|
432
|
+
// Y.nitpicker`) — that operation must not break `open`. The inner
|
|
433
|
+
// directory keeps whatever name `Archive.write()` baked in at write
|
|
434
|
+
// time, and `tmpDir` mirrors the OUTER basename (so concurrent
|
|
435
|
+
// crawls on differently-named copies of the same archive don't
|
|
436
|
+
// collide on the lockfile).
|
|
437
|
+
const outerBasename = path.basename(filePath, path.extname(filePath));
|
|
438
|
+
const innerDirName = await peekTarTopDir(filePath);
|
|
439
|
+
const tmpDir = path.resolve(cwd, Archive.TMP_DIR_PREFIX + outerBasename);
|
|
306
440
|
const releaseLock = await acquireArchiveLock(tmpDir);
|
|
307
441
|
try {
|
|
308
442
|
const openFiles = [];
|
|
309
443
|
if (!openPluginData) {
|
|
310
|
-
const relDdPath = path.join(
|
|
311
|
-
|
|
312
|
-
openFiles.push(relDdPath, relSnapshotPath);
|
|
444
|
+
const relDdPath = path.join(innerDirName, Archive.SQLITE_DB_FILE_NAME);
|
|
445
|
+
openFiles.push(relDdPath);
|
|
313
446
|
}
|
|
314
447
|
log('Unzip file: %s (%O)', filePath, openFiles);
|
|
315
448
|
await untar(filePath, {
|
|
316
449
|
cwd,
|
|
317
450
|
fileList: openFiles.length > 0 ? openFiles : undefined,
|
|
318
451
|
});
|
|
319
|
-
const extractedDir = path.resolve(cwd,
|
|
452
|
+
const extractedDir = path.resolve(cwd, innerDirName);
|
|
320
453
|
log('Move directory: %s to %s', extractedDir, tmpDir);
|
|
321
454
|
await rename(extractedDir, tmpDir, true);
|
|
322
455
|
return await Archive.#init(filePath, tmpDir, releaseLock);
|
|
@@ -373,13 +506,18 @@ export default class Archive extends ArchiveAccessor {
|
|
|
373
506
|
/**
|
|
374
507
|
* Connects to (or creates) the SQLite database in the given directory.
|
|
375
508
|
* @param tmpDir - Directory containing `db.sqlite`
|
|
509
|
+
* @param options - Optional connection flags forwarded to
|
|
510
|
+
* {@link Database.connect}. Used by {@link Archive.connect} to pass
|
|
511
|
+
* `readOnly: true` so no migrations run and a missing tmpDir is not
|
|
512
|
+
* resurrected.
|
|
513
|
+
* @param options.readOnly
|
|
376
514
|
*/
|
|
377
|
-
static async #connectDB(tmpDir) {
|
|
515
|
+
static async #connectDB(tmpDir, options) {
|
|
378
516
|
const dbPath = path.resolve(tmpDir, Archive.SQLITE_DB_FILE_NAME);
|
|
379
|
-
dbLog('connects database: %s', dbPath);
|
|
517
|
+
dbLog('connects database: %s (readOnly=%s)', dbPath, options?.readOnly ?? false);
|
|
380
518
|
return await Database.connect({
|
|
381
|
-
workingDir: tmpDir,
|
|
382
519
|
filename: dbPath,
|
|
520
|
+
readOnly: options?.readOnly,
|
|
383
521
|
});
|
|
384
522
|
}
|
|
385
523
|
/**
|
|
@@ -397,6 +535,34 @@ export default class Archive extends ArchiveAccessor {
|
|
|
397
535
|
const archive = new Archive(filePath, tmpDir, db, releaseLock);
|
|
398
536
|
return archive;
|
|
399
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Closes the archive. If the archive file does not yet exist on disk,
|
|
540
|
+
* it writes the archive first. If the temporary directory still exists,
|
|
541
|
+
* it is removed. The database connection is then closed via
|
|
542
|
+
* {@link ArchiveAccessor.close} (the base class owns the SQLite handle),
|
|
543
|
+
* and finally the archive's advisory lock is released.
|
|
544
|
+
*
|
|
545
|
+
* **Idempotent**: the first invocation captures the close promise;
|
|
546
|
+
* subsequent invocations (signal handlers, parallel teardowns, retried
|
|
547
|
+
* orchestrator paths) await the same promise instead of re-entering
|
|
548
|
+
* the destructive prologue on a half-mutated state. If the first
|
|
549
|
+
* close fails (e.g. ENOSPC during tar), the rejection propagates to
|
|
550
|
+
* all awaiters and the archive stays latched closed — there is no
|
|
551
|
+
* safe way to retry `write()` once `tmpDir` has been renamed.
|
|
552
|
+
*
|
|
553
|
+
* **Read-only consumers must not reach this override.** Anything that
|
|
554
|
+
* obtains an archive view via {@link Archive.connect} receives an
|
|
555
|
+
* {@link ArchiveAccessor} (not an `Archive`), so `close()` resolves to
|
|
556
|
+
* the safe base implementation — no `write()`, no `remove()`, no lock
|
|
557
|
+
* release — leaving the tmpDir intact for the live crawler.
|
|
558
|
+
*/
|
|
559
|
+
async close() {
|
|
560
|
+
if (this.#closeOnce) {
|
|
561
|
+
return this.#closeOnce;
|
|
562
|
+
}
|
|
563
|
+
this.#closeOnce = this.#runFullClose();
|
|
564
|
+
return this.#closeOnce;
|
|
565
|
+
}
|
|
400
566
|
/**
|
|
401
567
|
* Retrieves the crawl configuration stored in the archive database.
|
|
402
568
|
* @returns The configuration object.
|