@nitpicker/crawler 0.4.3 → 0.5.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.
@@ -1,5 +1,5 @@
1
1
  import type { Database } from './database.js';
2
- import type { DatabaseEvent, PageFilter } from './types.js';
2
+ import type { Config, DatabaseEvent, PageFilter } from './types.js';
3
3
  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';
@@ -31,6 +31,11 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
31
31
  * @returns An array of anchor records found on the page.
32
32
  */
33
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>;
34
39
  /**
35
40
  * Reads custom data stored in the archive by name.
36
41
  * @param name - The base name of the data file (without extension).
@@ -53,6 +58,13 @@ export declare class ArchiveAccessor extends EventEmitter<DatabaseEvent> {
53
58
  * @returns The HTML content as a string, or null if the snapshot is not found or filePath is null.
54
59
  */
55
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[]>;
56
68
  /**
57
69
  * Retrieves all pages from the archive, optionally filtered by type.
58
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, extractZip, outputJSON, outputText, readJSON, readText, unzip, } from './filesystem/index.js';
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';
@@ -50,6 +55,13 @@ export class ArchiveAccessor extends EventEmitter {
50
55
  const refs = await this.#db.getAnchorsOnPage(pageId);
51
56
  return refs;
52
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
+ }
53
65
  async getData(name, format = 'json') {
54
66
  const namespace = this.#namespace || '';
55
67
  const filePath = safePath(this.#tmpDir, namespace, `${name}.${format}`);
@@ -96,6 +108,15 @@ export class ArchiveAccessor extends EventEmitter {
96
108
  log('Succeeded: Extracts %s from zipped snapshots', name);
97
109
  return html;
98
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
+ }
99
120
  /**
100
121
  * Retrieves all pages from the archive, optionally filtered by type.
101
122
  * Eagerly loads redirect relationships (`redirectFrom`) but does NOT load
@@ -1,5 +1,5 @@
1
1
  import type { Config } from './types.js';
2
- import type { PageData, CrawlerError, Resource } from '../utils/index.js';
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.
@@ -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, exists, isDir, outputText, remove, rename, tar, untar, zip, } from './filesystem/index.js';
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
- snapshotTask.push(outputText(html, pageInfo.html));
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/index.js';
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.
@@ -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 { ErrorEmitter, eachSplitted } from '../utils/index.js';
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/index.js';
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().catch((error) => error);
406
+ await this.addOrderField();
378
407
  await this.setUrlOrder();
379
408
  dbLog('Get Pages');
380
409
  const pages = await this.#instance
@@ -723,7 +752,6 @@ let Database = (() => {
723
752
  t.string('name');
724
753
  t.string('baseUrl');
725
754
  t.boolean('recursive');
726
- t.boolean('useSubprocess');
727
755
  t.integer('interval');
728
756
  t.boolean('image');
729
757
  t.boolean('fetchExternal');
@@ -736,6 +764,8 @@ let Database = (() => {
736
764
  t.integer('retry');
737
765
  t.boolean('fromList');
738
766
  t.boolean('disableQueries');
767
+ t.string('userAgent');
768
+ t.boolean('ignoreRobots');
739
769
  })
740
770
  .createTable('pages', (t) => {
741
771
  t.increments('id');
@@ -951,6 +981,9 @@ function redirectTable(includeNull = true) {
951
981
  // ----- ----- ----- ----- -----
952
982
  /**
953
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.
954
987
  * @param data - The data to parse. Only string values are parsed; other types return the fallback.
955
988
  * @param fallback - The value to return if parsing fails or the result is falsy.
956
989
  * @returns The parsed JSON value, or the fallback.
@@ -965,8 +998,8 @@ function getJSON(data, fallback) {
965
998
  return fallback;
966
999
  }
967
1000
  }
968
- catch {
969
- // void
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));
970
1003
  }
971
1004
  return fallback;
972
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
- await fs.rename(oldPath, newPath);
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
  }
@@ -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
  */
@@ -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
- * Sets the aborted flag and immediately emits a `crawlEnd` event.
28
- * In-progress scrape tasks will check the flag and exit early.
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
  /**
@@ -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
- /** Flag set by `abort()` to signal in-progress tasks to exit early. */
40
- #aborted = false;
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
- * Sets the aborted flag and immediately emits a `crawlEnd` event.
94
- * In-progress scrape tasks will check the flag and exit early.
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.#aborted = true;
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
- void this.emit('error', {
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: pageList[0].href,
190
- error: error instanceof Error ? error : new Error(String(error)),
211
+ url: fallbackUrl,
212
+ isExternal: false,
213
+ error: e instanceof Error ? e : new Error(String(e)),
191
214
  });
192
- void this.emit('crawlEnd', {});
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
- const allDone = done + resumeOffset;
491
- const allTotal = total + resumeOffset;
492
- const extTotal = externalUrls.size;
493
- const extDone = externalDoneUrls.size;
494
- const pct = allTotal > 0 ? Math.round((allDone / allTotal) * 100) : 0;
495
- return (c.bold(`Crawling: ${allDone - extDone}/${allTotal - extTotal}`) +
496
- c.dim(`(${extDone}/${extTotal})`) +
497
- c.bold(` (${pct}%) [${limit} parallel]`));
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/index.js';
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,4 +1,4 @@
1
- import type { Resource } from '../utils/index.js';
1
+ import type { Resource } from '../utils/types/types.js';
2
2
  /**
3
3
  * Track a network resource response and determine if it is newly discovered.
4
4
  *
@@ -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/index.js';
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/index.js';
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.
@@ -1,4 +1,4 @@
1
- import type { Link, PageData } from '../utils/index.js';
1
+ import type { Link, PageData } from '../utils/types/types.js';
2
2
  import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
3
3
  /**
4
4
  * Manages the queue of URLs discovered during crawling.
@@ -1,4 +1,4 @@
1
- import type { Link, PageData } from '../utils/index.js';
1
+ import type { Link, PageData } from '../utils/types/types.js';
2
2
  /**
3
3
  * Convert a {@link Link} object into a {@link PageData} structure.
4
4
  *
@@ -1,4 +1,4 @@
1
- import type { PageData, CrawlerError, Resource } from '../utils/index.js';
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 and archive operations.
72
+ * Abort the current crawl operation.
73
73
  *
74
- * Delegates to the archive's abort method, which stops all in-progress
75
- * database writes and cleans up temporary resources.
76
- * @returns The result of the archive abort operation.
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, Crawler } from './crawler/index.js';
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 { cleanObject } from './utils/index.js';
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 and archive operations.
99
+ * Abort the current crawl operation.
95
100
  *
96
- * Delegates to the archive's abort method, which stops all in-progress
97
- * database writes and cleans up temporary resources.
98
- * @returns The result of the archive abort operation.
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
- return this.#archive.abort();
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', async ({ result }) => {
125
- await this.#archive.setPage(result).catch((error) => reject(error));
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
- this.#archive.setExternalPage(result).catch((error) => reject(error));
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
- this.#archive
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
- this.#archive.setResources(resource).catch((error) => reject(error));
146
+ writeQueue
147
+ .enqueue(() => this.#archive.setResources(resource))
148
+ .catch((error) => reject(error));
137
149
  });
138
150
  this.#crawler.on('responseReferrers', (resource) => {
139
- this.#archive.setResourcesReferrers(resource).catch((error) => reject(error));
151
+ writeQueue
152
+ .enqueue(() => this.#archive.setResourcesReferrers(resource))
153
+ .catch((error) => reject(error));
140
154
  });
141
155
  this.#crawler.on('crawlEnd', () => {
142
- resolve();
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 = Archive.joinPath(cwd, `${fileName}.${Archive.FILE_EXTENSION}`);
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
- // @ts-expect-error TODO: Fix CLI arguments
231
- excludes: normalizeToArray(options?.exclude),
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
- // @ts-expect-error TODO: Fix CLI arguments
237
- ...normalizeToArray(options?.excludeUrl),
253
+ ...normalizeToArray(options?.excludeUrls),
238
254
  ],
239
255
  maxExcludedDepth: options?.maxExcludedDepth || 10,
240
256
  retry: options?.retry ?? 3,
@@ -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
@@ -1,4 +1,4 @@
1
- import type { CrawlerError } from './utils/index.js';
1
+ import type { CrawlerError } from './utils/types/types.js';
2
2
  /**
3
3
  * Event map for the `CrawlerOrchestrator` class.
4
4
  *
@@ -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.4.3",
3
+ "version": "0.5.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/index.js",
22
- "types": "./lib/index.d.ts"
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.6.3",
31
+ "@d-zero/dealer": "1.7.0",
32
32
  "@d-zero/fs": "0.2.2",
33
- "@d-zero/shared": "0.20.0",
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": "0f4ca55751be2f83dd5b6622c3502503fc7dfb41"
51
+ "gitHead": "607d06bd596a0270d7088f32373d7367eb47ea94"
52
52
  }