@nitpicker/crawler 0.9.0 → 0.11.0

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