@nitpicker/crawler 0.18.1 → 0.19.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 (138) hide show
  1. package/lib/append-retry-failed-common-setup-phases.d.ts +15 -0
  2. package/lib/append-retry-failed-common-setup-phases.js +21 -0
  3. package/lib/append-setup-phases.d.ts +17 -0
  4. package/lib/append-setup-phases.js +22 -0
  5. package/lib/archive/archive-accessor.d.ts +31 -5
  6. package/lib/archive/archive-accessor.js +46 -24
  7. package/lib/archive/archive.d.ts +115 -37
  8. package/lib/archive/archive.js +118 -52
  9. package/lib/archive/cache/extract-archive-to-cache.d.ts +10 -1
  10. package/lib/archive/cache/extract-archive-to-cache.js +14 -4
  11. package/lib/archive/create-adjunct-tables.d.ts +12 -2
  12. package/lib/archive/create-adjunct-tables.js +91 -29
  13. package/lib/archive/create-entity-tables.d.ts +5 -1
  14. package/lib/archive/create-entity-tables.js +6 -1
  15. package/lib/archive/database.d.ts +39 -9
  16. package/lib/archive/database.js +63 -18
  17. package/lib/archive/db-ops/lifecycle/init.d.ts +14 -2
  18. package/lib/archive/db-ops/lifecycle/init.js +29 -11
  19. package/lib/archive/db-ops/meta/get-custom-elements-of-page.d.ts +11 -0
  20. package/lib/archive/db-ops/meta/get-custom-elements-of-page.js +15 -0
  21. package/lib/archive/db-ops/meta/get-page-technologies-of-page.d.ts +11 -0
  22. package/lib/archive/db-ops/meta/get-page-technologies-of-page.js +15 -0
  23. package/lib/archive/db-ops/meta/get-technology-signals-of-page.d.ts +11 -0
  24. package/lib/archive/db-ops/meta/get-technology-signals-of-page.js +15 -0
  25. package/lib/archive/db-ops/pages/order/set-url-order.d.ts +6 -1
  26. package/lib/archive/db-ops/pages/order/set-url-order.js +7 -1
  27. package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
  28. package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +6 -1
  29. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +12 -3
  30. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +6 -1
  31. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +10 -2
  32. package/lib/archive/db-ops/pages/write/insert-custom-elements.d.ts +27 -0
  33. package/lib/archive/db-ops/pages/write/insert-custom-elements.js +40 -0
  34. package/lib/archive/db-ops/pages/write/insert-page.d.ts +4 -4
  35. package/lib/archive/db-ops/pages/write/insert-page.js +3 -2
  36. package/lib/archive/db-ops/pages/write/insert-technologies.d.ts +25 -0
  37. package/lib/archive/db-ops/pages/write/insert-technologies.js +45 -0
  38. package/lib/archive/db-ops/pages/write/update-page.d.ts +4 -1
  39. package/lib/archive/db-ops/pages/write/update-page.js +38 -16
  40. package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +12 -1
  41. package/lib/archive/db-ops/resources/get-resource-url-list.js +43 -5
  42. package/lib/archive/filesystem/copy-file-with-progress.d.ts +20 -0
  43. package/lib/archive/filesystem/copy-file-with-progress.js +34 -0
  44. package/lib/archive/filesystem/parse-pax-path.d.ts +18 -0
  45. package/lib/archive/filesystem/parse-pax-path.js +47 -0
  46. package/lib/archive/filesystem/parse-tar-size-field.d.ts +13 -0
  47. package/lib/archive/filesystem/parse-tar-size-field.js +40 -0
  48. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +10 -3
  49. package/lib/archive/filesystem/peek-tar-top-dir.js +166 -16
  50. package/lib/archive/filesystem/tar.d.ts +13 -1
  51. package/lib/archive/filesystem/tar.js +63 -7
  52. package/lib/archive/filesystem/untar.d.ts +13 -0
  53. package/lib/archive/filesystem/untar.js +68 -8
  54. package/lib/archive/init-schema.d.ts +1 -1
  55. package/lib/archive/init-schema.js +6 -4
  56. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +13 -2
  57. package/lib/archive/meta/compute-main-contents-denormalized.js +15 -2
  58. package/lib/archive/meta/technologies/combine-technology-confidence.d.ts +46 -0
  59. package/lib/archive/meta/technologies/combine-technology-confidence.js +93 -0
  60. package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.d.ts +45 -0
  61. package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.js +53 -0
  62. package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.d.ts +33 -0
  63. package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.js +46 -0
  64. package/lib/archive/meta/technologies/extract-technologies-for-archive.d.ts +30 -0
  65. package/lib/archive/meta/technologies/extract-technologies-for-archive.js +27 -0
  66. package/lib/archive/meta/technologies/match-structural-technology-signals.d.ts +13 -0
  67. package/lib/archive/meta/technologies/match-structural-technology-signals.js +44 -0
  68. package/lib/archive/meta/technologies/normalize-wappalyzer-entries.d.ts +25 -0
  69. package/lib/archive/meta/technologies/normalize-wappalyzer-entries.js +35 -0
  70. package/lib/archive/meta/technologies/technology-signal-definitions.d.ts +43 -0
  71. package/lib/archive/meta/technologies/technology-signal-definitions.js +193 -0
  72. package/lib/archive/meta/technologies/types.d.ts +68 -0
  73. package/lib/archive/meta/technologies/types.js +13 -0
  74. package/lib/archive/meta/types.d.ts +97 -30
  75. package/lib/archive/migrate-content-items-alias-of-id.d.ts +6 -1
  76. package/lib/archive/migrate-content-items-alias-of-id.js +14 -3
  77. package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +6 -1
  78. package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +14 -3
  79. package/lib/archive/migrate-info-main-content-selector.d.ts +6 -1
  80. package/lib/archive/migrate-info-main-content-selector.js +14 -3
  81. package/lib/archive/migrate-info-roots.d.ts +6 -1
  82. package/lib/archive/migrate-info-roots.js +14 -3
  83. package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +6 -1
  84. package/lib/archive/migrate-inventory-runs-exclude-skipped.js +14 -3
  85. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +6 -1
  86. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +14 -3
  87. package/lib/archive/migrate-main-contents-columns.d.ts +6 -1
  88. package/lib/archive/migrate-main-contents-columns.js +14 -3
  89. package/lib/archive/migrate-page-meta-body-hash.d.ts +6 -1
  90. package/lib/archive/migrate-page-meta-body-hash.js +14 -3
  91. package/lib/archive/migrate-page-meta-console-error-count.d.ts +6 -1
  92. package/lib/archive/migrate-page-meta-console-error-count.js +14 -3
  93. package/lib/archive/migrate-page-meta-custom-element-count.d.ts +33 -0
  94. package/lib/archive/migrate-page-meta-custom-element-count.js +51 -0
  95. package/lib/archive/migrate-page-tags-to-page-technologies.d.ts +39 -0
  96. package/lib/archive/migrate-page-tags-to-page-technologies.js +79 -0
  97. package/lib/archive/page.d.ts +37 -13
  98. package/lib/archive/page.js +45 -17
  99. package/lib/archive/retarget-legacy-fk-tables.d.ts +27 -14
  100. package/lib/archive/retarget-legacy-fk-tables.js +61 -15
  101. package/lib/archive/types.d.ts +18 -0
  102. package/lib/crawler/assert-puppeteer-shared-with-beholder.d.ts +24 -0
  103. package/lib/crawler/assert-puppeteer-shared-with-beholder.js +36 -0
  104. package/lib/crawler/capture-custom-elements.d.ts +33 -0
  105. package/lib/crawler/capture-custom-elements.js +39 -0
  106. package/lib/crawler/close-browser-safely.d.ts +3 -3
  107. package/lib/crawler/close-browser-safely.js +8 -17
  108. package/lib/crawler/collect-custom-elements.d.ts +33 -0
  109. package/lib/crawler/collect-custom-elements.js +110 -0
  110. package/lib/crawler/crawler.js +101 -46
  111. package/lib/crawler/fetch-destination.js +6 -13
  112. package/lib/crawler/find-package-dir.d.ts +24 -0
  113. package/lib/crawler/find-package-dir.js +39 -0
  114. package/lib/crawler/scan-js-resource-for-license-comment.d.ts +32 -0
  115. package/lib/crawler/scan-js-resource-for-license-comment.js +140 -0
  116. package/lib/crawler/scan-js-resources-for-technology-signals.d.ts +58 -0
  117. package/lib/crawler/scan-js-resources-for-technology-signals.js +196 -0
  118. package/lib/crawler/types.d.ts +23 -0
  119. package/lib/crawler-orchestrator.d.ts +53 -9
  120. package/lib/crawler-orchestrator.js +223 -46
  121. package/lib/crawler.d.ts +11 -1
  122. package/lib/crawler.js +9 -0
  123. package/lib/inventory-setup-phases.d.ts +32 -0
  124. package/lib/inventory-setup-phases.js +45 -0
  125. package/lib/resume-setup-phases.d.ts +16 -0
  126. package/lib/resume-setup-phases.js +24 -0
  127. package/lib/retry-failed-setup-phases.d.ts +19 -0
  128. package/lib/retry-failed-setup-phases.js +24 -0
  129. package/lib/setup-recovery-phase-labels.d.ts +21 -0
  130. package/lib/setup-recovery-phase-labels.js +24 -0
  131. package/lib/types.d.ts +145 -0
  132. package/package.json +11 -8
  133. package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +0 -12
  134. package/lib/archive/db-ops/meta/get-tags-of-page.js +0 -28
  135. package/lib/archive/db-ops/pages/write/insert-tags.d.ts +0 -16
  136. package/lib/archive/db-ops/pages/write/insert-tags.js +0 -34
  137. package/lib/archive/meta/summarize-tags.d.ts +0 -16
  138. package/lib/archive/meta/summarize-tags.js +0 -33
@@ -1,10 +1,12 @@
1
- import { copyFile, unlink as unlinkFile } from 'node:fs/promises';
1
+ import { unlink as unlinkFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
4
4
  import { sortUrl } from '@d-zero/shared/sort-url';
5
5
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
6
6
  import pkg from '../package.json' with { type: 'json' };
7
+ import { APPEND_SETUP_PHASES } from './append-setup-phases.js';
7
8
  import Archive from './archive/archive.js';
9
+ import { copyFileWithProgress } from './archive/filesystem/copy-file-with-progress.js';
8
10
  import { REQUIRED_FORMAT_VERSION } from './archive/meta/assert-compatible-version.js';
9
11
  import { clearDestinationCache } from './crawler/clear-destination-cache.js';
10
12
  import { clearDnsBurnedHostCache } from './crawler/clear-dns-burned-host-cache.js';
@@ -18,11 +20,16 @@ import { PreloadShortCircuitError } from './crawler/preload-short-circuit-error.
18
20
  import { protocolAgnosticKey } from './crawler/protocol-agnostic-key.js';
19
21
  import { shouldSkipUrl } from './crawler/should-skip-url.js';
20
22
  import { crawlerLog, log } from './debug.js';
23
+ import { INVENTORY_SETUP_PHASES } from './inventory-setup-phases.js';
21
24
  import { normalizeToArray } from './normalize-to-array.js';
22
25
  import { resolveOutputPath } from './resolve-output-path.js';
23
26
  import { resourceRowToLookupResult } from './resource-row-to-lookup-result.js';
27
+ import { RESUME_SETUP_PHASES } from './resume-setup-phases.js';
28
+ import { RETRY_FAILED_SETUP_PHASES } from './retry-failed-setup-phases.js';
29
+ import { SETUP_RECOVERY_PHASE_LABELS } from './setup-recovery-phase-labels.js';
24
30
  import { cleanObject } from './utils/object/clean-object.js';
25
31
  import { WriteQueue } from './write-queue.js';
32
+ const [RECOVERY_RESTORE_FROM_BACKUP, RECOVERY_PERSIST_INGESTED_STATE] = SETUP_RECOVERY_PHASE_LABELS;
26
33
  /**
27
34
  * Default list of external URL prefixes excluded from crawling.
28
35
  * Includes social media sharing endpoints that are commonly linked
@@ -53,10 +60,13 @@ export const DEFAULT_EXCLUDED_EXTERNAL_URLS = [
53
60
  * writes the final archive file. It emits events defined by {@link CrawlEvent}.
54
61
  *
55
62
  * Instances are created via the static factory methods {@link CrawlerOrchestrator.crawling}
56
- * or {@link CrawlerOrchestrator.resume}; the constructor is private.
63
+ * or {@link CrawlerOrchestrator.resume}; the constructor is private. Implements
64
+ * `Symbol.asyncDispose` so callers can use `await using` to close the archive
65
+ * and reap zombie Chromium processes on scope exit instead of a manual
66
+ * `try`/`finally` around `archive.close()` + `garbageCollect()`.
57
67
  * @example
58
68
  * ```ts
59
- * const orchestrator = await CrawlerOrchestrator.crawling(['https://example.com'], { recursive: true });
69
+ * await using orchestrator = await CrawlerOrchestrator.crawling(['https://example.com'], { recursive: true });
60
70
  * await orchestrator.write();
61
71
  * ```
62
72
  */
@@ -177,6 +187,36 @@ export class CrawlerOrchestrator extends EventEmitter {
177
187
  preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
178
188
  });
179
189
  }
190
+ /**
191
+ * Enables `await using orchestrator = ...`. Closes the archive (write
192
+ * or remove tmpDir + release the lock, per {@link Archive.close}) and
193
+ * then reaps any zombie Chromium processes via {@link garbageCollect} —
194
+ * the same two-step teardown every CLI crawl command previously
195
+ * repeated by hand in a `finally` block.
196
+ *
197
+ * Relays `Archive.close()`'s recovery-write progress (issue #294) as
198
+ * `recoveringArchiveWrite`/`writeStep`/`writeTarProgress` — the same
199
+ * events `write()` emits — for the rare case where the file doesn't
200
+ * exist on disk yet at dispose time (e.g. an explicit `write()` call
201
+ * threw partway through). A CLI listener whose display is still open at
202
+ * that point (it hadn't yet seen `writeFileEnd`) picks these up for
203
+ * free; one that already tore down after the earlier failure silently
204
+ * drops them, same as any other post-close display update.
205
+ */
206
+ async [Symbol.asyncDispose]() {
207
+ await this.#archive.close({
208
+ onRecoveryStart: () => {
209
+ void this.emit('recoveringArchiveWrite', {});
210
+ },
211
+ onStep: (step) => {
212
+ void this.emit('writeStep', { step });
213
+ },
214
+ onTarProgress: (writtenBytes, totalBytes) => {
215
+ void this.emit('writeTarProgress', { writtenBytes, totalBytes });
216
+ },
217
+ });
218
+ this.garbageCollect();
219
+ }
180
220
  /**
181
221
  * Abort the current crawl operation.
182
222
  *
@@ -228,9 +268,9 @@ export class CrawlerOrchestrator extends EventEmitter {
228
268
  .catch((writeError) => reject(writeError));
229
269
  void this.emit('error', error);
230
270
  });
231
- this.#crawler.on('page', ({ result, source }) => {
271
+ this.#crawler.on('page', ({ result, source, bodyHash }) => {
232
272
  writeQueue
233
- .enqueue(() => this.#archive.setPage(result, source))
273
+ .enqueue(() => this.#archive.setPage(result, source, bodyHash))
234
274
  .catch((error) => reject(error));
235
275
  });
236
276
  this.#crawler.on('externalPage', ({ result, source }) => {
@@ -256,8 +296,13 @@ export class CrawlerOrchestrator extends EventEmitter {
256
296
  });
257
297
  this.#crawler.on('networkOutageConfirmed', ({ startedAt, detectedAt, probeHost, triggerErrorCount, triggerHostCount }) => {
258
298
  crawlerLog('Network outage confirmed: probeHost=%s triggerErrorCount=%d triggerHostCount=%d', probeHost, triggerErrorCount, triggerHostCount);
259
- // event notice; mirrors `#finalizeCrawlSession`'s unconditional
260
- // `console.error` for the DNS-burn short-circuit summary.
299
+ // Rare anomaly notice, printed unconditionally even though this
300
+ // fires while `deal()`'s own crawl-time `Lanes` is actively
301
+ // rendering (issue #294: unlike the crawl-tail notices reported
302
+ // via the `crawlSessionNotice` event, this one has no
303
+ // listener-based route available mid-crawl — visibility during
304
+ // the outage takes priority over the display glitch this
305
+ // causes).
261
306
  console.error(`[network] outage suspected — pausing workers (probe host: ${probeHost ?? 'none'})`);
262
307
  writeQueue
263
308
  .enqueue(async () => {
@@ -348,6 +393,13 @@ export class CrawlerOrchestrator extends EventEmitter {
348
393
  .catch((error) => reject(error));
349
394
  });
350
395
  this.#crawler.on('crawlEnd', () => {
396
+ // Read BEFORE enqueuing the dedupeCap-finalize closure below
397
+ // (issue #294) so this reflects genuine backlog from the
398
+ // crawl's own page/resource writes, not the finalize
399
+ // closure's own, always-present entry.
400
+ if (writeQueue.pending > 0) {
401
+ void this.emit('flushingPendingWrites', { pending: writeQueue.pending });
402
+ }
351
403
  // Deferred to INSIDE a queued closure, not read synchronously
352
404
  // here, for the same reason `networkOutageRecovered`'s handler
353
405
  // defers reading `#openNetworkOutageId`: a `dedupeCap` event's
@@ -439,14 +491,38 @@ export class CrawlerOrchestrator extends EventEmitter {
439
491
  * the time `write()` is called those tables are already populated.
440
492
  * This method just tars.
441
493
  *
442
- * Emits `writeFileStart` before writing and `writeFileEnd` after
443
- * the write completes successfully.
494
+ * Emits `writeFileStart` before writing and `writeFileEnd` after the
495
+ * write completes successfully. Also relays `Archive.write()`'s
496
+ * per-step (`writeStep`) and tar-byte (`writeTarProgress`) progress
497
+ * (issue #294) — tarring a 15 GB+ archive can take minutes, and without
498
+ * these events a CLI listener has no way to show it isn't hung.
444
499
  */
445
500
  async write() {
446
501
  void this.emit('writeFileStart', { filePath: this.#archive.filePath });
447
- await this.#archive.write();
502
+ await this.#archive.write({
503
+ onStep: (step) => {
504
+ void this.emit('writeStep', { step });
505
+ },
506
+ onTarProgress: (writtenBytes, totalBytes) => {
507
+ void this.emit('writeTarProgress', { writtenBytes, totalBytes });
508
+ },
509
+ });
448
510
  void this.emit('writeFileEnd', { filePath: this.#archive.filePath });
449
511
  }
512
+ /**
513
+ * Assign natural URL sort order to every internal page, relaying chunk
514
+ * progress through the `sortingUrls` event (issue #294). Always runs
515
+ * after `crawling()` has returned, i.e. once `initializedCallback` has
516
+ * already had a chance to attach listeners — unlike the setup-phase
517
+ * work in `append`/`inventory`/`retryFailed`, this has an orchestrator
518
+ * instance to emit from, so it goes through the event emitter rather
519
+ * than a `SetupProgressCallbacks` callback.
520
+ */
521
+ async #setUrlOrder() {
522
+ await this.#archive.setUrlOrder((processed, total) => {
523
+ void this.emit('sortingUrls', { processed, total });
524
+ });
525
+ }
450
526
  /**
451
527
  * Create a new CrawlerOrchestrator instance and start crawling the given URLs.
452
528
  *
@@ -523,9 +599,9 @@ export class CrawlerOrchestrator extends EventEmitter {
523
599
  log('Config %O', config);
524
600
  await orchestrator.crawling(list);
525
601
  log('Crawling completed');
526
- CrawlerOrchestrator.#finalizeCrawlSession();
602
+ CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
527
603
  log('Set order natural URL sort');
528
- await archive.setUrlOrder();
604
+ await orchestrator.#setUrlOrder();
529
605
  log('Sorting done');
530
606
  return orchestrator;
531
607
  }
@@ -546,10 +622,15 @@ export class CrawlerOrchestrator extends EventEmitter {
546
622
  * @param newUrls - New root URLs to add and crawl.
547
623
  * @param options - Optional config overrides applied on top of the archived config.
548
624
  * @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
625
+ * @param setupProgress - Optional progress callbacks for the setup phase
626
+ * (untar, `.bak` copy, repromote, state rebuild) that runs before
627
+ * `initializedCallback` — see {@link SetupProgressCallbacks} for why
628
+ * this can't go through the orchestrator's event emitter (issue #294).
549
629
  * @returns The orchestrator instance after the append crawl completes.
550
630
  * @throws {Error} When `newUrls` is empty, the archive is in list mode, or it cannot be parsed.
551
631
  */
552
- static async append(archivePath, newUrls, options, initializedCallback) {
632
+ static async append(archivePath, newUrls, options, initializedCallback, setupProgress) {
633
+ const [PHASE_EXTRACTING, PHASE_LOADING_CONFIG, PHASE_BACKING_UP, PHASE_REPROMOTING, PHASE_LOADING_DEDUPE_KEYS, PHASE_LOADING_CRAWL_STATE, PHASE_LOADING_RESOURCES, PHASE_LOADING_SCRAPED_COUNT, PHASE_RESTORING_CRAWL_STATE,] = APPEND_SETUP_PHASES;
553
634
  if (newUrls.length === 0) {
554
635
  throw new Error('append: newUrls is empty');
555
636
  }
@@ -559,16 +640,20 @@ export class CrawlerOrchestrator extends EventEmitter {
559
640
  : path.resolve(cwd, archivePath);
560
641
  // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
561
642
  // on every writer path that calls `write()`.
643
+ setupProgress?.onPhase?.(PHASE_EXTRACTING);
562
644
  const archive = await Archive.open({
563
645
  filePath: absFilePath,
564
646
  cwd,
565
647
  openPluginData: true,
648
+ onExtractProgress: setupProgress?.onExtractProgress,
649
+ onLog: setupProgress?.onLog,
566
650
  });
567
651
  // Any throw between here and the successful return must release the
568
652
  // archive lock and clean up tmpDir; the caller's `close()` only runs on
569
653
  // the happy path. Errors from `close()` itself are intentionally
570
654
  // best-effort: the original error is what matters.
571
655
  try {
656
+ setupProgress?.onPhase?.(PHASE_LOADING_CONFIG);
572
657
  const archived = await archive.getConfig();
573
658
  if (archived.fromList) {
574
659
  throw new Error('Cannot append to a list-mode archive: this archive was created with --list/--list-file and contains metadata-only pages. Create a fresh archive instead.');
@@ -588,7 +673,8 @@ export class CrawlerOrchestrator extends EventEmitter {
588
673
  baseUrl: mergedRoots[0],
589
674
  };
590
675
  const backupPath = absFilePath + '.bak';
591
- await copyFile(absFilePath, backupPath);
676
+ setupProgress?.onPhase?.(PHASE_BACKING_UP);
677
+ await copyFileWithProgress(absFilePath, backupPath, setupProgress?.onCopyProgress);
592
678
  try {
593
679
  await archive.updateConfig(mergedConfig);
594
680
  const scopeMap = new Map();
@@ -599,19 +685,25 @@ export class CrawlerOrchestrator extends EventEmitter {
599
685
  const existing = scopeMap.get(parsed.hostname) ?? [];
600
686
  scopeMap.set(parsed.hostname, [...existing, parsed]);
601
687
  }
602
- await archive.repromoteExternalPages(scopeMap, archived);
688
+ setupProgress?.onPhase?.(PHASE_REPROMOTING);
689
+ await archive.repromoteExternalPages(scopeMap, archived, setupProgress?.onChunkProgress);
603
690
  // Seed the sticky set from prior sessions' confirmed traps so
604
691
  // `--append` does not pay the cost of re-discovering them (see
605
692
  // `DedupeCapTracker`'s constructor JSDoc).
693
+ setupProgress?.onPhase?.(PHASE_LOADING_DEDUPE_KEYS);
606
694
  const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
607
695
  const orchestrator = new CrawlerOrchestrator(archive, {
608
696
  ...mergedConfig,
609
697
  roots: mergedRoots,
610
698
  preloadedStickyShapeKeys,
611
699
  });
700
+ setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE);
612
701
  const { scraped, pending } = await archive.getCrawlingState();
613
- const resources = await archive.getResourceUrlList();
702
+ setupProgress?.onPhase?.(PHASE_LOADING_RESOURCES);
703
+ const resources = await archive.getResourceUrlList(setupProgress?.onChunkProgress);
704
+ setupProgress?.onPhase?.(PHASE_LOADING_SCRAPED_COUNT);
614
705
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
706
+ setupProgress?.onPhase?.(PHASE_RESTORING_CRAWL_STATE);
615
707
  orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
616
708
  if (initializedCallback) {
617
709
  await initializedCallback(orchestrator, mergedConfig);
@@ -622,14 +714,15 @@ export class CrawlerOrchestrator extends EventEmitter {
622
714
  log('Merged roots %O', mergedRoots);
623
715
  await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
624
716
  await orchestrator.crawling(newParsed);
625
- CrawlerOrchestrator.#finalizeCrawlSession();
626
- await archive.setUrlOrder();
717
+ CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
718
+ await orchestrator.#setUrlOrder();
627
719
  await ignoreEnoent(unlinkFile(backupPath));
628
720
  return orchestrator;
629
721
  }
630
722
  catch (error) {
631
723
  try {
632
- await copyFile(backupPath, absFilePath);
724
+ setupProgress?.onPhase?.(RECOVERY_RESTORE_FROM_BACKUP);
725
+ await copyFileWithProgress(backupPath, absFilePath, setupProgress?.onCopyProgress);
633
726
  await ignoreEnoent(unlinkFile(backupPath));
634
727
  }
635
728
  catch (restoreError) {
@@ -723,10 +816,16 @@ export class CrawlerOrchestrator extends EventEmitter {
723
816
  * URLs. Pass `null` for programmatic callers that built
724
817
  * `inventoryUrls` in-memory; the audit row's `source_file_sha256`
725
818
  * column will be `NULL` and no source list is archived.
819
+ * @param setupProgress - Optional progress callbacks for the setup phase
820
+ * (untar, scope classification, bulk inserts, state rebuild) that runs
821
+ * before `initializedCallback` — see {@link SetupProgressCallbacks} for
822
+ * why this can't go through the orchestrator's event emitter (issue
823
+ * #294).
726
824
  * @returns The orchestrator instance after a successful inventory pass.
727
825
  * @throws {Error} When `inventoryUrls` is empty or the archive is in list mode. Unresolved pending URLs from a previous crawl do NOT throw — see step 3.
728
826
  */
729
- static async inventory(archivePath, inventoryUrls, options, initializedCallback, source = null) {
827
+ static async inventory(archivePath, inventoryUrls, options, initializedCallback, source = null, setupProgress) {
828
+ const [PHASE_EXTRACTING, PHASE_LOADING_CONFIG, PHASE_LOADING_CRAWL_STATE_PRE, PHASE_CHECKING_KNOWN_URLS, PHASE_BACKING_UP, PHASE_RECORDING_NON_HTML, PHASE_RECORDING_HTML_SEEDS, PHASE_RECORDING_EXCLUDED, PHASE_LOADING_CRAWL_STATE_POST, PHASE_LOADING_RESOURCES, PHASE_LOADING_SCRAPED_COUNT, PHASE_RESTORING_CRAWL_STATE,] = INVENTORY_SETUP_PHASES;
730
829
  if (inventoryUrls.length === 0) {
731
830
  throw new Error('inventory: URL list is empty');
732
831
  }
@@ -736,16 +835,21 @@ export class CrawlerOrchestrator extends EventEmitter {
736
835
  : path.resolve(cwd, archivePath);
737
836
  // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
738
837
  // on every writer path that calls `write()`.
838
+ setupProgress?.onPhase?.(PHASE_EXTRACTING);
739
839
  const archive = await Archive.open({
740
840
  filePath: absFilePath,
741
841
  cwd,
742
842
  openPluginData: true,
843
+ onExtractProgress: setupProgress?.onExtractProgress,
844
+ onLog: setupProgress?.onLog,
743
845
  });
744
846
  try {
847
+ setupProgress?.onPhase?.(PHASE_LOADING_CONFIG);
745
848
  const archived = await archive.getConfig();
746
849
  if (archived.fromList) {
747
850
  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.');
748
851
  }
852
+ setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE_PRE);
749
853
  const { pending } = await archive.getCrawlingState();
750
854
  if (pending.length > 0) {
751
855
  // `getCrawlingState` returns the STRICT pending set — in-scope,
@@ -759,8 +863,28 @@ export class CrawlerOrchestrator extends EventEmitter {
759
863
  // enough — the inventory pass continues and the crawled-wins
760
864
  // source priority keeps stale labels stable even if some of
761
865
  // the strict-pending rows happen to land on inventory seeds.
762
- // eslint-disable-next-line no-console -- operator-facing warning, must be visible regardless of DEBUG filters
763
- 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.`);
866
+ //
867
+ // Routed through `setupProgress.onLog` (issue #294 code
868
+ // review), not a bare `console.warn`: the `'Loading crawl
869
+ // state'` row is active at this exact point (`onPhase` just
870
+ // above), and a direct stream write here corrupts the setup
871
+ // `TaskList`'s cursor tracking the same way self-healing
872
+ // migration notices did. `onLog` sets this row's message,
873
+ // which then freezes as its permanent `done` text once the
874
+ // next `onPhase` call moves past it — still visible in the
875
+ // final terminal output, just not printed as its own
876
+ // interrupting line. Falls back to `console.warn` under
877
+ // `--silent` (`setupProgress` is `null` there, so there is no
878
+ // row to set this on, and the warning would otherwise never
879
+ // surface at all).
880
+ const message = `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.`;
881
+ if (setupProgress?.onLog) {
882
+ setupProgress.onLog(message);
883
+ }
884
+ else {
885
+ // eslint-disable-next-line no-console -- --silent has no TaskList row to report through
886
+ console.warn(message);
887
+ }
764
888
  }
765
889
  // Archive the exact source bytes before scope classification, so
766
890
  // even a run that discards every URL (out-of-scope or already
@@ -800,6 +924,7 @@ export class CrawlerOrchestrator extends EventEmitter {
800
924
  // to mirror what `resolveContentItemId` / `insertResource` actually store.
801
925
  // Two independent reads — Promise.all halves the wait on large
802
926
  // archives where each `WHERE url IN (?)` chunk costs real I/O.
927
+ setupProgress?.onPhase?.(PHASE_CHECKING_KNOWN_URLS);
803
928
  const candidateUrls = inScope.map((u) => u.withoutHashAndAuth);
804
929
  const [existingPageUrlList, existingResourceUrlList] = await Promise.all([
805
930
  archive.getExistingPageUrls(candidateUrls),
@@ -860,7 +985,8 @@ export class CrawlerOrchestrator extends EventEmitter {
860
985
  return orchestrator;
861
986
  }
862
987
  const backupPath = absFilePath + '.bak';
863
- await copyFile(absFilePath, backupPath);
988
+ setupProgress?.onPhase?.(PHASE_BACKING_UP);
989
+ await copyFileWithProgress(absFilePath, backupPath, setupProgress?.onCopyProgress);
864
990
  // Ingestion (pre-insert + audit) is `.bak`-protected — a failure
865
991
  // there restores the archive and the operator reruns. Once
866
992
  // ingestion completes and the `.bak` is released, the scrape
@@ -934,6 +1060,7 @@ export class CrawlerOrchestrator extends EventEmitter {
934
1060
  // inside the `.bak`-protected window on large inventory
935
1061
  // lists; the chunked bulk path collapses N round-trips
936
1062
  // to N/500.
1063
+ setupProgress?.onPhase?.(PHASE_RECORDING_NON_HTML);
937
1064
  await archive.insertInventoryResources(nonHtmlSeeds);
938
1065
  // Pre-insert HTML seeds as `scraped = 0`,
939
1066
  // `source = 'inventory-seed'` placeholders *before* the
@@ -941,6 +1068,7 @@ export class CrawlerOrchestrator extends EventEmitter {
941
1068
  // cannot lose the URL. The strict-pending set picks
942
1069
  // these rows up on the next `--resume` via the
943
1070
  // `OR p.source != 'crawled'` clause.
1071
+ setupProgress?.onPhase?.(PHASE_RECORDING_HTML_SEEDS);
944
1072
  await archive.insertInventorySeeds(htmlSeeds);
945
1073
  // Record exclude-matched novel URLs as terminal skipped pages
946
1074
  // (`is_skipped=1`, `skip_reason='excluded'`,
@@ -949,6 +1077,7 @@ export class CrawlerOrchestrator extends EventEmitter {
949
1077
  // excluded URLs, so the archive looks identical no matter
950
1078
  // how the URL was discovered. Inside the `.bak` window for
951
1079
  // the same all-or-nothing reason as the seed inserts above.
1080
+ setupProgress?.onPhase?.(PHASE_RECORDING_EXCLUDED);
952
1081
  await archive.insertInventorySkippedPages(excludedNovelUrls);
953
1082
  log('[inventory] %d HTML seed(s), %d non-HTML resource(s), %d skipped page(s) recorded', htmlSeeds.length, nonHtmlSeeds.length, excludedNovelUrls.length);
954
1083
  // Audit row is written *inside* the `.bak` window: a libsql
@@ -1015,15 +1144,19 @@ export class CrawlerOrchestrator extends EventEmitter {
1015
1144
  // `retryFailed` uses to drive the dealer from the
1016
1145
  // pending set alone (see retryFailed's
1017
1146
  // `crawling([], { recursive })` invocation).
1147
+ setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE_POST);
1018
1148
  const { scraped: scrapedAfter, pending: pendingAfter } = await archive.getCrawlingState();
1019
- const resources = await archive.getResourceUrlList();
1149
+ setupProgress?.onPhase?.(PHASE_LOADING_RESOURCES);
1150
+ const resources = await archive.getResourceUrlList(setupProgress?.onChunkProgress);
1020
1151
  // Pre-existing rendered HTML page count seeds the
1021
1152
  // session-spanning `pagesScraped` counter so the progress
1022
1153
  // header reads `internalDone(cumulative pagesScraped)`
1023
1154
  // rather than session-only — matches the `append` /
1024
1155
  // `retryFailed` / `resume` paths and avoids users reading
1025
1156
  // the parenthesised number as "inner pages dropped to N".
1157
+ setupProgress?.onPhase?.(PHASE_LOADING_SCRAPED_COUNT);
1026
1158
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
1159
+ setupProgress?.onPhase?.(PHASE_RESTORING_CRAWL_STATE);
1027
1160
  orchestrator.#crawler.resume(pendingAfter, scrapedAfter, resources, pagesScrapedOffset);
1028
1161
  if (initializedCallback) {
1029
1162
  await initializedCallback(orchestrator, baseConfig);
@@ -1033,8 +1166,8 @@ export class CrawlerOrchestrator extends EventEmitter {
1033
1166
  log('HTML seeds %O', htmlSeeds.map((u) => u.href));
1034
1167
  await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
1035
1168
  await orchestrator.crawling([], { recursive: true });
1036
- CrawlerOrchestrator.#finalizeCrawlSession();
1037
- await archive.setUrlOrder();
1169
+ CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
1170
+ await orchestrator.#setUrlOrder();
1038
1171
  return orchestrator;
1039
1172
  }
1040
1173
  // Only non-HTML URLs were imported — nothing left to render,
@@ -1043,7 +1176,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1043
1176
  if (initializedCallback) {
1044
1177
  await initializedCallback(orchestrator, baseConfig);
1045
1178
  }
1046
- await archive.setUrlOrder();
1179
+ await orchestrator.#setUrlOrder();
1047
1180
  return orchestrator;
1048
1181
  }
1049
1182
  catch (error) {
@@ -1064,6 +1197,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1064
1197
  // catch's `close()` becomes a no-op for the destructive
1065
1198
  // step and only runs `releaseLock` cleanup.
1066
1199
  try {
1200
+ setupProgress?.onPhase?.(RECOVERY_PERSIST_INGESTED_STATE);
1067
1201
  await archive.write();
1068
1202
  await archive.releaseHandle();
1069
1203
  }
@@ -1073,7 +1207,8 @@ export class CrawlerOrchestrator extends EventEmitter {
1073
1207
  throw error;
1074
1208
  }
1075
1209
  try {
1076
- await copyFile(backupPath, absFilePath);
1210
+ setupProgress?.onPhase?.(RECOVERY_RESTORE_FROM_BACKUP);
1211
+ await copyFileWithProgress(backupPath, absFilePath, setupProgress?.onCopyProgress);
1077
1212
  await ignoreEnoent(unlinkFile(backupPath));
1078
1213
  }
1079
1214
  catch (restoreError) {
@@ -1117,25 +1252,34 @@ export class CrawlerOrchestrator extends EventEmitter {
1117
1252
  * @param archivePath - Absolute or relative path to the existing `.nitpicker`.
1118
1253
  * @param options - Optional config overrides applied on top of the archived config.
1119
1254
  * @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
1255
+ * @param setupProgress - Optional progress callbacks for the setup phase
1256
+ * (untar, `.bak` copy, reset, state rebuild) that runs before
1257
+ * `initializedCallback` — see {@link SetupProgressCallbacks} for why
1258
+ * this can't go through the orchestrator's event emitter (issue #294).
1120
1259
  * @returns The orchestrator instance after the retry crawl completes.
1121
1260
  * @throws {Error} When the archive is in list mode or has no parseable roots.
1122
1261
  */
1123
- static async retryFailed(archivePath, options, initializedCallback) {
1262
+ static async retryFailed(archivePath, options, initializedCallback, setupProgress) {
1263
+ const [PHASE_EXTRACTING, PHASE_LOADING_CONFIG, PHASE_BACKING_UP, PHASE_RESETTING_FAILED, PHASE_LOADING_DEDUPE_KEYS, PHASE_LOADING_CRAWL_STATE, PHASE_LOADING_RESOURCES, PHASE_LOADING_SCRAPED_COUNT, PHASE_RESTORING_CRAWL_STATE,] = RETRY_FAILED_SETUP_PHASES;
1124
1264
  const cwd = options?.cwd ?? process.cwd();
1125
1265
  const absFilePath = path.isAbsolute(archivePath)
1126
1266
  ? archivePath
1127
1267
  : path.resolve(cwd, archivePath);
1128
1268
  // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
1129
1269
  // on every writer path that calls `write()`.
1270
+ setupProgress?.onPhase?.(PHASE_EXTRACTING);
1130
1271
  const archive = await Archive.open({
1131
1272
  filePath: absFilePath,
1132
1273
  cwd,
1133
1274
  openPluginData: true,
1275
+ onExtractProgress: setupProgress?.onExtractProgress,
1276
+ onLog: setupProgress?.onLog,
1134
1277
  });
1135
1278
  // Any throw between here and the successful return must release the
1136
1279
  // archive lock and clean up tmpDir; the caller's `close()` only runs on
1137
1280
  // the happy path.
1138
1281
  try {
1282
+ setupProgress?.onPhase?.(PHASE_LOADING_CONFIG);
1139
1283
  const archived = await archive.getConfig();
1140
1284
  if (archived.fromList) {
1141
1285
  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.');
@@ -1152,37 +1296,45 @@ export class CrawlerOrchestrator extends EventEmitter {
1152
1296
  baseUrl: archived.baseUrl,
1153
1297
  };
1154
1298
  const backupPath = absFilePath + '.bak';
1155
- await copyFile(absFilePath, backupPath);
1299
+ setupProgress?.onPhase?.(PHASE_BACKING_UP);
1300
+ await copyFileWithProgress(absFilePath, backupPath, setupProgress?.onCopyProgress);
1156
1301
  try {
1157
- const resetUrls = await archive.resetFailedPages();
1302
+ setupProgress?.onPhase?.(PHASE_RESETTING_FAILED);
1303
+ const resetUrls = await archive.resetFailedPages(setupProgress?.onChunkProgress);
1158
1304
  log('Start retrying failed pages');
1159
1305
  log('Archive %s', absFilePath);
1160
1306
  log('Reset %d failed page(s)', resetUrls.length);
1161
1307
  // Seed the sticky set from prior sessions' confirmed traps so
1162
1308
  // `--retry-failed` does not pay the cost of re-discovering
1163
1309
  // them (see `DedupeCapTracker`'s constructor JSDoc).
1310
+ setupProgress?.onPhase?.(PHASE_LOADING_DEDUPE_KEYS);
1164
1311
  const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
1165
1312
  const orchestrator = new CrawlerOrchestrator(archive, {
1166
1313
  ...config,
1167
1314
  preloadedStickyShapeKeys,
1168
1315
  });
1316
+ setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE);
1169
1317
  const { scraped, pending } = await archive.getCrawlingState();
1170
- const resources = await archive.getResourceUrlList();
1318
+ setupProgress?.onPhase?.(PHASE_LOADING_RESOURCES);
1319
+ const resources = await archive.getResourceUrlList(setupProgress?.onChunkProgress);
1320
+ setupProgress?.onPhase?.(PHASE_LOADING_SCRAPED_COUNT);
1171
1321
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
1322
+ setupProgress?.onPhase?.(PHASE_RESTORING_CRAWL_STATE);
1172
1323
  orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
1173
1324
  if (initializedCallback) {
1174
1325
  await initializedCallback(orchestrator, config);
1175
1326
  }
1176
1327
  await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
1177
1328
  await orchestrator.crawling([], { recursive: config.recursive });
1178
- CrawlerOrchestrator.#finalizeCrawlSession();
1179
- await archive.setUrlOrder();
1329
+ CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
1330
+ await orchestrator.#setUrlOrder();
1180
1331
  await ignoreEnoent(unlinkFile(backupPath));
1181
1332
  return orchestrator;
1182
1333
  }
1183
1334
  catch (error) {
1184
1335
  try {
1185
- await copyFile(backupPath, absFilePath);
1336
+ setupProgress?.onPhase?.(RECOVERY_RESTORE_FROM_BACKUP);
1337
+ await copyFileWithProgress(backupPath, absFilePath, setupProgress?.onCopyProgress);
1186
1338
  await ignoreEnoent(unlinkFile(backupPath));
1187
1339
  }
1188
1340
  catch (restoreError) {
@@ -1208,15 +1360,27 @@ export class CrawlerOrchestrator extends EventEmitter {
1208
1360
  * @param stubPath - Path to the existing archive file to resume from.
1209
1361
  * @param options - Optional configuration overrides to apply on top of the archived config.
1210
1362
  * @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
1363
+ * @param setupProgress - Optional progress callbacks for the setup phase
1364
+ * (self-healing migrations, state rebuild) that runs before
1365
+ * `initializedCallback` — see {@link SetupProgressCallbacks} for why
1366
+ * this can't go through the orchestrator's event emitter (issue #294).
1367
+ * No `onExtractProgress`/`onCopyProgress`: unlike `append`/`inventory`/
1368
+ * `retryFailed`, `resume` reconnects to an existing tmpDir (no untar)
1369
+ * and takes no `.bak` (nothing to restore — the interrupted crawl's
1370
+ * tmpDir IS the source of truth).
1211
1371
  * @returns A promise that resolves to the CrawlerOrchestrator instance after crawling completes.
1212
1372
  * @throws {Error} If the archived URL is invalid.
1213
1373
  */
1214
- static async resume(stubPath, options, initializedCallback) {
1215
- const archive = await Archive.resume(stubPath);
1374
+ static async resume(stubPath, options, initializedCallback, setupProgress) {
1375
+ const [PHASE_RECONNECTING, PHASE_LOADING_CONFIG, PHASE_LOADING_DEDUPE_KEYS, PHASE_LOADING_CRAWL_STATE, PHASE_LOADING_RESOURCES, PHASE_LOADING_SCRAPED_COUNT, PHASE_RESTORING_CRAWL_STATE,] = RESUME_SETUP_PHASES;
1376
+ setupProgress?.onPhase?.(PHASE_RECONNECTING);
1377
+ const archive = await Archive.resume(stubPath, setupProgress?.onLog);
1378
+ setupProgress?.onPhase?.(PHASE_LOADING_CONFIG);
1216
1379
  const archivedConfig = await archive.getConfig();
1217
1380
  // Seed the sticky set from prior sessions' confirmed traps so
1218
1381
  // `--resume` does not pay the cost of re-discovering them (see
1219
1382
  // `DedupeCapTracker`'s constructor JSDoc).
1383
+ setupProgress?.onPhase?.(PHASE_LOADING_DEDUPE_KEYS);
1220
1384
  const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
1221
1385
  const config = {
1222
1386
  ...archivedConfig,
@@ -1229,9 +1393,13 @@ export class CrawlerOrchestrator extends EventEmitter {
1229
1393
  if (!url) {
1230
1394
  throw new Error(`URL (${_url}) is invalid`);
1231
1395
  }
1396
+ setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE);
1232
1397
  const { scraped, pending } = await archive.getCrawlingState();
1233
- const resources = await archive.getResourceUrlList();
1398
+ setupProgress?.onPhase?.(PHASE_LOADING_RESOURCES);
1399
+ const resources = await archive.getResourceUrlList(setupProgress?.onChunkProgress);
1400
+ setupProgress?.onPhase?.(PHASE_LOADING_SCRAPED_COUNT);
1234
1401
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
1402
+ setupProgress?.onPhase?.(PHASE_RESTORING_CRAWL_STATE);
1235
1403
  orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
1236
1404
  if (initializedCallback) {
1237
1405
  await initializedCallback(orchestrator, config);
@@ -1242,7 +1410,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1242
1410
  log('Config %O', config);
1243
1411
  await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
1244
1412
  await orchestrator.crawling([url]);
1245
- CrawlerOrchestrator.#finalizeCrawlSession();
1413
+ CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
1246
1414
  return orchestrator;
1247
1415
  }
1248
1416
  /**
@@ -1315,21 +1483,30 @@ export class CrawlerOrchestrator extends EventEmitter {
1315
1483
  });
1316
1484
  }
1317
1485
  /**
1318
- * Tears down session-scoped crawler caches and prints a short-circuit
1486
+ * Tears down session-scoped crawler caches and reports a short-circuit
1319
1487
  * summary if any URL fetches were skipped. Invoked at every
1320
1488
  * crawl-session boundary (`crawling` / `append` / `inventory` /
1321
- * `retryFailed` / `resume`).
1489
+ * `retryFailed` / `resume`), in the same crawl-tail window as
1490
+ * `flushingPendingWrites`/`sortingUrls` — reports through the
1491
+ * `crawlSessionNotice` event rather than a bare `console.error` (issue
1492
+ * #294 code review) for the same reason those two do: this runs while a
1493
+ * caller's `Lanes`/`TaskList` display can already be active, and a
1494
+ * direct stream write there corrupts its cursor tracking.
1495
+ * @param orchestrator - The session's orchestrator instance, to emit
1496
+ * `crawlSessionNotice` from.
1322
1497
  */
1323
- static #finalizeCrawlSession() {
1498
+ static #finalizeCrawlSession(orchestrator) {
1324
1499
  const skipped = dnsBurnedHostShortCircuitCounter.count;
1325
1500
  if (skipped > 0) {
1326
- // eslint-disable-next-line no-console
1327
- console.error(`[preload] Short-circuited ${skipped} URL(s) on DNS-burned hosts`);
1501
+ void orchestrator.emit('crawlSessionNotice', {
1502
+ message: `[preload] Short-circuited ${skipped} URL(s) on DNS-burned hosts`,
1503
+ });
1328
1504
  }
1329
1505
  const { confirmedCount, totalDurationMs } = networkOutageSummaryCounter;
1330
1506
  if (confirmedCount > 0) {
1331
- // eslint-disable-next-line no-console
1332
- console.error(`[network] ${confirmedCount} outage(s), ${Math.round(totalDurationMs / 1000)}s total`);
1507
+ void orchestrator.emit('crawlSessionNotice', {
1508
+ message: `[network] ${confirmedCount} outage(s), ${Math.round(totalDurationMs / 1000)}s total`,
1509
+ });
1333
1510
  }
1334
1511
  networkOutageSummaryCounter.confirmedCount = 0;
1335
1512
  networkOutageSummaryCounter.totalDurationMs = 0;