@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.
Files changed (75) hide show
  1. package/lib/archive/archive.d.ts +117 -2
  2. package/lib/archive/archive.js +147 -2
  3. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  4. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  5. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  6. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  7. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  8. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  9. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  10. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  11. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  12. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  13. package/lib/archive/database.d.ts +216 -15
  14. package/lib/archive/database.js +1459 -938
  15. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  16. package/lib/archive/derive-lineage-from-parent.js +42 -0
  17. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  18. package/lib/archive/get-failed-page-messages.js +131 -0
  19. package/lib/archive/init-schema.js +153 -1
  20. package/lib/archive/is-inventory-source.d.ts +21 -0
  21. package/lib/archive/is-inventory-source.js +22 -0
  22. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  23. package/lib/archive/migrate-inventory-runs.js +52 -0
  24. package/lib/archive/types.d.ts +33 -0
  25. package/lib/classify-error-kind.d.ts +19 -0
  26. package/lib/classify-error-kind.js +122 -0
  27. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  28. package/lib/crawler/build-js-redirect-edge.js +57 -0
  29. package/lib/crawler/build-redirect-event.d.ts +24 -0
  30. package/lib/crawler/build-redirect-event.js +28 -0
  31. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  32. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  33. package/lib/crawler/crawler.d.ts +3 -1
  34. package/lib/crawler/crawler.js +655 -107
  35. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  36. package/lib/crawler/derive-js-redirect-target.js +129 -0
  37. package/lib/crawler/derive-resource-source.d.ts +25 -15
  38. package/lib/crawler/derive-resource-source.js +28 -17
  39. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  40. package/lib/crawler/dns-burned-host-cache.js +25 -0
  41. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  42. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  43. package/lib/crawler/fetch-destination.d.ts +12 -4
  44. package/lib/crawler/fetch-destination.js +94 -16
  45. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  46. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  47. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  48. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  49. package/lib/crawler/link-list.d.ts +21 -1
  50. package/lib/crawler/link-list.js +23 -3
  51. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  52. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  53. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  54. package/lib/crawler/preload-short-circuit-error.js +25 -0
  55. package/lib/crawler/should-burn-host.d.ts +78 -0
  56. package/lib/crawler/should-burn-host.js +61 -0
  57. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  58. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  59. package/lib/crawler/types.d.ts +107 -0
  60. package/lib/crawler-orchestrator.d.ts +13 -3
  61. package/lib/crawler-orchestrator.js +292 -69
  62. package/lib/crawler.d.ts +3 -2
  63. package/lib/crawler.js +3 -1
  64. package/lib/permanent-error-kinds.d.ts +43 -0
  65. package/lib/permanent-error-kinds.js +48 -0
  66. package/lib/types.d.ts +84 -0
  67. package/lib/utils/compute-file-sha256.d.ts +23 -0
  68. package/lib/utils/compute-file-sha256.js +55 -0
  69. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  70. package/lib/utils/error/emit-error-with-retry.js +44 -0
  71. package/lib/utils/error/emit-error.d.ts +39 -0
  72. package/lib/utils/error/emit-error.js +41 -0
  73. package/package.json +11 -11
  74. package/lib/utils/error/error-emitter.d.ts +0 -18
  75. 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 { fetchDestination } from './crawler/fetch-destination.js';
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 { isHtmlContentType } from './crawler/is-html-content-type.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';
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. The first URL is used as the root.
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
- clearDestinationCache();
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
- clearDestinationCache();
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 { scraped, pending } = await archive.getCrawlingState();
517
+ const { pending } = await archive.getCrawlingState();
487
518
  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'.`);
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
- 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 };
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
- catch (headError) {
560
- const error = headError instanceof Error ? headError : new Error(String(headError));
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 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
- });
650
+ for (const url of rawHtmlSeeds) {
651
+ const key = protocolAgnosticKey(url.withoutHashAndAuth);
652
+ if (seenKeys.has(key)) {
579
653
  continue;
580
654
  }
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
- }
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
- // 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);
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 orchestrator.crawling(htmlSeeds, { recursive: true });
631
- clearDestinationCache();
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 orchestrator.crawling(rootsParsed, { recursive: config.recursive });
736
- clearDestinationCache();
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
+ ]);