@nitpicker/crawler 0.11.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.d.ts +117 -2
- package/lib/archive/archive.js +147 -2
- 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 +216 -15
- package/lib/archive/database.js +1459 -938
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -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.js +153 -1
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/migrate-inventory-runs.d.ts +29 -0
- package/lib/archive/migrate-inventory-runs.js +52 -0
- package/lib/archive/types.d.ts +33 -0
- 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/crawler.d.ts +3 -1
- package/lib/crawler/crawler.js +655 -107
- 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-resource-source.d.ts +25 -15
- package/lib/crawler/derive-resource-source.js +28 -17
- 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/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +94 -16
- 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-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +23 -3
- 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/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 +107 -0
- package/lib/crawler-orchestrator.d.ts +13 -3
- package/lib/crawler-orchestrator.js +292 -69
- package/lib/crawler.d.ts +3 -2
- package/lib/crawler.js +3 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/types.d.ts +84 -0
- 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/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,14 @@ 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';
|
|
10
|
-
import {
|
|
11
|
+
import { dnsBurnedHostCache } from './crawler/dns-burned-host-cache.js';
|
|
12
|
+
import { dnsBurnedHostShortCircuitCounter } from './crawler/dns-burned-host-short-circuit-counter.js';
|
|
11
13
|
import { findScopeEntry } from './crawler/find-scope-entry.js';
|
|
12
|
-
import {
|
|
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';
|
|
13
17
|
import { crawlerLog, log } from './debug.js';
|
|
14
18
|
import { normalizeToArray } from './normalize-to-array.js';
|
|
15
19
|
import { resolveOutputPath } from './resolve-output-path.js';
|
|
@@ -116,6 +120,14 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
116
120
|
const row = await this.#writeQueue.enqueue(() => this.#archive.getResourceByUrl(urls));
|
|
117
121
|
return row ? resourceRowToLookupResult(row) : null;
|
|
118
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),
|
|
119
131
|
// Inventory mode is opted into by `CrawlerOrchestrator.inventory`
|
|
120
132
|
// (see T3); the default crawl path stays in normal mode so new
|
|
121
133
|
// rows continue to land in pages/resources with the DB DEFAULT
|
|
@@ -139,22 +151,30 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
139
151
|
* Sets up event listeners on the crawler, starts crawling, and resolves
|
|
140
152
|
* when the crawl completes. Discovered pages, external pages, skipped pages,
|
|
141
153
|
* and resources are forwarded to the archive for storage.
|
|
142
|
-
* @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`).
|
|
143
156
|
* @param opts - Optional crawl overrides.
|
|
144
157
|
* @param opts.recursive - Whether discovered URLs are followed. Defaults to
|
|
145
158
|
* `!fromList` (recursive unless the archive was created from a URL list), so
|
|
146
159
|
* existing callers keep their behaviour; the retry flow passes it explicitly.
|
|
147
160
|
* @returns A promise that resolves when crawling is complete.
|
|
148
|
-
* @throws {Error} If the URL list is empty.
|
|
149
161
|
*/
|
|
150
162
|
async crawling(list, opts) {
|
|
151
|
-
const root = list[0];
|
|
152
|
-
if (!root) {
|
|
153
|
-
throw new Error('URL is empty');
|
|
154
|
-
}
|
|
155
163
|
const writeQueue = this.#writeQueue;
|
|
156
164
|
return new Promise((resolve, reject) => {
|
|
157
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
|
+
}
|
|
158
178
|
crawlerLog('On error: %O', error);
|
|
159
179
|
writeQueue
|
|
160
180
|
.enqueue(() => this.#archive.addError(error))
|
|
@@ -181,9 +201,9 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
181
201
|
.enqueue(() => this.#archive.addPageError(url, phase, message, isExternal))
|
|
182
202
|
.catch((error) => reject(error));
|
|
183
203
|
});
|
|
184
|
-
this.#crawler.on('redirect', ({ result }) => {
|
|
204
|
+
this.#crawler.on('redirect', ({ result, source }) => {
|
|
185
205
|
writeQueue
|
|
186
|
-
.enqueue(() => this.#archive.setRedirect(result))
|
|
206
|
+
.enqueue(() => this.#archive.setRedirect(result, source))
|
|
187
207
|
.catch((error) => reject(error));
|
|
188
208
|
void this.emit('redirect', { result });
|
|
189
209
|
});
|
|
@@ -314,7 +334,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
314
334
|
log('Config %O', config);
|
|
315
335
|
await orchestrator.crawling(list);
|
|
316
336
|
log('Crawling completed');
|
|
317
|
-
|
|
337
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
318
338
|
log('Set order natural URL sort');
|
|
319
339
|
await archive.setUrlOrder();
|
|
320
340
|
log('Sorting done');
|
|
@@ -400,8 +420,9 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
400
420
|
log('Archive %s', absFilePath);
|
|
401
421
|
log('New roots %O', newRoots);
|
|
402
422
|
log('Merged roots %O', mergedRoots);
|
|
423
|
+
await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
|
|
403
424
|
await orchestrator.crawling(newParsed);
|
|
404
|
-
|
|
425
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
405
426
|
await archive.setUrlOrder();
|
|
406
427
|
await ignoreEnoent(unlinkFile(backupPath));
|
|
407
428
|
return orchestrator;
|
|
@@ -466,10 +487,20 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
466
487
|
* @param inventoryUrls - Pre-read URL list (one URL per element).
|
|
467
488
|
* @param options - Optional config overrides — most callers leave this blank and let the archived config flow through.
|
|
468
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`.
|
|
469
500
|
* @returns The orchestrator instance after a successful inventory pass.
|
|
470
501
|
* @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
|
|
471
502
|
*/
|
|
472
|
-
static async inventory(archivePath, inventoryUrls, options, initializedCallback) {
|
|
503
|
+
static async inventory(archivePath, inventoryUrls, options, initializedCallback, sourceFileSha256 = null) {
|
|
473
504
|
if (inventoryUrls.length === 0) {
|
|
474
505
|
throw new Error('inventory: URL list is empty');
|
|
475
506
|
}
|
|
@@ -483,9 +514,20 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
483
514
|
if (archived.fromList) {
|
|
484
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.');
|
|
485
516
|
}
|
|
486
|
-
const {
|
|
517
|
+
const { pending } = await archive.getCrawlingState();
|
|
487
518
|
if (pending.length > 0) {
|
|
488
|
-
|
|
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.`);
|
|
489
531
|
}
|
|
490
532
|
// Parse + scope-classify the candidate URLs. sortUrl drops
|
|
491
533
|
// unparseable strings; findScopeEntry separates in-scope from
|
|
@@ -546,56 +588,115 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
546
588
|
}
|
|
547
589
|
const backupPath = absFilePath + '.bak';
|
|
548
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;
|
|
549
600
|
try {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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);
|
|
558
635
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
return { url, head: null, error };
|
|
636
|
+
else {
|
|
637
|
+
nonHtmlSeeds.push(url);
|
|
562
638
|
}
|
|
563
|
-
}
|
|
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();
|
|
564
649
|
const htmlSeeds = [];
|
|
565
|
-
for (const
|
|
566
|
-
const
|
|
567
|
-
if (
|
|
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
|
-
});
|
|
650
|
+
for (const url of rawHtmlSeeds) {
|
|
651
|
+
const key = protocolAgnosticKey(url.withoutHashAndAuth);
|
|
652
|
+
if (seenKeys.has(key)) {
|
|
579
653
|
continue;
|
|
580
654
|
}
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
}
|
|
655
|
+
seenKeys.add(key);
|
|
656
|
+
htmlSeeds.push(url);
|
|
598
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));
|
|
599
700
|
// Config sent to the user-facing `initializedCallback`
|
|
600
701
|
// (matches the rest of the orchestrator's public surface —
|
|
601
702
|
// no inventory bookkeeping leaks out).
|
|
@@ -615,22 +716,33 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
615
716
|
};
|
|
616
717
|
if (htmlSeeds.length > 0) {
|
|
617
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();
|
|
618
727
|
const resources = await archive.getResourceUrlList();
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
|
|
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);
|
|
624
736
|
if (initializedCallback) {
|
|
625
737
|
await initializedCallback(orchestrator, baseConfig);
|
|
626
738
|
}
|
|
627
739
|
log('Start inventory');
|
|
628
740
|
log('Archive %s', absFilePath);
|
|
629
741
|
log('HTML seeds %O', htmlSeeds.map((u) => u.href));
|
|
630
|
-
await
|
|
631
|
-
|
|
742
|
+
await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
|
|
743
|
+
await orchestrator.crawling([], { recursive: true });
|
|
744
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
632
745
|
await archive.setUrlOrder();
|
|
633
|
-
await ignoreEnoent(unlinkFile(backupPath));
|
|
634
746
|
return orchestrator;
|
|
635
747
|
}
|
|
636
748
|
// Only non-HTML URLs were imported — nothing left to render,
|
|
@@ -640,10 +752,34 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
640
752
|
await initializedCallback(orchestrator, baseConfig);
|
|
641
753
|
}
|
|
642
754
|
await archive.setUrlOrder();
|
|
643
|
-
await ignoreEnoent(unlinkFile(backupPath));
|
|
644
755
|
return orchestrator;
|
|
645
756
|
}
|
|
646
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
|
+
}
|
|
647
783
|
try {
|
|
648
784
|
await copyFile(backupPath, absFilePath);
|
|
649
785
|
await ignoreEnoent(unlinkFile(backupPath));
|
|
@@ -732,8 +868,9 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
732
868
|
if (initializedCallback) {
|
|
733
869
|
await initializedCallback(orchestrator, config);
|
|
734
870
|
}
|
|
735
|
-
await
|
|
736
|
-
|
|
871
|
+
await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
|
|
872
|
+
await orchestrator.crawling([], { recursive: config.recursive });
|
|
873
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
737
874
|
await archive.setUrlOrder();
|
|
738
875
|
await ignoreEnoent(unlinkFile(backupPath));
|
|
739
876
|
return orchestrator;
|
|
@@ -793,9 +930,95 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
793
930
|
log('Data %s', stubPath);
|
|
794
931
|
log('URL %s', url.href);
|
|
795
932
|
log('Config %O', config);
|
|
933
|
+
await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
|
|
796
934
|
await orchestrator.crawling([url]);
|
|
935
|
+
CrawlerOrchestrator.#finalizeCrawlSession();
|
|
797
936
|
return orchestrator;
|
|
798
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
|
+
}
|
|
799
1022
|
}
|
|
800
1023
|
/**
|
|
801
1024
|
* Await a filesystem promise but silently swallow only `ENOENT` errors. Any
|
package/lib/crawler.d.ts
CHANGED
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
export * from './utils/types/types.js';
|
|
8
8
|
export { eachSplitted } from './utils/array/each-splitted.js';
|
|
9
9
|
export { DOMEvaluationError } from './utils/error/dom-evaluation-error.js';
|
|
10
|
-
export { ErrorEmitter } from './utils/error/error-emitter.js';
|
|
11
|
-
export type { ErrorEvent } from './utils/error/error-emitter.js';
|
|
12
10
|
export * from './utils/object/clean-object.js';
|
|
13
11
|
export { globalLog as log } from './utils/debug.js';
|
|
14
12
|
export { ArchiveAccessor } from './archive/archive-accessor.js';
|
|
@@ -17,6 +15,7 @@ export { default as Page } from './archive/page.js';
|
|
|
17
15
|
export { default as ArchiveResource } from './archive/resource.js';
|
|
18
16
|
export * from './archive/types.js';
|
|
19
17
|
export { default as Archive } from './archive/archive.js';
|
|
18
|
+
export { isArchiveCacheDisabled } from './archive/cache/is-archive-cache-disabled.js';
|
|
20
19
|
export { peekArchiveLockHolder } from './archive/peek-archive-lock.js';
|
|
21
20
|
export type { ArchiveLockHolder } from './archive/peek-archive-lock.js';
|
|
22
21
|
export type { FlatPageMetaColumns, JsonLdRow, JsonLdRowForInsert, TagRow, TagRowForInsert, JsonLdSummary, TagsSummary, TagInventoryEntry, PageDenormalizedColumns, } from './archive/meta/types.js';
|
|
@@ -25,3 +24,5 @@ export { REQUIRED_FORMAT_VERSION } from './archive/meta/assert-compatible-versio
|
|
|
25
24
|
export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
|
|
26
25
|
export * from './types.js';
|
|
27
26
|
export * from './crawler/types.js';
|
|
27
|
+
export { classifyErrorKind } from './classify-error-kind.js';
|
|
28
|
+
export { computeFileSha256 } from './utils/compute-file-sha256.js';
|
package/lib/crawler.js
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
export * from './utils/types/types.js';
|
|
9
9
|
export { eachSplitted } from './utils/array/each-splitted.js';
|
|
10
10
|
export { DOMEvaluationError } from './utils/error/dom-evaluation-error.js';
|
|
11
|
-
export { ErrorEmitter } from './utils/error/error-emitter.js';
|
|
12
11
|
export * from './utils/object/clean-object.js';
|
|
13
12
|
export { globalLog as log } from './utils/debug.js';
|
|
14
13
|
// Archive
|
|
@@ -17,6 +16,7 @@ export { default as Page } from './archive/page.js';
|
|
|
17
16
|
export { default as ArchiveResource } from './archive/resource.js';
|
|
18
17
|
export * from './archive/types.js';
|
|
19
18
|
export { default as Archive } from './archive/archive.js';
|
|
19
|
+
export { isArchiveCacheDisabled } from './archive/cache/is-archive-cache-disabled.js';
|
|
20
20
|
export { peekArchiveLockHolder } from './archive/peek-archive-lock.js';
|
|
21
21
|
export { IncompatibleArchiveError } from './archive/meta/types.js';
|
|
22
22
|
export { REQUIRED_FORMAT_VERSION } from './archive/meta/assert-compatible-version.js';
|
|
@@ -24,3 +24,5 @@ export { REQUIRED_FORMAT_VERSION } from './archive/meta/assert-compatible-versio
|
|
|
24
24
|
export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
|
|
25
25
|
export * from './types.js';
|
|
26
26
|
export * from './crawler/types.js';
|
|
27
|
+
export { classifyErrorKind } from './classify-error-kind.js';
|
|
28
|
+
export { computeFileSha256 } from './utils/compute-file-sha256.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ErrorKind } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Error kinds whose failure mode is **persistent** — re-fetching the same URL
|
|
4
|
+
* (without changing the network, the certificate, the destination host, or the
|
|
5
|
+
* browser's block-list state) will almost certainly produce the same failure.
|
|
6
|
+
*
|
|
7
|
+
* Used by {@link resetFailedPages} to exclude pages whose latest recorded error
|
|
8
|
+
* falls in this set, so `--retry-failed` actually converges: without the
|
|
9
|
+
* exclusion, NXDOMAIN / TLS mismatch / `ERR_BLOCKED_BY_CLIENT` /
|
|
10
|
+
* `ECONNREFUSED` / HTTP parse-error pages would be reset to pending on every
|
|
11
|
+
* iteration, the crawler would re-attempt them, they would fail again the
|
|
12
|
+
* same way, and the retry-target count would stay constant forever.
|
|
13
|
+
*
|
|
14
|
+
* Why these five and not others:
|
|
15
|
+
* - **dns** — `ENOTFOUND` / `ERR_NAME_NOT_RESOLVED` are authoritative DNS
|
|
16
|
+
* answers; the host is gone (or never existed). EAI_AGAIN is split out as
|
|
17
|
+
* `dns-transient` precisely so it is NOT in this set.
|
|
18
|
+
* - **tls** — cert expiry / SAN mismatch / SSL protocol errors require the
|
|
19
|
+
* server operator to fix something; retrying within the same archive run
|
|
20
|
+
* cannot succeed.
|
|
21
|
+
* - **client-blocked** — Chromium's `ERR_BLOCKED_*` family is a deliberate
|
|
22
|
+
* browser-side rejection; the request will be blocked identically every
|
|
23
|
+
* time the browser sees the same URL.
|
|
24
|
+
* - **parse-error** — the server's HTTP response is malformed (`Expected
|
|
25
|
+
* HTTP/, RTSP/ or ICE/`, `Unexpected end of stream`). Retrying the same
|
|
26
|
+
* request hits the same parser failure.
|
|
27
|
+
* - **connection-refused** — `ECONNREFUSED` is an authoritative TCP RST from
|
|
28
|
+
* the listener; either no process is listening on the port or its accept
|
|
29
|
+
* queue rejected the connection. Either way the answer is final until the
|
|
30
|
+
* server operator intervenes.
|
|
31
|
+
*
|
|
32
|
+
* Notably absent (intentionally retryable):
|
|
33
|
+
* - `connection-reset` / `connection-timeout` — could be middlebox or
|
|
34
|
+
* transient overload
|
|
35
|
+
* - `dns-transient` (EAI_AGAIN / EREFUSED) — local resolver hiccup
|
|
36
|
+
* - `local-network` — operator-side connectivity loss
|
|
37
|
+
* - `timeout` — slow but reachable server (HEAD-timeout escalation gives
|
|
38
|
+
* these a real chance)
|
|
39
|
+
* - `protocol` — puppeteer lifecycle race, often recovers on retry
|
|
40
|
+
* - `unknown` — by definition we don't know it's permanent, so we keep
|
|
41
|
+
* retrying (errs on the side of investigating)
|
|
42
|
+
*/
|
|
43
|
+
export declare const PERMANENT_ERROR_KINDS: ReadonlySet<ErrorKind>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error kinds whose failure mode is **persistent** — re-fetching the same URL
|
|
3
|
+
* (without changing the network, the certificate, the destination host, or the
|
|
4
|
+
* browser's block-list state) will almost certainly produce the same failure.
|
|
5
|
+
*
|
|
6
|
+
* Used by {@link resetFailedPages} to exclude pages whose latest recorded error
|
|
7
|
+
* falls in this set, so `--retry-failed` actually converges: without the
|
|
8
|
+
* exclusion, NXDOMAIN / TLS mismatch / `ERR_BLOCKED_BY_CLIENT` /
|
|
9
|
+
* `ECONNREFUSED` / HTTP parse-error pages would be reset to pending on every
|
|
10
|
+
* iteration, the crawler would re-attempt them, they would fail again the
|
|
11
|
+
* same way, and the retry-target count would stay constant forever.
|
|
12
|
+
*
|
|
13
|
+
* Why these five and not others:
|
|
14
|
+
* - **dns** — `ENOTFOUND` / `ERR_NAME_NOT_RESOLVED` are authoritative DNS
|
|
15
|
+
* answers; the host is gone (or never existed). EAI_AGAIN is split out as
|
|
16
|
+
* `dns-transient` precisely so it is NOT in this set.
|
|
17
|
+
* - **tls** — cert expiry / SAN mismatch / SSL protocol errors require the
|
|
18
|
+
* server operator to fix something; retrying within the same archive run
|
|
19
|
+
* cannot succeed.
|
|
20
|
+
* - **client-blocked** — Chromium's `ERR_BLOCKED_*` family is a deliberate
|
|
21
|
+
* browser-side rejection; the request will be blocked identically every
|
|
22
|
+
* time the browser sees the same URL.
|
|
23
|
+
* - **parse-error** — the server's HTTP response is malformed (`Expected
|
|
24
|
+
* HTTP/, RTSP/ or ICE/`, `Unexpected end of stream`). Retrying the same
|
|
25
|
+
* request hits the same parser failure.
|
|
26
|
+
* - **connection-refused** — `ECONNREFUSED` is an authoritative TCP RST from
|
|
27
|
+
* the listener; either no process is listening on the port or its accept
|
|
28
|
+
* queue rejected the connection. Either way the answer is final until the
|
|
29
|
+
* server operator intervenes.
|
|
30
|
+
*
|
|
31
|
+
* Notably absent (intentionally retryable):
|
|
32
|
+
* - `connection-reset` / `connection-timeout` — could be middlebox or
|
|
33
|
+
* transient overload
|
|
34
|
+
* - `dns-transient` (EAI_AGAIN / EREFUSED) — local resolver hiccup
|
|
35
|
+
* - `local-network` — operator-side connectivity loss
|
|
36
|
+
* - `timeout` — slow but reachable server (HEAD-timeout escalation gives
|
|
37
|
+
* these a real chance)
|
|
38
|
+
* - `protocol` — puppeteer lifecycle race, often recovers on retry
|
|
39
|
+
* - `unknown` — by definition we don't know it's permanent, so we keep
|
|
40
|
+
* retrying (errs on the side of investigating)
|
|
41
|
+
*/
|
|
42
|
+
export const PERMANENT_ERROR_KINDS = new Set([
|
|
43
|
+
'dns',
|
|
44
|
+
'tls',
|
|
45
|
+
'client-blocked',
|
|
46
|
+
'parse-error',
|
|
47
|
+
'connection-refused',
|
|
48
|
+
]);
|