@nitpicker/crawler 0.8.0 → 0.11.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 +87 -14
- package/lib/archive/archive-accessor.js +162 -36
- package/lib/archive/archive.d.ts +147 -24
- package/lib/archive/archive.js +252 -86
- package/lib/archive/database.d.ts +209 -25
- package/lib/archive/database.js +928 -108
- package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
- package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
- package/lib/archive/init-schema.d.ts +53 -2
- package/lib/archive/init-schema.js +247 -15
- package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
- package/lib/archive/meta/assert-compatible-version.js +72 -0
- package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
- package/lib/archive/meta/classify-jsonld-type.js +43 -0
- package/lib/archive/meta/compare-semver.d.ts +23 -0
- package/lib/archive/meta/compare-semver.js +51 -0
- package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
- package/lib/archive/meta/compute-page-denormalized.js +35 -0
- package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
- package/lib/archive/meta/derive-flat-from-meta.js +158 -0
- package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
- package/lib/archive/meta/derive-meta-extras.js +23 -0
- package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
- package/lib/archive/meta/extract-tags-for-archive.js +36 -0
- package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
- package/lib/archive/meta/summarize-jsonld.js +29 -0
- package/lib/archive/meta/summarize-tags.d.ts +16 -0
- package/lib/archive/meta/summarize-tags.js +33 -0
- package/lib/archive/meta/types.d.ts +207 -0
- package/lib/archive/meta/types.js +33 -0
- package/lib/archive/migrate-crawl-errors.d.ts +20 -0
- package/lib/archive/migrate-crawl-errors.js +38 -0
- package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
- package/lib/archive/migrate-html-blob-tables.js +53 -0
- package/lib/archive/migrate-page-errors.d.ts +16 -0
- package/lib/archive/migrate-page-errors.js +35 -0
- package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
- package/lib/archive/migrate-pages-resources-source.js +46 -0
- package/lib/archive/page.d.ts +187 -49
- package/lib/archive/page.js +258 -63
- package/lib/archive/peek-archive-lock.d.ts +40 -0
- package/lib/archive/peek-archive-lock.js +62 -0
- package/lib/archive/resolve-redirect-chain.d.ts +33 -0
- package/lib/archive/resolve-redirect-chain.js +27 -0
- package/lib/archive/types.d.ts +135 -26
- package/lib/crawler/close-browser-safely.d.ts +64 -0
- package/lib/crawler/close-browser-safely.js +73 -0
- package/lib/crawler/crawler.d.ts +4 -1
- package/lib/crawler/crawler.js +290 -32
- package/lib/crawler/create-change-phase-handler.d.ts +54 -0
- package/lib/crawler/create-change-phase-handler.js +44 -0
- package/lib/crawler/derive-page-source.d.ts +23 -0
- package/lib/crawler/derive-page-source.js +28 -0
- package/lib/crawler/derive-resource-source.d.ts +23 -0
- package/lib/crawler/derive-resource-source.js +26 -0
- package/lib/crawler/drain-phase-errors.d.ts +48 -0
- package/lib/crawler/drain-phase-errors.js +35 -0
- package/lib/crawler/fetch-destination.js +38 -2
- package/lib/crawler/format-crawl-progress.d.ts +12 -3
- package/lib/crawler/format-crawl-progress.js +14 -6
- package/lib/crawler/handle-browser-close.d.ts +29 -0
- package/lib/crawler/handle-browser-close.js +28 -0
- package/lib/crawler/is-html-content-type.d.ts +17 -0
- package/lib/crawler/is-html-content-type.js +19 -0
- package/lib/crawler/is-likely-html-url.d.ts +22 -0
- package/lib/crawler/is-likely-html-url.js +65 -0
- package/lib/crawler/kill-process-tree.d.ts +94 -0
- package/lib/crawler/kill-process-tree.js +178 -0
- package/lib/crawler/link-list.js +2 -1
- package/lib/crawler/link-to-page-data.d.ts +13 -5
- package/lib/crawler/link-to-page-data.js +26 -5
- package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
- package/lib/crawler/log-undrained-phase-errors.js +34 -0
- package/lib/crawler/normalize-content-type.d.ts +14 -0
- package/lib/crawler/normalize-content-type.js +20 -0
- package/lib/crawler/partition-urls-by-html.d.ts +16 -0
- package/lib/crawler/partition-urls-by-html.js +23 -0
- package/lib/crawler/redirect-dest-key.d.ts +19 -0
- package/lib/crawler/redirect-dest-key.js +27 -0
- package/lib/crawler/resource-to-page-data.d.ts +28 -0
- package/lib/crawler/resource-to-page-data.js +59 -0
- package/lib/crawler/types.d.ts +122 -1
- package/lib/crawler-orchestrator.d.ts +93 -1
- package/lib/crawler-orchestrator.js +389 -12
- package/lib/crawler.d.ts +5 -0
- package/lib/crawler.js +3 -0
- package/lib/resource-row-to-lookup-result.d.ts +13 -0
- package/lib/resource-row-to-lookup-result.js +20 -0
- package/lib/types.d.ts +11 -1
- package/lib/utils/object/parse-response-headers.d.ts +12 -0
- package/lib/utils/object/parse-response-headers.js +26 -0
- package/package.json +4 -4
|
@@ -7,9 +7,13 @@ import pkg from '../package.json' with { type: 'json' };
|
|
|
7
7
|
import Archive from './archive/archive.js';
|
|
8
8
|
import { clearDestinationCache } from './crawler/clear-destination-cache.js';
|
|
9
9
|
import Crawler from './crawler/crawler.js';
|
|
10
|
+
import { fetchDestination } from './crawler/fetch-destination.js';
|
|
11
|
+
import { findScopeEntry } from './crawler/find-scope-entry.js';
|
|
12
|
+
import { isHtmlContentType } from './crawler/is-html-content-type.js';
|
|
10
13
|
import { crawlerLog, log } from './debug.js';
|
|
11
14
|
import { normalizeToArray } from './normalize-to-array.js';
|
|
12
15
|
import { resolveOutputPath } from './resolve-output-path.js';
|
|
16
|
+
import { resourceRowToLookupResult } from './resource-row-to-lookup-result.js';
|
|
13
17
|
import { cleanObject } from './utils/object/clean-object.js';
|
|
14
18
|
import { WriteQueue } from './write-queue.js';
|
|
15
19
|
/**
|
|
@@ -53,6 +57,8 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
53
57
|
#crawler;
|
|
54
58
|
/** Whether the crawl was started from a pre-defined URL list (non-recursive mode). */
|
|
55
59
|
#fromList;
|
|
60
|
+
/** Serializes archive writes from crawler event handlers (FIFO). */
|
|
61
|
+
#writeQueue = new WriteQueue();
|
|
56
62
|
/**
|
|
57
63
|
* The underlying archive instance used for storing crawl results.
|
|
58
64
|
*/
|
|
@@ -95,6 +101,26 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
95
101
|
verbose: options?.verbose ?? false,
|
|
96
102
|
userAgent: options?.userAgent || defaultUserAgent,
|
|
97
103
|
ignoreRobots: options?.ignoreRobots ?? false,
|
|
104
|
+
// Let the crawler reuse sub-resource data captured during page
|
|
105
|
+
// rendering instead of issuing a redundant HEAD pre-flight.
|
|
106
|
+
lookupResource: async (urls) => {
|
|
107
|
+
// Fast path: read directly — the row is usually flushed long
|
|
108
|
+
// before the queued URL is dequeued, and a direct read does not
|
|
109
|
+
// block behind pending writes.
|
|
110
|
+
const direct = await this.#archive.getResourceByUrl(urls);
|
|
111
|
+
if (direct) {
|
|
112
|
+
return resourceRowToLookupResult(direct);
|
|
113
|
+
}
|
|
114
|
+
// A miss may be an insert still queued — re-read serialized
|
|
115
|
+
// behind the write queue so hit/miss is deterministic.
|
|
116
|
+
const row = await this.#writeQueue.enqueue(() => this.#archive.getResourceByUrl(urls));
|
|
117
|
+
return row ? resourceRowToLookupResult(row) : null;
|
|
118
|
+
},
|
|
119
|
+
// Inventory mode is opted into by `CrawlerOrchestrator.inventory`
|
|
120
|
+
// (see T3); the default crawl path stays in normal mode so new
|
|
121
|
+
// rows continue to land in pages/resources with the DB DEFAULT
|
|
122
|
+
// `'crawled'` provenance label.
|
|
123
|
+
inventoryMode: options?.inventoryMode ?? null,
|
|
98
124
|
});
|
|
99
125
|
}
|
|
100
126
|
/**
|
|
@@ -114,29 +140,35 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
114
140
|
* when the crawl completes. Discovered pages, external pages, skipped pages,
|
|
115
141
|
* and resources are forwarded to the archive for storage.
|
|
116
142
|
* @param list - The list of parsed URLs to crawl. The first URL is used as the root.
|
|
143
|
+
* @param opts - Optional crawl overrides.
|
|
144
|
+
* @param opts.recursive - Whether discovered URLs are followed. Defaults to
|
|
145
|
+
* `!fromList` (recursive unless the archive was created from a URL list), so
|
|
146
|
+
* existing callers keep their behaviour; the retry flow passes it explicitly.
|
|
117
147
|
* @returns A promise that resolves when crawling is complete.
|
|
118
148
|
* @throws {Error} If the URL list is empty.
|
|
119
149
|
*/
|
|
120
|
-
async crawling(list) {
|
|
150
|
+
async crawling(list, opts) {
|
|
121
151
|
const root = list[0];
|
|
122
152
|
if (!root) {
|
|
123
153
|
throw new Error('URL is empty');
|
|
124
154
|
}
|
|
125
|
-
const writeQueue =
|
|
155
|
+
const writeQueue = this.#writeQueue;
|
|
126
156
|
return new Promise((resolve, reject) => {
|
|
127
157
|
this.#crawler.on('error', (error) => {
|
|
128
158
|
crawlerLog('On error: %O', error);
|
|
129
|
-
|
|
159
|
+
writeQueue
|
|
160
|
+
.enqueue(() => this.#archive.addError(error))
|
|
161
|
+
.catch((writeError) => reject(writeError));
|
|
130
162
|
void this.emit('error', error);
|
|
131
163
|
});
|
|
132
|
-
this.#crawler.on('page', ({ result }) => {
|
|
164
|
+
this.#crawler.on('page', ({ result, source }) => {
|
|
133
165
|
writeQueue
|
|
134
|
-
.enqueue(() => this.#archive.setPage(result))
|
|
166
|
+
.enqueue(() => this.#archive.setPage(result, source))
|
|
135
167
|
.catch((error) => reject(error));
|
|
136
168
|
});
|
|
137
|
-
this.#crawler.on('externalPage', ({ result }) => {
|
|
169
|
+
this.#crawler.on('externalPage', ({ result, source }) => {
|
|
138
170
|
writeQueue
|
|
139
|
-
.enqueue(() => this.#archive.setExternalPage(result))
|
|
171
|
+
.enqueue(() => this.#archive.setExternalPage(result, source))
|
|
140
172
|
.catch((error) => reject(error));
|
|
141
173
|
});
|
|
142
174
|
this.#crawler.on('skip', ({ url, reason, isExternal }) => {
|
|
@@ -144,9 +176,20 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
144
176
|
.enqueue(() => this.#archive.setSkippedPage(url, reason, isExternal))
|
|
145
177
|
.catch((error) => reject(error));
|
|
146
178
|
});
|
|
147
|
-
this.#crawler.on('
|
|
179
|
+
this.#crawler.on('pageError', ({ url, phase, message, isExternal }) => {
|
|
180
|
+
writeQueue
|
|
181
|
+
.enqueue(() => this.#archive.addPageError(url, phase, message, isExternal))
|
|
182
|
+
.catch((error) => reject(error));
|
|
183
|
+
});
|
|
184
|
+
this.#crawler.on('redirect', ({ result }) => {
|
|
185
|
+
writeQueue
|
|
186
|
+
.enqueue(() => this.#archive.setRedirect(result))
|
|
187
|
+
.catch((error) => reject(error));
|
|
188
|
+
void this.emit('redirect', { result });
|
|
189
|
+
});
|
|
190
|
+
this.#crawler.on('response', ({ resource, source }) => {
|
|
148
191
|
writeQueue
|
|
149
|
-
.enqueue(() => this.#archive.setResources(resource))
|
|
192
|
+
.enqueue(() => this.#archive.setResources(resource, source))
|
|
150
193
|
.catch((error) => reject(error));
|
|
151
194
|
});
|
|
152
195
|
this.#crawler.on('responseReferrers', (resource) => {
|
|
@@ -160,7 +203,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
160
203
|
.then(() => resolve())
|
|
161
204
|
.catch((error) => reject(error));
|
|
162
205
|
});
|
|
163
|
-
this.#crawler.start(list, { recursive: !this.#fromList });
|
|
206
|
+
this.#crawler.start(list, { recursive: opts?.recursive ?? !this.#fromList });
|
|
164
207
|
});
|
|
165
208
|
}
|
|
166
209
|
/**
|
|
@@ -348,7 +391,8 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
348
391
|
});
|
|
349
392
|
const { scraped, pending } = await archive.getCrawlingState();
|
|
350
393
|
const resources = await archive.getResourceUrlList();
|
|
351
|
-
|
|
394
|
+
const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
|
|
395
|
+
orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
|
|
352
396
|
if (initializedCallback) {
|
|
353
397
|
await initializedCallback(orchestrator, mergedConfig);
|
|
354
398
|
}
|
|
@@ -381,6 +425,338 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
381
425
|
throw error;
|
|
382
426
|
}
|
|
383
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Inventory mode: cross-reference a user-supplied URL list against an
|
|
430
|
+
* existing `.nitpicker` archive and import ONLY the URLs that are not yet
|
|
431
|
+
* tracked there. Designed to surface "orphan" landing pages that link
|
|
432
|
+
* graph traversal could not reach, and "unused" server-side files that
|
|
433
|
+
* no crawled page references — both of which the
|
|
434
|
+
* `listIsolatedPages` / `listUnusedResources` queries can then list.
|
|
435
|
+
*
|
|
436
|
+
* Flow:
|
|
437
|
+
*
|
|
438
|
+
* 1. Open the archive (writer mode, takes the archive lock).
|
|
439
|
+
* 2. Reject list-mode archives — they hold metadata-only rows that
|
|
440
|
+
* inventory has no business touching.
|
|
441
|
+
* 3. Reject archives with unfinished `pending` URLs — those would inherit
|
|
442
|
+
* the inventory `source` label by mistake. Operator must resume /
|
|
443
|
+
* retry-failed first.
|
|
444
|
+
* 4. Parse the URL list. Anything outside the archived scope is warned
|
|
445
|
+
* and skipped (inventory is per-server by design).
|
|
446
|
+
* 5. Subtract URLs that already exist in `pages` or `resources` so the
|
|
447
|
+
* second (and N-th) inventory pass is a no-op for known rows — keeps
|
|
448
|
+
* `'inventory-seed'` rows from being silently demoted.
|
|
449
|
+
* 6. Make `<archive>.bak`. Anything thrown beyond this point restores
|
|
450
|
+
* from the backup.
|
|
451
|
+
* 7. HEAD-probe each novel URL. Responses classified as HTML are queued
|
|
452
|
+
* as Crawler seeds (`'inventory-seed'`); everything else is recorded
|
|
453
|
+
* in `resources` directly as `'inventory-seed'` (no browser launch).
|
|
454
|
+
* 8. If any HTML seeds exist, start a Crawler with
|
|
455
|
+
* `inventoryMode = { seedUrls }` so the rendered page and every newly
|
|
456
|
+
* discovered downstream link is labelled correctly. `resume` is fed
|
|
457
|
+
* the existing `scraped` / `resources` sets so links into already-
|
|
458
|
+
* crawled pages stop at the seen-gate without re-rendering.
|
|
459
|
+
* 9. Drop the backup on success; restore it on any throw.
|
|
460
|
+
*
|
|
461
|
+
* Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
|
|
462
|
+
* `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
|
|
463
|
+
* CLI dispatch enforces this; this method assumes the caller honoured
|
|
464
|
+
* the contract.
|
|
465
|
+
* @param archivePath - Absolute or cwd-relative path to the `.nitpicker` archive.
|
|
466
|
+
* @param inventoryUrls - Pre-read URL list (one URL per element).
|
|
467
|
+
* @param options - Optional config overrides — most callers leave this blank and let the archived config flow through.
|
|
468
|
+
* @param initializedCallback - Hook invoked once the orchestrator is constructed but before `crawling` runs (the CLI uses it to attach progress reporting).
|
|
469
|
+
* @returns The orchestrator instance after a successful inventory pass.
|
|
470
|
+
* @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
|
|
471
|
+
*/
|
|
472
|
+
static async inventory(archivePath, inventoryUrls, options, initializedCallback) {
|
|
473
|
+
if (inventoryUrls.length === 0) {
|
|
474
|
+
throw new Error('inventory: URL list is empty');
|
|
475
|
+
}
|
|
476
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
477
|
+
const absFilePath = path.isAbsolute(archivePath)
|
|
478
|
+
? archivePath
|
|
479
|
+
: path.resolve(cwd, archivePath);
|
|
480
|
+
const archive = await Archive.open({ filePath: absFilePath, cwd });
|
|
481
|
+
try {
|
|
482
|
+
const archived = await archive.getConfig();
|
|
483
|
+
if (archived.fromList) {
|
|
484
|
+
throw new Error('Cannot run inventory on a list-mode archive: this archive was created with --list/--list-file and contains metadata-only pages. Create a fresh archive instead.');
|
|
485
|
+
}
|
|
486
|
+
const { scraped, pending } = await archive.getCrawlingState();
|
|
487
|
+
if (pending.length > 0) {
|
|
488
|
+
throw new Error(`inventory: archive has ${pending.length} pending URLs from a previous crawl. Resume or retry-failed first so inventory does not mislabel them as 'inventory-discovered'.`);
|
|
489
|
+
}
|
|
490
|
+
// Parse + scope-classify the candidate URLs. sortUrl drops
|
|
491
|
+
// unparseable strings; findScopeEntry separates in-scope from
|
|
492
|
+
// out-of-scope.
|
|
493
|
+
const parsedAll = sortUrl(inventoryUrls, archived);
|
|
494
|
+
const scopeMap = new Map();
|
|
495
|
+
for (const raw of archived.roots) {
|
|
496
|
+
const parsed = parseUrl(raw, archived);
|
|
497
|
+
if (!parsed)
|
|
498
|
+
continue;
|
|
499
|
+
const existing = scopeMap.get(parsed.hostname) ?? [];
|
|
500
|
+
scopeMap.set(parsed.hostname, [...existing, parsed]);
|
|
501
|
+
}
|
|
502
|
+
const inScope = [];
|
|
503
|
+
let outOfScope = 0;
|
|
504
|
+
for (const url of parsedAll) {
|
|
505
|
+
if (findScopeEntry(url, scopeMap, archived) === null) {
|
|
506
|
+
outOfScope++;
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
inScope.push(url);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (outOfScope > 0) {
|
|
513
|
+
log('[inventory] %d URL(s) skipped (outside archived scope: %O)', outOfScope, archived.roots);
|
|
514
|
+
}
|
|
515
|
+
// Drop URLs that are already represented in the archive (either
|
|
516
|
+
// as pages or resources). Comparison key is `withoutHashAndAuth`
|
|
517
|
+
// to mirror what `#getIdByUrl` / `insertResource` actually store.
|
|
518
|
+
// Two independent reads — Promise.all halves the wait on large
|
|
519
|
+
// archives where each `WHERE url IN (?)` chunk costs real I/O.
|
|
520
|
+
const candidateUrls = inScope.map((u) => u.withoutHashAndAuth);
|
|
521
|
+
const [existingPageUrlList, existingResourceUrlList] = await Promise.all([
|
|
522
|
+
archive.getExistingPageUrls(candidateUrls),
|
|
523
|
+
archive.getExistingResourceUrls(candidateUrls),
|
|
524
|
+
]);
|
|
525
|
+
const existingPageUrls = new Set(existingPageUrlList);
|
|
526
|
+
const existingResourceUrls = new Set(existingResourceUrlList);
|
|
527
|
+
const novelUrls = inScope.filter((u) => {
|
|
528
|
+
const key = u.withoutHashAndAuth;
|
|
529
|
+
return !existingPageUrls.has(key) && !existingResourceUrls.has(key);
|
|
530
|
+
});
|
|
531
|
+
const knownCount = existingPageUrls.size + existingResourceUrls.size;
|
|
532
|
+
log('[inventory] %d in-scope, %d already in archive, %d new', inScope.length, knownCount, novelUrls.length);
|
|
533
|
+
if (novelUrls.length === 0) {
|
|
534
|
+
// Nothing to do — release the archive cleanly without taking a
|
|
535
|
+
// backup. The orchestrator returned here is empty; the caller
|
|
536
|
+
// should only invoke `close` on it.
|
|
537
|
+
const noopConfig = {
|
|
538
|
+
...archived,
|
|
539
|
+
...cleanObject(options),
|
|
540
|
+
};
|
|
541
|
+
const orchestrator = new CrawlerOrchestrator(archive, noopConfig);
|
|
542
|
+
if (initializedCallback) {
|
|
543
|
+
await initializedCallback(orchestrator, noopConfig);
|
|
544
|
+
}
|
|
545
|
+
return orchestrator;
|
|
546
|
+
}
|
|
547
|
+
const backupPath = absFilePath + '.bak';
|
|
548
|
+
await copyFile(absFilePath, backupPath);
|
|
549
|
+
try {
|
|
550
|
+
const headResults = await Promise.all(novelUrls.map(async (url) => {
|
|
551
|
+
try {
|
|
552
|
+
const head = await fetchDestination({
|
|
553
|
+
url,
|
|
554
|
+
isExternal: false,
|
|
555
|
+
userAgent: archived.userAgent,
|
|
556
|
+
});
|
|
557
|
+
return { url, head, error: null };
|
|
558
|
+
}
|
|
559
|
+
catch (headError) {
|
|
560
|
+
const error = headError instanceof Error ? headError : new Error(String(headError));
|
|
561
|
+
return { url, head: null, error };
|
|
562
|
+
}
|
|
563
|
+
}));
|
|
564
|
+
const htmlSeeds = [];
|
|
565
|
+
for (const result of headResults) {
|
|
566
|
+
const { url, head, error } = result;
|
|
567
|
+
if (error !== null) {
|
|
568
|
+
// HEAD failure is recorded as a crawl_errors row so
|
|
569
|
+
// the URL is visible in `query error-kinds`, but does
|
|
570
|
+
// NOT abort the whole inventory pass — other novel
|
|
571
|
+
// URLs may still succeed.
|
|
572
|
+
await archive.addError({
|
|
573
|
+
pid: process.pid,
|
|
574
|
+
isMainProcess: true,
|
|
575
|
+
url: url.href,
|
|
576
|
+
isExternal: false,
|
|
577
|
+
error,
|
|
578
|
+
});
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (head.contentType == null || isHtmlContentType(head.contentType)) {
|
|
582
|
+
htmlSeeds.push(url);
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
await archive.setResources({
|
|
586
|
+
url,
|
|
587
|
+
isExternal: false,
|
|
588
|
+
isError: false,
|
|
589
|
+
status: head.status,
|
|
590
|
+
statusText: head.statusText,
|
|
591
|
+
contentType: head.contentType,
|
|
592
|
+
contentLength: head.contentLength,
|
|
593
|
+
compress: false,
|
|
594
|
+
cdn: false,
|
|
595
|
+
headers: head.responseHeaders ?? null,
|
|
596
|
+
}, 'inventory-seed');
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Config sent to the user-facing `initializedCallback`
|
|
600
|
+
// (matches the rest of the orchestrator's public surface —
|
|
601
|
+
// no inventory bookkeeping leaks out).
|
|
602
|
+
const baseConfig = {
|
|
603
|
+
...archived,
|
|
604
|
+
...cleanObject(options),
|
|
605
|
+
recursive: true,
|
|
606
|
+
fromList: false,
|
|
607
|
+
};
|
|
608
|
+
const seedSet = new Set(htmlSeeds.map((u) => u.withoutHashAndAuth));
|
|
609
|
+
// CrawlConfig overlay handed to the orchestrator constructor —
|
|
610
|
+
// carries the runtime-only `inventoryMode` that drives source
|
|
611
|
+
// labelling. Not persisted to the archive.
|
|
612
|
+
const orchestratorOptions = {
|
|
613
|
+
...baseConfig,
|
|
614
|
+
inventoryMode: { seedUrls: seedSet },
|
|
615
|
+
};
|
|
616
|
+
if (htmlSeeds.length > 0) {
|
|
617
|
+
const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
|
|
618
|
+
const resources = await archive.getResourceUrlList();
|
|
619
|
+
// Empty pending (we rejected non-empty above) but feed
|
|
620
|
+
// every already-scraped URL into `seen` so the Crawler's
|
|
621
|
+
// link enqueueing path drops links that hit a known
|
|
622
|
+
// page without re-rendering it.
|
|
623
|
+
orchestrator.#crawler.resume(pending, scraped, resources, 0);
|
|
624
|
+
if (initializedCallback) {
|
|
625
|
+
await initializedCallback(orchestrator, baseConfig);
|
|
626
|
+
}
|
|
627
|
+
log('Start inventory');
|
|
628
|
+
log('Archive %s', absFilePath);
|
|
629
|
+
log('HTML seeds %O', htmlSeeds.map((u) => u.href));
|
|
630
|
+
await orchestrator.crawling(htmlSeeds, { recursive: true });
|
|
631
|
+
clearDestinationCache();
|
|
632
|
+
await archive.setUrlOrder();
|
|
633
|
+
await ignoreEnoent(unlinkFile(backupPath));
|
|
634
|
+
return orchestrator;
|
|
635
|
+
}
|
|
636
|
+
// Only non-HTML URLs were imported — nothing left to render,
|
|
637
|
+
// but still update sort order and finalize.
|
|
638
|
+
const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
|
|
639
|
+
if (initializedCallback) {
|
|
640
|
+
await initializedCallback(orchestrator, baseConfig);
|
|
641
|
+
}
|
|
642
|
+
await archive.setUrlOrder();
|
|
643
|
+
await ignoreEnoent(unlinkFile(backupPath));
|
|
644
|
+
return orchestrator;
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
try {
|
|
648
|
+
await copyFile(backupPath, absFilePath);
|
|
649
|
+
await ignoreEnoent(unlinkFile(backupPath));
|
|
650
|
+
}
|
|
651
|
+
catch (restoreError) {
|
|
652
|
+
throw new AggregateError([error, restoreError], `inventory failed AND restore from backup failed. Original archive backup is left at: ${backupPath}`);
|
|
653
|
+
}
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
await archive.close().catch(() => { });
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Re-fetch previously-failed pages in an existing `.nitpicker` archive.
|
|
664
|
+
*
|
|
665
|
+
* Opens the archive, resets every page whose previous attempt ended in a
|
|
666
|
+
* recoverable failure (missing status / content type, or a 5xx status — see
|
|
667
|
+
* {@link Archive.resetFailedPages}) back to pending, and resumes crawling.
|
|
668
|
+
* The archived crawl configuration is reused — scopes, excludes, keywords,
|
|
669
|
+
* user agent, etc. — so the retry honours the original crawl boundaries
|
|
670
|
+
* unless a field is explicitly overridden via `options`. The exception is
|
|
671
|
+
* `recursive`: it is taken from `options` (the CLI flag defaults it to
|
|
672
|
+
* `true`) rather than inherited from the archive, so a retry decides afresh
|
|
673
|
+
* whether to follow newly-discovered URLs regardless of how the original
|
|
674
|
+
* crawl was run.
|
|
675
|
+
*
|
|
676
|
+
* When `recursive` is enabled (the default), newly-discovered URLs from the
|
|
677
|
+
* re-fetched pages are followed and crawled from scratch; when disabled, only
|
|
678
|
+
* the failed pages themselves are re-fetched. The archived roots seed the
|
|
679
|
+
* crawl scope while the reset pages are picked up through the resumed pending
|
|
680
|
+
* set, so failed external pages stay external (metadata-only) instead of being
|
|
681
|
+
* promoted into scope, and a failed root is re-fetched in place.
|
|
682
|
+
*
|
|
683
|
+
* A `<archive>.bak` is created before any DB mutation and removed on success;
|
|
684
|
+
* if the crawl throws, the backup is restored to keep the original archive
|
|
685
|
+
* intact.
|
|
686
|
+
*
|
|
687
|
+
* List-mode archives (`info.fromList === true`) are rejected for the same
|
|
688
|
+
* reason as {@link CrawlerOrchestrator.append}: their pages are metadata-only.
|
|
689
|
+
* @param archivePath - Absolute or relative path to the existing `.nitpicker`.
|
|
690
|
+
* @param options - Optional config overrides applied on top of the archived config.
|
|
691
|
+
* @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
|
|
692
|
+
* @returns The orchestrator instance after the retry crawl completes.
|
|
693
|
+
* @throws {Error} When the archive is in list mode or has no parseable roots.
|
|
694
|
+
*/
|
|
695
|
+
static async retryFailed(archivePath, options, initializedCallback) {
|
|
696
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
697
|
+
const absFilePath = path.isAbsolute(archivePath)
|
|
698
|
+
? archivePath
|
|
699
|
+
: path.resolve(cwd, archivePath);
|
|
700
|
+
const archive = await Archive.open({ filePath: absFilePath, cwd });
|
|
701
|
+
// Any throw between here and the successful return must release the
|
|
702
|
+
// archive lock and clean up tmpDir; the caller's `close()` only runs on
|
|
703
|
+
// the happy path.
|
|
704
|
+
try {
|
|
705
|
+
const archived = await archive.getConfig();
|
|
706
|
+
if (archived.fromList) {
|
|
707
|
+
throw new Error('Cannot retry a list-mode archive: this archive was created with --list/--list-file and contains metadata-only pages. Create a fresh archive instead.');
|
|
708
|
+
}
|
|
709
|
+
const rootsParsed = sortUrl(archived.roots, archived);
|
|
710
|
+
if (rootsParsed.length === 0) {
|
|
711
|
+
throw new Error('retry: archive has no parseable root URLs');
|
|
712
|
+
}
|
|
713
|
+
const config = {
|
|
714
|
+
...archived,
|
|
715
|
+
...cleanObject(options),
|
|
716
|
+
roots: archived.roots,
|
|
717
|
+
fromList: false,
|
|
718
|
+
baseUrl: archived.baseUrl,
|
|
719
|
+
};
|
|
720
|
+
const backupPath = absFilePath + '.bak';
|
|
721
|
+
await copyFile(absFilePath, backupPath);
|
|
722
|
+
try {
|
|
723
|
+
const resetUrls = await archive.resetFailedPages();
|
|
724
|
+
log('Start retrying failed pages');
|
|
725
|
+
log('Archive %s', absFilePath);
|
|
726
|
+
log('Reset %d failed page(s)', resetUrls.length);
|
|
727
|
+
const orchestrator = new CrawlerOrchestrator(archive, config);
|
|
728
|
+
const { scraped, pending } = await archive.getCrawlingState();
|
|
729
|
+
const resources = await archive.getResourceUrlList();
|
|
730
|
+
const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
|
|
731
|
+
orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
|
|
732
|
+
if (initializedCallback) {
|
|
733
|
+
await initializedCallback(orchestrator, config);
|
|
734
|
+
}
|
|
735
|
+
await orchestrator.crawling(rootsParsed, { recursive: config.recursive });
|
|
736
|
+
clearDestinationCache();
|
|
737
|
+
await archive.setUrlOrder();
|
|
738
|
+
await ignoreEnoent(unlinkFile(backupPath));
|
|
739
|
+
return orchestrator;
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
try {
|
|
743
|
+
await copyFile(backupPath, absFilePath);
|
|
744
|
+
await ignoreEnoent(unlinkFile(backupPath));
|
|
745
|
+
}
|
|
746
|
+
catch (restoreError) {
|
|
747
|
+
// Restore itself failed — surface both so the operator knows
|
|
748
|
+
// the .bak still exists and the original archive may be
|
|
749
|
+
// corrupt. The outer `catch` still releases the lock.
|
|
750
|
+
throw new AggregateError([error, restoreError], `retry failed AND restore from backup failed. Original archive backup is left at: ${backupPath}`);
|
|
751
|
+
}
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
await archive.close().catch(() => { });
|
|
757
|
+
throw error;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
384
760
|
/**
|
|
385
761
|
* Resume a previously interrupted crawl from an existing archive file.
|
|
386
762
|
*
|
|
@@ -408,7 +784,8 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
408
784
|
}
|
|
409
785
|
const { scraped, pending } = await archive.getCrawlingState();
|
|
410
786
|
const resources = await archive.getResourceUrlList();
|
|
411
|
-
|
|
787
|
+
const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
|
|
788
|
+
orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
|
|
412
789
|
if (initializedCallback) {
|
|
413
790
|
await initializedCallback(orchestrator, config);
|
|
414
791
|
}
|
package/lib/crawler.d.ts
CHANGED
|
@@ -17,6 +17,11 @@ export { default as Page } from './archive/page.js';
|
|
|
17
17
|
export { default as ArchiveResource } from './archive/resource.js';
|
|
18
18
|
export * from './archive/types.js';
|
|
19
19
|
export { default as Archive } from './archive/archive.js';
|
|
20
|
+
export { peekArchiveLockHolder } from './archive/peek-archive-lock.js';
|
|
21
|
+
export type { ArchiveLockHolder } from './archive/peek-archive-lock.js';
|
|
22
|
+
export type { FlatPageMetaColumns, JsonLdRow, JsonLdRowForInsert, TagRow, TagRowForInsert, JsonLdSummary, TagsSummary, TagInventoryEntry, PageDenormalizedColumns, } from './archive/meta/types.js';
|
|
23
|
+
export { IncompatibleArchiveError } from './archive/meta/types.js';
|
|
24
|
+
export { REQUIRED_FORMAT_VERSION } from './archive/meta/assert-compatible-version.js';
|
|
20
25
|
export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
|
|
21
26
|
export * from './types.js';
|
|
22
27
|
export * from './crawler/types.js';
|
package/lib/crawler.js
CHANGED
|
@@ -17,6 +17,9 @@ export { default as Page } from './archive/page.js';
|
|
|
17
17
|
export { default as ArchiveResource } from './archive/resource.js';
|
|
18
18
|
export * from './archive/types.js';
|
|
19
19
|
export { default as Archive } from './archive/archive.js';
|
|
20
|
+
export { peekArchiveLockHolder } from './archive/peek-archive-lock.js';
|
|
21
|
+
export { IncompatibleArchiveError } from './archive/meta/types.js';
|
|
22
|
+
export { REQUIRED_FORMAT_VERSION } from './archive/meta/assert-compatible-version.js';
|
|
20
23
|
// Core
|
|
21
24
|
export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
|
|
22
25
|
export * from './types.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DB_Resource } from './archive/types.js';
|
|
2
|
+
import type { ResourceLookupResult } from './crawler/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Convert a raw `resources` table row into the minimal lookup result the
|
|
5
|
+
* crawler needs to reuse captured sub-resource data.
|
|
6
|
+
*
|
|
7
|
+
* Header parsing degrades to `null` on malformed JSON instead of throwing
|
|
8
|
+
* because a missing header set only loses fidelity — the reuse path stays
|
|
9
|
+
* valid.
|
|
10
|
+
* @param row - The raw database row.
|
|
11
|
+
* @returns The lookup result consumed by the crawler's resource-reuse hook.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resourceRowToLookupResult(row: DB_Resource): ResourceLookupResult;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseResponseHeaders } from './utils/object/parse-response-headers.js';
|
|
2
|
+
/**
|
|
3
|
+
* Convert a raw `resources` table row into the minimal lookup result the
|
|
4
|
+
* crawler needs to reuse captured sub-resource data.
|
|
5
|
+
*
|
|
6
|
+
* Header parsing degrades to `null` on malformed JSON instead of throwing
|
|
7
|
+
* because a missing header set only loses fidelity — the reuse path stays
|
|
8
|
+
* valid.
|
|
9
|
+
* @param row - The raw database row.
|
|
10
|
+
* @returns The lookup result consumed by the crawler's resource-reuse hook.
|
|
11
|
+
*/
|
|
12
|
+
export function resourceRowToLookupResult(row) {
|
|
13
|
+
return {
|
|
14
|
+
status: row.status,
|
|
15
|
+
statusText: row.statusText,
|
|
16
|
+
contentType: row.contentType,
|
|
17
|
+
contentLength: row.contentLength,
|
|
18
|
+
responseHeaders: parseResponseHeaders(row.responseHeaders),
|
|
19
|
+
};
|
|
20
|
+
}
|
package/lib/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CrawlerError } from './utils/types/types.js';
|
|
1
|
+
import type { CrawlerError, PageData } from './utils/types/types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Event map for the `CrawlerOrchestrator` class.
|
|
4
4
|
*
|
|
@@ -24,4 +24,14 @@ export interface CrawlEvent {
|
|
|
24
24
|
* Emitted when an error occurs during crawling or archiving.
|
|
25
25
|
*/
|
|
26
26
|
error: CrawlerError;
|
|
27
|
+
/**
|
|
28
|
+
* Emitted when a URL redirects to a destination already rendered during this
|
|
29
|
+
* crawl, so only the redirect edge is recorded and the destination is not
|
|
30
|
+
* re-rendered (#73). Mirrors the crawler's `redirect` event; useful for
|
|
31
|
+
* observing how much redirect-convergence work was skipped.
|
|
32
|
+
*/
|
|
33
|
+
redirect: {
|
|
34
|
+
/** HEAD-resolved page data carrying the redirect chain (source → destination). */
|
|
35
|
+
result: PageData;
|
|
36
|
+
};
|
|
27
37
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a JSON-serialized HTTP response headers column from the archive
|
|
3
|
+
* database.
|
|
4
|
+
*
|
|
5
|
+
* The columns hold JSON produced by `JSON.stringify` at insert time; absent,
|
|
6
|
+
* malformed, or non-object JSON (the string `"null"`, arrays, primitives)
|
|
7
|
+
* degrades to `null` instead of throwing because a missing header set only
|
|
8
|
+
* loses fidelity — callers decide their own fallback (`?? {}` etc.).
|
|
9
|
+
* @param json - The raw column value.
|
|
10
|
+
* @returns The parsed header record, or `null` when absent or malformed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseResponseHeaders(json: string | null): Record<string, string | string[] | undefined> | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a JSON-serialized HTTP response headers column from the archive
|
|
3
|
+
* database.
|
|
4
|
+
*
|
|
5
|
+
* The columns hold JSON produced by `JSON.stringify` at insert time; absent,
|
|
6
|
+
* malformed, or non-object JSON (the string `"null"`, arrays, primitives)
|
|
7
|
+
* degrades to `null` instead of throwing because a missing header set only
|
|
8
|
+
* loses fidelity — callers decide their own fallback (`?? {}` etc.).
|
|
9
|
+
* @param json - The raw column value.
|
|
10
|
+
* @returns The parsed header record, or `null` when absent or malformed.
|
|
11
|
+
*/
|
|
12
|
+
export function parseResponseHeaders(json) {
|
|
13
|
+
if (json == null) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(json);
|
|
18
|
+
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitpicker/crawler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"clean": "tsc --build --clean"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@d-zero/beholder": "
|
|
31
|
-
"@d-zero/dealer": "1.
|
|
30
|
+
"@d-zero/beholder": "3.1.1",
|
|
31
|
+
"@d-zero/dealer": "1.9.0",
|
|
32
32
|
"@d-zero/fs": "0.2.2",
|
|
33
33
|
"@d-zero/shared": "0.20.1",
|
|
34
34
|
"ansi-colors": "4.1.3",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"@types/tar": "7.0.87",
|
|
49
49
|
"@types/unzipper": "0.10.11"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "b11dbe3691746b3cb980476808d1f745bc6d0e98"
|
|
52
52
|
}
|