@nitpicker/crawler 0.4.4 → 0.6.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 +12 -5
- package/lib/archive/archive-accessor.js +22 -8
- package/lib/archive/archive.d.ts +9 -6
- package/lib/archive/archive.js +32 -11
- package/lib/archive/database.d.ts +20 -2
- package/lib/archive/database.js +38 -6
- package/lib/archive/filesystem/rename.d.ts +5 -0
- package/lib/archive/filesystem/rename.js +31 -1
- package/lib/archive/types.d.ts +29 -0
- package/lib/crawler/crawler.d.ts +11 -2
- package/lib/crawler/crawler.js +67 -26
- package/lib/crawler/format-crawl-progress.d.ts +33 -0
- package/lib/crawler/format-crawl-progress.js +29 -0
- package/lib/crawler/handle-ignore-and-skip.d.ts +1 -1
- package/lib/crawler/handle-resource-response.d.ts +1 -1
- package/lib/crawler/handle-scrape-end.d.ts +1 -1
- package/lib/crawler/handle-scrape-error.d.ts +1 -1
- package/lib/crawler/link-list.d.ts +1 -1
- package/lib/crawler/link-to-page-data.d.ts +1 -1
- package/lib/crawler/types.d.ts +1 -1
- package/lib/crawler-orchestrator.d.ts +4 -4
- package/lib/crawler-orchestrator.js +40 -24
- package/lib/crawler.d.ts +22 -0
- package/lib/crawler.js +23 -0
- package/lib/resolve-output-path.d.ts +12 -0
- package/lib/resolve-output-path.js +26 -0
- package/lib/types.d.ts +1 -1
- package/lib/utils/types/types.d.ts +2 -0
- package/lib/write-queue.d.ts +32 -0
- package/lib/write-queue.js +55 -0
- package/package.json +6 -6
|
@@ -25,17 +25,17 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
|
|
|
25
25
|
* When null, `setData` is not available.
|
|
26
26
|
*/
|
|
27
27
|
constructor(tmpDir: string, db: Database, namespace?: string | null);
|
|
28
|
-
/**
|
|
29
|
-
* Retrieves the crawl configuration stored in the archive database.
|
|
30
|
-
* @returns The parsed {@link Config} object.
|
|
31
|
-
*/
|
|
32
|
-
getConfig(): Promise<Config>;
|
|
33
28
|
/**
|
|
34
29
|
* Retrieves anchor (link) data for a specific page by its database ID.
|
|
35
30
|
* @param pageId - The database ID of the page whose anchors to retrieve.
|
|
36
31
|
* @returns An array of anchor records found on the page.
|
|
37
32
|
*/
|
|
38
33
|
getAnchorsOnPage(pageId: number): Promise<any[]>;
|
|
34
|
+
/**
|
|
35
|
+
* Retrieves the crawl configuration stored in the archive database.
|
|
36
|
+
* @returns The parsed {@link Config} object.
|
|
37
|
+
*/
|
|
38
|
+
getConfig(): Promise<Config>;
|
|
39
39
|
/**
|
|
40
40
|
* Reads custom data stored in the archive by name.
|
|
41
41
|
* @param name - The base name of the data file (without extension).
|
|
@@ -58,6 +58,13 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
|
|
|
58
58
|
* @returns The HTML content as a string, or null if the snapshot is not found or filePath is null.
|
|
59
59
|
*/
|
|
60
60
|
getHtmlOfPage(filePath: string | null, openZipped?: boolean): Promise<string | null>;
|
|
61
|
+
/**
|
|
62
|
+
* Returns the underlying Knex query builder instance for direct SQL access.
|
|
63
|
+
* Enables advanced queries (GROUP BY, HAVING, JOINs) at the database layer
|
|
64
|
+
* for performance-critical operations on large datasets.
|
|
65
|
+
* @returns The Knex instance connected to the SQLite database.
|
|
66
|
+
*/
|
|
67
|
+
getKnex(): import("knex").Knex<any, any[]>;
|
|
61
68
|
/**
|
|
62
69
|
* Retrieves all pages from the archive, optionally filtered by type.
|
|
63
70
|
* Eagerly loads redirect relationships (`redirectFrom`) but does NOT load
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { extractZip, unzip } from '@d-zero/fs/zip';
|
|
2
3
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
3
4
|
import { log } from './debug.js';
|
|
4
|
-
import { exists
|
|
5
|
+
import { exists } from './filesystem/exists.js';
|
|
6
|
+
import { outputJSON } from './filesystem/output-json.js';
|
|
7
|
+
import { outputText } from './filesystem/output-text.js';
|
|
8
|
+
import { readJSON } from './filesystem/read-json.js';
|
|
9
|
+
import { readText } from './filesystem/read-text.js';
|
|
5
10
|
import Page from './page.js';
|
|
6
11
|
import Resource from './resource.js';
|
|
7
12
|
import { safePath } from './safe-path.js';
|
|
@@ -41,13 +46,6 @@ export class ArchiveAccessor extends EventEmitter {
|
|
|
41
46
|
void this.emit('error', e);
|
|
42
47
|
});
|
|
43
48
|
}
|
|
44
|
-
/**
|
|
45
|
-
* Retrieves the crawl configuration stored in the archive database.
|
|
46
|
-
* @returns The parsed {@link Config} object.
|
|
47
|
-
*/
|
|
48
|
-
async getConfig() {
|
|
49
|
-
return this.#db.getConfig();
|
|
50
|
-
}
|
|
51
49
|
/**
|
|
52
50
|
* Retrieves anchor (link) data for a specific page by its database ID.
|
|
53
51
|
* @param pageId - The database ID of the page whose anchors to retrieve.
|
|
@@ -57,6 +55,13 @@ export class ArchiveAccessor extends EventEmitter {
|
|
|
57
55
|
const refs = await this.#db.getAnchorsOnPage(pageId);
|
|
58
56
|
return refs;
|
|
59
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Retrieves the crawl configuration stored in the archive database.
|
|
60
|
+
* @returns The parsed {@link Config} object.
|
|
61
|
+
*/
|
|
62
|
+
async getConfig() {
|
|
63
|
+
return this.#db.getConfig();
|
|
64
|
+
}
|
|
60
65
|
async getData(name, format = 'json') {
|
|
61
66
|
const namespace = this.#namespace || '';
|
|
62
67
|
const filePath = safePath(this.#tmpDir, namespace, `${name}.${format}`);
|
|
@@ -103,6 +108,15 @@ export class ArchiveAccessor extends EventEmitter {
|
|
|
103
108
|
log('Succeeded: Extracts %s from zipped snapshots', name);
|
|
104
109
|
return html;
|
|
105
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Returns the underlying Knex query builder instance for direct SQL access.
|
|
113
|
+
* Enables advanced queries (GROUP BY, HAVING, JOINs) at the database layer
|
|
114
|
+
* for performance-critical operations on large datasets.
|
|
115
|
+
* @returns The Knex instance connected to the SQLite database.
|
|
116
|
+
*/
|
|
117
|
+
getKnex() {
|
|
118
|
+
return this.#db.getKnex();
|
|
119
|
+
}
|
|
106
120
|
/**
|
|
107
121
|
* Retrieves all pages from the archive, optionally filtered by type.
|
|
108
122
|
* Eagerly loads redirect relationships (`redirectFrom`) but does NOT load
|
package/lib/archive/archive.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Config } from './types.js';
|
|
2
|
-
import type { PageData, CrawlerError, Resource } from '../utils/
|
|
2
|
+
import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
|
|
3
3
|
import type { ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
4
4
|
import { ArchiveAccessor } from './archive-accessor.js';
|
|
5
5
|
/**
|
|
@@ -34,11 +34,6 @@ export default class Archive extends ArchiveAccessor {
|
|
|
34
34
|
* it is removed.
|
|
35
35
|
*/
|
|
36
36
|
close(): Promise<void>;
|
|
37
|
-
/**
|
|
38
|
-
* Retrieves the crawl configuration stored in the archive database.
|
|
39
|
-
* @returns The configuration object.
|
|
40
|
-
*/
|
|
41
|
-
getConfig(): Promise<Config>;
|
|
42
37
|
/**
|
|
43
38
|
* Retrieves the current crawling state, including lists of scraped and pending URLs.
|
|
44
39
|
* @returns An object with `scraped` and `pending` URL arrays.
|
|
@@ -64,8 +59,11 @@ export default class Archive extends ArchiveAccessor {
|
|
|
64
59
|
setExternalPage(pageInfo: PageData): Promise<void>;
|
|
65
60
|
/**
|
|
66
61
|
* Stores a crawled page's data in the archive database and optionally saves an HTML snapshot.
|
|
62
|
+
* If the snapshot file write fails, the HTML path in the database is cleared to prevent
|
|
63
|
+
* referencing a non-existent file, and the error is re-thrown.
|
|
67
64
|
* @param pageInfo - The page data to store.
|
|
68
65
|
* @returns The database ID of the stored page.
|
|
66
|
+
* @throws {Error} Re-throws any error from the snapshot file write after clearing the HTML path.
|
|
69
67
|
*/
|
|
70
68
|
setPage(pageInfo: PageData): Promise<number>;
|
|
71
69
|
/**
|
|
@@ -154,6 +152,11 @@ export default class Archive extends ArchiveAccessor {
|
|
|
154
152
|
* @returns A formatted timestamp string.
|
|
155
153
|
*/
|
|
156
154
|
static timestamp(): string;
|
|
155
|
+
/**
|
|
156
|
+
* Retrieves the crawl configuration stored in the archive database.
|
|
157
|
+
* @returns The configuration object.
|
|
158
|
+
*/
|
|
159
|
+
getConfig(): Promise<Config>;
|
|
157
160
|
}
|
|
158
161
|
/**
|
|
159
162
|
* Options for creating or opening an archive.
|
package/lib/archive/archive.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { zip } from '@d-zero/fs/zip';
|
|
2
3
|
import { ArchiveAccessor } from './archive-accessor.js';
|
|
3
4
|
import { Database } from './database.js';
|
|
4
5
|
import { dbLog, log, saveLog } from './debug.js';
|
|
5
|
-
import { appendText
|
|
6
|
+
import { appendText } from './filesystem/append-text.js';
|
|
7
|
+
import { exists } from './filesystem/exists.js';
|
|
8
|
+
import { isDir } from './filesystem/is-dir.js';
|
|
9
|
+
import { outputText } from './filesystem/output-text.js';
|
|
10
|
+
import { remove } from './filesystem/remove.js';
|
|
11
|
+
import { rename } from './filesystem/rename.js';
|
|
12
|
+
import { tar } from './filesystem/tar.js';
|
|
13
|
+
import { untar } from './filesystem/untar.js';
|
|
6
14
|
/**
|
|
7
15
|
* Main archive class for creating, opening, resuming, and writing Nitpicker archive files (`.nitpicker`).
|
|
8
16
|
*
|
|
@@ -74,13 +82,6 @@ export default class Archive extends ArchiveAccessor {
|
|
|
74
82
|
await this.#db.destroy();
|
|
75
83
|
log('Closing done');
|
|
76
84
|
}
|
|
77
|
-
/**
|
|
78
|
-
* Retrieves the crawl configuration stored in the archive database.
|
|
79
|
-
* @returns The configuration object.
|
|
80
|
-
*/
|
|
81
|
-
async getConfig() {
|
|
82
|
-
return this.#db.getConfig();
|
|
83
|
-
}
|
|
84
85
|
/**
|
|
85
86
|
* Retrieves the current crawling state, including lists of scraped and pending URLs.
|
|
86
87
|
* @returns An object with `scraped` and `pending` URL arrays.
|
|
@@ -113,17 +114,30 @@ export default class Archive extends ArchiveAccessor {
|
|
|
113
114
|
}
|
|
114
115
|
/**
|
|
115
116
|
* Stores a crawled page's data in the archive database and optionally saves an HTML snapshot.
|
|
117
|
+
* If the snapshot file write fails, the HTML path in the database is cleared to prevent
|
|
118
|
+
* referencing a non-existent file, and the error is re-thrown.
|
|
116
119
|
* @param pageInfo - The page data to store.
|
|
117
120
|
* @returns The database ID of the stored page.
|
|
121
|
+
* @throws {Error} Re-throws any error from the snapshot file write after clearing the HTML path.
|
|
118
122
|
*/
|
|
119
123
|
async setPage(pageInfo) {
|
|
120
124
|
dbLog('Set page: %s', pageInfo.url.href);
|
|
121
125
|
const { html, pageId } = await this.#db.updatePage(pageInfo, this.#snapshotDir, pageInfo.isTarget);
|
|
122
|
-
const snapshotTask = [];
|
|
123
126
|
if (html) {
|
|
124
|
-
|
|
127
|
+
try {
|
|
128
|
+
await outputText(html, pageInfo.html);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
dbLog('Snapshot write failed for page %d, clearing html path: %s', pageId, html);
|
|
132
|
+
try {
|
|
133
|
+
await this.#db.clearHtmlPath(pageId);
|
|
134
|
+
}
|
|
135
|
+
catch (clearError) {
|
|
136
|
+
dbLog('Failed to clear html path for page %d: %s', pageId, clearError);
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
125
140
|
}
|
|
126
|
-
await Promise.all(snapshotTask);
|
|
127
141
|
return pageId;
|
|
128
142
|
}
|
|
129
143
|
/**
|
|
@@ -328,4 +342,11 @@ export default class Archive extends ArchiveAccessor {
|
|
|
328
342
|
const archive = new Archive(filePath, tmpDir, db);
|
|
329
343
|
return archive;
|
|
330
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Retrieves the crawl configuration stored in the archive database.
|
|
347
|
+
* @returns The configuration object.
|
|
348
|
+
*/
|
|
349
|
+
async getConfig() {
|
|
350
|
+
return this.#db.getConfig();
|
|
351
|
+
}
|
|
331
352
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Config, DB_Anchor, DB_Page, DB_Redirect, DB_Referrer, DB_Resource, DatabaseEvent, PageFilter } from './types.js';
|
|
2
|
-
import type { PageData, Resource } from '../utils/
|
|
2
|
+
import type { PageData, Resource } from '../utils/types/types.js';
|
|
3
|
+
import type { Knex } from 'knex';
|
|
3
4
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
4
5
|
/**
|
|
5
6
|
* Low-level database abstraction layer for the archive's SQLite database.
|
|
@@ -17,8 +18,9 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
|
|
|
17
18
|
private constructor();
|
|
18
19
|
/**
|
|
19
20
|
* Adds the `order` column to the `pages` table for URL sort ordering.
|
|
21
|
+
* If the column already exists, this method does nothing.
|
|
20
22
|
* @deprecated Since v0.1.x. The column is now created during table initialization.
|
|
21
|
-
* @returns The result of the schema alteration.
|
|
23
|
+
* @returns The result of the schema alteration, or void if the column already exists.
|
|
22
24
|
*/
|
|
23
25
|
addOrderField(): Promise<void>;
|
|
24
26
|
/**
|
|
@@ -27,6 +29,15 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
|
|
|
27
29
|
* This ensures the database is fully self-contained in `db.sqlite` before archiving.
|
|
28
30
|
*/
|
|
29
31
|
checkpoint(): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Clears the HTML snapshot path for a page.
|
|
34
|
+
* Used to roll back the snapshot reference when the snapshot file write fails.
|
|
35
|
+
* @param pageId - The database ID of the page whose HTML path should be cleared.
|
|
36
|
+
*/
|
|
37
|
+
clearHtmlPath(pageId: number): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Destroys the database connection, releasing all pooled resources.
|
|
40
|
+
*/
|
|
30
41
|
destroy(): Promise<void>;
|
|
31
42
|
/**
|
|
32
43
|
* Retrieves all anchors (outgoing links) on a specific page.
|
|
@@ -62,6 +73,13 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
|
|
|
62
73
|
* @returns The relative file path to the HTML snapshot, or null if not saved.
|
|
63
74
|
*/
|
|
64
75
|
getHtmlPathOnPage(pageId: number): Promise<any>;
|
|
76
|
+
/**
|
|
77
|
+
* Returns the underlying Knex query builder instance for direct SQL access.
|
|
78
|
+
* This enables advanced queries (GROUP BY, HAVING, JOINs) at the database
|
|
79
|
+
* layer for performance with large datasets.
|
|
80
|
+
* @returns The Knex instance connected to the SQLite database.
|
|
81
|
+
*/
|
|
82
|
+
getKnex(): Knex;
|
|
65
83
|
/**
|
|
66
84
|
* Retrieves the crawl session name from the `info` table.
|
|
67
85
|
* @returns The name string.
|
package/lib/archive/database.js
CHANGED
|
@@ -38,9 +38,10 @@ import { retry } from '@d-zero/shared/retry';
|
|
|
38
38
|
import { pathComparator } from '@d-zero/shared/sort/path';
|
|
39
39
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
40
40
|
import knex from 'knex';
|
|
41
|
-
import {
|
|
41
|
+
import { eachSplitted } from '../utils/array/each-splitted.js';
|
|
42
|
+
import { ErrorEmitter } from '../utils/error/error-emitter.js';
|
|
42
43
|
import { dbLog } from './debug.js';
|
|
43
|
-
import { mkdir } from './filesystem/
|
|
44
|
+
import { mkdir } from './filesystem/mkdir.js';
|
|
44
45
|
const retrySetting = {
|
|
45
46
|
interval: 300,
|
|
46
47
|
retries: 3,
|
|
@@ -59,6 +60,7 @@ const retrySetting = {
|
|
|
59
60
|
let Database = (() => {
|
|
60
61
|
let _classSuper = EventEmitter;
|
|
61
62
|
let _instanceExtraInitializers = [];
|
|
63
|
+
let _clearHtmlPath_decorators;
|
|
62
64
|
let _getAnchorsOnPage_decorators;
|
|
63
65
|
let _getBaseUrl_decorators;
|
|
64
66
|
let _getConfig_decorators;
|
|
@@ -81,6 +83,7 @@ let Database = (() => {
|
|
|
81
83
|
return class Database extends _classSuper {
|
|
82
84
|
static {
|
|
83
85
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
86
|
+
_clearHtmlPath_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
84
87
|
_getAnchorsOnPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
85
88
|
_getBaseUrl_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
86
89
|
_getConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
@@ -100,6 +103,7 @@ let Database = (() => {
|
|
|
100
103
|
_setConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
101
104
|
_setSkippedPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
102
105
|
_updatePage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
106
|
+
__esDecorate(this, null, _clearHtmlPath_decorators, { kind: "method", name: "clearHtmlPath", static: false, private: false, access: { has: obj => "clearHtmlPath" in obj, get: obj => obj.clearHtmlPath }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
103
107
|
__esDecorate(this, null, _getAnchorsOnPage_decorators, { kind: "method", name: "getAnchorsOnPage", static: false, private: false, access: { has: obj => "getAnchorsOnPage" in obj, get: obj => obj.getAnchorsOnPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
104
108
|
__esDecorate(this, null, _getBaseUrl_decorators, { kind: "method", name: "getBaseUrl", static: false, private: false, access: { has: obj => "getBaseUrl" in obj, get: obj => obj.getBaseUrl }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
105
109
|
__esDecorate(this, null, _getConfig_decorators, { kind: "method", name: "getConfig", static: false, private: false, access: { has: obj => "getConfig" in obj, get: obj => obj.getConfig }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -150,10 +154,15 @@ let Database = (() => {
|
|
|
150
154
|
}
|
|
151
155
|
/**
|
|
152
156
|
* Adds the `order` column to the `pages` table for URL sort ordering.
|
|
157
|
+
* If the column already exists, this method does nothing.
|
|
153
158
|
* @deprecated Since v0.1.x. The column is now created during table initialization.
|
|
154
|
-
* @returns The result of the schema alteration.
|
|
159
|
+
* @returns The result of the schema alteration, or void if the column already exists.
|
|
155
160
|
*/
|
|
156
161
|
async addOrderField() {
|
|
162
|
+
const hasColumn = await this.#instance.schema.hasColumn('pages', 'order');
|
|
163
|
+
if (hasColumn) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
157
166
|
return await this.#instance.schema.table('pages', (t) => {
|
|
158
167
|
t.integer('order').unsigned().nullable().defaultTo(null);
|
|
159
168
|
});
|
|
@@ -166,6 +175,17 @@ let Database = (() => {
|
|
|
166
175
|
async checkpoint() {
|
|
167
176
|
await this.#instance.raw('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
168
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Clears the HTML snapshot path for a page.
|
|
180
|
+
* Used to roll back the snapshot reference when the snapshot file write fails.
|
|
181
|
+
* @param pageId - The database ID of the page whose HTML path should be cleared.
|
|
182
|
+
*/
|
|
183
|
+
async clearHtmlPath(pageId) {
|
|
184
|
+
await this.#instance('pages').where('id', pageId).update({ html: null });
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Destroys the database connection, releasing all pooled resources.
|
|
188
|
+
*/
|
|
169
189
|
async destroy() {
|
|
170
190
|
await this.#instance.destroy();
|
|
171
191
|
}
|
|
@@ -255,6 +275,15 @@ let Database = (() => {
|
|
|
255
275
|
return html || null;
|
|
256
276
|
});
|
|
257
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Returns the underlying Knex query builder instance for direct SQL access.
|
|
280
|
+
* This enables advanced queries (GROUP BY, HAVING, JOINs) at the database
|
|
281
|
+
* layer for performance with large datasets.
|
|
282
|
+
* @returns The Knex instance connected to the SQLite database.
|
|
283
|
+
*/
|
|
284
|
+
getKnex() {
|
|
285
|
+
return this.#instance;
|
|
286
|
+
}
|
|
258
287
|
/**
|
|
259
288
|
* Retrieves the crawl session name from the `info` table.
|
|
260
289
|
* @returns The name string.
|
|
@@ -374,7 +403,7 @@ let Database = (() => {
|
|
|
374
403
|
* @returns An object containing `pages`, `redirects`, `anchors`, and `referrers` arrays.
|
|
375
404
|
*/
|
|
376
405
|
async getPagesWithRels(offset, limit) {
|
|
377
|
-
await this.addOrderField()
|
|
406
|
+
await this.addOrderField();
|
|
378
407
|
await this.setUrlOrder();
|
|
379
408
|
dbLog('Get Pages');
|
|
380
409
|
const pages = await this.#instance
|
|
@@ -952,6 +981,9 @@ function redirectTable(includeNull = true) {
|
|
|
952
981
|
// ----- ----- ----- ----- -----
|
|
953
982
|
/**
|
|
954
983
|
* Safely parses a JSON string, returning a fallback value if parsing fails or the input is not a string.
|
|
984
|
+
* Logs a warning via {@link dbLog} when invalid JSON is detected, including a truncated preview
|
|
985
|
+
* of the data and the parse error message.
|
|
986
|
+
* @template T The expected type of the parsed JSON value and the fallback.
|
|
955
987
|
* @param data - The data to parse. Only string values are parsed; other types return the fallback.
|
|
956
988
|
* @param fallback - The value to return if parsing fails or the result is falsy.
|
|
957
989
|
* @returns The parsed JSON value, or the fallback.
|
|
@@ -966,8 +998,8 @@ function getJSON(data, fallback) {
|
|
|
966
998
|
return fallback;
|
|
967
999
|
}
|
|
968
1000
|
}
|
|
969
|
-
catch {
|
|
970
|
-
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
dbLog('Warning: Invalid JSON detected in database field. Using fallback value. Data: %s, Error: %s', String(data).slice(0, 200), error instanceof Error ? error.message : String(error));
|
|
971
1003
|
}
|
|
972
1004
|
return fallback;
|
|
973
1005
|
}
|
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
* If `override` is `true`, the destination is unconditionally removed
|
|
5
5
|
* before renaming. This avoids a TOCTOU race condition between
|
|
6
6
|
* checking existence and performing the removal.
|
|
7
|
+
*
|
|
8
|
+
* When `fs.rename` fails with `EPERM` (common on Windows due to file locks
|
|
9
|
+
* from antivirus or indexer processes) or `EXDEV` (cross-device move),
|
|
10
|
+
* falls back to a copy-then-remove strategy.
|
|
7
11
|
* @param oldPath - The current path of the file or directory.
|
|
8
12
|
* @param newPath - The new path for the file or directory.
|
|
9
13
|
* @param override - Whether to overwrite the destination if it already exists. Defaults to `false`.
|
|
14
|
+
* @returns A promise that resolves when the rename (or fallback copy-then-remove) is complete.
|
|
10
15
|
*/
|
|
11
16
|
export declare function rename(oldPath: string, newPath: string, override?: boolean): Promise<void>;
|
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import { remove } from './remove.js';
|
|
3
|
+
/**
|
|
4
|
+
* Checks whether a value is a {@link NodeJS.ErrnoException}.
|
|
5
|
+
* @param error - The value to check.
|
|
6
|
+
* @returns `true` if the value has a `code` property of type `string`.
|
|
7
|
+
*/
|
|
8
|
+
function isNodeError(error) {
|
|
9
|
+
return (error instanceof Error && typeof error.code === 'string');
|
|
10
|
+
}
|
|
3
11
|
/**
|
|
4
12
|
* Renames (moves) a file or directory from one path to another.
|
|
5
13
|
*
|
|
6
14
|
* If `override` is `true`, the destination is unconditionally removed
|
|
7
15
|
* before renaming. This avoids a TOCTOU race condition between
|
|
8
16
|
* checking existence and performing the removal.
|
|
17
|
+
*
|
|
18
|
+
* When `fs.rename` fails with `EPERM` (common on Windows due to file locks
|
|
19
|
+
* from antivirus or indexer processes) or `EXDEV` (cross-device move),
|
|
20
|
+
* falls back to a copy-then-remove strategy.
|
|
9
21
|
* @param oldPath - The current path of the file or directory.
|
|
10
22
|
* @param newPath - The new path for the file or directory.
|
|
11
23
|
* @param override - Whether to overwrite the destination if it already exists. Defaults to `false`.
|
|
24
|
+
* @returns A promise that resolves when the rename (or fallback copy-then-remove) is complete.
|
|
12
25
|
*/
|
|
13
26
|
export async function rename(oldPath, newPath, override = false) {
|
|
14
27
|
if (override) {
|
|
15
28
|
await remove(newPath).catch(() => { });
|
|
16
29
|
}
|
|
17
|
-
|
|
30
|
+
try {
|
|
31
|
+
await fs.rename(oldPath, newPath);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (isNodeError(error) && (error.code === 'EPERM' || error.code === 'EXDEV')) {
|
|
35
|
+
try {
|
|
36
|
+
await fs.cp(oldPath, newPath, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
catch (cpError) {
|
|
39
|
+
await remove(newPath).catch(() => { });
|
|
40
|
+
throw cpError;
|
|
41
|
+
}
|
|
42
|
+
await remove(oldPath);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
18
48
|
}
|
package/lib/archive/types.d.ts
CHANGED
|
@@ -183,6 +183,35 @@ export interface DB_Referrer {
|
|
|
183
183
|
/** The text content of the referring anchor element, or null if empty. */
|
|
184
184
|
textContent: string | null;
|
|
185
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Raw database row representing an image element found on a page in the `images` table.
|
|
188
|
+
*/
|
|
189
|
+
export interface DB_Image {
|
|
190
|
+
/** Auto-incremented primary key. */
|
|
191
|
+
id: number;
|
|
192
|
+
/** Foreign key to the page that contains this image. */
|
|
193
|
+
pageId: number;
|
|
194
|
+
/** The `src` attribute value of the image element. */
|
|
195
|
+
src: string | null;
|
|
196
|
+
/** The actual loaded source URL of the image (after srcset/picture resolution). */
|
|
197
|
+
currentSrc: string | null;
|
|
198
|
+
/** The `alt` attribute value, or null if not present. */
|
|
199
|
+
alt: string | null;
|
|
200
|
+
/** The rendered width of the image in CSS pixels. */
|
|
201
|
+
width: number;
|
|
202
|
+
/** The rendered height of the image in CSS pixels. */
|
|
203
|
+
height: number;
|
|
204
|
+
/** The intrinsic width of the image in pixels. */
|
|
205
|
+
naturalWidth: number;
|
|
206
|
+
/** The intrinsic height of the image in pixels. */
|
|
207
|
+
naturalHeight: number;
|
|
208
|
+
/** Whether the image uses lazy loading. */
|
|
209
|
+
isLazy: number | null;
|
|
210
|
+
/** The viewport width at the time of capture. */
|
|
211
|
+
viewportWidth: number;
|
|
212
|
+
/** The raw HTML source code of the image element. */
|
|
213
|
+
sourceCode: string | null;
|
|
214
|
+
}
|
|
186
215
|
/**
|
|
187
216
|
* Raw database row representing a sub-resource (CSS, JS, image, etc.) in the `resources` table.
|
|
188
217
|
*/
|
package/lib/crawler/crawler.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export type { CrawlerOptions } from './types.js';
|
|
|
15
15
|
*/
|
|
16
16
|
export default class Crawler extends EventEmitter<CrawlerEventTypes> {
|
|
17
17
|
#private;
|
|
18
|
+
/**
|
|
19
|
+
* The AbortSignal associated with this crawler's AbortController.
|
|
20
|
+
*
|
|
21
|
+
* Passed to `deal()` so that it stops launching new workers after abort.
|
|
22
|
+
* Also available to the orchestrator for forwarding to other subsystems.
|
|
23
|
+
*/
|
|
24
|
+
get signal(): AbortSignal;
|
|
18
25
|
/**
|
|
19
26
|
* Create a new Crawler instance.
|
|
20
27
|
* @param options - Configuration options for crawling behavior. All fields have
|
|
@@ -24,8 +31,10 @@ export default class Crawler extends EventEmitter<CrawlerEventTypes> {
|
|
|
24
31
|
/**
|
|
25
32
|
* Abort the current crawl operation.
|
|
26
33
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
34
|
+
* Signals the AbortController so that the dealer stops launching new
|
|
35
|
+
* workers. Currently running workers will finish, after which `deal()`
|
|
36
|
+
* resolves and `crawlEnd` is emitted by the normal completion path in
|
|
37
|
+
* {@link #runDeal}.
|
|
29
38
|
*/
|
|
30
39
|
abort(): void;
|
|
31
40
|
/**
|
package/lib/crawler/crawler.js
CHANGED
|
@@ -11,6 +11,7 @@ import pkg from '../../package.json' with { type: 'json' };
|
|
|
11
11
|
import { crawlerLog } from '../debug.js';
|
|
12
12
|
import { detectPaginationPattern } from './detect-pagination-pattern.js';
|
|
13
13
|
import { fetchDestination } from './fetch-destination.js';
|
|
14
|
+
import { formatCrawlProgress } from './format-crawl-progress.js';
|
|
14
15
|
import { generatePredictedUrls } from './generate-predicted-urls.js';
|
|
15
16
|
import { handleIgnoreAndSkip } from './handle-ignore-and-skip.js';
|
|
16
17
|
import { handleResourceResponse } from './handle-resource-response.js';
|
|
@@ -36,8 +37,8 @@ import { shouldSkipUrl } from './should-skip-url.js';
|
|
|
36
37
|
* configurable parallelism up to {@link Crawler.MAX_PROCESS_LENGTH}.
|
|
37
38
|
*/
|
|
38
39
|
class Crawler extends EventEmitter {
|
|
39
|
-
/**
|
|
40
|
-
#
|
|
40
|
+
/** Controller used to cancel the deal-based crawl via its AbortSignal. */
|
|
41
|
+
#abortController = new AbortController();
|
|
41
42
|
/** Tracks discovered URLs, their scrape status, and deduplication. */
|
|
42
43
|
#linkList = new LinkList();
|
|
43
44
|
/** Merged crawler configuration (user overrides + defaults). */
|
|
@@ -52,6 +53,15 @@ class Crawler extends EventEmitter {
|
|
|
52
53
|
#robotsChecker;
|
|
53
54
|
/** Maps hostnames to their scope URLs. Defines the crawl boundary for internal/external classification. */
|
|
54
55
|
#scope = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* The AbortSignal associated with this crawler's AbortController.
|
|
58
|
+
*
|
|
59
|
+
* Passed to `deal()` so that it stops launching new workers after abort.
|
|
60
|
+
* Also available to the orchestrator for forwarding to other subsystems.
|
|
61
|
+
*/
|
|
62
|
+
get signal() {
|
|
63
|
+
return this.#abortController.signal;
|
|
64
|
+
}
|
|
55
65
|
/**
|
|
56
66
|
* Create a new Crawler instance.
|
|
57
67
|
* @param options - Configuration options for crawling behavior. All fields have
|
|
@@ -90,12 +100,13 @@ class Crawler extends EventEmitter {
|
|
|
90
100
|
/**
|
|
91
101
|
* Abort the current crawl operation.
|
|
92
102
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
103
|
+
* Signals the AbortController so that the dealer stops launching new
|
|
104
|
+
* workers. Currently running workers will finish, after which `deal()`
|
|
105
|
+
* resolves and `crawlEnd` is emitted by the normal completion path in
|
|
106
|
+
* {@link #runDeal}.
|
|
95
107
|
*/
|
|
96
108
|
abort() {
|
|
97
|
-
this.#
|
|
98
|
-
void this.emit('crawlEnd', {});
|
|
109
|
+
this.#abortController.abort();
|
|
99
110
|
}
|
|
100
111
|
/**
|
|
101
112
|
* Retrieve the list of Chromium process IDs that are still running.
|
|
@@ -147,12 +158,7 @@ class Crawler extends EventEmitter {
|
|
|
147
158
|
}
|
|
148
159
|
void this.#runDeal(initialUrls, resumeOffset).catch((error) => {
|
|
149
160
|
crawlerLog('runDeal error: %O', error);
|
|
150
|
-
|
|
151
|
-
pid: process.pid,
|
|
152
|
-
isMainProcess: true,
|
|
153
|
-
url: url.href,
|
|
154
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
155
|
-
});
|
|
161
|
+
this.#emitDealErrors(error, url.href);
|
|
156
162
|
void this.emit('crawlEnd', {});
|
|
157
163
|
});
|
|
158
164
|
}
|
|
@@ -183,14 +189,30 @@ class Crawler extends EventEmitter {
|
|
|
183
189
|
this.#options.fromList = true;
|
|
184
190
|
void this.#runDeal(pageList).catch((error) => {
|
|
185
191
|
crawlerLog('runDeal error: %O', error);
|
|
192
|
+
this.#emitDealErrors(error, pageList[0].href);
|
|
193
|
+
void this.emit('crawlEnd', {});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Emits error events for a deal-level failure.
|
|
198
|
+
*
|
|
199
|
+
* When the dealer rejects with an `AggregateError` (e.g. multiple worker
|
|
200
|
+
* failures), each inner error is emitted as a separate `error` event.
|
|
201
|
+
* For any other error type, a single `error` event is emitted.
|
|
202
|
+
* @param error - The error thrown by `#runDeal`.
|
|
203
|
+
* @param fallbackUrl - URL string used as the error context (typically the root URL).
|
|
204
|
+
*/
|
|
205
|
+
#emitDealErrors(error, fallbackUrl) {
|
|
206
|
+
const errors = error instanceof AggregateError ? error.errors : [error];
|
|
207
|
+
for (const e of errors) {
|
|
186
208
|
void this.emit('error', {
|
|
187
209
|
pid: process.pid,
|
|
188
210
|
isMainProcess: true,
|
|
189
|
-
url:
|
|
190
|
-
|
|
211
|
+
url: fallbackUrl,
|
|
212
|
+
isExternal: false,
|
|
213
|
+
error: e instanceof Error ? e : new Error(String(e)),
|
|
191
214
|
});
|
|
192
|
-
|
|
193
|
-
});
|
|
215
|
+
}
|
|
194
216
|
}
|
|
195
217
|
/**
|
|
196
218
|
* Processes captured sub-resources from a page scrape, deduplicates them,
|
|
@@ -295,6 +317,7 @@ class Crawler extends EventEmitter {
|
|
|
295
317
|
shutdown: result.error.shutdown,
|
|
296
318
|
pid: undefined,
|
|
297
319
|
}, this.#linkList, this.#scope, this.#options);
|
|
320
|
+
const isExternal = isExternalUrl(url, this.#scope);
|
|
298
321
|
if (pageResult) {
|
|
299
322
|
if (pageResult.isExternal) {
|
|
300
323
|
void this.emit('externalPage', { result: pageResult });
|
|
@@ -307,6 +330,7 @@ class Crawler extends EventEmitter {
|
|
|
307
330
|
pid: process.pid,
|
|
308
331
|
isMainProcess: true,
|
|
309
332
|
url: url.href,
|
|
333
|
+
isExternal,
|
|
310
334
|
error,
|
|
311
335
|
});
|
|
312
336
|
break;
|
|
@@ -423,8 +447,6 @@ class Crawler extends EventEmitter {
|
|
|
423
447
|
this.#linkList.add(url);
|
|
424
448
|
this.#linkList.progress(url);
|
|
425
449
|
return async () => {
|
|
426
|
-
if (this.#aborted)
|
|
427
|
-
return;
|
|
428
450
|
const log = createTimedUpdate(update, this.#options.verbose);
|
|
429
451
|
try {
|
|
430
452
|
const robotsAllowed = await this.#robotsChecker.isAllowed(url);
|
|
@@ -476,6 +498,24 @@ class Crawler extends EventEmitter {
|
|
|
476
498
|
this.#handleResources(result.resources);
|
|
477
499
|
log(formatResultSummary(result));
|
|
478
500
|
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
crawlerLog('Worker error for %s: %O', url.href, error);
|
|
503
|
+
log(c.red('Error'));
|
|
504
|
+
const workerError = error instanceof Error ? error : new Error(String(error));
|
|
505
|
+
handleScrapeError({
|
|
506
|
+
url,
|
|
507
|
+
error: workerError,
|
|
508
|
+
shutdown: false,
|
|
509
|
+
pid: process.pid,
|
|
510
|
+
}, this.#linkList, this.#scope, this.#options);
|
|
511
|
+
void this.emit('error', {
|
|
512
|
+
pid: process.pid,
|
|
513
|
+
isMainProcess: true,
|
|
514
|
+
url: url.href,
|
|
515
|
+
isExternal,
|
|
516
|
+
error: workerError,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
479
519
|
finally {
|
|
480
520
|
if (isExternal) {
|
|
481
521
|
externalDoneUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
|
|
@@ -486,15 +526,16 @@ class Crawler extends EventEmitter {
|
|
|
486
526
|
limit: concurrency,
|
|
487
527
|
interval: this.#options.interval,
|
|
488
528
|
verbose: this.#options.verbose || !process.stdout.isTTY,
|
|
529
|
+
signal: this.#abortController.signal,
|
|
489
530
|
header: (_progress, done, total, limit) => {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
531
|
+
return formatCrawlProgress({
|
|
532
|
+
done,
|
|
533
|
+
total,
|
|
534
|
+
resumeOffset,
|
|
535
|
+
externalTotal: externalUrls.size,
|
|
536
|
+
externalDone: externalDoneUrls.size,
|
|
537
|
+
limit,
|
|
538
|
+
});
|
|
498
539
|
},
|
|
499
540
|
onPush: (url) => {
|
|
500
541
|
const key = protocolAgnosticKey(url.withoutHashAndAuth);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parameters for formatting crawl progress display.
|
|
3
|
+
*/
|
|
4
|
+
interface FormatCrawlProgressParams {
|
|
5
|
+
/** Number of URLs completed by the deal queue */
|
|
6
|
+
readonly done: number;
|
|
7
|
+
/** Total number of URLs in the deal queue (including completed) */
|
|
8
|
+
readonly total: number;
|
|
9
|
+
/** Offset from a previous resumed session */
|
|
10
|
+
readonly resumeOffset: number;
|
|
11
|
+
/** Number of external URLs discovered */
|
|
12
|
+
readonly externalTotal: number;
|
|
13
|
+
/** Number of external URLs completed */
|
|
14
|
+
readonly externalDone: number;
|
|
15
|
+
/** Number of parallel workers */
|
|
16
|
+
readonly limit: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Formats the crawl progress header for the deal() progress display.
|
|
20
|
+
*
|
|
21
|
+
* Shows "done / found (remaining)" format instead of "done/total"
|
|
22
|
+
* to make it clearer that the total is expected to grow during crawling.
|
|
23
|
+
* @param params - The crawl progress parameters.
|
|
24
|
+
* @param params.done - Number of URLs completed by the deal queue.
|
|
25
|
+
* @param params.total - Total number of URLs in the deal queue (including completed).
|
|
26
|
+
* @param params.resumeOffset - Offset from a previous resumed session.
|
|
27
|
+
* @param params.externalTotal - Number of external URLs discovered.
|
|
28
|
+
* @param params.externalDone - Number of external URLs completed.
|
|
29
|
+
* @param params.limit - Number of parallel workers.
|
|
30
|
+
* @returns The formatted progress string with ANSI color codes.
|
|
31
|
+
*/
|
|
32
|
+
export declare function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, limit, }: FormatCrawlProgressParams): string;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import c from 'ansi-colors';
|
|
2
|
+
/**
|
|
3
|
+
* Formats the crawl progress header for the deal() progress display.
|
|
4
|
+
*
|
|
5
|
+
* Shows "done / found (remaining)" format instead of "done/total"
|
|
6
|
+
* to make it clearer that the total is expected to grow during crawling.
|
|
7
|
+
* @param params - The crawl progress parameters.
|
|
8
|
+
* @param params.done - Number of URLs completed by the deal queue.
|
|
9
|
+
* @param params.total - Total number of URLs in the deal queue (including completed).
|
|
10
|
+
* @param params.resumeOffset - Offset from a previous resumed session.
|
|
11
|
+
* @param params.externalTotal - Number of external URLs discovered.
|
|
12
|
+
* @param params.externalDone - Number of external URLs completed.
|
|
13
|
+
* @param params.limit - Number of parallel workers.
|
|
14
|
+
* @returns The formatted progress string with ANSI color codes.
|
|
15
|
+
*/
|
|
16
|
+
export function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, limit, }) {
|
|
17
|
+
const allDone = done + resumeOffset;
|
|
18
|
+
const allTotal = total + resumeOffset;
|
|
19
|
+
const internalDone = allDone - externalDone;
|
|
20
|
+
const internalTotal = allTotal - externalTotal;
|
|
21
|
+
const internalRemaining = internalTotal - internalDone;
|
|
22
|
+
const externalRemaining = externalTotal - externalDone;
|
|
23
|
+
const totalRemaining = internalRemaining + externalRemaining;
|
|
24
|
+
const pct = allTotal > 0 ? Math.round((allDone / allTotal) * 100) : 0;
|
|
25
|
+
return (c.bold(`Crawling: ${internalDone} done / ${internalTotal} found`) +
|
|
26
|
+
c.dim(` (+${externalDone}/${externalTotal} ext)`) +
|
|
27
|
+
c.bold(` (${pct}%) [${totalRemaining} remaining]`) +
|
|
28
|
+
c.dim(` [${limit} parallel]`));
|
|
29
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type LinkList from './link-list.js';
|
|
2
2
|
import type { CrawlerOptions } from './types.js';
|
|
3
|
-
import type { Link } from '../utils/
|
|
3
|
+
import type { Link } from '../utils/types/types.js';
|
|
4
4
|
import type { ExURL } from '@d-zero/shared/parse-url';
|
|
5
5
|
/**
|
|
6
6
|
* Handle a URL that was ignored or skipped during scraping.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type LinkList from './link-list.js';
|
|
2
2
|
import type { CrawlerOptions } from './types.js';
|
|
3
|
-
import type { Link, PageData } from '../utils/
|
|
3
|
+
import type { Link, PageData } from '../utils/types/types.js';
|
|
4
4
|
import type { ExURL } from '@d-zero/shared/parse-url';
|
|
5
5
|
/**
|
|
6
6
|
* Process the result of a successful page scrape.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type LinkList from './link-list.js';
|
|
2
2
|
import type { CrawlerOptions } from './types.js';
|
|
3
|
-
import type { Link, PageData } from '../utils/
|
|
3
|
+
import type { Link, PageData } from '../utils/types/types.js';
|
|
4
4
|
import type { ExURL } from '@d-zero/shared/parse-url';
|
|
5
5
|
/**
|
|
6
6
|
* Handle an error that occurred during page scraping.
|
package/lib/crawler/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PageData, CrawlerError, Resource } from '../utils/
|
|
1
|
+
import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
|
|
2
2
|
import type { ChangePhaseEvent } from '@d-zero/beholder';
|
|
3
3
|
import type { ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
4
4
|
/**
|
|
@@ -69,11 +69,11 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
|
|
|
69
69
|
get archive(): Archive;
|
|
70
70
|
private constructor();
|
|
71
71
|
/**
|
|
72
|
-
* Abort the current crawl
|
|
72
|
+
* Abort the current crawl operation.
|
|
73
73
|
*
|
|
74
|
-
* Delegates to the
|
|
75
|
-
*
|
|
76
|
-
*
|
|
74
|
+
* Delegates to the crawler's AbortController so that the dealer stops
|
|
75
|
+
* launching new workers. Currently running workers will finish, after
|
|
76
|
+
* which `deal()` resolves and `crawlEnd` is emitted normally.
|
|
77
77
|
*/
|
|
78
78
|
abort(): void;
|
|
79
79
|
/**
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
|
|
2
3
|
import { sortUrl } from '@d-zero/shared/sort-url';
|
|
3
4
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
4
5
|
import pkg from '../package.json' with { type: 'json' };
|
|
5
6
|
import Archive from './archive/archive.js';
|
|
6
|
-
import { clearDestinationCache
|
|
7
|
+
import { clearDestinationCache } from './crawler/clear-destination-cache.js';
|
|
8
|
+
import Crawler from './crawler/crawler.js';
|
|
7
9
|
import { crawlerLog, log } from './debug.js';
|
|
8
|
-
import {
|
|
10
|
+
import { resolveOutputPath } from './resolve-output-path.js';
|
|
11
|
+
import { cleanObject } from './utils/object/clean-object.js';
|
|
12
|
+
import { WriteQueue } from './write-queue.js';
|
|
9
13
|
/**
|
|
10
14
|
* Default list of external URL prefixes excluded from crawling.
|
|
11
15
|
* Includes social media sharing endpoints that are commonly linked
|
|
@@ -64,6 +68,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
64
68
|
pid: process.pid,
|
|
65
69
|
isMainProcess: true,
|
|
66
70
|
url: null,
|
|
71
|
+
isExternal: false,
|
|
67
72
|
error: e instanceof Error ? e : new Error(String(e)),
|
|
68
73
|
});
|
|
69
74
|
});
|
|
@@ -91,14 +96,14 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
91
96
|
});
|
|
92
97
|
}
|
|
93
98
|
/**
|
|
94
|
-
* Abort the current crawl
|
|
99
|
+
* Abort the current crawl operation.
|
|
95
100
|
*
|
|
96
|
-
* Delegates to the
|
|
97
|
-
*
|
|
98
|
-
*
|
|
101
|
+
* Delegates to the crawler's AbortController so that the dealer stops
|
|
102
|
+
* launching new workers. Currently running workers will finish, after
|
|
103
|
+
* which `deal()` resolves and `crawlEnd` is emitted normally.
|
|
99
104
|
*/
|
|
100
105
|
abort() {
|
|
101
|
-
|
|
106
|
+
this.#crawler.abort();
|
|
102
107
|
}
|
|
103
108
|
/**
|
|
104
109
|
* Execute the crawl for the given list of URLs.
|
|
@@ -115,31 +120,43 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
115
120
|
if (!root) {
|
|
116
121
|
throw new Error('URL is empty');
|
|
117
122
|
}
|
|
123
|
+
const writeQueue = new WriteQueue();
|
|
118
124
|
return new Promise((resolve, reject) => {
|
|
119
125
|
this.#crawler.on('error', (error) => {
|
|
120
126
|
crawlerLog('On error: %O', error);
|
|
121
|
-
void this.#archive.addError(error);
|
|
127
|
+
void writeQueue.enqueue(() => this.#archive.addError(error));
|
|
122
128
|
void this.emit('error', error);
|
|
123
129
|
});
|
|
124
|
-
this.#crawler.on('page',
|
|
125
|
-
|
|
130
|
+
this.#crawler.on('page', ({ result }) => {
|
|
131
|
+
writeQueue
|
|
132
|
+
.enqueue(() => this.#archive.setPage(result))
|
|
133
|
+
.catch((error) => reject(error));
|
|
126
134
|
});
|
|
127
135
|
this.#crawler.on('externalPage', ({ result }) => {
|
|
128
|
-
|
|
136
|
+
writeQueue
|
|
137
|
+
.enqueue(() => this.#archive.setExternalPage(result))
|
|
138
|
+
.catch((error) => reject(error));
|
|
129
139
|
});
|
|
130
140
|
this.#crawler.on('skip', ({ url, reason, isExternal }) => {
|
|
131
|
-
|
|
132
|
-
.setSkippedPage(url, reason, isExternal)
|
|
141
|
+
writeQueue
|
|
142
|
+
.enqueue(() => this.#archive.setSkippedPage(url, reason, isExternal))
|
|
133
143
|
.catch((error) => reject(error));
|
|
134
144
|
});
|
|
135
145
|
this.#crawler.on('response', ({ resource }) => {
|
|
136
|
-
|
|
146
|
+
writeQueue
|
|
147
|
+
.enqueue(() => this.#archive.setResources(resource))
|
|
148
|
+
.catch((error) => reject(error));
|
|
137
149
|
});
|
|
138
150
|
this.#crawler.on('responseReferrers', (resource) => {
|
|
139
|
-
|
|
151
|
+
writeQueue
|
|
152
|
+
.enqueue(() => this.#archive.setResourcesReferrers(resource))
|
|
153
|
+
.catch((error) => reject(error));
|
|
140
154
|
});
|
|
141
155
|
this.#crawler.on('crawlEnd', () => {
|
|
142
|
-
|
|
156
|
+
writeQueue
|
|
157
|
+
.drain()
|
|
158
|
+
.then(() => resolve())
|
|
159
|
+
.catch((error) => reject(error));
|
|
143
160
|
});
|
|
144
161
|
if (this.#fromList) {
|
|
145
162
|
this.#crawler.startMultiple(list);
|
|
@@ -211,9 +228,11 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
211
228
|
if (!urlParsed) {
|
|
212
229
|
throw new Error('URL is empty');
|
|
213
230
|
}
|
|
214
|
-
const fileName = `${urlParsed.hostname}-${Archive.timestamp()}`;
|
|
215
231
|
const cwd = options?.cwd ?? process.cwd();
|
|
216
|
-
const filePath =
|
|
232
|
+
const filePath = options?.filePath
|
|
233
|
+
? resolveOutputPath(options.filePath, cwd)
|
|
234
|
+
: Archive.joinPath(cwd, `${urlParsed.hostname}-${Archive.timestamp()}.${Archive.FILE_EXTENSION}`);
|
|
235
|
+
const fileName = path.basename(filePath, `.${Archive.FILE_EXTENSION}`) || path.basename(filePath);
|
|
217
236
|
const disableQueries = options?.disableQueries || false;
|
|
218
237
|
const defaultUserAgent = `Nitpicker/${pkg.version}`;
|
|
219
238
|
const archive = await Archive.create({ filePath, cwd, disableQueries });
|
|
@@ -227,14 +246,11 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
227
246
|
interval: options?.interval || 0,
|
|
228
247
|
parallels: options?.parallels || 0,
|
|
229
248
|
scope: options?.scope ?? [],
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// @ts-expect-error TODO: Fix CLI arguments
|
|
233
|
-
excludeKeywords: normalizeToArray(options?.excludeKeyword),
|
|
249
|
+
excludes: normalizeToArray(options?.excludes),
|
|
250
|
+
excludeKeywords: normalizeToArray(options?.excludeKeywords),
|
|
234
251
|
excludeUrls: [
|
|
235
252
|
...DEFAULT_EXCLUDED_EXTERNAL_URLS,
|
|
236
|
-
|
|
237
|
-
...normalizeToArray(options?.excludeUrl),
|
|
253
|
+
...normalizeToArray(options?.excludeUrls),
|
|
238
254
|
],
|
|
239
255
|
maxExcludedDepth: options?.maxExcludedDepth || 10,
|
|
240
256
|
retry: options?.retry ?? 3,
|
package/lib/crawler.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @nitpicker/crawler
|
|
3
|
+
*
|
|
4
|
+
* Core module of Nitpicker that provides the main crawling engine,
|
|
5
|
+
* utility functions, type definitions, and archive storage layer.
|
|
6
|
+
*/
|
|
7
|
+
export * from './utils/types/types.js';
|
|
8
|
+
export { eachSplitted } from './utils/array/each-splitted.js';
|
|
9
|
+
export { DOMEvaluationError } from './utils/error/dom-evaluation-error.js';
|
|
10
|
+
export { ErrorEmitter } from './utils/error/error-emitter.js';
|
|
11
|
+
export type { ErrorEvent } from './utils/error/error-emitter.js';
|
|
12
|
+
export * from './utils/object/clean-object.js';
|
|
13
|
+
export { globalLog as log } from './utils/debug.js';
|
|
14
|
+
export { ArchiveAccessor } from './archive/archive-accessor.js';
|
|
15
|
+
export type { Redirect, Referrer, Anchor, StaticPageData } from './archive/page.js';
|
|
16
|
+
export { default as Page } from './archive/page.js';
|
|
17
|
+
export { default as ArchiveResource } from './archive/resource.js';
|
|
18
|
+
export * from './archive/types.js';
|
|
19
|
+
export { default as Archive } from './archive/archive.js';
|
|
20
|
+
export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
|
|
21
|
+
export * from './types.js';
|
|
22
|
+
export * from './crawler/types.js';
|
package/lib/crawler.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @nitpicker/crawler
|
|
3
|
+
*
|
|
4
|
+
* Core module of Nitpicker that provides the main crawling engine,
|
|
5
|
+
* utility functions, type definitions, and archive storage layer.
|
|
6
|
+
*/
|
|
7
|
+
// Types + Utils (旧 @nitpicker/types + utils)
|
|
8
|
+
export * from './utils/types/types.js';
|
|
9
|
+
export { eachSplitted } from './utils/array/each-splitted.js';
|
|
10
|
+
export { DOMEvaluationError } from './utils/error/dom-evaluation-error.js';
|
|
11
|
+
export { ErrorEmitter } from './utils/error/error-emitter.js';
|
|
12
|
+
export * from './utils/object/clean-object.js';
|
|
13
|
+
export { globalLog as log } from './utils/debug.js';
|
|
14
|
+
// Archive
|
|
15
|
+
export { ArchiveAccessor } from './archive/archive-accessor.js';
|
|
16
|
+
export { default as Page } from './archive/page.js';
|
|
17
|
+
export { default as ArchiveResource } from './archive/resource.js';
|
|
18
|
+
export * from './archive/types.js';
|
|
19
|
+
export { default as Archive } from './archive/archive.js';
|
|
20
|
+
// Core
|
|
21
|
+
export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
|
|
22
|
+
export * from './types.js';
|
|
23
|
+
export * from './crawler/types.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves and validates an output file path for the `.nitpicker` archive.
|
|
3
|
+
*
|
|
4
|
+
* Converts relative paths to absolute using the given working directory,
|
|
5
|
+
* appends the `.nitpicker` extension if missing, and verifies that the
|
|
6
|
+
* parent directory exists.
|
|
7
|
+
* @param outputPath - The user-specified output file path (relative or absolute).
|
|
8
|
+
* @param cwd - The working directory used to resolve relative paths.
|
|
9
|
+
* @returns The resolved absolute file path with the `.nitpicker` extension.
|
|
10
|
+
* @throws {Error} If the parent directory of the resolved path does not exist.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveOutputPath(outputPath: string, cwd: string): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import Archive from './archive/archive.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves and validates an output file path for the `.nitpicker` archive.
|
|
6
|
+
*
|
|
7
|
+
* Converts relative paths to absolute using the given working directory,
|
|
8
|
+
* appends the `.nitpicker` extension if missing, and verifies that the
|
|
9
|
+
* parent directory exists.
|
|
10
|
+
* @param outputPath - The user-specified output file path (relative or absolute).
|
|
11
|
+
* @param cwd - The working directory used to resolve relative paths.
|
|
12
|
+
* @returns The resolved absolute file path with the `.nitpicker` extension.
|
|
13
|
+
* @throws {Error} If the parent directory of the resolved path does not exist.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveOutputPath(outputPath, cwd) {
|
|
16
|
+
const resolved = path.isAbsolute(outputPath)
|
|
17
|
+
? outputPath
|
|
18
|
+
: path.resolve(cwd, outputPath);
|
|
19
|
+
const ext = `.${Archive.FILE_EXTENSION}`;
|
|
20
|
+
const withExt = resolved.endsWith(ext) ? resolved : `${resolved}${ext}`;
|
|
21
|
+
const dir = path.dirname(withExt);
|
|
22
|
+
if (!existsSync(dir)) {
|
|
23
|
+
throw new Error(`Output directory does not exist: ${dir}. Please create the directory before running the command.`);
|
|
24
|
+
}
|
|
25
|
+
return withExt;
|
|
26
|
+
}
|
package/lib/types.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface CrawlerError {
|
|
|
41
41
|
isMainProcess: boolean;
|
|
42
42
|
/** The URL being processed when the error occurred, or `null` if not applicable. */
|
|
43
43
|
url: string | null;
|
|
44
|
+
/** Whether the error occurred while processing an external (out-of-scope) URL. */
|
|
45
|
+
isExternal: boolean;
|
|
44
46
|
/** The error object. */
|
|
45
47
|
error: Error;
|
|
46
48
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lightweight serial write queue that ensures asynchronous operations
|
|
3
|
+
* execute one at a time in FIFO order.
|
|
4
|
+
*
|
|
5
|
+
* Used to serialize concurrent Archive writes from crawler event handlers,
|
|
6
|
+
* preventing SQLite write-lock contention under high parallelism.
|
|
7
|
+
*/
|
|
8
|
+
export declare class WriteQueue {
|
|
9
|
+
#private;
|
|
10
|
+
/**
|
|
11
|
+
* The number of operations currently waiting or executing in the queue.
|
|
12
|
+
* @returns The current pending operation count.
|
|
13
|
+
*/
|
|
14
|
+
get pending(): number;
|
|
15
|
+
/**
|
|
16
|
+
* Returns a promise that resolves when all currently enqueued operations
|
|
17
|
+
* have completed.
|
|
18
|
+
* @returns A promise that resolves when the queue is drained.
|
|
19
|
+
*/
|
|
20
|
+
drain(): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Enqueues an operation to run after all previously enqueued operations
|
|
23
|
+
* have completed. Operations are guaranteed to execute serially in the
|
|
24
|
+
* order they were enqueued. Both async and synchronous (throwing)
|
|
25
|
+
* operations are supported.
|
|
26
|
+
* @template T The resolved type of the operation's promise.
|
|
27
|
+
* @param operation - The function to execute. May return a `Promise` or a plain value.
|
|
28
|
+
* @returns A promise that resolves with the operation's result, or rejects
|
|
29
|
+
* if the operation throws.
|
|
30
|
+
*/
|
|
31
|
+
enqueue<T>(operation: () => Promise<T> | T): Promise<T>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lightweight serial write queue that ensures asynchronous operations
|
|
3
|
+
* execute one at a time in FIFO order.
|
|
4
|
+
*
|
|
5
|
+
* Used to serialize concurrent Archive writes from crawler event handlers,
|
|
6
|
+
* preventing SQLite write-lock contention under high parallelism.
|
|
7
|
+
*/
|
|
8
|
+
export class WriteQueue {
|
|
9
|
+
/** The tail of the promise chain used for serialization. */
|
|
10
|
+
#chain = Promise.resolve();
|
|
11
|
+
/** The number of operations currently waiting or executing in the queue. */
|
|
12
|
+
#pending = 0;
|
|
13
|
+
/**
|
|
14
|
+
* The number of operations currently waiting or executing in the queue.
|
|
15
|
+
* @returns The current pending operation count.
|
|
16
|
+
*/
|
|
17
|
+
get pending() {
|
|
18
|
+
return this.#pending;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Returns a promise that resolves when all currently enqueued operations
|
|
22
|
+
* have completed.
|
|
23
|
+
* @returns A promise that resolves when the queue is drained.
|
|
24
|
+
*/
|
|
25
|
+
async drain() {
|
|
26
|
+
await this.#chain;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Enqueues an operation to run after all previously enqueued operations
|
|
30
|
+
* have completed. Operations are guaranteed to execute serially in the
|
|
31
|
+
* order they were enqueued. Both async and synchronous (throwing)
|
|
32
|
+
* operations are supported.
|
|
33
|
+
* @template T The resolved type of the operation's promise.
|
|
34
|
+
* @param operation - The function to execute. May return a `Promise` or a plain value.
|
|
35
|
+
* @returns A promise that resolves with the operation's result, or rejects
|
|
36
|
+
* if the operation throws.
|
|
37
|
+
*/
|
|
38
|
+
enqueue(operation) {
|
|
39
|
+
this.#pending++;
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
this.#chain = this.#chain.then(async () => {
|
|
42
|
+
try {
|
|
43
|
+
const result = await operation();
|
|
44
|
+
resolve(result);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
reject(error);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
this.#pending--;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitpicker/crawler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Web crawler engine with headless browser rendering and archive storage",
|
|
5
5
|
"author": "D-ZERO",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"type": "module",
|
|
19
19
|
"exports": {
|
|
20
20
|
".": {
|
|
21
|
-
"import": "./lib/
|
|
22
|
-
"types": "./lib/
|
|
21
|
+
"import": "./lib/crawler.js",
|
|
22
|
+
"types": "./lib/crawler.d.ts"
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@d-zero/beholder": "2.0.0",
|
|
31
|
-
"@d-zero/dealer": "1.
|
|
31
|
+
"@d-zero/dealer": "1.7.0",
|
|
32
32
|
"@d-zero/fs": "0.2.2",
|
|
33
|
-
"@d-zero/shared": "0.20.
|
|
33
|
+
"@d-zero/shared": "0.20.1",
|
|
34
34
|
"ansi-colors": "4.1.3",
|
|
35
35
|
"debug": "4.4.3",
|
|
36
36
|
"follow-redirects": "1.15.11",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"@types/tar": "7.0.87",
|
|
49
49
|
"@types/unzipper": "0.10.11"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "eab407f5e4b58fa3c122001d3c034488e7f6da11"
|
|
52
52
|
}
|