@nitpicker/crawler 0.9.0 → 0.12.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 +262 -24
- package/lib/archive/archive.js +397 -86
- package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
- package/lib/archive/cache/compute-archive-cache-key.js +95 -0
- package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
- package/lib/archive/cache/extract-archive-to-cache.js +309 -0
- package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
- package/lib/archive/cache/get-archive-cache-root.js +53 -0
- package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
- package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
- package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
- package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
- package/lib/archive/database.d.ts +411 -26
- package/lib/archive/database.js +1881 -540
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- 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/get-failed-page-messages.d.ts +43 -0
- package/lib/archive/get-failed-page-messages.js +131 -0
- package/lib/archive/init-schema.d.ts +53 -2
- package/lib/archive/init-schema.js +399 -15
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- 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-inventory-runs.d.ts +29 -0
- package/lib/archive/migrate-inventory-runs.js +52 -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 +168 -26
- package/lib/classify-error-kind.d.ts +19 -0
- package/lib/classify-error-kind.js +122 -0
- package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
- package/lib/crawler/build-js-redirect-edge.js +57 -0
- package/lib/crawler/build-redirect-event.d.ts +24 -0
- package/lib/crawler/build-redirect-event.js +28 -0
- package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
- 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 +7 -2
- package/lib/crawler/crawler.js +919 -113
- 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-js-redirect-target.d.ts +68 -0
- package/lib/crawler/derive-js-redirect-target.js +129 -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 +33 -0
- package/lib/crawler/derive-resource-source.js +37 -0
- package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
- package/lib/crawler/dns-burned-host-cache.js +25 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -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.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +132 -18
- 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-js-redirect-error-shape.d.ts +40 -0
- package/lib/crawler/is-js-redirect-error-shape.js +53 -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/is-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -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.d.ts +21 -1
- package/lib/crawler/link-list.js +25 -4
- 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/plan-sub-resource-emits.d.ts +63 -0
- package/lib/crawler/plan-sub-resource-emits.js +44 -0
- package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
- package/lib/crawler/preload-short-circuit-error.js +25 -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/should-burn-host.d.ts +78 -0
- package/lib/crawler/should-burn-host.js +61 -0
- package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
- package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
- package/lib/crawler/types.d.ts +229 -1
- package/lib/crawler-orchestrator.d.ts +105 -3
- package/lib/crawler-orchestrator.js +620 -20
- package/lib/crawler.d.ts +8 -2
- package/lib/crawler.js +6 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -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 +95 -1
- package/lib/utils/compute-file-sha256.d.ts +23 -0
- package/lib/utils/compute-file-sha256.js +55 -0
- package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
- package/lib/utils/error/emit-error-with-retry.js +44 -0
- package/lib/utils/error/emit-error.d.ts +39 -0
- package/lib/utils/error/emit-error.js +41 -0
- package/lib/utils/object/parse-response-headers.d.ts +12 -0
- package/lib/utils/object/parse-response-headers.js +26 -0
- package/package.json +11 -11
- package/lib/utils/error/error-emitter.d.ts +0 -18
- package/lib/utils/error/error-emitter.js +0 -29
|
@@ -6,10 +6,18 @@ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-awa
|
|
|
6
6
|
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
|
+
import { clearDnsBurnedHostCache } from './crawler/clear-dns-burned-host-cache.js';
|
|
9
10
|
import Crawler from './crawler/crawler.js';
|
|
11
|
+
import { dnsBurnedHostCache } from './crawler/dns-burned-host-cache.js';
|
|
12
|
+
import { dnsBurnedHostShortCircuitCounter } from './crawler/dns-burned-host-short-circuit-counter.js';
|
|
13
|
+
import { findScopeEntry } from './crawler/find-scope-entry.js';
|
|
14
|
+
import { isLikelyHtmlUrl } from './crawler/is-likely-html-url.js';
|
|
15
|
+
import { PreloadShortCircuitError } from './crawler/preload-short-circuit-error.js';
|
|
16
|
+
import { protocolAgnosticKey } from './crawler/protocol-agnostic-key.js';
|
|
10
17
|
import { crawlerLog, log } from './debug.js';
|
|
11
18
|
import { normalizeToArray } from './normalize-to-array.js';
|
|
12
19
|
import { resolveOutputPath } from './resolve-output-path.js';
|
|
20
|
+
import { resourceRowToLookupResult } from './resource-row-to-lookup-result.js';
|
|
13
21
|
import { cleanObject } from './utils/object/clean-object.js';
|
|
14
22
|
import { WriteQueue } from './write-queue.js';
|
|
15
23
|
/**
|
|
@@ -53,6 +61,8 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
53
61
|
#crawler;
|
|
54
62
|
/** Whether the crawl was started from a pre-defined URL list (non-recursive mode). */
|
|
55
63
|
#fromList;
|
|
64
|
+
/** Serializes archive writes from crawler event handlers (FIFO). */
|
|
65
|
+
#writeQueue = new WriteQueue();
|
|
56
66
|
/**
|
|
57
67
|
* The underlying archive instance used for storing crawl results.
|
|
58
68
|
*/
|
|
@@ -95,6 +105,34 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
95
105
|
verbose: options?.verbose ?? false,
|
|
96
106
|
userAgent: options?.userAgent || defaultUserAgent,
|
|
97
107
|
ignoreRobots: options?.ignoreRobots ?? false,
|
|
108
|
+
// Let the crawler reuse sub-resource data captured during page
|
|
109
|
+
// rendering instead of issuing a redundant HEAD pre-flight.
|
|
110
|
+
lookupResource: async (urls) => {
|
|
111
|
+
// Fast path: read directly — the row is usually flushed long
|
|
112
|
+
// before the queued URL is dequeued, and a direct read does not
|
|
113
|
+
// block behind pending writes.
|
|
114
|
+
const direct = await this.#archive.getResourceByUrl(urls);
|
|
115
|
+
if (direct) {
|
|
116
|
+
return resourceRowToLookupResult(direct);
|
|
117
|
+
}
|
|
118
|
+
// A miss may be an insert still queued — re-read serialized
|
|
119
|
+
// behind the write queue so hit/miss is deterministic.
|
|
120
|
+
const row = await this.#writeQueue.enqueue(() => this.#archive.getResourceByUrl(urls));
|
|
121
|
+
return row ? resourceRowToLookupResult(row) : null;
|
|
122
|
+
},
|
|
123
|
+
// Let the crawler propagate the parent's source lineage to
|
|
124
|
+
// sub-resources on `--resume` / `--retry-failed` sessions, where
|
|
125
|
+
// `inventoryMode` is not in memory but the DB still remembers
|
|
126
|
+
// the parent's `source`. Without this, sub-resources captured
|
|
127
|
+
// during a re-render of an inventory-labelled page would fall
|
|
128
|
+
// back to the DB DEFAULT `'crawled'` and lose their
|
|
129
|
+
// `'inventory-discovered'` provenance.
|
|
130
|
+
lookupPageSource: async (url) => this.#archive.getPageSourceByUrl(url),
|
|
131
|
+
// Inventory mode is opted into by `CrawlerOrchestrator.inventory`
|
|
132
|
+
// (see T3); the default crawl path stays in normal mode so new
|
|
133
|
+
// rows continue to land in pages/resources with the DB DEFAULT
|
|
134
|
+
// `'crawled'` provenance label.
|
|
135
|
+
inventoryMode: options?.inventoryMode ?? null,
|
|
98
136
|
});
|
|
99
137
|
}
|
|
100
138
|
/**
|
|
@@ -113,30 +151,44 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
113
151
|
* Sets up event listeners on the crawler, starts crawling, and resolves
|
|
114
152
|
* when the crawl completes. Discovered pages, external pages, skipped pages,
|
|
115
153
|
* and resources are forwarded to the archive for storage.
|
|
116
|
-
* @param list - The list of parsed URLs to crawl.
|
|
154
|
+
* @param list - The list of parsed URLs to crawl. May be empty when a resumed
|
|
155
|
+
* session already has pending pages queued (for example `--retry-failed`).
|
|
156
|
+
* @param opts - Optional crawl overrides.
|
|
157
|
+
* @param opts.recursive - Whether discovered URLs are followed. Defaults to
|
|
158
|
+
* `!fromList` (recursive unless the archive was created from a URL list), so
|
|
159
|
+
* existing callers keep their behaviour; the retry flow passes it explicitly.
|
|
117
160
|
* @returns A promise that resolves when crawling is complete.
|
|
118
|
-
* @throws {Error} If the URL list is empty.
|
|
119
161
|
*/
|
|
120
|
-
async crawling(list) {
|
|
121
|
-
const
|
|
122
|
-
if (!root) {
|
|
123
|
-
throw new Error('URL is empty');
|
|
124
|
-
}
|
|
125
|
-
const writeQueue = new WriteQueue();
|
|
162
|
+
async crawling(list, opts) {
|
|
163
|
+
const writeQueue = this.#writeQueue;
|
|
126
164
|
return new Promise((resolve, reject) => {
|
|
127
165
|
this.#crawler.on('error', (error) => {
|
|
166
|
+
if (error.error instanceof PreloadShortCircuitError) {
|
|
167
|
+
// DNS-burned host short-circuit: the underlying cause already
|
|
168
|
+
// lives in `crawl_errors` from the original DNS failure.
|
|
169
|
+
// Writing it again on every subsequent URL would amplify the
|
|
170
|
+
// row count on each `--retry-failed` re-run and could even
|
|
171
|
+
// inflate the preload selection on the next open. Drop it
|
|
172
|
+
// here; `pages.status = -1` still gets set via the normal
|
|
173
|
+
// scrape-error path (handleScrapeError → addPageError) so the
|
|
174
|
+
// page record itself is unchanged.
|
|
175
|
+
crawlerLog('Skipping addError for preload short-circuit: %s', error.url);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
128
178
|
crawlerLog('On error: %O', error);
|
|
129
|
-
|
|
179
|
+
writeQueue
|
|
180
|
+
.enqueue(() => this.#archive.addError(error))
|
|
181
|
+
.catch((writeError) => reject(writeError));
|
|
130
182
|
void this.emit('error', error);
|
|
131
183
|
});
|
|
132
|
-
this.#crawler.on('page', ({ result }) => {
|
|
184
|
+
this.#crawler.on('page', ({ result, source }) => {
|
|
133
185
|
writeQueue
|
|
134
|
-
.enqueue(() => this.#archive.setPage(result))
|
|
186
|
+
.enqueue(() => this.#archive.setPage(result, source))
|
|
135
187
|
.catch((error) => reject(error));
|
|
136
188
|
});
|
|
137
|
-
this.#crawler.on('externalPage', ({ result }) => {
|
|
189
|
+
this.#crawler.on('externalPage', ({ result, source }) => {
|
|
138
190
|
writeQueue
|
|
139
|
-
.enqueue(() => this.#archive.setExternalPage(result))
|
|
191
|
+
.enqueue(() => this.#archive.setExternalPage(result, source))
|
|
140
192
|
.catch((error) => reject(error));
|
|
141
193
|
});
|
|
142
194
|
this.#crawler.on('skip', ({ url, reason, isExternal }) => {
|
|
@@ -144,9 +196,20 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
144
196
|
.enqueue(() => this.#archive.setSkippedPage(url, reason, isExternal))
|
|
145
197
|
.catch((error) => reject(error));
|
|
146
198
|
});
|
|
147
|
-
this.#crawler.on('
|
|
199
|
+
this.#crawler.on('pageError', ({ url, phase, message, isExternal }) => {
|
|
200
|
+
writeQueue
|
|
201
|
+
.enqueue(() => this.#archive.addPageError(url, phase, message, isExternal))
|
|
202
|
+
.catch((error) => reject(error));
|
|
203
|
+
});
|
|
204
|
+
this.#crawler.on('redirect', ({ result, source }) => {
|
|
205
|
+
writeQueue
|
|
206
|
+
.enqueue(() => this.#archive.setRedirect(result, source))
|
|
207
|
+
.catch((error) => reject(error));
|
|
208
|
+
void this.emit('redirect', { result });
|
|
209
|
+
});
|
|
210
|
+
this.#crawler.on('response', ({ resource, source }) => {
|
|
148
211
|
writeQueue
|
|
149
|
-
.enqueue(() => this.#archive.setResources(resource))
|
|
212
|
+
.enqueue(() => this.#archive.setResources(resource, source))
|
|
150
213
|
.catch((error) => reject(error));
|
|
151
214
|
});
|
|
152
215
|
this.#crawler.on('responseReferrers', (resource) => {
|
|
@@ -160,7 +223,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
160
223
|
.then(() => resolve())
|
|
161
224
|
.catch((error) => reject(error));
|
|
162
225
|
});
|
|
163
|
-
this.#crawler.start(list, { recursive: !this.#fromList });
|
|
226
|
+
this.#crawler.start(list, { recursive: opts?.recursive ?? !this.#fromList });
|
|
164
227
|
});
|
|
165
228
|
}
|
|
166
229
|
/**
|
|
@@ -271,7 +334,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
271
334
|
log('Config %O', config);
|
|
272
335
|
await orchestrator.crawling(list);
|
|
273
336
|
log('Crawling completed');
|
|
274
|
-
|
|
337
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
275
338
|
log('Set order natural URL sort');
|
|
276
339
|
await archive.setUrlOrder();
|
|
277
340
|
log('Sorting done');
|
|
@@ -348,7 +411,8 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
348
411
|
});
|
|
349
412
|
const { scraped, pending } = await archive.getCrawlingState();
|
|
350
413
|
const resources = await archive.getResourceUrlList();
|
|
351
|
-
|
|
414
|
+
const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
|
|
415
|
+
orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
|
|
352
416
|
if (initializedCallback) {
|
|
353
417
|
await initializedCallback(orchestrator, mergedConfig);
|
|
354
418
|
}
|
|
@@ -356,8 +420,9 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
356
420
|
log('Archive %s', absFilePath);
|
|
357
421
|
log('New roots %O', newRoots);
|
|
358
422
|
log('Merged roots %O', mergedRoots);
|
|
423
|
+
await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
|
|
359
424
|
await orchestrator.crawling(newParsed);
|
|
360
|
-
|
|
425
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
361
426
|
await archive.setUrlOrder();
|
|
362
427
|
await ignoreEnoent(unlinkFile(backupPath));
|
|
363
428
|
return orchestrator;
|
|
@@ -381,6 +446,454 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
381
446
|
throw error;
|
|
382
447
|
}
|
|
383
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Inventory mode: cross-reference a user-supplied URL list against an
|
|
451
|
+
* existing `.nitpicker` archive and import ONLY the URLs that are not yet
|
|
452
|
+
* tracked there. Designed to surface "orphan" landing pages that link
|
|
453
|
+
* graph traversal could not reach, and "unused" server-side files that
|
|
454
|
+
* no crawled page references — both of which the
|
|
455
|
+
* `listIsolatedPages` / `listUnusedResources` queries can then list.
|
|
456
|
+
*
|
|
457
|
+
* Flow:
|
|
458
|
+
*
|
|
459
|
+
* 1. Open the archive (writer mode, takes the archive lock).
|
|
460
|
+
* 2. Reject list-mode archives — they hold metadata-only rows that
|
|
461
|
+
* inventory has no business touching.
|
|
462
|
+
* 3. Reject archives with unfinished `pending` URLs — those would inherit
|
|
463
|
+
* the inventory `source` label by mistake. Operator must resume /
|
|
464
|
+
* retry-failed first.
|
|
465
|
+
* 4. Parse the URL list. Anything outside the archived scope is warned
|
|
466
|
+
* and skipped (inventory is per-server by design).
|
|
467
|
+
* 5. Subtract URLs that already exist in `pages` or `resources` so the
|
|
468
|
+
* second (and N-th) inventory pass is a no-op for known rows — keeps
|
|
469
|
+
* `'inventory-seed'` rows from being silently demoted.
|
|
470
|
+
* 6. Make `<archive>.bak`. Anything thrown beyond this point restores
|
|
471
|
+
* from the backup.
|
|
472
|
+
* 7. HEAD-probe each novel URL. Responses classified as HTML are queued
|
|
473
|
+
* as Crawler seeds (`'inventory-seed'`); everything else is recorded
|
|
474
|
+
* in `resources` directly as `'inventory-seed'` (no browser launch).
|
|
475
|
+
* 8. If any HTML seeds exist, start a Crawler with
|
|
476
|
+
* `inventoryMode = { seedUrls }` so the rendered page and every newly
|
|
477
|
+
* discovered downstream link is labelled correctly. `resume` is fed
|
|
478
|
+
* the existing `scraped` / `resources` sets so links into already-
|
|
479
|
+
* crawled pages stop at the seen-gate without re-rendering.
|
|
480
|
+
* 9. Drop the backup on success; restore it on any throw.
|
|
481
|
+
*
|
|
482
|
+
* Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
|
|
483
|
+
* `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
|
|
484
|
+
* CLI dispatch enforces this; this method assumes the caller honoured
|
|
485
|
+
* the contract.
|
|
486
|
+
* @param archivePath - Absolute or cwd-relative path to the `.nitpicker` archive.
|
|
487
|
+
* @param inventoryUrls - Pre-read URL list (one URL per element).
|
|
488
|
+
* @param options - Optional config overrides — most callers leave this blank and let the archived config flow through.
|
|
489
|
+
* @param initializedCallback - Hook invoked once the orchestrator is constructed but before `crawling` runs (the CLI uses it to attach progress reporting).
|
|
490
|
+
* @param sourceFileSha256 - **Pre-computed** SHA-256 hex digest of the
|
|
491
|
+
* source URL list. The orchestrator deliberately does NOT receive
|
|
492
|
+
* the file path: the path is privacy-sensitive (leaks user-home /
|
|
493
|
+
* OS structure when archives are shared) and we want it lifted off
|
|
494
|
+
* this boundary so no future log line / breadcrumb / error message
|
|
495
|
+
* inside the orchestrator can accidentally re-leak it. The CLI
|
|
496
|
+
* computes the digest via `computeFileSha256(resolvedListFile)`
|
|
497
|
+
* and passes it through here. Pass `null` for programmatic
|
|
498
|
+
* callers that built `inventoryUrls` in-memory; the audit row's
|
|
499
|
+
* `source_file_sha256` column will be `NULL`.
|
|
500
|
+
* @returns The orchestrator instance after a successful inventory pass.
|
|
501
|
+
* @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
|
|
502
|
+
*/
|
|
503
|
+
static async inventory(archivePath, inventoryUrls, options, initializedCallback, sourceFileSha256 = null) {
|
|
504
|
+
if (inventoryUrls.length === 0) {
|
|
505
|
+
throw new Error('inventory: URL list is empty');
|
|
506
|
+
}
|
|
507
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
508
|
+
const absFilePath = path.isAbsolute(archivePath)
|
|
509
|
+
? archivePath
|
|
510
|
+
: path.resolve(cwd, archivePath);
|
|
511
|
+
const archive = await Archive.open({ filePath: absFilePath, cwd });
|
|
512
|
+
try {
|
|
513
|
+
const archived = await archive.getConfig();
|
|
514
|
+
if (archived.fromList) {
|
|
515
|
+
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.');
|
|
516
|
+
}
|
|
517
|
+
const { pending } = await archive.getCrawlingState();
|
|
518
|
+
if (pending.length > 0) {
|
|
519
|
+
// `getCrawlingState` returns the STRICT pending set — in-scope,
|
|
520
|
+
// anchor-referenced, `scraped=0` rows. Predicted-discard leaks
|
|
521
|
+
// and external anomalies are filtered out at the reader, so a
|
|
522
|
+
// non-empty pending here means the previous session genuinely
|
|
523
|
+
// stopped with interrupted in-scope work. The original hard
|
|
524
|
+
// rejection blocked legitimate inventory runs in practice
|
|
525
|
+
// because leak rows polluted the count; with the strict
|
|
526
|
+
// reader those false positives are gone, so a warning is
|
|
527
|
+
// enough — the inventory pass continues and the crawled-wins
|
|
528
|
+
// source priority keeps stale labels stable even if some of
|
|
529
|
+
// the strict-pending rows happen to land on inventory seeds.
|
|
530
|
+
console.warn(`inventory: archive has ${pending.length} pending URLs from a previous crawl. Proceeding — crawled-wins priority keeps their labels stable. Consider \`--resume\` first if you want the prior work finalized.`);
|
|
531
|
+
}
|
|
532
|
+
// Parse + scope-classify the candidate URLs. sortUrl drops
|
|
533
|
+
// unparseable strings; findScopeEntry separates in-scope from
|
|
534
|
+
// out-of-scope.
|
|
535
|
+
const parsedAll = sortUrl(inventoryUrls, archived);
|
|
536
|
+
const scopeMap = new Map();
|
|
537
|
+
for (const raw of archived.roots) {
|
|
538
|
+
const parsed = parseUrl(raw, archived);
|
|
539
|
+
if (!parsed)
|
|
540
|
+
continue;
|
|
541
|
+
const existing = scopeMap.get(parsed.hostname) ?? [];
|
|
542
|
+
scopeMap.set(parsed.hostname, [...existing, parsed]);
|
|
543
|
+
}
|
|
544
|
+
const inScope = [];
|
|
545
|
+
let outOfScope = 0;
|
|
546
|
+
for (const url of parsedAll) {
|
|
547
|
+
if (findScopeEntry(url, scopeMap, archived) === null) {
|
|
548
|
+
outOfScope++;
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
inScope.push(url);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (outOfScope > 0) {
|
|
555
|
+
log('[inventory] %d URL(s) skipped (outside archived scope: %O)', outOfScope, archived.roots);
|
|
556
|
+
}
|
|
557
|
+
// Drop URLs that are already represented in the archive (either
|
|
558
|
+
// as pages or resources). Comparison key is `withoutHashAndAuth`
|
|
559
|
+
// to mirror what `#getIdByUrl` / `insertResource` actually store.
|
|
560
|
+
// Two independent reads — Promise.all halves the wait on large
|
|
561
|
+
// archives where each `WHERE url IN (?)` chunk costs real I/O.
|
|
562
|
+
const candidateUrls = inScope.map((u) => u.withoutHashAndAuth);
|
|
563
|
+
const [existingPageUrlList, existingResourceUrlList] = await Promise.all([
|
|
564
|
+
archive.getExistingPageUrls(candidateUrls),
|
|
565
|
+
archive.getExistingResourceUrls(candidateUrls),
|
|
566
|
+
]);
|
|
567
|
+
const existingPageUrls = new Set(existingPageUrlList);
|
|
568
|
+
const existingResourceUrls = new Set(existingResourceUrlList);
|
|
569
|
+
const novelUrls = inScope.filter((u) => {
|
|
570
|
+
const key = u.withoutHashAndAuth;
|
|
571
|
+
return !existingPageUrls.has(key) && !existingResourceUrls.has(key);
|
|
572
|
+
});
|
|
573
|
+
const knownCount = existingPageUrls.size + existingResourceUrls.size;
|
|
574
|
+
log('[inventory] %d in-scope, %d already in archive, %d new', inScope.length, knownCount, novelUrls.length);
|
|
575
|
+
if (novelUrls.length === 0) {
|
|
576
|
+
// Nothing to do — release the archive cleanly without taking a
|
|
577
|
+
// backup. The orchestrator returned here is empty; the caller
|
|
578
|
+
// should only invoke `close` on it.
|
|
579
|
+
const noopConfig = {
|
|
580
|
+
...archived,
|
|
581
|
+
...cleanObject(options),
|
|
582
|
+
};
|
|
583
|
+
const orchestrator = new CrawlerOrchestrator(archive, noopConfig);
|
|
584
|
+
if (initializedCallback) {
|
|
585
|
+
await initializedCallback(orchestrator, noopConfig);
|
|
586
|
+
}
|
|
587
|
+
return orchestrator;
|
|
588
|
+
}
|
|
589
|
+
const backupPath = absFilePath + '.bak';
|
|
590
|
+
await copyFile(absFilePath, backupPath);
|
|
591
|
+
// Ingestion (pre-insert + audit) is `.bak`-protected — a failure
|
|
592
|
+
// there restores the archive and the operator reruns. Once
|
|
593
|
+
// ingestion completes and the `.bak` is released, the scrape
|
|
594
|
+
// phase runs without `.bak` protection: a Ctrl+C / crash leaves
|
|
595
|
+
// the pre-inserted `inventory-seed` rows in `pages` so
|
|
596
|
+
// `crawl --resume` recovers them via the strict-pending set
|
|
597
|
+
// (see {@link Database.getCrawlingState}'s `OR p.source != 'crawled'`
|
|
598
|
+
// clause). This flag steers the catch below.
|
|
599
|
+
let ingestionComplete = false;
|
|
600
|
+
try {
|
|
601
|
+
// Classify novel URLs by URL-extension heuristic (no I/O).
|
|
602
|
+
// Source file lists come from `ls` on the doc-root, so the
|
|
603
|
+
// extension reflects the real file type — a HEAD pre-flight
|
|
604
|
+
// here would be pure wasted I/O. Edge cases:
|
|
605
|
+
//
|
|
606
|
+
// - `.html` returning 404 / 200: the normal crawler HEAD/GET
|
|
607
|
+
// path absorbs this because every HTML-classified URL is
|
|
608
|
+
// fed through the dealer and gets its real HEAD/GET there.
|
|
609
|
+
//
|
|
610
|
+
// - Extensionless API endpoints (e.g. `/api/foo`) that the
|
|
611
|
+
// server returns as `text/html`: `isLikelyHtmlUrl` accepts
|
|
612
|
+
// them as HTML so the dealer's render path runs — the
|
|
613
|
+
// real content-type wins downstream.
|
|
614
|
+
//
|
|
615
|
+
// - `.aspx` / `.do` / `.jsp` / other server-handler
|
|
616
|
+
// extensions that the heuristic does NOT recognise as
|
|
617
|
+
// HTML: these are classified as non-HTML here, recorded
|
|
618
|
+
// as `resources` rows with all-null metadata, and never
|
|
619
|
+
// get a HEAD/GET probe. The accepted trade-off for
|
|
620
|
+
// `--inventory`'s "list of static-looking server files"
|
|
621
|
+
// contract; sites that mix server-handlers into the
|
|
622
|
+
// inventory list will need a follow-up `--retry-failed`
|
|
623
|
+
// pass (or a re-`--inventory` with the corrected list)
|
|
624
|
+
// to populate metadata.
|
|
625
|
+
//
|
|
626
|
+
// non-HTML rows are recorded with null status/content-type
|
|
627
|
+
// which is sufficient for `listUnusedResources` (referrer
|
|
628
|
+
// count = 0) but means downstream consumers must treat
|
|
629
|
+
// null as "not probed" rather than "failed".
|
|
630
|
+
const rawHtmlSeeds = [];
|
|
631
|
+
const nonHtmlSeeds = [];
|
|
632
|
+
for (const url of novelUrls) {
|
|
633
|
+
if (isLikelyHtmlUrl(url)) {
|
|
634
|
+
rawHtmlSeeds.push(url);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
nonHtmlSeeds.push(url);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Dedup HTML seeds by `protocolAgnosticKey` so an inventory
|
|
641
|
+
// list that mixes `http://` and `https://` for the same
|
|
642
|
+
// origin does not produce two `pages` rows that the dealer
|
|
643
|
+
// later collapses to one — the loser would otherwise stay
|
|
644
|
+
// `scraped=0, source='inventory-seed'` forever and look like
|
|
645
|
+
// a real recovery candidate on `--resume`. `getExistingPageUrls`
|
|
646
|
+
// keys on the full URL (with protocol), so it cannot catch
|
|
647
|
+
// the cross-scheme duplicate; this is the dedup boundary.
|
|
648
|
+
const seenKeys = new Set();
|
|
649
|
+
const htmlSeeds = [];
|
|
650
|
+
for (const url of rawHtmlSeeds) {
|
|
651
|
+
const key = protocolAgnosticKey(url.withoutHashAndAuth);
|
|
652
|
+
if (seenKeys.has(key)) {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
seenKeys.add(key);
|
|
656
|
+
htmlSeeds.push(url);
|
|
657
|
+
}
|
|
658
|
+
// Bulk-record non-HTML novel URLs in `resources` as
|
|
659
|
+
// `source='inventory-seed'` placeholders. The previous
|
|
660
|
+
// per-URL `await setResources(...)` loop spent minutes
|
|
661
|
+
// inside the `.bak`-protected window on large inventory
|
|
662
|
+
// lists; the chunked bulk path collapses N round-trips
|
|
663
|
+
// to N/500.
|
|
664
|
+
await archive.insertInventoryResources(nonHtmlSeeds);
|
|
665
|
+
// Pre-insert HTML seeds as `scraped = 0`,
|
|
666
|
+
// `source = 'inventory-seed'` placeholders *before* the
|
|
667
|
+
// scrape phase, so a Ctrl+C between here and `setPage`
|
|
668
|
+
// no longer loses the URL. The strict-pending set picks
|
|
669
|
+
// these rows up on the next `--resume` via the
|
|
670
|
+
// `OR p.source != 'crawled'` clause.
|
|
671
|
+
await archive.insertInventorySeeds(htmlSeeds);
|
|
672
|
+
log('[inventory] %d HTML seed(s), %d non-HTML resource(s) recorded', htmlSeeds.length, nonHtmlSeeds.length);
|
|
673
|
+
// Audit row is written *inside* the `.bak` window: a libsql
|
|
674
|
+
// hiccup or transient lock on the INSERT aborts the ingestion
|
|
675
|
+
// and the `.bak` restore wipes the pre-inserted seeds too,
|
|
676
|
+
// so "either the whole run took or none of it did" holds at
|
|
677
|
+
// the ingestion boundary. Past behaviour swallowed the
|
|
678
|
+
// failure post-scrape; the new boundary makes restore safe
|
|
679
|
+
// and useful, so the swallow is gone (see
|
|
680
|
+
// {@link CrawlerOrchestrator.#writeInventoryRunRow}).
|
|
681
|
+
await CrawlerOrchestrator.#writeInventoryRunRow(archive, {
|
|
682
|
+
inventoryUrlsCount: inventoryUrls.length,
|
|
683
|
+
htmlSeedsCount: htmlSeeds.length,
|
|
684
|
+
nonHtmlCount: nonHtmlSeeds.length,
|
|
685
|
+
outOfScope,
|
|
686
|
+
sourceFileSha256,
|
|
687
|
+
});
|
|
688
|
+
// Ingestion's DB writes are now committed. From here on a
|
|
689
|
+
// throw must NOT trigger the `.bak` restore (it would wipe
|
|
690
|
+
// the durable seeds + audit row). Setting the flag *before*
|
|
691
|
+
// the `.bak` unlink covers the rare Windows / antivirus
|
|
692
|
+
// path where `unlinkFile` itself fails with EBUSY/EPERM —
|
|
693
|
+
// the `.bak` may leak on disk for the operator to delete
|
|
694
|
+
// manually, but the archive state stays intact.
|
|
695
|
+
ingestionComplete = true;
|
|
696
|
+
// Release `.bak` — ingestion succeeded. Beyond this point a
|
|
697
|
+
// throw is the scrape phase's problem; the archive stays
|
|
698
|
+
// intact and the operator runs `--resume` to recover.
|
|
699
|
+
await ignoreEnoent(unlinkFile(backupPath));
|
|
700
|
+
// Config sent to the user-facing `initializedCallback`
|
|
701
|
+
// (matches the rest of the orchestrator's public surface —
|
|
702
|
+
// no inventory bookkeeping leaks out).
|
|
703
|
+
const baseConfig = {
|
|
704
|
+
...archived,
|
|
705
|
+
...cleanObject(options),
|
|
706
|
+
recursive: true,
|
|
707
|
+
fromList: false,
|
|
708
|
+
};
|
|
709
|
+
const seedSet = new Set(htmlSeeds.map((u) => u.withoutHashAndAuth));
|
|
710
|
+
// CrawlConfig overlay handed to the orchestrator constructor —
|
|
711
|
+
// carries the runtime-only `inventoryMode` that drives source
|
|
712
|
+
// labelling. Not persisted to the archive.
|
|
713
|
+
const orchestratorOptions = {
|
|
714
|
+
...baseConfig,
|
|
715
|
+
inventoryMode: { seedUrls: seedSet },
|
|
716
|
+
};
|
|
717
|
+
if (htmlSeeds.length > 0) {
|
|
718
|
+
const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
|
|
719
|
+
// Re-read pending *after* the pre-insert so the strict-
|
|
720
|
+
// pending set includes the freshly inserted
|
|
721
|
+
// `inventory-seed` rows; feed that into `crawler.resume`
|
|
722
|
+
// and start a seedless `crawling([])` — the same pattern
|
|
723
|
+
// `retryFailed` uses to drive the dealer from the
|
|
724
|
+
// pending set alone (see retryFailed's
|
|
725
|
+
// `crawling([], { recursive })` invocation).
|
|
726
|
+
const { scraped: scrapedAfter, pending: pendingAfter } = await archive.getCrawlingState();
|
|
727
|
+
const resources = await archive.getResourceUrlList();
|
|
728
|
+
// Pre-existing rendered HTML page count seeds the
|
|
729
|
+
// session-spanning `pagesScraped` counter so the progress
|
|
730
|
+
// header reads `internalDone(cumulative pagesScraped)`
|
|
731
|
+
// rather than session-only — matches the `append` /
|
|
732
|
+
// `retryFailed` / `resume` paths and avoids users reading
|
|
733
|
+
// the parenthesised number as "inner pages dropped to N".
|
|
734
|
+
const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
|
|
735
|
+
orchestrator.#crawler.resume(pendingAfter, scrapedAfter, resources, pagesScrapedOffset);
|
|
736
|
+
if (initializedCallback) {
|
|
737
|
+
await initializedCallback(orchestrator, baseConfig);
|
|
738
|
+
}
|
|
739
|
+
log('Start inventory');
|
|
740
|
+
log('Archive %s', absFilePath);
|
|
741
|
+
log('HTML seeds %O', htmlSeeds.map((u) => u.href));
|
|
742
|
+
await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
|
|
743
|
+
await orchestrator.crawling([], { recursive: true });
|
|
744
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
745
|
+
await archive.setUrlOrder();
|
|
746
|
+
return orchestrator;
|
|
747
|
+
}
|
|
748
|
+
// Only non-HTML URLs were imported — nothing left to render,
|
|
749
|
+
// but still update sort order and finalize.
|
|
750
|
+
const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
|
|
751
|
+
if (initializedCallback) {
|
|
752
|
+
await initializedCallback(orchestrator, baseConfig);
|
|
753
|
+
}
|
|
754
|
+
await archive.setUrlOrder();
|
|
755
|
+
return orchestrator;
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
if (ingestionComplete) {
|
|
759
|
+
// Scrape phase failed; the pre-inserted seeds + audit
|
|
760
|
+
// row are durable inside `tmpDir/db.sqlite` but not yet
|
|
761
|
+
// on disk as a `.nitpicker` tar. The outer catch below
|
|
762
|
+
// runs `archive.close()`, which sees the original
|
|
763
|
+
// (pre-inventory) `.nitpicker` already on disk and
|
|
764
|
+
// would just `remove(tmpDir)` — silently wiping every
|
|
765
|
+
// `inventory-seed` row and the audit row.
|
|
766
|
+
//
|
|
767
|
+
// Persist the ingested state ourselves before letting
|
|
768
|
+
// the outer catch unwind, then re-throw so the operator
|
|
769
|
+
// learns about the scrape failure (and can recover via
|
|
770
|
+
// `crawl --resume <archive>`). `releaseHandle` shares
|
|
771
|
+
// the orchestrator's `#closeOnce` guard, so the outer
|
|
772
|
+
// catch's `close()` becomes a no-op for the destructive
|
|
773
|
+
// step and only runs `releaseLock` cleanup.
|
|
774
|
+
try {
|
|
775
|
+
await archive.write();
|
|
776
|
+
await archive.releaseHandle();
|
|
777
|
+
}
|
|
778
|
+
catch (persistError) {
|
|
779
|
+
throw new AggregateError([error, persistError], 'inventory scrape phase failed AND persisting the ingested state to disk also failed. The archive may be in an inconsistent state — check tmpDir.');
|
|
780
|
+
}
|
|
781
|
+
throw error;
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
await copyFile(backupPath, absFilePath);
|
|
785
|
+
await ignoreEnoent(unlinkFile(backupPath));
|
|
786
|
+
}
|
|
787
|
+
catch (restoreError) {
|
|
788
|
+
throw new AggregateError([error, restoreError], `inventory failed AND restore from backup failed. Original archive backup is left at: ${backupPath}`);
|
|
789
|
+
}
|
|
790
|
+
throw error;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
await archive.close().catch(() => { });
|
|
795
|
+
throw error;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Re-fetch previously-failed pages in an existing `.nitpicker` archive.
|
|
800
|
+
*
|
|
801
|
+
* Opens the archive, resets every page whose previous attempt ended in a
|
|
802
|
+
* recoverable failure (missing status / content type, or a 5xx status — see
|
|
803
|
+
* {@link Archive.resetFailedPages}) back to pending, and resumes crawling.
|
|
804
|
+
* The archived crawl configuration is reused — scopes, excludes, keywords,
|
|
805
|
+
* user agent, etc. — so the retry honours the original crawl boundaries
|
|
806
|
+
* unless a field is explicitly overridden via `options`. The exception is
|
|
807
|
+
* `recursive`: it is taken from `options` (the CLI flag defaults it to
|
|
808
|
+
* `true`) rather than inherited from the archive, so a retry decides afresh
|
|
809
|
+
* whether to follow newly-discovered URLs regardless of how the original
|
|
810
|
+
* crawl was run.
|
|
811
|
+
*
|
|
812
|
+
* When `recursive` is enabled (the default), newly-discovered URLs from the
|
|
813
|
+
* re-fetched pages are followed and crawled from scratch; when disabled, only
|
|
814
|
+
* the failed pages themselves are re-fetched. The archived roots seed the
|
|
815
|
+
* crawl scope while the reset pages are picked up through the resumed pending
|
|
816
|
+
* set, so failed external pages stay external (metadata-only) instead of being
|
|
817
|
+
* promoted into scope, and a failed root is re-fetched in place.
|
|
818
|
+
*
|
|
819
|
+
* A `<archive>.bak` is created before any DB mutation and removed on success;
|
|
820
|
+
* if the crawl throws, the backup is restored to keep the original archive
|
|
821
|
+
* intact.
|
|
822
|
+
*
|
|
823
|
+
* List-mode archives (`info.fromList === true`) are rejected for the same
|
|
824
|
+
* reason as {@link CrawlerOrchestrator.append}: their pages are metadata-only.
|
|
825
|
+
* @param archivePath - Absolute or relative path to the existing `.nitpicker`.
|
|
826
|
+
* @param options - Optional config overrides applied on top of the archived config.
|
|
827
|
+
* @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
|
|
828
|
+
* @returns The orchestrator instance after the retry crawl completes.
|
|
829
|
+
* @throws {Error} When the archive is in list mode or has no parseable roots.
|
|
830
|
+
*/
|
|
831
|
+
static async retryFailed(archivePath, options, initializedCallback) {
|
|
832
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
833
|
+
const absFilePath = path.isAbsolute(archivePath)
|
|
834
|
+
? archivePath
|
|
835
|
+
: path.resolve(cwd, archivePath);
|
|
836
|
+
const archive = await Archive.open({ filePath: absFilePath, cwd });
|
|
837
|
+
// Any throw between here and the successful return must release the
|
|
838
|
+
// archive lock and clean up tmpDir; the caller's `close()` only runs on
|
|
839
|
+
// the happy path.
|
|
840
|
+
try {
|
|
841
|
+
const archived = await archive.getConfig();
|
|
842
|
+
if (archived.fromList) {
|
|
843
|
+
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.');
|
|
844
|
+
}
|
|
845
|
+
const rootsParsed = sortUrl(archived.roots, archived);
|
|
846
|
+
if (rootsParsed.length === 0) {
|
|
847
|
+
throw new Error('retry: archive has no parseable root URLs');
|
|
848
|
+
}
|
|
849
|
+
const config = {
|
|
850
|
+
...archived,
|
|
851
|
+
...cleanObject(options),
|
|
852
|
+
roots: archived.roots,
|
|
853
|
+
fromList: false,
|
|
854
|
+
baseUrl: archived.baseUrl,
|
|
855
|
+
};
|
|
856
|
+
const backupPath = absFilePath + '.bak';
|
|
857
|
+
await copyFile(absFilePath, backupPath);
|
|
858
|
+
try {
|
|
859
|
+
const resetUrls = await archive.resetFailedPages();
|
|
860
|
+
log('Start retrying failed pages');
|
|
861
|
+
log('Archive %s', absFilePath);
|
|
862
|
+
log('Reset %d failed page(s)', resetUrls.length);
|
|
863
|
+
const orchestrator = new CrawlerOrchestrator(archive, config);
|
|
864
|
+
const { scraped, pending } = await archive.getCrawlingState();
|
|
865
|
+
const resources = await archive.getResourceUrlList();
|
|
866
|
+
const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
|
|
867
|
+
orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
|
|
868
|
+
if (initializedCallback) {
|
|
869
|
+
await initializedCallback(orchestrator, config);
|
|
870
|
+
}
|
|
871
|
+
await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
|
|
872
|
+
await orchestrator.crawling([], { recursive: config.recursive });
|
|
873
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
874
|
+
await archive.setUrlOrder();
|
|
875
|
+
await ignoreEnoent(unlinkFile(backupPath));
|
|
876
|
+
return orchestrator;
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
try {
|
|
880
|
+
await copyFile(backupPath, absFilePath);
|
|
881
|
+
await ignoreEnoent(unlinkFile(backupPath));
|
|
882
|
+
}
|
|
883
|
+
catch (restoreError) {
|
|
884
|
+
// Restore itself failed — surface both so the operator knows
|
|
885
|
+
// the .bak still exists and the original archive may be
|
|
886
|
+
// corrupt. The outer `catch` still releases the lock.
|
|
887
|
+
throw new AggregateError([error, restoreError], `retry failed AND restore from backup failed. Original archive backup is left at: ${backupPath}`);
|
|
888
|
+
}
|
|
889
|
+
throw error;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
catch (error) {
|
|
893
|
+
await archive.close().catch(() => { });
|
|
894
|
+
throw error;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
384
897
|
/**
|
|
385
898
|
* Resume a previously interrupted crawl from an existing archive file.
|
|
386
899
|
*
|
|
@@ -408,7 +921,8 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
408
921
|
}
|
|
409
922
|
const { scraped, pending } = await archive.getCrawlingState();
|
|
410
923
|
const resources = await archive.getResourceUrlList();
|
|
411
|
-
|
|
924
|
+
const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
|
|
925
|
+
orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
|
|
412
926
|
if (initializedCallback) {
|
|
413
927
|
await initializedCallback(orchestrator, config);
|
|
414
928
|
}
|
|
@@ -416,9 +930,95 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
416
930
|
log('Data %s', stubPath);
|
|
417
931
|
log('URL %s', url.href);
|
|
418
932
|
log('Config %O', config);
|
|
933
|
+
await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
|
|
419
934
|
await orchestrator.crawling([url]);
|
|
935
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
420
936
|
return orchestrator;
|
|
421
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* Seeds {@link dnsBurnedHostCache} from `crawl_errors` history at re-open
|
|
940
|
+
* (append / inventory / retryFailed / resume). Called after Archive.open
|
|
941
|
+
* succeeds and before crawling starts, so the first URL on a burned host
|
|
942
|
+
* already short-circuits — no retry budget is spent on a dead host that
|
|
943
|
+
* the previous crawl already proved was dead.
|
|
944
|
+
*
|
|
945
|
+
* Fresh `crawling()` skips this — there is no archive history to seed
|
|
946
|
+
* from. Within-session learning still kicks in via the `onGiveUp` mark.
|
|
947
|
+
* @param archive - The opened archive whose `crawl_errors` is read.
|
|
948
|
+
*/
|
|
949
|
+
static async #preloadDnsBurnedHostCache(archive) {
|
|
950
|
+
const hosts = await archive.listDnsBurnedHostCandidates();
|
|
951
|
+
for (const host of hosts) {
|
|
952
|
+
dnsBurnedHostCache.set(host, 'dns');
|
|
953
|
+
}
|
|
954
|
+
if (hosts.length > 0) {
|
|
955
|
+
// eslint-disable-next-line no-console
|
|
956
|
+
console.error(`[preload] DNS-burned hosts: ${hosts.length} (will short-circuit subsequent URLs)`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Persist one `inventory_runs` audit row inside the ingestion phase of a
|
|
961
|
+
* `--inventory` invocation, before the `.bak` is released. Lives as a
|
|
962
|
+
* static helper because the audit-row shape (timestamp stamping + label
|
|
963
|
+
* auto-gen + the privacy-driven path elision documented below) is a
|
|
964
|
+
* cohesive concern that benefits from staying outside the long
|
|
965
|
+
* `inventory()` body even though only one caller remains after the
|
|
966
|
+
* ingestion-phase consolidation.
|
|
967
|
+
*
|
|
968
|
+
* `ran_at` is stamped now (ingestion-completion timestamp; the scrape
|
|
969
|
+
* phase that may follow is treated as separate). `list_label` is
|
|
970
|
+
* auto-generated from `ran_at` when the CLI did not pass one — Phase 1
|
|
971
|
+
* has no `--label` flag, so this is always the auto form.
|
|
972
|
+
* `source_file_sha256` arrives pre-computed via
|
|
973
|
+
* `aggregates.sourceFileSha256` (the CLI's `inventoryCrawl` ran
|
|
974
|
+
* `computeFileSha256` against the input txt before the orchestrator
|
|
975
|
+
* was even invoked). The orchestrator boundary deliberately never sees
|
|
976
|
+
* the absolute path — see {@link InventoryRunAggregates} for the
|
|
977
|
+
* privacy rationale.
|
|
978
|
+
*
|
|
979
|
+
* **Audit-write failures abort the ingestion phase.** The earlier
|
|
980
|
+
* implementation swallowed them because the audit was the last write
|
|
981
|
+
* after* the scrape, so re-throwing would have wiped a completed crawl;
|
|
982
|
+
* with audit now lifted into the `.bak`-protected ingestion phase the
|
|
983
|
+
* trade-off flips. A failed audit row is restorable: the outer catch
|
|
984
|
+
* copies `.bak` back over the archive and the operator reruns the
|
|
985
|
+
* (short) ingestion from scratch. That keeps `inventory_runs` honest
|
|
986
|
+
* (no "ran but unrecorded" rows) at the cost of one rerun.
|
|
987
|
+
*
|
|
988
|
+
* Forward-compat: when Phase 2 introduces an explicit `--label` flag,
|
|
989
|
+
* thread `labelOverride` through {@link inventory} into the `aggregates`
|
|
990
|
+
* shape so the auto-name can be overridden.
|
|
991
|
+
* @param archive - The opened archive to write the audit row into.
|
|
992
|
+
* @param aggregates - The counts captured during the inventory pass; see {@link InventoryRunAggregates}.
|
|
993
|
+
*/
|
|
994
|
+
static async #writeInventoryRunRow(archive, aggregates) {
|
|
995
|
+
const ranAt = new Date().toISOString();
|
|
996
|
+
await archive.recordInventoryRun({
|
|
997
|
+
ran_at: ranAt,
|
|
998
|
+
list_label: `inventory-${ranAt}`,
|
|
999
|
+
source_file_sha256: aggregates.sourceFileSha256,
|
|
1000
|
+
total_lines: aggregates.inventoryUrlsCount,
|
|
1001
|
+
new_pages: aggregates.htmlSeedsCount,
|
|
1002
|
+
new_resources: aggregates.nonHtmlCount,
|
|
1003
|
+
scope_skipped: aggregates.outOfScope,
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Tears down session-scoped crawler caches and prints a short-circuit
|
|
1008
|
+
* summary if any URL fetches were skipped. Invoked at the four
|
|
1009
|
+
* crawl-session boundaries (`crawling` / `append` / `inventory` /
|
|
1010
|
+
* `retryFailed` / `resume`) where the previous `clearDestinationCache`
|
|
1011
|
+
* call already lived.
|
|
1012
|
+
*/
|
|
1013
|
+
static #finalizeCrawlSession() {
|
|
1014
|
+
const skipped = dnsBurnedHostShortCircuitCounter.count;
|
|
1015
|
+
if (skipped > 0) {
|
|
1016
|
+
// eslint-disable-next-line no-console
|
|
1017
|
+
console.error(`[preload] Short-circuited ${skipped} URL(s) on DNS-burned hosts`);
|
|
1018
|
+
}
|
|
1019
|
+
clearDestinationCache();
|
|
1020
|
+
clearDnsBurnedHostCache();
|
|
1021
|
+
}
|
|
422
1022
|
}
|
|
423
1023
|
/**
|
|
424
1024
|
* Await a filesystem promise but silently swallow only `ENOENT` errors. Any
|