@nitpicker/crawler 0.7.0 → 0.8.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-lock.d.ts +38 -0
- package/lib/archive/archive-lock.js +147 -0
- package/lib/archive/archive.d.ts +17 -2
- package/lib/archive/archive.js +86 -31
- package/lib/archive/database.d.ts +41 -4
- package/lib/archive/database.js +176 -42
- package/lib/archive/init-schema.js +1 -1
- package/lib/archive/libsql-dialect.d.ts +25 -0
- package/lib/archive/libsql-dialect.js +28 -0
- package/lib/archive/migrate-info-roots.d.ts +15 -0
- package/lib/archive/migrate-info-roots.js +41 -0
- package/lib/archive/types.d.ts +6 -26
- package/lib/crawler/crawler.d.ts +18 -14
- package/lib/crawler/crawler.js +62 -54
- package/lib/crawler/find-scope-entry.d.ts +25 -0
- package/lib/crawler/find-scope-entry.js +45 -0
- package/lib/crawler/handle-scrape-end.js +15 -14
- package/lib/crawler/inject-scope-auth.d.ts +10 -7
- package/lib/crawler/inject-scope-auth.js +13 -14
- package/lib/crawler/is-external-url.d.ts +13 -6
- package/lib/crawler/is-external-url.js +14 -6
- package/lib/crawler/types.d.ts +2 -2
- package/lib/crawler-orchestrator.d.ts +21 -0
- package/lib/crawler-orchestrator.js +131 -10
- package/package.json +3 -3
- package/lib/crawler/find-best-matching-scope.d.ts +0 -13
- package/lib/crawler/find-best-matching-scope.js +0 -52
- package/lib/crawler/is-in-any-lower-layer.d.ts +0 -13
- package/lib/crawler/is-in-any-lower-layer.js +0 -15
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock failure surfaced when an archive's tmpDir is already in use by another
|
|
3
|
+
* live process.
|
|
4
|
+
*
|
|
5
|
+
* The lock path and PID of the holder are exposed so callers (CLI / orchestrator)
|
|
6
|
+
* can render a precise error message and let operators recover manually.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ArchiveLockError extends Error {
|
|
9
|
+
/** PID of the live process currently holding the lock, or `null` if unknown. */
|
|
10
|
+
readonly holderPid: number | null;
|
|
11
|
+
/** The lock directory path that could not be acquired. */
|
|
12
|
+
readonly lockPath: string;
|
|
13
|
+
/**
|
|
14
|
+
* @param lockPath - Absolute path of the lock directory.
|
|
15
|
+
* @param holderPid - PID stored in the lock's `pid.txt`, or `null` if unreadable.
|
|
16
|
+
*/
|
|
17
|
+
constructor(lockPath: string, holderPid: number | null);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Acquire an advisory lock on the given tmpDir.
|
|
21
|
+
*
|
|
22
|
+
* Uses `fs.mkdir(lockPath, { recursive: false })` as the atomic primitive so
|
|
23
|
+
* that two concurrent processes targeting the same archive cannot both believe
|
|
24
|
+
* they own it. The lock is published as a sibling directory named
|
|
25
|
+
* `{tmpDir}.lock` containing a `pid.txt` file with the holder's PID.
|
|
26
|
+
*
|
|
27
|
+
* On `EEXIST` the function tries once to detect a stale lock: if the recorded
|
|
28
|
+
* PID is not alive any more (`process.kill(pid, 0)` throws `ESRCH`), the stale
|
|
29
|
+
* directory is removed and a single retry is issued. If acquisition still
|
|
30
|
+
* fails — or the holder is alive — an {@link ArchiveLockError} is thrown.
|
|
31
|
+
*
|
|
32
|
+
* The returned function releases the lock; it is idempotent so callers can put
|
|
33
|
+
* it in a `finally` block without worrying about double-release.
|
|
34
|
+
* @param tmpDir - Absolute path to the archive's temporary working directory.
|
|
35
|
+
* @returns A release function to be called when the work is done.
|
|
36
|
+
* @throws {ArchiveLockError} When the lock cannot be acquired even after a stale-lock retry.
|
|
37
|
+
*/
|
|
38
|
+
export declare function acquireArchiveLock(tmpDir: string): Promise<() => Promise<void>>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Lock failure surfaced when an archive's tmpDir is already in use by another
|
|
5
|
+
* live process.
|
|
6
|
+
*
|
|
7
|
+
* The lock path and PID of the holder are exposed so callers (CLI / orchestrator)
|
|
8
|
+
* can render a precise error message and let operators recover manually.
|
|
9
|
+
*/
|
|
10
|
+
export class ArchiveLockError extends Error {
|
|
11
|
+
/** PID of the live process currently holding the lock, or `null` if unknown. */
|
|
12
|
+
holderPid;
|
|
13
|
+
/** The lock directory path that could not be acquired. */
|
|
14
|
+
lockPath;
|
|
15
|
+
/**
|
|
16
|
+
* @param lockPath - Absolute path of the lock directory.
|
|
17
|
+
* @param holderPid - PID stored in the lock's `pid.txt`, or `null` if unreadable.
|
|
18
|
+
*/
|
|
19
|
+
constructor(lockPath, holderPid) {
|
|
20
|
+
const suffix = holderPid === null ? '' : ` (PID ${holderPid})`;
|
|
21
|
+
super(`Archive is being used by another process${suffix}: ${lockPath}`);
|
|
22
|
+
this.name = 'ArchiveLockError';
|
|
23
|
+
this.lockPath = lockPath;
|
|
24
|
+
this.holderPid = holderPid;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Acquire an advisory lock on the given tmpDir.
|
|
29
|
+
*
|
|
30
|
+
* Uses `fs.mkdir(lockPath, { recursive: false })` as the atomic primitive so
|
|
31
|
+
* that two concurrent processes targeting the same archive cannot both believe
|
|
32
|
+
* they own it. The lock is published as a sibling directory named
|
|
33
|
+
* `{tmpDir}.lock` containing a `pid.txt` file with the holder's PID.
|
|
34
|
+
*
|
|
35
|
+
* On `EEXIST` the function tries once to detect a stale lock: if the recorded
|
|
36
|
+
* PID is not alive any more (`process.kill(pid, 0)` throws `ESRCH`), the stale
|
|
37
|
+
* directory is removed and a single retry is issued. If acquisition still
|
|
38
|
+
* fails — or the holder is alive — an {@link ArchiveLockError} is thrown.
|
|
39
|
+
*
|
|
40
|
+
* The returned function releases the lock; it is idempotent so callers can put
|
|
41
|
+
* it in a `finally` block without worrying about double-release.
|
|
42
|
+
* @param tmpDir - Absolute path to the archive's temporary working directory.
|
|
43
|
+
* @returns A release function to be called when the work is done.
|
|
44
|
+
* @throws {ArchiveLockError} When the lock cannot be acquired even after a stale-lock retry.
|
|
45
|
+
*/
|
|
46
|
+
export async function acquireArchiveLock(tmpDir) {
|
|
47
|
+
const lockPath = `${tmpDir}.lock`;
|
|
48
|
+
await tryAcquire(lockPath);
|
|
49
|
+
let released = false;
|
|
50
|
+
return async () => {
|
|
51
|
+
if (released) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
released = true;
|
|
55
|
+
await releaseLock(lockPath);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Attempt to create the lock directory and write the holder PID, handling one
|
|
60
|
+
* round of stale-lock recovery.
|
|
61
|
+
* @param lockPath - The absolute lock directory path.
|
|
62
|
+
*/
|
|
63
|
+
async function tryAcquire(lockPath) {
|
|
64
|
+
try {
|
|
65
|
+
await fs.mkdir(lockPath, { recursive: false });
|
|
66
|
+
await fs.writeFile(path.join(lockPath, 'pid.txt'), String(process.pid), 'utf8');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!isEexist(error)) {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const holderPid = await readHolderPid(lockPath);
|
|
75
|
+
if (holderPid !== null && isProcessAlive(holderPid)) {
|
|
76
|
+
throw new ArchiveLockError(lockPath, holderPid);
|
|
77
|
+
}
|
|
78
|
+
// Stale lock — clean up and retry once. A concurrent acquirer may have
|
|
79
|
+
// already reclaimed the directory, so a second EEXIST is treated as a real
|
|
80
|
+
// collision and surfaced to the caller.
|
|
81
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
82
|
+
try {
|
|
83
|
+
await fs.mkdir(lockPath, { recursive: false });
|
|
84
|
+
await fs.writeFile(path.join(lockPath, 'pid.txt'), String(process.pid), 'utf8');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (isEexist(error)) {
|
|
88
|
+
const pid = await readHolderPid(lockPath);
|
|
89
|
+
throw new ArchiveLockError(lockPath, pid);
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Remove the lock directory. `ENOENT` is swallowed so `finally`-style callers
|
|
96
|
+
* tolerate an externally-cleaned lock, but any other failure (permissions,
|
|
97
|
+
* disk full, etc.) is propagated so the caller can react.
|
|
98
|
+
* @param lockPath - The absolute lock directory path.
|
|
99
|
+
*/
|
|
100
|
+
async function releaseLock(lockPath) {
|
|
101
|
+
try {
|
|
102
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error.code !== 'ENOENT') {
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Read the PID stored in the lock directory's `pid.txt`.
|
|
112
|
+
* @param lockPath - The absolute lock directory path.
|
|
113
|
+
* @returns The parsed PID, or `null` if the file is missing or malformed.
|
|
114
|
+
*/
|
|
115
|
+
async function readHolderPid(lockPath) {
|
|
116
|
+
try {
|
|
117
|
+
const raw = await fs.readFile(path.join(lockPath, 'pid.txt'), 'utf8');
|
|
118
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
119
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Check whether the given PID is still alive using a signal-0 probe.
|
|
127
|
+
* @param pid - The process id to probe.
|
|
128
|
+
* @returns `true` if the process exists (regardless of permission).
|
|
129
|
+
*/
|
|
130
|
+
function isProcessAlive(pid) {
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, 0);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
// EPERM means the process exists but is owned by another user — still alive.
|
|
137
|
+
return error.code === 'EPERM';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Type-guard for an `EEXIST` filesystem error.
|
|
142
|
+
* @param error - The error value to inspect.
|
|
143
|
+
* @returns `true` when the error is a Node `EEXIST` from a filesystem call.
|
|
144
|
+
*/
|
|
145
|
+
function isEexist(error) {
|
|
146
|
+
return error?.code === 'EEXIST';
|
|
147
|
+
}
|
package/lib/archive/archive.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Config } from './types.js';
|
|
2
2
|
import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
|
|
3
|
-
import type { ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
3
|
+
import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
4
4
|
import { ArchiveAccessor } from './archive-accessor.js';
|
|
5
5
|
/**
|
|
6
6
|
* Main archive class for creating, opening, resuming, and writing Nitpicker archive files (`.nitpicker`).
|
|
@@ -47,11 +47,20 @@ export default class Archive extends ArchiveAccessor {
|
|
|
47
47
|
* @returns The base URL string.
|
|
48
48
|
*/
|
|
49
49
|
getUrl(): Promise<any>;
|
|
50
|
+
/**
|
|
51
|
+
* Promote previously-external pages that now fall under the (possibly extended)
|
|
52
|
+
* scope back to a pending state so that the crawler re-scrapes them as fully
|
|
53
|
+
* internal pages on the next pass.
|
|
54
|
+
* @param scopes - Hostname-indexed scope map representing the new scope.
|
|
55
|
+
* @param options - URL parsing options forwarded to the scope-entry lookup.
|
|
56
|
+
* @returns The URLs that were repromoted.
|
|
57
|
+
*/
|
|
58
|
+
repromoteExternalPages(scopes: ReadonlyMap<string, readonly ExURL[]>, options?: ParseURLOptions): Promise<string[]>;
|
|
50
59
|
/**
|
|
51
60
|
* Stores the crawl configuration into the archive database.
|
|
52
61
|
* @param config - The configuration object to store.
|
|
53
62
|
*/
|
|
54
|
-
setConfig(config: Config): Promise<
|
|
63
|
+
setConfig(config: Config): Promise<number[]>;
|
|
55
64
|
/**
|
|
56
65
|
* Stores an external page's data in the archive database without saving a snapshot.
|
|
57
66
|
* @param pageInfo - The page data to store.
|
|
@@ -93,6 +102,12 @@ export default class Archive extends ArchiveAccessor {
|
|
|
93
102
|
* that do not yet have an `order` field set.
|
|
94
103
|
*/
|
|
95
104
|
setUrlOrder(): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Updates a subset of fields on the archive's `info` row. Used by the append
|
|
107
|
+
* flow to extend `roots` / `scope` without rewriting the entire config.
|
|
108
|
+
* @param patch - Partial {@link Config} fields to overwrite. `undefined` values are ignored.
|
|
109
|
+
*/
|
|
110
|
+
updateConfig(patch: Partial<Config>): Promise<void>;
|
|
96
111
|
/**
|
|
97
112
|
* Writes the archive to disk as a compressed `.nitpicker` file.
|
|
98
113
|
*
|
package/lib/archive/archive.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { zip } from '@d-zero/fs/zip';
|
|
3
3
|
import { ArchiveAccessor } from './archive-accessor.js';
|
|
4
|
+
import { acquireArchiveLock } from './archive-lock.js';
|
|
4
5
|
import { Database } from './database.js';
|
|
5
6
|
import { dbLog, log, saveLog } from './debug.js';
|
|
6
7
|
import { appendText } from './filesystem/append-text.js';
|
|
@@ -26,6 +27,8 @@ export default class Archive extends ArchiveAccessor {
|
|
|
26
27
|
#db;
|
|
27
28
|
/** Absolute path to the output `.nitpicker` archive file. */
|
|
28
29
|
#filePath;
|
|
30
|
+
/** Lock release function held while the writer owns the archive. */
|
|
31
|
+
#releaseLock;
|
|
29
32
|
/** Absolute path to the HTML snapshot directory within the temporary working directory. */
|
|
30
33
|
#snapshotDir;
|
|
31
34
|
/** Absolute path to the temporary working directory containing the SQLite DB and snapshots. */
|
|
@@ -37,12 +40,13 @@ export default class Archive extends ArchiveAccessor {
|
|
|
37
40
|
return this.#filePath;
|
|
38
41
|
}
|
|
39
42
|
// eslint-disable-next-line no-restricted-syntax
|
|
40
|
-
constructor(filePath, tmpDir, db) {
|
|
43
|
+
constructor(filePath, tmpDir, db, releaseLock) {
|
|
41
44
|
super(tmpDir, db, '');
|
|
42
45
|
this.#filePath = filePath;
|
|
43
46
|
this.#tmpDir = tmpDir;
|
|
44
47
|
this.#snapshotDir = path.resolve(this.#tmpDir, Archive.SNAPSHOT_HTML_DIR);
|
|
45
48
|
this.#db = db;
|
|
49
|
+
this.#releaseLock = releaseLock;
|
|
46
50
|
log('create instance: %O', {
|
|
47
51
|
filePath,
|
|
48
52
|
tmpDir,
|
|
@@ -71,15 +75,20 @@ export default class Archive extends ArchiveAccessor {
|
|
|
71
75
|
*/
|
|
72
76
|
async close() {
|
|
73
77
|
log('Closing');
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
try {
|
|
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();
|
|
77
88
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await remove(this.#tmpDir);
|
|
89
|
+
finally {
|
|
90
|
+
await this.#releaseLock();
|
|
81
91
|
}
|
|
82
|
-
await this.#db.destroy();
|
|
83
92
|
log('Closing done');
|
|
84
93
|
}
|
|
85
94
|
/**
|
|
@@ -96,6 +105,18 @@ export default class Archive extends ArchiveAccessor {
|
|
|
96
105
|
async getUrl() {
|
|
97
106
|
return this.#db.getBaseUrl();
|
|
98
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Promote previously-external pages that now fall under the (possibly extended)
|
|
110
|
+
* scope back to a pending state so that the crawler re-scrapes them as fully
|
|
111
|
+
* internal pages on the next pass.
|
|
112
|
+
* @param scopes - Hostname-indexed scope map representing the new scope.
|
|
113
|
+
* @param options - URL parsing options forwarded to the scope-entry lookup.
|
|
114
|
+
* @returns The URLs that were repromoted.
|
|
115
|
+
*/
|
|
116
|
+
async repromoteExternalPages(scopes, options) {
|
|
117
|
+
dbLog('Repromote external pages with %d hostnames in scope', scopes.size);
|
|
118
|
+
return this.#db.repromoteExternalPages(scopes, options);
|
|
119
|
+
}
|
|
99
120
|
/**
|
|
100
121
|
* Stores the crawl configuration into the archive database.
|
|
101
122
|
* @param config - The configuration object to store.
|
|
@@ -176,6 +197,15 @@ export default class Archive extends ArchiveAccessor {
|
|
|
176
197
|
dbLog("Pages didn't have `order` field. So set URL order.");
|
|
177
198
|
await this.#db.setUrlOrder();
|
|
178
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Updates a subset of fields on the archive's `info` row. Used by the append
|
|
202
|
+
* flow to extend `roots` / `scope` without rewriting the entire config.
|
|
203
|
+
* @param patch - Partial {@link Config} fields to overwrite. `undefined` values are ignored.
|
|
204
|
+
*/
|
|
205
|
+
async updateConfig(patch) {
|
|
206
|
+
dbLog('Update config: %O', patch);
|
|
207
|
+
await this.#db.updateConfig(patch);
|
|
208
|
+
}
|
|
179
209
|
/**
|
|
180
210
|
* Writes the archive to disk as a compressed `.nitpicker` file.
|
|
181
211
|
*
|
|
@@ -240,7 +270,14 @@ export default class Archive extends ArchiveAccessor {
|
|
|
240
270
|
});
|
|
241
271
|
const fileName = path.basename(filePath, path.extname(filePath));
|
|
242
272
|
const tmpDir = path.resolve(cwd, Archive.TMP_DIR_PREFIX + fileName);
|
|
243
|
-
|
|
273
|
+
const releaseLock = await acquireArchiveLock(tmpDir);
|
|
274
|
+
try {
|
|
275
|
+
return await Archive.#init(filePath, tmpDir, releaseLock);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
await releaseLock();
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
244
281
|
}
|
|
245
282
|
/**
|
|
246
283
|
* Joins path segments into an absolute path.
|
|
@@ -266,21 +303,28 @@ export default class Archive extends ArchiveAccessor {
|
|
|
266
303
|
});
|
|
267
304
|
const fileName = path.basename(filePath, path.extname(filePath));
|
|
268
305
|
const tmpDir = path.resolve(cwd, Archive.TMP_DIR_PREFIX + fileName);
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
306
|
+
const releaseLock = await acquireArchiveLock(tmpDir);
|
|
307
|
+
try {
|
|
308
|
+
const openFiles = [];
|
|
309
|
+
if (!openPluginData) {
|
|
310
|
+
const relDdPath = path.join(fileName, Archive.SQLITE_DB_FILE_NAME);
|
|
311
|
+
const relSnapshotPath = path.join(fileName, Archive.SNAPSHOT_HTML_DIR + '.zip');
|
|
312
|
+
openFiles.push(relDdPath, relSnapshotPath);
|
|
313
|
+
}
|
|
314
|
+
log('Unzip file: %s (%O)', filePath, openFiles);
|
|
315
|
+
await untar(filePath, {
|
|
316
|
+
cwd,
|
|
317
|
+
fileList: openFiles.length > 0 ? openFiles : undefined,
|
|
318
|
+
});
|
|
319
|
+
const extractedDir = path.resolve(cwd, fileName);
|
|
320
|
+
log('Move directory: %s to %s', extractedDir, tmpDir);
|
|
321
|
+
await rename(extractedDir, tmpDir, true);
|
|
322
|
+
return await Archive.#init(filePath, tmpDir, releaseLock);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
await releaseLock();
|
|
326
|
+
throw error;
|
|
274
327
|
}
|
|
275
|
-
log('Unzip file: %s (%O)', filePath, openFiles);
|
|
276
|
-
await untar(filePath, {
|
|
277
|
-
cwd,
|
|
278
|
-
fileList: openFiles.length > 0 ? openFiles : undefined,
|
|
279
|
-
});
|
|
280
|
-
const extractedDir = path.resolve(cwd, fileName);
|
|
281
|
-
log('Move directory: %s to %s', extractedDir, tmpDir);
|
|
282
|
-
await rename(extractedDir, tmpDir, true);
|
|
283
|
-
return await Archive.#init(filePath, tmpDir);
|
|
284
328
|
}
|
|
285
329
|
/**
|
|
286
330
|
* Resumes an archive from an existing temporary directory
|
|
@@ -293,11 +337,18 @@ export default class Archive extends ArchiveAccessor {
|
|
|
293
337
|
log('Resume: %s', targetPath);
|
|
294
338
|
if (await isDir(targetPath)) {
|
|
295
339
|
const tmpDir = targetPath;
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
340
|
+
const releaseLock = await acquireArchiveLock(tmpDir);
|
|
341
|
+
try {
|
|
342
|
+
const db = await Archive.#connectDB(tmpDir);
|
|
343
|
+
const name = (await db.getName()) ||
|
|
344
|
+
path.basename(targetPath).replace(Archive.TMP_DIR_PREFIX, '');
|
|
345
|
+
const filePath = path.resolve(process.cwd(), name + '.' + Archive.FILE_EXTENSION);
|
|
346
|
+
return new Archive(filePath, tmpDir, db, releaseLock);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
await releaseLock();
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
301
352
|
}
|
|
302
353
|
throw new Error('The specified path is not a directory. Please ensure the path points to a valid directory.');
|
|
303
354
|
}
|
|
@@ -327,19 +378,23 @@ export default class Archive extends ArchiveAccessor {
|
|
|
327
378
|
const dbPath = path.resolve(tmpDir, Archive.SQLITE_DB_FILE_NAME);
|
|
328
379
|
dbLog('connects database: %s', dbPath);
|
|
329
380
|
return await Database.connect({
|
|
330
|
-
type: 'sqlite3',
|
|
331
381
|
workingDir: tmpDir,
|
|
332
382
|
filename: dbPath,
|
|
333
383
|
});
|
|
334
384
|
}
|
|
335
385
|
/**
|
|
336
386
|
* Initializes an Archive instance by connecting to the database.
|
|
387
|
+
*
|
|
388
|
+
* The advisory lock must already be acquired by the caller; this helper just
|
|
389
|
+
* threads the release function through to the resulting `Archive` so that
|
|
390
|
+
* {@link Archive.close} can drop the lock when work is done.
|
|
337
391
|
* @param filePath - Output `.nitpicker` file path
|
|
338
392
|
* @param tmpDir - Temporary working directory path
|
|
393
|
+
* @param releaseLock - Function returned by {@link acquireArchiveLock}.
|
|
339
394
|
*/
|
|
340
|
-
static async #init(filePath, tmpDir) {
|
|
395
|
+
static async #init(filePath, tmpDir, releaseLock) {
|
|
341
396
|
const db = await Archive.#connectDB(tmpDir);
|
|
342
|
-
const archive = new Archive(filePath, tmpDir, db);
|
|
397
|
+
const archive = new Archive(filePath, tmpDir, db, releaseLock);
|
|
343
398
|
return archive;
|
|
344
399
|
}
|
|
345
400
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Config, DatabaseOption, DB_Anchor, DB_Page, DB_Redirect, DB_Referrer, DB_Resource, DatabaseEvent, PageFilter } from './types.js';
|
|
2
2
|
import type { PageData, Resource } from '../utils/types/types.js';
|
|
3
|
+
import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
3
4
|
import type { Knex } from 'knex';
|
|
4
5
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
5
6
|
/**
|
|
@@ -54,7 +55,7 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
|
|
|
54
55
|
getBaseUrl(): Promise<any>;
|
|
55
56
|
/**
|
|
56
57
|
* Retrieves the full crawl configuration from the `info` table.
|
|
57
|
-
* Deserializes JSON-encoded fields (`excludes`, `excludeKeywords`, `
|
|
58
|
+
* Deserializes JSON-encoded fields (`roots`, `excludes`, `excludeKeywords`, `excludeUrls`).
|
|
58
59
|
* @returns The parsed {@link Config} object.
|
|
59
60
|
* @throws {Error} If no configuration is found in the database.
|
|
60
61
|
*/
|
|
@@ -154,12 +155,34 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
|
|
|
154
155
|
* @param pageUrl - The URL of the page that references the resource.
|
|
155
156
|
*/
|
|
156
157
|
insertResourceReferrers(src: string, pageUrl: string): Promise<void>;
|
|
158
|
+
/**
|
|
159
|
+
* Promote previously-external pages whose URL falls under any of the new scope
|
|
160
|
+
* entries back to a "needs scraping" state so that the next crawl picks them up
|
|
161
|
+
* as full internal pages.
|
|
162
|
+
*
|
|
163
|
+
* For each matching page:
|
|
164
|
+
* - clears the scrape metadata (status, headers, snapshot path, etc.),
|
|
165
|
+
* - flips `isExternal` to `0` and `scraped` to `0`,
|
|
166
|
+
* - removes stale `anchors`, `images`, and `resources-referrers` rows so that
|
|
167
|
+
* the re-scrape can re-insert fresh ones without duplicates.
|
|
168
|
+
*
|
|
169
|
+
* The page row itself is kept (id is preserved) so existing referrers via
|
|
170
|
+
* `anchors.hrefId` remain valid. SELECT and UPDATE/DELETE statements are
|
|
171
|
+
* chunked to stay below SQLite's `SQLITE_LIMIT_VARIABLE_NUMBER`.
|
|
172
|
+
* @param scopes - The hostname-indexed scope map after the new roots are merged.
|
|
173
|
+
* @param options - URL parsing options forwarded to {@link findScopeEntry}.
|
|
174
|
+
* @returns The URLs of the pages that were promoted.
|
|
175
|
+
*/
|
|
176
|
+
repromoteExternalPages(scopes: ReadonlyMap<string, readonly ExURL[]>, options?: ParseURLOptions): Promise<string[]>;
|
|
157
177
|
/**
|
|
158
178
|
* Stores the crawl configuration in the `info` table.
|
|
159
|
-
*
|
|
179
|
+
* Only fields in {@link INFO_COLUMN_ALLOWLIST} are forwarded — any extra
|
|
180
|
+
* runtime-only field on the input is silently dropped so callers can splat
|
|
181
|
+
* a wider config object without producing SQL errors. JSON-array fields
|
|
182
|
+
* are serialized via `JSON.stringify`.
|
|
160
183
|
* @param config - The {@link Config} object to store.
|
|
161
184
|
*/
|
|
162
|
-
setConfig(config: Config): Promise<
|
|
185
|
+
setConfig(config: Config): Promise<number[]>;
|
|
163
186
|
/**
|
|
164
187
|
* Marks a page as skipped in the database with the given reason.
|
|
165
188
|
* Creates the page row if it does not already exist.
|
|
@@ -173,6 +196,20 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
|
|
|
173
196
|
* Pages are sorted using {@link pathComparator} and assigned sequential order numbers.
|
|
174
197
|
*/
|
|
175
198
|
setUrlOrder(): Promise<void>;
|
|
199
|
+
/**
|
|
200
|
+
* Update the single row in the `info` table with a partial config patch.
|
|
201
|
+
*
|
|
202
|
+
* Used by the append flow to extend `roots` (and any other tweakable
|
|
203
|
+
* field) without replacing the entire row. JSON-array fields are serialized on
|
|
204
|
+
* the fly; primitive fields are written verbatim. Unspecified fields stay as-is.
|
|
205
|
+
*
|
|
206
|
+
* Unknown keys (anything outside the allow-list of `info`-table columns) are
|
|
207
|
+
* silently dropped instead of being passed to SQL, so callers that splat a
|
|
208
|
+
* wider runtime config (e.g. `CrawlConfig` with `cwd` / `executablePath`)
|
|
209
|
+
* cannot accidentally trigger a "no such column" SQL error.
|
|
210
|
+
* @param patch - Partial {@link Config} fields to overwrite. `undefined` values are skipped.
|
|
211
|
+
*/
|
|
212
|
+
updateConfig(patch: Partial<Config>): Promise<void>;
|
|
176
213
|
/**
|
|
177
214
|
* Inserts or updates a crawled page in the database, including its redirect chain,
|
|
178
215
|
* anchors, and images. Optionally creates an HTML snapshot file path entry.
|
|
@@ -194,7 +231,7 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
|
|
|
194
231
|
* Creates and initializes a new Database instance.
|
|
195
232
|
* Creates the parent directory for the database file if needed,
|
|
196
233
|
* establishes the connection, and initializes tables if they do not exist.
|
|
197
|
-
* @param options -
|
|
234
|
+
* @param options - Database connection options (working directory + SQLite file path).
|
|
198
235
|
* @returns A fully initialized Database instance.
|
|
199
236
|
*/
|
|
200
237
|
static connect(options: DatabaseOption): Promise<Database>;
|