@nitpicker/crawler 0.9.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
|
@@ -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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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(
|
|
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
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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(
|
|
81
|
-
|
|
82
|
-
|
|
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
|
}
|
package/lib/archive/archive.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import type { Config } from './types.js';
|
|
1
|
+
import type { Config, PageSource } from './types.js';
|
|
2
2
|
import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
|
|
3
3
|
import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
4
4
|
import { ArchiveAccessor } from './archive-accessor.js';
|
|
5
5
|
/**
|
|
6
|
-
* Main archive class for creating, opening, resuming, and writing Nitpicker
|
|
6
|
+
* Main archive class for creating, opening, resuming, and writing Nitpicker
|
|
7
|
+
* archive files (`.nitpicker`).
|
|
7
8
|
*
|
|
8
|
-
* An Archive wraps a SQLite database
|
|
9
|
-
*
|
|
9
|
+
* An Archive wraps a single SQLite database into a tar archive. HTML
|
|
10
|
+
* bodies live inside the same DB as zstd-compressed BLOBs (see #75) — the
|
|
11
|
+
* tar payload is effectively just `db.sqlite`. It extends
|
|
12
|
+
* {@link ArchiveAccessor} to provide read access to stored data.
|
|
10
13
|
*
|
|
11
14
|
* Use the static factory methods ({@link Archive.create}, {@link Archive.open},
|
|
12
15
|
* {@link Archive.resume}, {@link Archive.connect}) to obtain instances.
|
|
@@ -18,22 +21,42 @@ export default class Archive extends ArchiveAccessor {
|
|
|
18
21
|
* The absolute file path of the archive (`.nitpicker` file).
|
|
19
22
|
*/
|
|
20
23
|
get filePath(): string;
|
|
24
|
+
/**
|
|
25
|
+
* The intermediate directory `Archive.write()` produces by renaming
|
|
26
|
+
* `tmpDir` before tarring (`{cwd}/{archiveName}`). Exposed so the
|
|
27
|
+
* manager can include it in its cleanup-on-failure path: if `tar()`
|
|
28
|
+
* fails after the rename, this directory is orphaned and would
|
|
29
|
+
* otherwise be invisible to a `rmSync(tmpDir)` recovery.
|
|
30
|
+
*/
|
|
31
|
+
get renamedDir(): string;
|
|
21
32
|
private constructor();
|
|
22
33
|
/**
|
|
23
34
|
* @deprecated This method is no longer functional.
|
|
24
35
|
*/
|
|
25
36
|
abort(): void;
|
|
26
37
|
/**
|
|
27
|
-
*
|
|
38
|
+
* Records a crawler-level error to both the human-readable `error.log` (full
|
|
39
|
+
* stack, for debugging) and the structured `crawl_errors` table (queryable,
|
|
40
|
+
* for the `error-kinds` analysis). The cause is not classified here — it is
|
|
41
|
+
* derived on read. `error.log` keeps the full stack while `crawl_errors`
|
|
42
|
+
* stores `error.message`; both normally carry the same cause token (e.g.
|
|
43
|
+
* `ENOTFOUND`), so classification agrees across the two — only an error whose
|
|
44
|
+
* cause lives solely in deeper stack frames could differ.
|
|
28
45
|
* @param error - The crawler error object containing process and URL information.
|
|
29
46
|
*/
|
|
30
47
|
addError(error: CrawlerError): Promise<void>;
|
|
31
48
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
49
|
+
* Records a partial scrape failure against the page identified by `url`.
|
|
50
|
+
*
|
|
51
|
+
* The corresponding `pages` row is created on demand (or matched if it
|
|
52
|
+
* already exists), so the call works even if the page's normal data has
|
|
53
|
+
* not been written yet.
|
|
54
|
+
* @param url - URL of the affected page.
|
|
55
|
+
* @param phase - Scrape phase name (typically `'retryExhausted'`).
|
|
56
|
+
* @param message - Human-readable failure message.
|
|
57
|
+
* @param isExternal - Whether the URL is external. Defaults to `false`.
|
|
35
58
|
*/
|
|
36
|
-
|
|
59
|
+
addPageError(url: string, phase: string, message: string, isExternal?: boolean): Promise<void>;
|
|
37
60
|
/**
|
|
38
61
|
* Retrieves the current crawling state, including lists of scraped and pending URLs.
|
|
39
62
|
* @returns An object with `scraped` and `pending` URL arrays.
|
|
@@ -42,11 +65,56 @@ export default class Archive extends ArchiveAccessor {
|
|
|
42
65
|
scraped: string[];
|
|
43
66
|
pending: string[];
|
|
44
67
|
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Return the subset of `urls` that already exist as `pages.url`. Used by
|
|
70
|
+
* `CrawlerOrchestrator.inventory` to filter the user-supplied URL list
|
|
71
|
+
* down to "URLs that are NOT yet in the archive" — only those reach the
|
|
72
|
+
* HEAD / scrape pipeline. Existing URLs are skipped to keep the second
|
|
73
|
+
* (and N-th) `--inventory` pass non-destructive.
|
|
74
|
+
* @param urls - Candidate URLs in `withoutHashAndAuth` form.
|
|
75
|
+
* @returns URLs already present in `pages`.
|
|
76
|
+
*/
|
|
77
|
+
getExistingPageUrls(urls: readonly string[]): Promise<string[]>;
|
|
78
|
+
/**
|
|
79
|
+
* Return the subset of `urls` that already exist as `resources.url`. See
|
|
80
|
+
* {@link Archive.getExistingPageUrls} — the resource-side counterpart used
|
|
81
|
+
* by inventory mode to skip URLs that are already tracked as
|
|
82
|
+
* sub-resources.
|
|
83
|
+
* @param urls - Candidate URLs.
|
|
84
|
+
* @returns URLs already present in `resources`.
|
|
85
|
+
*/
|
|
86
|
+
getExistingResourceUrls(urls: readonly string[]): Promise<string[]>;
|
|
87
|
+
/**
|
|
88
|
+
* Retrieves a single recorded sub-resource by its URL.
|
|
89
|
+
* @param urls - URL candidates to match against the stored resource URL.
|
|
90
|
+
* @returns The raw resource row, or `null` if none match.
|
|
91
|
+
*/
|
|
92
|
+
getResourceByUrl(urls: readonly string[]): Promise<import("./types.js").DB_Resource | null>;
|
|
93
|
+
/**
|
|
94
|
+
* Counts the number of pages already scraped as crawl targets in the archive.
|
|
95
|
+
*
|
|
96
|
+
* Lets the crawler initialize its session-progress counter on resume so the
|
|
97
|
+
* displayed HTML-page count accounts for previously-rendered pages.
|
|
98
|
+
* @returns The count of pages with `isTarget = 1` and `scraped = 1`.
|
|
99
|
+
*/
|
|
100
|
+
getScrapedHtmlPageCount(): Promise<number>;
|
|
45
101
|
/**
|
|
46
102
|
* Retrieves the base URL of the crawl session from the archive database.
|
|
47
103
|
* @returns The base URL string.
|
|
48
104
|
*/
|
|
49
105
|
getUrl(): Promise<any>;
|
|
106
|
+
/**
|
|
107
|
+
* Releases the SQLite handle and the advisory lock **without** writing
|
|
108
|
+
* the archive or removing `tmpDir`.
|
|
109
|
+
*
|
|
110
|
+
* Use this when you need to detach from a freshly-created `Archive`
|
|
111
|
+
* without finalising it — fixtures producing a stub state for tests,
|
|
112
|
+
* tooling that wants to leave the tmpDir alive for `crawl --resume`,
|
|
113
|
+
* or any non-orchestrator caller that owns the lifecycle externally.
|
|
114
|
+
* Shares the same idempotency guard as {@link close}, so the two paths
|
|
115
|
+
* are mutually exclusive (the first one called wins).
|
|
116
|
+
*/
|
|
117
|
+
releaseHandle(): Promise<void>;
|
|
50
118
|
/**
|
|
51
119
|
* Promote previously-external pages that now fall under the (possibly extended)
|
|
52
120
|
* scope back to a pending state so that the crawler re-scrapes them as fully
|
|
@@ -56,30 +124,53 @@ export default class Archive extends ArchiveAccessor {
|
|
|
56
124
|
* @returns The URLs that were repromoted.
|
|
57
125
|
*/
|
|
58
126
|
repromoteExternalPages(scopes: ReadonlyMap<string, readonly ExURL[]>, options?: ParseURLOptions): Promise<string[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Reset previously-failed pages back to pending so a follow-up crawl re-fetches them.
|
|
129
|
+
*
|
|
130
|
+
* Delegates to {@link Database.resetFailedPages}. See that method for the
|
|
131
|
+
* exact failure criteria (missing status / content type, or a 5xx status).
|
|
132
|
+
* @returns The URLs of the pages that were reset to pending.
|
|
133
|
+
*/
|
|
134
|
+
resetFailedPages(): Promise<string[]>;
|
|
59
135
|
/**
|
|
60
136
|
* Stores the crawl configuration into the archive database.
|
|
61
137
|
* @param config - The configuration object to store.
|
|
62
138
|
*/
|
|
63
139
|
setConfig(config: Config): Promise<number[]>;
|
|
64
140
|
/**
|
|
65
|
-
* Stores an external page's data in the archive database without
|
|
141
|
+
* Stores an external page's data in the archive database without storing
|
|
142
|
+
* an HTML snapshot. External-page rows carry only metadata (status, title,
|
|
143
|
+
* content-type), never a rendered body.
|
|
66
144
|
* @param pageInfo - The page data to store.
|
|
145
|
+
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
67
146
|
*/
|
|
68
|
-
setExternalPage(pageInfo: PageData): Promise<void>;
|
|
147
|
+
setExternalPage(pageInfo: PageData, source?: PageSource): Promise<void>;
|
|
69
148
|
/**
|
|
70
|
-
* Stores a crawled page's data in the archive database
|
|
71
|
-
*
|
|
72
|
-
*
|
|
149
|
+
* Stores a crawled page's data in the archive database, persisting the
|
|
150
|
+
* rendered HTML body as a zstd-compressed BLOB inside the same SQLite
|
|
151
|
+
* transaction. Storage is content-addressable: identical bodies across
|
|
152
|
+
* pages share a single `page_html_blobs` row.
|
|
73
153
|
* @param pageInfo - The page data to store.
|
|
154
|
+
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
74
155
|
* @returns The database ID of the stored page.
|
|
75
|
-
* @throws {Error} Re-throws any error from the snapshot file write after clearing the HTML path.
|
|
76
156
|
*/
|
|
77
|
-
setPage(pageInfo: PageData): Promise<number>;
|
|
157
|
+
setPage(pageInfo: PageData, source?: PageSource): Promise<number>;
|
|
158
|
+
/**
|
|
159
|
+
* Records a redirect edge without re-storing the destination's content.
|
|
160
|
+
*
|
|
161
|
+
* The crawler calls this (instead of {@link setPage}) when a URL redirects to
|
|
162
|
+
* a destination that has already been rendered (#73): only the source →
|
|
163
|
+
* destination edge is written, leaving the destination's stored title / meta /
|
|
164
|
+
* anchors / images untouched.
|
|
165
|
+
* @param pageInfo - The HEAD-resolved page data carrying the redirect chain.
|
|
166
|
+
*/
|
|
167
|
+
setRedirect(pageInfo: PageData): Promise<void>;
|
|
78
168
|
/**
|
|
79
169
|
* Stores a sub-resource (CSS, JS, image, etc.) in the archive database.
|
|
80
170
|
* @param resource - The resource data to store.
|
|
171
|
+
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
81
172
|
*/
|
|
82
|
-
setResources(resource: Resource): Promise<void>;
|
|
173
|
+
setResources(resource: Resource, source?: PageSource): Promise<void>;
|
|
83
174
|
/**
|
|
84
175
|
* Stores the referrer relationship between a resource and the page that references it.
|
|
85
176
|
* @param params - An object containing `url` (the page URL) and `src` (the resource URL).
|
|
@@ -109,25 +200,35 @@ export default class Archive extends ArchiveAccessor {
|
|
|
109
200
|
*/
|
|
110
201
|
updateConfig(patch: Partial<Config>): Promise<void>;
|
|
111
202
|
/**
|
|
112
|
-
* Writes the archive to disk as a
|
|
203
|
+
* Writes the archive to disk as a `.nitpicker` tar file.
|
|
113
204
|
*
|
|
114
|
-
*
|
|
115
|
-
* renames the temporary working directory
|
|
116
|
-
*
|
|
205
|
+
* Checkpoints the SQLite WAL so the database is self-contained inside
|
|
206
|
+
* `db.sqlite`, renames the temporary working directory to the archive's
|
|
207
|
+
* basename, and tars it into the final `.nitpicker`. The tar container
|
|
208
|
+
* holds a single `db.sqlite` file (the legacy `snapshot-html.zip` is gone
|
|
209
|
+
* — HTML lives as BLOBs in the DB), so finalisation is effectively a
|
|
210
|
+
* single-file copy with no per-snapshot syscalls.
|
|
117
211
|
*/
|
|
118
212
|
write(): Promise<void>;
|
|
119
213
|
/** The file extension for Nitpicker archive files (without the leading dot). */
|
|
120
214
|
static FILE_EXTENSION: string;
|
|
121
|
-
/** The directory name used for storing HTML snapshots within the archive. */
|
|
122
|
-
static readonly SNAPSHOT_HTML_DIR = "snapshot-html";
|
|
123
215
|
/** The filename of the SQLite database within the archive. */
|
|
124
216
|
static readonly SQLITE_DB_FILE_NAME = "db.sqlite";
|
|
125
217
|
/** The prefix used for temporary working directories during archive operations. */
|
|
126
218
|
static TMP_DIR_PREFIX: string;
|
|
127
219
|
/**
|
|
128
220
|
* Opens a read-only connection to an existing archive's database.
|
|
221
|
+
*
|
|
129
222
|
* Returns an {@link ArchiveAccessor} that provides query methods
|
|
130
|
-
* without the ability to modify or write the archive.
|
|
223
|
+
* without the ability to modify or write the archive. The DB is opened
|
|
224
|
+
* in **read-only mode**: no schema migrations run, and the connection
|
|
225
|
+
* refuses to resurrect a missing parent directory or db file (so a
|
|
226
|
+
* TOCTOU window between source classification and this call cannot
|
|
227
|
+
* silently produce an empty phantom tmpDir).
|
|
228
|
+
*
|
|
229
|
+
* The returned accessor is also marked read-only so consumer-facing
|
|
230
|
+
* helpers (e.g. {@link ArchiveAccessor.getHtmlOfPage}) avoid any
|
|
231
|
+
* filesystem mutation on the user's tmpDir.
|
|
131
232
|
* @param tmpDir - The path to the temporary directory containing the database.
|
|
132
233
|
* @param namespace - An optional namespace for scoping data access within the archive.
|
|
133
234
|
* @returns An ArchiveAccessor instance for querying the archive data.
|
|
@@ -167,6 +268,28 @@ export default class Archive extends ArchiveAccessor {
|
|
|
167
268
|
* @returns A formatted timestamp string.
|
|
168
269
|
*/
|
|
169
270
|
static timestamp(): string;
|
|
271
|
+
/**
|
|
272
|
+
* Closes the archive. If the archive file does not yet exist on disk,
|
|
273
|
+
* it writes the archive first. If the temporary directory still exists,
|
|
274
|
+
* it is removed. The database connection is then closed via
|
|
275
|
+
* {@link ArchiveAccessor.close} (the base class owns the SQLite handle),
|
|
276
|
+
* and finally the archive's advisory lock is released.
|
|
277
|
+
*
|
|
278
|
+
* **Idempotent**: the first invocation captures the close promise;
|
|
279
|
+
* subsequent invocations (signal handlers, parallel teardowns, retried
|
|
280
|
+
* orchestrator paths) await the same promise instead of re-entering
|
|
281
|
+
* the destructive prologue on a half-mutated state. If the first
|
|
282
|
+
* close fails (e.g. ENOSPC during tar), the rejection propagates to
|
|
283
|
+
* all awaiters and the archive stays latched closed — there is no
|
|
284
|
+
* safe way to retry `write()` once `tmpDir` has been renamed.
|
|
285
|
+
*
|
|
286
|
+
* **Read-only consumers must not reach this override.** Anything that
|
|
287
|
+
* obtains an archive view via {@link Archive.connect} receives an
|
|
288
|
+
* {@link ArchiveAccessor} (not an `Archive`), so `close()` resolves to
|
|
289
|
+
* the safe base implementation — no `write()`, no `remove()`, no lock
|
|
290
|
+
* release — leaving the tmpDir intact for the live crawler.
|
|
291
|
+
*/
|
|
292
|
+
close(): Promise<void>;
|
|
170
293
|
/**
|
|
171
294
|
* Retrieves the crawl configuration stored in the archive database.
|
|
172
295
|
* @returns The configuration object.
|