@nitpicker/crawler 0.6.5-alpha.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { copyFile, unlink as unlinkFile } from 'node:fs/promises';
1
2
  import path from 'node:path';
2
3
  import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
3
4
  import { sortUrl } from '@d-zero/shared/sort-url';
@@ -81,7 +82,7 @@ export class CrawlerOrchestrator extends EventEmitter {
81
82
  executablePath: options?.executablePath || null,
82
83
  fetchExternal: options?.fetchExternal ?? true,
83
84
  recursive: options?.recursive ?? true,
84
- scope: options?.scope ?? [],
85
+ roots: options?.roots ?? [],
85
86
  excludes: normalizeToArray(options?.excludes),
86
87
  excludeKeywords: normalizeToArray(options?.excludeKeywords),
87
88
  excludeUrls: [
@@ -159,12 +160,7 @@ export class CrawlerOrchestrator extends EventEmitter {
159
160
  .then(() => resolve())
160
161
  .catch((error) => reject(error));
161
162
  });
162
- if (this.#fromList) {
163
- this.#crawler.startMultiple(list);
164
- }
165
- else {
166
- this.#crawler.start(root);
167
- }
163
+ this.#crawler.start(list, { recursive: !this.#fromList });
168
164
  });
169
165
  }
170
166
  /**
@@ -237,16 +233,18 @@ export class CrawlerOrchestrator extends EventEmitter {
237
233
  const disableQueries = options?.disableQueries || false;
238
234
  const defaultUserAgent = `Nitpicker/${pkg.version}`;
239
235
  const archive = await Archive.create({ filePath, cwd, disableQueries });
236
+ // Each positional URL is both a starting point and a scope entry.
237
+ const rootHrefs = list.map((u) => u.withoutHash);
240
238
  await archive.setConfig({
241
239
  version: pkg.version,
242
240
  name: fileName,
243
- baseUrl: urlParsed.withoutHash,
241
+ baseUrl: rootHrefs[0],
242
+ roots: rootHrefs,
244
243
  recursive: options?.recursive ?? true,
245
244
  fetchExternal: options?.fetchExternal ?? true,
246
245
  image: options?.image ?? true,
247
246
  interval: options?.interval || 0,
248
247
  parallels: options?.parallels || 0,
249
- scope: options?.scope ?? [],
250
248
  excludes: normalizeToArray(options?.excludes),
251
249
  excludeKeywords: normalizeToArray(options?.excludeKeywords),
252
250
  excludeUrls: [
@@ -260,7 +258,10 @@ export class CrawlerOrchestrator extends EventEmitter {
260
258
  userAgent: options?.userAgent || defaultUserAgent,
261
259
  ignoreRobots: options?.ignoreRobots ?? false,
262
260
  });
263
- const orchestrator = new CrawlerOrchestrator(archive, options);
261
+ const orchestrator = new CrawlerOrchestrator(archive, {
262
+ ...options,
263
+ roots: rootHrefs,
264
+ });
264
265
  const config = await archive.getConfig();
265
266
  if (initializedCallback) {
266
267
  await initializedCallback(orchestrator, config);
@@ -276,6 +277,110 @@ export class CrawlerOrchestrator extends EventEmitter {
276
277
  log('Sorting done');
277
278
  return orchestrator;
278
279
  }
280
+ /**
281
+ * Append a fresh crawl to an existing `.nitpicker` archive.
282
+ *
283
+ * The given `newUrls` become additional recursive roots: their `withoutHash`
284
+ * form is merged into `info.roots` and the crawler picks them up as
285
+ * starting URLs. Previously-external pages whose URL now falls under
286
+ * the expanded scope are demoted back to "needs scraping" so the next pass
287
+ * re-fetches them as full internal pages. A `<archive>.bak` is created
288
+ * before the crawl and removed on success; if the crawl throws, the backup
289
+ * is restored to keep the original archive intact.
290
+ *
291
+ * List-mode archives (`info.fromList === true`) are rejected because their
292
+ * pages are all metadata-only and cannot host a recursive append.
293
+ * @param archivePath - Absolute or relative path to the existing `.nitpicker`.
294
+ * @param newUrls - New root URLs to add and crawl.
295
+ * @param options - Optional config overrides applied on top of the archived config.
296
+ * @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
297
+ * @returns The orchestrator instance after the append crawl completes.
298
+ * @throws {Error} When `newUrls` is empty, the archive is in list mode, or it cannot be parsed.
299
+ */
300
+ static async append(archivePath, newUrls, options, initializedCallback) {
301
+ if (newUrls.length === 0) {
302
+ throw new Error('append: newUrls is empty');
303
+ }
304
+ const cwd = options?.cwd ?? process.cwd();
305
+ const absFilePath = path.isAbsolute(archivePath)
306
+ ? archivePath
307
+ : path.resolve(cwd, archivePath);
308
+ const archive = await Archive.open({ filePath: absFilePath, cwd });
309
+ // Any throw between here and the successful return must release the
310
+ // archive lock and clean up tmpDir; the caller's `close()` only runs on
311
+ // the happy path. Errors from `close()` itself are intentionally
312
+ // best-effort: the original error is what matters.
313
+ try {
314
+ const archived = await archive.getConfig();
315
+ if (archived.fromList) {
316
+ throw new Error('Cannot append to a list-mode archive: this archive was created with --list/--list-file and contains metadata-only pages. Create a fresh archive instead.');
317
+ }
318
+ const newParsed = sortUrl(newUrls, archived);
319
+ if (newParsed.length === 0) {
320
+ throw new Error('append: no parseable URLs provided');
321
+ }
322
+ const newRoots = newParsed.map((u) => u.withoutHash);
323
+ const mergedRoots = [...new Set([...archived.roots, ...newRoots])];
324
+ const mergedConfig = {
325
+ ...archived,
326
+ ...cleanObject(options),
327
+ roots: mergedRoots,
328
+ fromList: false,
329
+ recursive: true,
330
+ baseUrl: mergedRoots[0],
331
+ };
332
+ const backupPath = absFilePath + '.bak';
333
+ await copyFile(absFilePath, backupPath);
334
+ try {
335
+ await archive.updateConfig(mergedConfig);
336
+ const scopeMap = new Map();
337
+ for (const raw of mergedRoots) {
338
+ const parsed = parseUrl(raw, archived);
339
+ if (!parsed)
340
+ continue;
341
+ const existing = scopeMap.get(parsed.hostname) ?? [];
342
+ scopeMap.set(parsed.hostname, [...existing, parsed]);
343
+ }
344
+ await archive.repromoteExternalPages(scopeMap, archived);
345
+ const orchestrator = new CrawlerOrchestrator(archive, {
346
+ ...mergedConfig,
347
+ roots: mergedRoots,
348
+ });
349
+ const { scraped, pending } = await archive.getCrawlingState();
350
+ const resources = await archive.getResourceUrlList();
351
+ orchestrator.#crawler.resume(pending, scraped, resources);
352
+ if (initializedCallback) {
353
+ await initializedCallback(orchestrator, mergedConfig);
354
+ }
355
+ log('Start appending');
356
+ log('Archive %s', absFilePath);
357
+ log('New roots %O', newRoots);
358
+ log('Merged roots %O', mergedRoots);
359
+ await orchestrator.crawling(newParsed);
360
+ clearDestinationCache();
361
+ await archive.setUrlOrder();
362
+ await ignoreEnoent(unlinkFile(backupPath));
363
+ return orchestrator;
364
+ }
365
+ catch (error) {
366
+ try {
367
+ await copyFile(backupPath, absFilePath);
368
+ await ignoreEnoent(unlinkFile(backupPath));
369
+ }
370
+ catch (restoreError) {
371
+ // Restore itself failed — surface both so the operator knows
372
+ // the .bak still exists and the original archive may be
373
+ // corrupt. The outer `catch` still releases the lock.
374
+ throw new AggregateError([error, restoreError], `append failed AND restore from backup failed. Original archive backup is left at: ${backupPath}`);
375
+ }
376
+ throw error;
377
+ }
378
+ }
379
+ catch (error) {
380
+ await archive.close().catch(() => { });
381
+ throw error;
382
+ }
383
+ }
279
384
  /**
280
385
  * Resume a previously interrupted crawl from an existing archive file.
281
386
  *
@@ -315,3 +420,19 @@ export class CrawlerOrchestrator extends EventEmitter {
315
420
  return orchestrator;
316
421
  }
317
422
  }
423
+ /**
424
+ * Await a filesystem promise but silently swallow only `ENOENT` errors. Any
425
+ * other failure (permissions, disk full, etc.) propagates so the caller can
426
+ * react instead of guessing whether the operation worked.
427
+ * @param promise - Filesystem operation to await.
428
+ */
429
+ async function ignoreEnoent(promise) {
430
+ try {
431
+ await promise;
432
+ }
433
+ catch (error) {
434
+ if (error.code !== 'ENOENT') {
435
+ throw error;
436
+ }
437
+ }
438
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitpicker/crawler",
3
- "version": "0.6.5-alpha.0",
3
+ "version": "0.8.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",
@@ -36,9 +36,9 @@
36
36
  "follow-redirects": "1.15.11",
37
37
  "fs-extra": "11.3.3",
38
38
  "knex": "3.1.0",
39
+ "libsql": "0.5.29",
39
40
  "puppeteer": "24.37.5",
40
41
  "robots-parser": "3.0.1",
41
- "sqlite3": "5.1.7",
42
42
  "tar": "7.5.9"
43
43
  },
44
44
  "devDependencies": {
@@ -48,5 +48,5 @@
48
48
  "@types/tar": "7.0.87",
49
49
  "@types/unzipper": "0.10.11"
50
50
  },
51
- "gitHead": "e084aba5a0887a80059ff8aa0608f61a58cc9288"
51
+ "gitHead": "8456d02c3052b2b3438f0d32e096ed7e916c5234"
52
52
  }
@@ -1,13 +0,0 @@
1
- import type { ExURL } from '@d-zero/shared/parse-url';
2
- /**
3
- * Find the scope URL with the deepest matching path for a given URL.
4
- *
5
- * Among all scope URLs sharing the same hostname, returns the one whose
6
- * path segments are a prefix of the target URL's path segments and which
7
- * has the greatest depth. A root scope (paths: `['']`) matches all paths
8
- * under the same hostname. Returns `null` if no scope URL matches.
9
- * @param url - The parsed URL to match against scope URLs.
10
- * @param scopes - The list of scope URLs to search.
11
- * @returns The best-matching scope URL, or `null` if none match.
12
- */
13
- export declare function findBestMatchingScope(url: ExURL, scopes: readonly ExURL[]): ExURL | null;
@@ -1,52 +0,0 @@
1
- /**
2
- * Find the scope URL with the deepest matching path for a given URL.
3
- *
4
- * Among all scope URLs sharing the same hostname, returns the one whose
5
- * path segments are a prefix of the target URL's path segments and which
6
- * has the greatest depth. A root scope (paths: `['']`) matches all paths
7
- * under the same hostname. Returns `null` if no scope URL matches.
8
- * @param url - The parsed URL to match against scope URLs.
9
- * @param scopes - The list of scope URLs to search.
10
- * @returns The best-matching scope URL, or `null` if none match.
11
- */
12
- export function findBestMatchingScope(url, scopes) {
13
- let bestMatch = null;
14
- let maxDepth = -1;
15
- for (const scope of scopes) {
16
- if (url.hostname !== scope.hostname) {
17
- continue;
18
- }
19
- const isMatch = isPathMatch(url.paths, scope.paths);
20
- if (isMatch && scope.depth > maxDepth) {
21
- bestMatch = scope;
22
- maxDepth = scope.depth;
23
- }
24
- }
25
- return bestMatch;
26
- }
27
- /**
28
- * Check whether a target path is equal to or is a descendant of a base path.
29
- *
30
- * A root base path (`['']`) unconditionally matches any target path.
31
- * Otherwise, compares path segments element by element — the target path
32
- * matches if all segments of the base path appear in the same positions
33
- * at the beginning of the target path.
34
- * @param targetPaths - The path segments of the URL being checked.
35
- * @param basePaths - The path segments of the scope URL to match against.
36
- * @returns `true` if the target path starts with or equals the base path.
37
- */
38
- function isPathMatch(targetPaths, basePaths) {
39
- // Root scope (paths: ['']) matches all paths under the same hostname
40
- if (basePaths.length === 1 && basePaths[0] === '') {
41
- return true;
42
- }
43
- if (targetPaths.length < basePaths.length) {
44
- return false;
45
- }
46
- for (const [i, basePath] of basePaths.entries()) {
47
- if (targetPaths[i] !== basePath) {
48
- return false;
49
- }
50
- }
51
- return true;
52
- }
@@ -1,13 +0,0 @@
1
- import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
2
- /**
3
- * Check whether a URL is in a lower layer (subdirectory) of any scope URL.
4
- *
5
- * Tests the URL against each scope URL using the `isLowerLayer` utility,
6
- * which checks if the URL's path is at the same level or deeper than
7
- * the scope URL's path.
8
- * @param url - The parsed URL to check.
9
- * @param scopes - The list of scope URLs to test against.
10
- * @param options - URL parsing options used for layer comparison.
11
- * @returns `true` if the URL is in a lower layer of at least one scope URL.
12
- */
13
- export declare function isInAnyLowerLayer(url: ExURL, scopes: readonly ExURL[], options: ParseURLOptions): boolean;
@@ -1,15 +0,0 @@
1
- import { isLowerLayer } from '@d-zero/shared/is-lower-layer';
2
- /**
3
- * Check whether a URL is in a lower layer (subdirectory) of any scope URL.
4
- *
5
- * Tests the URL against each scope URL using the `isLowerLayer` utility,
6
- * which checks if the URL's path is at the same level or deeper than
7
- * the scope URL's path.
8
- * @param url - The parsed URL to check.
9
- * @param scopes - The list of scope URLs to test against.
10
- * @param options - URL parsing options used for layer comparison.
11
- * @returns `true` if the URL is in a lower layer of at least one scope URL.
12
- */
13
- export function isInAnyLowerLayer(url, scopes, options) {
14
- return scopes.some((scope) => isLowerLayer(url.href, scope.href, options));
15
- }