@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.
- package/lib/archive/archive-lock.d.ts +38 -0
- package/lib/archive/archive-lock.js +147 -0
- package/lib/archive/archive.d.ts +17 -2
- package/lib/archive/archive.js +86 -31
- package/lib/archive/database.d.ts +41 -4
- package/lib/archive/database.js +176 -42
- package/lib/archive/init-schema.js +1 -1
- package/lib/archive/libsql-dialect.d.ts +25 -0
- package/lib/archive/libsql-dialect.js +28 -0
- package/lib/archive/migrate-info-roots.d.ts +15 -0
- package/lib/archive/migrate-info-roots.js +41 -0
- package/lib/archive/types.d.ts +6 -26
- package/lib/crawler/crawler.d.ts +18 -14
- package/lib/crawler/crawler.js +62 -54
- package/lib/crawler/fetch-destination.js +11 -5
- package/lib/crawler/find-scope-entry.d.ts +25 -0
- package/lib/crawler/find-scope-entry.js +45 -0
- package/lib/crawler/handle-scrape-end.js +15 -14
- package/lib/crawler/inject-scope-auth.d.ts +10 -7
- package/lib/crawler/inject-scope-auth.js +13 -14
- package/lib/crawler/is-external-url.d.ts +13 -6
- package/lib/crawler/is-external-url.js +14 -6
- package/lib/crawler/types.d.ts +2 -2
- package/lib/crawler-orchestrator.d.ts +21 -0
- package/lib/crawler-orchestrator.js +131 -10
- package/package.json +3 -3
- package/lib/crawler/find-best-matching-scope.d.ts +0 -13
- package/lib/crawler/find-best-matching-scope.js +0 -52
- package/lib/crawler/is-in-any-lower-layer.d.ts +0 -13
- package/lib/crawler/is-in-any-lower-layer.js +0 -15
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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.
|
|
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": "
|
|
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
|
-
}
|