@nitpicker/crawler 0.20.0 → 0.22.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 (113) hide show
  1. package/lib/append-retry-failed-common-setup-phases.d.ts +1 -1
  2. package/lib/append-retry-failed-common-setup-phases.js +5 -1
  3. package/lib/append-setup-phases.d.ts +1 -1
  4. package/lib/archive/archive.d.ts +57 -8
  5. package/lib/archive/archive.js +83 -12
  6. package/lib/archive/create-adjunct-tables.d.ts +3 -2
  7. package/lib/archive/create-adjunct-tables.js +12 -9
  8. package/lib/archive/create-entity-tables.js +3 -0
  9. package/lib/archive/database.d.ts +39 -7
  10. package/lib/archive/database.js +49 -9
  11. package/lib/archive/db-ops/_shared/keyset-paginate-by-id.d.ts +49 -0
  12. package/lib/archive/db-ops/_shared/keyset-paginate-by-id.js +69 -0
  13. package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +13 -12
  14. package/lib/archive/db-ops/_shared/resolve-content-item-id.js +63 -31
  15. package/lib/archive/db-ops/_shared/types.d.ts +39 -0
  16. package/lib/archive/db-ops/analysis/replace-analysis-violations.js +7 -4
  17. package/lib/archive/db-ops/config/get-created-cwd.d.ts +11 -0
  18. package/lib/archive/db-ops/config/get-created-cwd.js +13 -0
  19. package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
  20. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.d.ts +57 -0
  21. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.js +87 -0
  22. package/lib/archive/db-ops/errors/insert-page-error.js +3 -1
  23. package/lib/archive/db-ops/lifecycle/init.d.ts +13 -6
  24. package/lib/archive/db-ops/lifecycle/init.js +30 -10
  25. package/lib/archive/db-ops/list-reconcile/record-list-reconcile-run.d.ts +22 -0
  26. package/lib/archive/db-ops/{inventory/record-inventory-run.js → list-reconcile/record-list-reconcile-run.js} +11 -10
  27. package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
  28. package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +9 -2
  29. package/lib/archive/db-ops/pages/read/get-crawling-state.js +11 -3
  30. package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.d.ts +20 -0
  31. package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.js +37 -0
  32. package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +3 -2
  33. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +17 -37
  34. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +22 -1
  35. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +79 -33
  36. package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.d.ts +78 -0
  37. package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.js +146 -0
  38. package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.d.ts +24 -0
  39. package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.js +24 -0
  40. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +2 -1
  41. package/lib/archive/db-ops/pages/write/insert-page.js +4 -2
  42. package/lib/archive/db-ops/pages/write/link-redirect-sources.js +3 -1
  43. package/lib/archive/db-ops/pages/write/record-redirect.js +1 -1
  44. package/lib/archive/db-ops/pages/write/set-skipped-page.js +3 -1
  45. package/lib/archive/db-ops/pages/write/update-page.d.ts +9 -1
  46. package/lib/archive/db-ops/pages/write/update-page.js +28 -6
  47. package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +3 -6
  48. package/lib/archive/db-ops/resources/get-resource-url-list.js +10 -35
  49. package/lib/archive/init-schema.d.ts +1 -1
  50. package/lib/archive/init-schema.js +3 -2
  51. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +11 -3
  52. package/lib/archive/meta/compute-main-contents-denormalized.js +14 -2
  53. package/lib/archive/meta/types.d.ts +24 -0
  54. package/lib/archive/migrate-content-items-is-metadata-only.d.ts +44 -0
  55. package/lib/archive/migrate-content-items-is-metadata-only.js +60 -0
  56. package/lib/archive/migrate-info-created-cwd.d.ts +17 -0
  57. package/lib/archive/migrate-info-created-cwd.js +36 -0
  58. package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.d.ts +34 -0
  59. package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.js +51 -0
  60. package/lib/archive/{migrate-inventory-runs-exclude-skipped.d.ts → migrate-list-reconcile-runs-exclude-skipped.d.ts} +8 -8
  61. package/lib/archive/{migrate-inventory-runs-exclude-skipped.js → migrate-list-reconcile-runs-exclude-skipped.js} +12 -12
  62. package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.d.ts +16 -0
  63. package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.js +35 -0
  64. package/lib/archive/migrate-page-meta-image-scan.d.ts +27 -0
  65. package/lib/archive/migrate-page-meta-image-scan.js +52 -0
  66. package/lib/archive/page.d.ts +12 -0
  67. package/lib/archive/page.js +16 -0
  68. package/lib/archive/types.d.ts +97 -10
  69. package/lib/compute-auto-retry-backoff-delay.d.ts +30 -0
  70. package/lib/compute-auto-retry-backoff-delay.js +36 -0
  71. package/lib/crawler/apply-crawl-runtime-options-patch.d.ts +32 -0
  72. package/lib/crawler/apply-crawl-runtime-options-patch.js +129 -0
  73. package/lib/crawler/crawler.d.ts +35 -3
  74. package/lib/crawler/crawler.js +486 -338
  75. package/lib/crawler/dedupe/build-dedupe-cap-observation.d.ts +74 -0
  76. package/lib/crawler/dedupe/build-dedupe-cap-observation.js +97 -0
  77. package/lib/crawler/dedupe/compute-meta-signature.d.ts +7 -5
  78. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  79. package/lib/crawler/dedupe/compute-shape-key.d.ts +20 -0
  80. package/lib/crawler/dedupe/compute-shape-key.js +73 -1
  81. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +6 -1
  82. package/lib/crawler/dedupe/dedupe-cap-tracker.js +6 -1
  83. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +7 -5
  84. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +5 -3
  85. package/lib/crawler/dedupe/types.d.ts +18 -0
  86. package/lib/crawler/fetch-destination.js +1 -0
  87. package/lib/crawler/link-list.d.ts +9 -1
  88. package/lib/crawler/link-list.js +12 -2
  89. package/lib/crawler/link-to-page-data.js +1 -0
  90. package/lib/crawler/resource-to-page-data.js +1 -0
  91. package/lib/crawler/types.d.ts +75 -0
  92. package/lib/crawler-orchestrator.d.ts +171 -8
  93. package/lib/crawler-orchestrator.js +1177 -234
  94. package/lib/crawler.d.ts +3 -0
  95. package/lib/crawler.js +3 -0
  96. package/lib/delay-or-abort.d.ts +20 -0
  97. package/lib/delay-or-abort.js +35 -0
  98. package/lib/pending-urls-remain-error.d.ts +35 -0
  99. package/lib/pending-urls-remain-error.js +43 -0
  100. package/lib/recrawl-setup-phases.d.ts +38 -0
  101. package/lib/recrawl-setup-phases.js +52 -0
  102. package/lib/resume-setup-phases.d.ts +1 -1
  103. package/lib/resume-setup-phases.js +5 -1
  104. package/lib/retry-failed-setup-phases.d.ts +1 -1
  105. package/lib/setup-recovery-phase-labels.d.ts +17 -8
  106. package/lib/setup-recovery-phase-labels.js +17 -8
  107. package/lib/types.d.ts +62 -12
  108. package/lib/utils/compute-file-sha256.d.ts +1 -1
  109. package/lib/utils/compute-file-sha256.js +1 -1
  110. package/package.json +4 -4
  111. package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +0 -21
  112. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +0 -16
  113. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +0 -35
@@ -8,9 +8,11 @@ import { APPEND_SETUP_PHASES } from './append-setup-phases.js';
8
8
  import Archive from './archive/archive.js';
9
9
  import { copyFileWithProgress } from './archive/filesystem/copy-file-with-progress.js';
10
10
  import { REQUIRED_FORMAT_VERSION } from './archive/meta/assert-compatible-version.js';
11
+ import { computeAutoRetryBackoffDelayMs } from './compute-auto-retry-backoff-delay.js';
11
12
  import { clearDestinationCache } from './crawler/clear-destination-cache.js';
12
13
  import { clearDnsBurnedHostCache } from './crawler/clear-dns-burned-host-cache.js';
13
14
  import Crawler from './crawler/crawler.js';
15
+ import { buildDedupeCapObservation } from './crawler/dedupe/build-dedupe-cap-observation.js';
14
16
  import { dnsBurnedHostCache } from './crawler/dns-burned-host-cache.js';
15
17
  import { dnsBurnedHostShortCircuitCounter } from './crawler/dns-burned-host-short-circuit-counter.js';
16
18
  import { findScopeEntry } from './crawler/find-scope-entry.js';
@@ -20,8 +22,11 @@ import { PreloadShortCircuitError } from './crawler/preload-short-circuit-error.
20
22
  import { protocolAgnosticKey } from './crawler/protocol-agnostic-key.js';
21
23
  import { shouldSkipUrl } from './crawler/should-skip-url.js';
22
24
  import { crawlerLog, log } from './debug.js';
25
+ import { delayOrAbort } from './delay-or-abort.js';
23
26
  import { INVENTORY_SETUP_PHASES } from './inventory-setup-phases.js';
24
27
  import { normalizeToArray } from './normalize-to-array.js';
28
+ import { PendingUrlsRemainError } from './pending-urls-remain-error.js';
29
+ import { RECRAWL_SETUP_PHASES } from './recrawl-setup-phases.js';
25
30
  import { resolveOutputPath } from './resolve-output-path.js';
26
31
  import { resourceRowToLookupResult } from './resource-row-to-lookup-result.js';
27
32
  import { RESUME_SETUP_PHASES } from './resume-setup-phases.js';
@@ -29,7 +34,7 @@ import { RETRY_FAILED_SETUP_PHASES } from './retry-failed-setup-phases.js';
29
34
  import { SETUP_RECOVERY_PHASE_LABELS } from './setup-recovery-phase-labels.js';
30
35
  import { cleanObject } from './utils/object/clean-object.js';
31
36
  import { WriteQueue } from './write-queue.js';
32
- const [RECOVERY_RESTORE_FROM_BACKUP, RECOVERY_PERSIST_INGESTED_STATE] = SETUP_RECOVERY_PHASE_LABELS;
37
+ const [RECOVERY_RESTORE_FROM_BACKUP, RECOVERY_LEAVE_STATE_FOR_RESUME] = SETUP_RECOVERY_PHASE_LABELS;
33
38
  /**
34
39
  * Default list of external URL prefixes excluded from crawling.
35
40
  * Includes social media sharing endpoints that are commonly linked
@@ -73,8 +78,27 @@ export const DEFAULT_EXCLUDED_EXTERNAL_URLS = [
73
78
  export class CrawlerOrchestrator extends EventEmitter {
74
79
  /** The archive instance for persisting crawl results to SQLite + tar. */
75
80
  #archive;
81
+ /**
82
+ * Set when the archive's own `'error'` event fires (a DB/storage-level
83
+ * failure — see the constructor's listener), so
84
+ * `#crawlUntilPendingClears` can tell "the session ended because pages
85
+ * are still pending" apart from "the underlying storage broke" (issue
86
+ * #350). Retrying scrape work cannot fix the latter, so the auto-retry
87
+ * loop re-throws it immediately instead of burning retry attempts
88
+ * against it — `crawling()`'s own promise resolves normally either way
89
+ * (the constructor's listener only aborts the crawler; it does not
90
+ * reject anything), so this flag is the only way to distinguish the
91
+ * two after the fact.
92
+ */
93
+ #archiveFailure = null;
76
94
  /** The crawler engine that discovers and scrapes pages. */
77
95
  #crawler;
96
+ /**
97
+ * Monotonic counter bumped on every `crawling()` call — see that
98
+ * method's JSDoc for why its listeners key off this instead of relying
99
+ * on listener removal.
100
+ */
101
+ #crawlGeneration = 0;
78
102
  /**
79
103
  * `dedupe_cap_events.id` for each shape confirmed capped this session, so
80
104
  * `crawlEnd` can look up the right row to finalize with
@@ -86,6 +110,18 @@ export class CrawlerOrchestrator extends EventEmitter {
86
110
  #dedupeCapEventIds = new Map();
87
111
  /** Whether the crawl was started from a pre-defined URL list (non-recursive mode). */
88
112
  #fromList;
113
+ /**
114
+ * The caller-owned `Lanes` instance, if any — see `CrawlConfig.lanes`'s
115
+ * JSDoc. Used by `#crawlUntilPendingClears`'s auto-retry wait to render
116
+ * through `Lanes#header` instead of `console.error` when present (and
117
+ * {@link #verbose} is `false` — see that field's JSDoc for why verbose
118
+ * excludes this path), since an injected `Lanes` stays alive across
119
+ * auto-retry rounds (unlike `deal()`'s own `Lanes`, which closes each
120
+ * round) and a bare `console.error` would corrupt its live frame.
121
+ */
122
+ #lanes;
123
+ /** See `CrawlConfig.maxAutoRetry`'s JSDoc. */
124
+ #maxAutoRetry;
89
125
  /**
90
126
  * The `network_outages` row id for the currently-open outage, or `null`
91
127
  * when none is open. Set by the `networkOutageConfirmed` handler (once
@@ -97,6 +133,41 @@ export class CrawlerOrchestrator extends EventEmitter {
97
133
  #openNetworkOutageId = null;
98
134
  /** `startedAt` of the currently-open outage, tracked alongside {@link #openNetworkOutageId} so `networkOutageRecovered` can compute a duration for {@link networkOutageSummaryCounter}. */
99
135
  #openNetworkOutageStartedAt = null;
136
+ /**
137
+ * Set for the duration of {@link updateRuntimeOptions}'s enqueued
138
+ * `archive.updateConfig()` call — the constructor's `Archive` `'error'`
139
+ * listener checks this to skip its normal fatal handling (setting
140
+ * {@link #archiveFailure}, aborting the crawler, emitting `'error'`) for
141
+ * that one write. A failure there means only that the new
142
+ * `parallels`/`interval`/exclude values won't survive a later
143
+ * `--resume`/`--append`/`--retry-failed` — the in-memory change already
144
+ * took effect (`Crawler#updateRuntimeOptions` runs synchronously, before
145
+ * this write is even enqueued) and the crawl itself is otherwise
146
+ * unaffected, so treating it as crawl-fatal (like a real page/resource
147
+ * write failure, which DOES indicate DB corruption) would let a
148
+ * transient DB conflict on an optional persistence write cut short a
149
+ * multi-hour crawl over nothing worse than "an exclude pattern won't be
150
+ * remembered on resume." Safe without additional locking: every write
151
+ * this class issues — this one included — is serialized through the same
152
+ * {@link #writeQueue}, so at most one write (and therefore at most one
153
+ * meaning for this flag) is ever in flight at a time.
154
+ */
155
+ #persistingRuntimeOptionsPatch = false;
156
+ /**
157
+ * Mirrors `CrawlerOptions.verbose` (forwarded to `Crawler` at
158
+ * construction). `#crawlUntilPendingClears`'s auto-retry wait reads this
159
+ * alongside {@link #lanes}: `Lanes#header()` only queues its text in
160
+ * verbose mode (prefixed onto the *next* `update()` call) rather than
161
+ * writing immediately, so routing the auto-retry wait message through it
162
+ * while verbose would leave that message effectively invisible for the
163
+ * whole backoff wait — a regression from the unconditional
164
+ * `console.error` this replaced, which always printed immediately. Verbose
165
+ * mode falls back to `console.error` instead, matching its pre-`#lanes`
166
+ * behavior exactly (verbose output is append-only lines anyway, so an
167
+ * interleaved `console.error` line is harmless there, unlike in
168
+ * non-verbose mode where it would corrupt the live frame).
169
+ */
170
+ #verbose;
100
171
  /** Serializes archive writes from crawler event handlers (FIFO). */
101
172
  #writeQueue = new WriteQueue();
102
173
  /**
@@ -109,15 +180,26 @@ export class CrawlerOrchestrator extends EventEmitter {
109
180
  constructor(archive, options) {
110
181
  super();
111
182
  this.#fromList = !!options?.list;
183
+ this.#maxAutoRetry = options?.maxAutoRetry ?? 3;
112
184
  this.#archive = archive;
113
185
  this.#archive.on('error', (e) => {
186
+ const error = e instanceof Error ? e : new Error(String(e));
187
+ if (this.#persistingRuntimeOptionsPatch) {
188
+ // See `#persistingRuntimeOptionsPatch`'s JSDoc for why this one
189
+ // write's failure does not escalate to `#archiveFailure`/abort/
190
+ // `'error'` — only debug-logged (`DEBUG=Nitpicker:*`), matching
191
+ // this class's other best-effort-write logging.
192
+ log('updateRuntimeOptions: archive.updateConfig failed (non-fatal): %O', error);
193
+ return;
194
+ }
195
+ this.#archiveFailure = error;
114
196
  this.#crawler.abort();
115
197
  void this.emit('error', {
116
198
  pid: process.pid,
117
199
  isMainProcess: true,
118
200
  url: null,
119
201
  isExternal: false,
120
- error: e instanceof Error ? e : new Error(String(e)),
202
+ error: this.#archiveFailure,
121
203
  });
122
204
  });
123
205
  const defaultUserAgent = `Nitpicker/${pkg.version}`;
@@ -180,12 +262,18 @@ export class CrawlerOrchestrator extends EventEmitter {
180
262
  networkProbe: options?.networkProbe ?? null,
181
263
  dedupeCap: options?.dedupeCap ?? null,
182
264
  dedupeMapCap: options?.dedupeMapCap,
183
- // Only the four resuming-session static methods
184
- // (`append`/`inventory`/`retryFailed`/`resume`) pass this — a
185
- // fresh `crawling()` has no archive history to seed from (see
186
- // `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc).
265
+ // Only the five resuming-session static methods
266
+ // (`append`/`inventory`/`recrawl`/`retryFailed`/`resume`) pass
267
+ // this — a fresh `crawling()` has no archive history to seed from
268
+ // (see `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc).
187
269
  preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
270
+ // Same five methods, same rationale (see
271
+ // `CrawlConfig.preloadedDedupeObservations`'s JSDoc).
272
+ preloadedDedupeObservations: options?.preloadedDedupeObservations ?? [],
273
+ lanes: options?.lanes,
188
274
  });
275
+ this.#lanes = options?.lanes;
276
+ this.#verbose = options?.verbose ?? false;
189
277
  }
190
278
  /**
191
279
  * Enables `await using orchestrator = ...`. Closes the archive (write
@@ -233,23 +321,74 @@ export class CrawlerOrchestrator extends EventEmitter {
233
321
  * Sets up event listeners on the crawler, starts crawling, and resolves
234
322
  * when the crawl completes. Discovered pages, external pages, skipped pages,
235
323
  * and resources are forwarded to the archive for storage.
324
+ *
325
+ * Safe to call more than once on the same instance —
326
+ * `#crawlUntilPendingClears` (issue #350) re-invokes this for each
327
+ * auto-retry attempt against the same long-lived `#crawler`. `Crawler`
328
+ * (`TypedAwaitEventEmitter`) has no listener-removal API, so a second
329
+ * call cannot replace the first call's listeners — it can only stack
330
+ * another set alongside them. Every listener this method attaches is
331
+ * instead guarded by a monotonic generation counter (`isCurrent()`,
332
+ * defined below): once a later call bumps it, every earlier call's
333
+ * listeners permanently fail the check and become inert no-ops, leaving
334
+ * exactly the latest call's listeners actually writing anything.
236
335
  * @param list - The list of parsed URLs to crawl. May be empty when a resumed
237
336
  * session already has pending pages queued (for example `--retry-failed`).
238
337
  * @param opts - Optional crawl overrides.
239
338
  * @param opts.recursive - Whether discovered URLs are followed. Defaults to
240
339
  * `!fromList` (recursive unless the archive was created from a URL list), so
241
340
  * existing callers keep their behaviour; the retry flow passes it explicitly.
341
+ * @param opts.suppressFlushNotice - Skip emitting `flushingPendingWrites`
342
+ * (issue #350). Set by `#crawlUntilPendingClears` for every auto-retry
343
+ * attempt after the first: that event starts the CLI's crawl-tail
344
+ * `TaskList` (`attach-crawl-display.ts`), which must stay closed until
345
+ * the whole retry loop is done — a second `deal()`/`Lanes` cycle
346
+ * starting while that `TaskList` is still open would corrupt the
347
+ * display (see ARCHITECTURE.md's `Lanes`/`Display` single-instance
348
+ * invariant). The write-queue drain itself is unaffected; only the
349
+ * CLI-facing progress event is skipped.
350
+ * @param opts.isRetryContinuation - Forwarded to `Crawler#start()`
351
+ * (issue #350). Set by `#crawlUntilPendingClears` for every auto-retry
352
+ * attempt after the first, so `#runDeal` preserves cross-attempt
353
+ * learned state (known-good hosts, outage-detector window) instead of
354
+ * discarding it as if this were an unrelated fresh session.
242
355
  * @returns A promise that resolves when crawling is complete.
243
356
  */
244
357
  async crawling(list, opts) {
245
358
  const writeQueue = this.#writeQueue;
359
+ // Same value passed to `Crawler#start()` below (line ~822) — computed
360
+ // once here so the `setPage`/`setExternalPage` listeners can forward
361
+ // it for `is_metadata_only` (#369) without recomputing the default.
362
+ const recursive = opts?.recursive ?? !this.#fromList;
246
363
  // Per-session state, like `Crawler`'s own `#successfulHosts.clear()` /
247
364
  // `#networkGate.open()` reset at the start of `#runDeal` — a fresh
248
365
  // session must not inherit a dangling outage id from a prior one.
249
366
  this.#openNetworkOutageId = null;
250
367
  this.#openNetworkOutageStartedAt = null;
368
+ // `Crawler` (`TypedAwaitEventEmitter`) has no listener-removal API, so
369
+ // a second call on the same instance (an auto-retry attempt, issue
370
+ // #350) cannot replace the first call's listeners — it can only add
371
+ // another set alongside them. Every listener below is instead guarded
372
+ // by `isCurrent()`, keyed off a monotonic generation counter bumped
373
+ // here: once a later call starts, every earlier call's listeners
374
+ // permanently fail this check and become inert no-ops (their
375
+ // `writeQueue`/`#archive` side effects never run), leaving exactly
376
+ // one "live" set — this call's — actually writing anything.
377
+ const generation = ++this.#crawlGeneration;
378
+ const isCurrent = () => generation === this.#crawlGeneration;
379
+ // The ONLY way any listener below reaches `this.#crawler.on()` — a
380
+ // structural guarantee (not a per-listener discipline a future edit
381
+ // could forget) that no handler can run without the `isCurrent()`
382
+ // check, however many are added here in the future.
383
+ const registerGuarded = (event, handler) => {
384
+ this.#crawler.on(event, (payload) => {
385
+ if (!isCurrent())
386
+ return;
387
+ handler(payload);
388
+ });
389
+ };
251
390
  return new Promise((resolve, reject) => {
252
- this.#crawler.on('error', (error) => {
391
+ registerGuarded('error', (error) => {
253
392
  if (error.error instanceof PreloadShortCircuitError) {
254
393
  // DNS-burned host short-circuit: the underlying cause already
255
394
  // lives in `crawl_errors` from the original DNS failure.
@@ -268,33 +407,33 @@ export class CrawlerOrchestrator extends EventEmitter {
268
407
  .catch((writeError) => reject(writeError));
269
408
  void this.emit('error', error);
270
409
  });
271
- this.#crawler.on('page', ({ result, source, bodyHash }) => {
410
+ registerGuarded('page', ({ result, source, bodyHash }) => {
272
411
  writeQueue
273
- .enqueue(() => this.#archive.setPage(result, source, bodyHash))
412
+ .enqueue(() => this.#archive.setPage(result, source, bodyHash, recursive))
274
413
  .catch((error) => reject(error));
275
414
  });
276
- this.#crawler.on('externalPage', ({ result, source }) => {
415
+ registerGuarded('externalPage', ({ result, source }) => {
277
416
  writeQueue
278
- .enqueue(() => this.#archive.setExternalPage(result, source))
417
+ .enqueue(() => this.#archive.setExternalPage(result, source, recursive))
279
418
  .catch((error) => reject(error));
280
419
  });
281
- this.#crawler.on('skip', ({ url, reason, isExternal }) => {
420
+ registerGuarded('skip', ({ url, reason, isExternal }) => {
282
421
  writeQueue
283
422
  .enqueue(() => this.#archive.setSkippedPage(url, reason, isExternal))
284
423
  .catch((error) => reject(error));
285
424
  });
286
- this.#crawler.on('pageError', ({ url, phase, message, isExternal }) => {
425
+ registerGuarded('pageError', ({ url, phase, message, isExternal }) => {
287
426
  writeQueue
288
427
  .enqueue(() => this.#archive.addPageError(url, phase, message, isExternal))
289
428
  .catch((error) => reject(error));
290
429
  });
291
- this.#crawler.on('redirect', ({ result, source }) => {
430
+ registerGuarded('redirect', ({ result, source }) => {
292
431
  writeQueue
293
432
  .enqueue(() => this.#archive.setRedirect(result, source))
294
433
  .catch((error) => reject(error));
295
434
  void this.emit('redirect', { result });
296
435
  });
297
- this.#crawler.on('networkOutageConfirmed', ({ startedAt, detectedAt, probeHost, triggerErrorCount, triggerHostCount }) => {
436
+ registerGuarded('networkOutageConfirmed', ({ startedAt, detectedAt, probeHost, triggerErrorCount, triggerHostCount }) => {
298
437
  crawlerLog('Network outage confirmed: probeHost=%s triggerErrorCount=%d triggerHostCount=%d', probeHost, triggerErrorCount, triggerHostCount);
299
438
  // Rare anomaly notice, printed unconditionally even though this
300
439
  // fires while `deal()`'s own crawl-time `Lanes` is actively
@@ -324,7 +463,7 @@ export class CrawlerOrchestrator extends EventEmitter {
324
463
  })
325
464
  .catch((error) => reject(error));
326
465
  });
327
- this.#crawler.on('networkOutageRecovered', ({ endedAt }) => {
466
+ registerGuarded('networkOutageRecovered', ({ endedAt }) => {
328
467
  // The `id` read is deferred to INSIDE the queued closure, not
329
468
  // read synchronously here, because `networkOutageConfirmed`'s
330
469
  // INSERT is itself only queued (not awaited) when that event
@@ -360,7 +499,7 @@ export class CrawlerOrchestrator extends EventEmitter {
360
499
  })
361
500
  .catch((error) => reject(error));
362
501
  });
363
- this.#crawler.on('dedupeCap', ({ shapeKey, sampleUrl, bodyHash, effectiveThreshold, observedCount }) => {
502
+ registerGuarded('dedupeCap', ({ shapeKey, sampleUrl, bodyHash, effectiveThreshold, observedCount }) => {
364
503
  crawlerLog('Dedupe cap reached: shapeKey=%s effectiveThreshold=%d observedCount=%d', shapeKey, effectiveThreshold, observedCount);
365
504
  console.error(`[dedupe-cap] same-cluster trap confirmed: ${shapeKey} (sample: ${sampleUrl})`);
366
505
  writeQueue
@@ -377,27 +516,27 @@ export class CrawlerOrchestrator extends EventEmitter {
377
516
  })
378
517
  .catch((error) => reject(error));
379
518
  });
380
- this.#crawler.on('response', ({ resource, source }) => {
519
+ registerGuarded('response', ({ resource, source }) => {
381
520
  writeQueue
382
521
  .enqueue(() => this.#archive.setResources(resource, source))
383
522
  .catch((error) => reject(error));
384
523
  });
385
- this.#crawler.on('responseReferrers', (resource) => {
524
+ registerGuarded('responseReferrers', (resource) => {
386
525
  writeQueue
387
526
  .enqueue(() => this.#archive.setResourcesReferrers(resource))
388
527
  .catch((error) => reject(error));
389
528
  });
390
- this.#crawler.on('consoleLogs', ({ pageUrl, redirectPaths, entries }) => {
529
+ registerGuarded('consoleLogs', ({ pageUrl, redirectPaths, entries }) => {
391
530
  writeQueue
392
531
  .enqueue(() => this.#archive.setConsoleLogs(pageUrl, redirectPaths, entries))
393
532
  .catch((error) => reject(error));
394
533
  });
395
- this.#crawler.on('crawlEnd', () => {
534
+ registerGuarded('crawlEnd', () => {
396
535
  // Read BEFORE enqueuing the dedupeCap-finalize closure below
397
536
  // (issue #294) so this reflects genuine backlog from the
398
537
  // crawl's own page/resource writes, not the finalize
399
538
  // closure's own, always-present entry.
400
- if (writeQueue.pending > 0) {
539
+ if (writeQueue.pending > 0 && !opts?.suppressFlushNotice) {
401
540
  void this.emit('flushingPendingWrites', { pending: writeQueue.pending });
402
541
  }
403
542
  // Deferred to INSIDE a queued closure, not read synchronously
@@ -450,7 +589,10 @@ export class CrawlerOrchestrator extends EventEmitter {
450
589
  .then(() => resolve())
451
590
  .catch((error) => reject(error));
452
591
  });
453
- this.#crawler.start(list, { recursive: opts?.recursive ?? !this.#fromList });
592
+ this.#crawler.start(list, {
593
+ recursive,
594
+ isRetryContinuation: opts?.isRetryContinuation,
595
+ });
454
596
  });
455
597
  }
456
598
  /**
@@ -482,6 +624,58 @@ export class CrawlerOrchestrator extends EventEmitter {
482
624
  getUndeadPid() {
483
625
  return this.#crawler.getUndeadPid();
484
626
  }
627
+ /**
628
+ * Applies a runtime change to the in-progress crawl's tunable options
629
+ * (`parallels`/`interval`/the three exclude arrays — see
630
+ * {@link Crawler.updateRuntimeOptions}) and persists the resulting
631
+ * values to the archive's `info` row so a later `--resume` / `--append`
632
+ * / `--retry-failed` reuses them (`Archive#updateConfig`).
633
+ *
634
+ * The in-memory change takes effect immediately, before this method
635
+ * returns. The archive write is enqueued on {@link #writeQueue} — so it
636
+ * is ordered relative to the crawl's own page/resource writes — but not
637
+ * awaited: this method is synchronous so a caller (e.g. the CLI reading
638
+ * a console command) can report the new value back without waiting on
639
+ * disk I/O. A failure in that write does NOT abort the crawl or set
640
+ * {@link #archiveFailure} — see {@link #persistingRuntimeOptionsPatch}'s
641
+ * JSDoc for why this one write is deliberately exempted from the fatal
642
+ * handling every other archive write gets; it is only debug-logged
643
+ * (`DEBUG=Nitpicker:*`).
644
+ * @param patch - The runtime change to apply.
645
+ * @returns A snapshot of the tunable options after applying `patch`.
646
+ * @throws {RangeError} If `parallels` is present and not an integer `>= 1`, or `interval` is present and not an integer `>= 0`.
647
+ * @throws {TypeError} If any exclude entry is present and not a non-empty string.
648
+ * @example
649
+ * ```ts
650
+ * const snapshot = orchestrator.updateRuntimeOptions({ parallels: 4 });
651
+ * console.log(snapshot.parallels); // 4
652
+ * ```
653
+ */
654
+ updateRuntimeOptions(patch) {
655
+ const snapshot = this.#crawler.updateRuntimeOptions(patch);
656
+ this.#writeQueue
657
+ .enqueue(async () => {
658
+ this.#persistingRuntimeOptionsPatch = true;
659
+ try {
660
+ await this.#archive.updateConfig({
661
+ parallels: snapshot.parallels,
662
+ interval: snapshot.interval,
663
+ excludes: [...snapshot.excludes],
664
+ excludeUrls: [...snapshot.excludeUrls],
665
+ excludeKeywords: [...snapshot.excludeKeywords],
666
+ });
667
+ }
668
+ finally {
669
+ this.#persistingRuntimeOptionsPatch = false;
670
+ }
671
+ })
672
+ // The `Archive` `'error'` listener above already logs a failure
673
+ // here (non-fatal, see `#persistingRuntimeOptionsPatch`) — this
674
+ // catch exists only to prevent an unhandled rejection from this
675
+ // fire-and-forget enqueue.
676
+ .catch(() => { });
677
+ return snapshot;
678
+ }
485
679
  /**
486
680
  * Write the archive to its configured file path.
487
681
  *
@@ -509,6 +703,195 @@ export class CrawlerOrchestrator extends EventEmitter {
509
703
  });
510
704
  void this.emit('writeFileEnd', { filePath: this.#archive.filePath });
511
705
  }
706
+ /**
707
+ * Releases the archive handle (lock dropped, tmpDir left intact — see
708
+ * {@link Archive.releaseHandle}) and throws a {@link PendingUrlsRemainError}
709
+ * (issue #350) describing why `#crawlUntilPendingClears` gave up. Typed
710
+ * to return `never` so callers can `return this.#abandonPendingRetryLoop(...)`
711
+ * and satisfy control-flow analysis without an unreachable trailing
712
+ * `throw`.
713
+ * @param params - See the matching {@link PendingUrlsRemainError} field for each property's meaning.
714
+ * @param params.pendingCount - Pending URL count at the moment of giving up.
715
+ * @param params.attemptsMade - Auto-retry attempts actually run before giving up.
716
+ * @param params.reason - See {@link PendingUrlsRemainError}'s `reason` field.
717
+ */
718
+ async #abandonPendingRetryLoop(params) {
719
+ const { pendingCount, attemptsMade, reason } = params;
720
+ await this.#archive.releaseHandle();
721
+ throw new PendingUrlsRemainError({
722
+ pendingCount,
723
+ attemptsMade,
724
+ maxAutoRetry: this.#maxAutoRetry,
725
+ reason,
726
+ stubPath: this.#archive.tmpDir,
727
+ });
728
+ }
729
+ /**
730
+ * Runs `crawling()` and, if the session ends with pages still pending
731
+ * (issue #350), automatically re-queues them and re-runs the crawl loop
732
+ * up to `#maxAutoRetry` times with an exponential backoff between
733
+ * attempts ({@link computeAutoRetryBackoffDelayMs}) before giving up.
734
+ *
735
+ * Every one of the six session-starting static factories
736
+ * (`crawling`/`append`/`inventory`/`recrawl`/`retryFailed`/`resume`)
737
+ * routes its first `crawling()` call through here instead of calling it
738
+ * directly, so that **a `.nitpicker` file existing on disk always
739
+ * implies `pending === 0`**: this method never lets a factory reach
740
+ * `orchestrator.write()` (called later by the CLI's post-crawl step)
741
+ * while pages remain unscraped. When retrying cannot (or should not)
742
+ * continue, it releases the archive handle — leaving the stub (tmpDir)
743
+ * on disk, un-packaged, lock released — and throws
744
+ * {@link PendingUrlsRemainError} so the operator can recover via
745
+ * `crawl --resume` or `--retry-failed`.
746
+ *
747
+ * Three conditions end the loop early, before `#maxAutoRetry` is reached:
748
+ * - The archive itself failed (`#archiveFailure` set by the constructor's
749
+ * `Archive` `'error'` listener — a DB/storage-level failure). Retrying
750
+ * scrape work cannot fix a broken database, so this re-throws the
751
+ * original failure immediately without releasing-and-wrapping it in a
752
+ * `PendingUrlsRemainError` — there is nothing "pending-remains"-shaped
753
+ * about a storage failure.
754
+ * - The crawl was explicitly aborted (`#crawler.signal.aborted` — the
755
+ * public `abort()` method, e.g. a caller-driven cancellation or a
756
+ * Ctrl+C proxy in tests). `AbortController.signal` cannot be
757
+ * un-aborted, so every subsequent `crawling()` call on this same
758
+ * `#crawler` would deal zero work forever — retrying would just waste
759
+ * one full backoff wait before "no progress" gives up anyway. This
760
+ * returns immediately instead, matching this method's pre-#350
761
+ * behaviour for an explicit abort: the caller gets the orchestrator
762
+ * back with pending possibly `> 0` and decides for itself (the CLI's
763
+ * own SIGINT handler never reaches this far — see `crawl.ts` — so the
764
+ * `.nitpicker` ⟹ pending = 0 invariant still holds for that path). The
765
+ * backoff wait itself is also abort-interruptible ({@link delayOrAbort}
766
+ * rather than a bare `delay()`, issue #350 code review): a library
767
+ * consumer calling `abort()` mid-wait (unlike the CLI's SIGINT path)
768
+ * must not be stuck waiting up to 5 minutes for nothing.
769
+ * - An attempt makes no dent in the pending count (unchanged or grown):
770
+ * burning the remaining budget against a cause retrying will not fix
771
+ * (e.g. a wholesale host outage) just delays the operator finding out.
772
+ * Checked AFTER the exhaustion check below it in the loop body so
773
+ * that a final attempt which is both exhausted AND made no progress
774
+ * reports as `'exhausted'` — the more actionable of the two (it tells
775
+ * the operator the budget, not just that this one attempt stalled).
776
+ *
777
+ * Each retry attempt re-reads `getCrawlingState()` and
778
+ * `getResourceUrlList()` in full — the same cost `retryFailed`/`resume`/
779
+ * `append`/`inventory` already pay once per invocation, now paid up to
780
+ * `#maxAutoRetry` additional times (bounded, default 3). Retry attempts
781
+ * pass `isRetryContinuation: true` through to `Crawler#start()` so
782
+ * `#runDeal` preserves cross-attempt learned state (known-good hosts,
783
+ * network-outage detector window) instead of discarding it as if this
784
+ * were an unrelated fresh session (issue #350 code review) — the whole
785
+ * point of retrying is to avoid re-paying that detection cost.
786
+ * @param list - Forwarded to the first `crawling()` call.
787
+ * @param opts - Forwarded to the first `crawling()` call.
788
+ * @param opts.recursive
789
+ */
790
+ async #crawlUntilPendingClears(list, opts) {
791
+ await this.crawling(list, opts);
792
+ if (this.#archiveFailure) {
793
+ throw this.#archiveFailure;
794
+ }
795
+ if (this.#crawler.signal.aborted) {
796
+ return;
797
+ }
798
+ // `list` is this call's own root/seed URLs — always a full-scrape
799
+ // target regardless of `is_metadata_only` (a root added via
800
+ // `crawler.ts`'s direct seeding path never goes through
801
+ // `processAnchors`, so the anchor-derived flag on its row, if any,
802
+ // reflects some OTHER page's anchor to the same URL, not this URL's
803
+ // own root status). Excluded here rather than never written in the
804
+ // first place — see `replaceAnchorEdges`'s `recursive` doc for why a
805
+ // root's `is_metadata_only` can be wrongly set to `1` by a
806
+ // same-crawl page that happens to link to it before it is scraped
807
+ // itself (#369).
808
+ const rootKeys = new Set(list.map((u) => u.withoutHashAndAuth));
809
+ // Fetched at most once across the whole retry loop (issue #350 code
810
+ // review), not per attempt: `getResourceUrlList()` is a full scan of
811
+ // every known resource URL, but `Crawler#resume()`'s use of it is
812
+ // just seeding the in-memory `#resources` Set — idempotent, and
813
+ // already kept current independently as the live crawl writes new
814
+ // resources during each attempt. Re-fetching the full list on every
815
+ // attempt would re-pay that scan cost for no benefit on a large
816
+ // archive. `undefined` until the first retry actually needs it, so
817
+ // the common case (pending clears without ever retrying) never
818
+ // fetches it at all.
819
+ let cachedResources;
820
+ let previousPendingCount = null;
821
+ for (let attempt = 1;; attempt++) {
822
+ // `pendingMetadataOnly` defaults to `[]`: `.filter()` runs on it a
823
+ // few lines below, and a test-mocked `Archive` may return
824
+ // `{ scraped, pending }` without the field (production's real
825
+ // `getCrawlingState()` always includes it).
826
+ const { scraped, pending, pendingMetadataOnly = [], } = await this.#archive.getCrawlingState();
827
+ if (pending.length === 0) {
828
+ return;
829
+ }
830
+ if (attempt > this.#maxAutoRetry) {
831
+ return this.#abandonPendingRetryLoop({
832
+ pendingCount: pending.length,
833
+ attemptsMade: attempt - 1,
834
+ reason: 'exhausted',
835
+ });
836
+ }
837
+ if (previousPendingCount !== null && pending.length >= previousPendingCount) {
838
+ return this.#abandonPendingRetryLoop({
839
+ pendingCount: pending.length,
840
+ attemptsMade: attempt - 1,
841
+ reason: 'no-progress',
842
+ });
843
+ }
844
+ previousPendingCount = pending.length;
845
+ const delayMs = computeAutoRetryBackoffDelayMs(attempt);
846
+ void this.emit('autoRetryWaiting', {
847
+ attempt,
848
+ maxAttempts: this.#maxAutoRetry,
849
+ pendingCount: pending.length,
850
+ delayMs,
851
+ });
852
+ // A caller-injected `#lanes` (see `CrawlConfig.lanes`'s JSDoc)
853
+ // stays alive across auto-retry rounds — unlike `deal()`'s own
854
+ // Lanes, which closes each round — so a bare `console.error`
855
+ // here would corrupt its still-live frame. Route through
856
+ // `header()` instead; its `%countdown(...)%` placeholder
857
+ // animates via the Lanes' own redraw loop, same as the
858
+ // per-URL interval wait in `crawler.ts`. `#verbose` excludes
859
+ // this path (see that field's JSDoc: `header()` only queues its
860
+ // text for the next `update()` call in verbose mode, so it would
861
+ // go effectively unseen for the whole wait). Without an injected
862
+ // `#lanes` (or while verbose), this always fires in the gap
863
+ // after `deal()`'s own Lanes has closed and before the next
864
+ // round's opens (the retry's own `crawling()` call hasn't run
865
+ // yet), so there is no active display to corrupt and
866
+ // `console.error` is safe.
867
+ if (this.#lanes && !this.#verbose) {
868
+ this.#lanes.header(`[auto-retry] ${pending.length} pending page(s) remain — retry ${attempt}/${this.#maxAutoRetry} in %countdown(${delayMs},autoRetry,s)%s`);
869
+ }
870
+ else {
871
+ // eslint-disable-next-line no-console -- see comment above
872
+ console.error(`[auto-retry] ${pending.length} pending page(s) remain — waiting ${Math.round(delayMs / 1000)}s before retry ${attempt}/${this.#maxAutoRetry}`);
873
+ }
874
+ await delayOrAbort(delayMs, this.#crawler.signal);
875
+ if (this.#crawler.signal.aborted) {
876
+ return;
877
+ }
878
+ cachedResources ??= await this.#archive.getResourceUrlList();
879
+ const pagesScrapedOffset = await this.#archive.getScrapedHtmlPageCount();
880
+ const metadataOnlyUrls = pendingMetadataOnly.filter((url) => !rootKeys.has(url));
881
+ this.#crawler.resume(pending, scraped, cachedResources, pagesScrapedOffset, metadataOnlyUrls);
882
+ await this.crawling([], {
883
+ recursive: false,
884
+ suppressFlushNotice: true,
885
+ isRetryContinuation: true,
886
+ });
887
+ if (this.#archiveFailure) {
888
+ throw this.#archiveFailure;
889
+ }
890
+ if (this.#crawler.signal.aborted) {
891
+ return;
892
+ }
893
+ }
894
+ }
512
895
  /**
513
896
  * Assign natural URL sort order to every internal page, relaying chunk
514
897
  * progress through the `sortingUrls` event (issue #294). Always runs
@@ -538,6 +921,7 @@ export class CrawlerOrchestrator extends EventEmitter {
538
921
  * @param initializedCallback - Optional callback invoked after initialization but before crawling starts.
539
922
  * @returns A promise that resolves to the CrawlerOrchestrator instance after crawling completes.
540
923
  * @throws {Error} If the URL list is empty or contains no valid URLs.
924
+ * @throws {PendingUrlsRemainError} When the crawl session ends with pages still pending after exhausting auto-retry.
541
925
  */
542
926
  static async crawling(url, options, initializedCallback) {
543
927
  const list = sortUrl(url, options);
@@ -585,6 +969,7 @@ export class CrawlerOrchestrator extends EventEmitter {
585
969
  userAgent: options?.userAgent || defaultUserAgent,
586
970
  ignoreRobots: options?.ignoreRobots ?? false,
587
971
  mainContentSelector: options?.mainContentSelector ?? null,
972
+ ...buildCreatedCwdPatch(cwd),
588
973
  });
589
974
  const orchestrator = new CrawlerOrchestrator(archive, {
590
975
  ...options,
@@ -597,7 +982,7 @@ export class CrawlerOrchestrator extends EventEmitter {
597
982
  log('Start crawling');
598
983
  log('URL %O', list.map((url) => url.href));
599
984
  log('Config %O', config);
600
- await orchestrator.crawling(list);
985
+ await orchestrator.#crawlUntilPendingClears(list);
601
986
  log('Crawling completed');
602
987
  CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
603
988
  log('Set order natural URL sort');
@@ -614,7 +999,11 @@ export class CrawlerOrchestrator extends EventEmitter {
614
999
  * the expanded scope are demoted back to "needs scraping" so the next pass
615
1000
  * re-fetches them as full internal pages. A `<archive>.bak` is created
616
1001
  * before the crawl and removed on success; if the crawl throws, the backup
617
- * is restored to keep the original archive intact.
1002
+ * is restored to keep the original archive intact — except when the crawl
1003
+ * ends with {@link PendingUrlsRemainError} (issue #350), where the
1004
+ * un-packaged stub itself is the recovery path and the backup is instead
1005
+ * left untouched (deleted, not restored — see
1006
+ * {@link CrawlerOrchestrator.#abandonBackupOnPendingRemains}).
618
1007
  *
619
1008
  * List-mode archives (`info.fromList === true`) are rejected because their
620
1009
  * pages are all metadata-only and cannot host a recursive append.
@@ -628,6 +1017,7 @@ export class CrawlerOrchestrator extends EventEmitter {
628
1017
  * this can't go through the orchestrator's event emitter (issue #294).
629
1018
  * @returns The orchestrator instance after the append crawl completes.
630
1019
  * @throws {Error} When `newUrls` is empty, the archive is in list mode, or it cannot be parsed.
1020
+ * @throws {PendingUrlsRemainError} When the crawl session ends with pages still pending after exhausting auto-retry.
631
1021
  */
632
1022
  static async append(archivePath, newUrls, options, initializedCallback, setupProgress) {
633
1023
  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;
@@ -671,6 +1061,12 @@ export class CrawlerOrchestrator extends EventEmitter {
671
1061
  fromList: false,
672
1062
  recursive: true,
673
1063
  baseUrl: mergedRoots[0],
1064
+ // Stamped for `Archive.resume` (issue #350) — this session's
1065
+ // cwd, not `options.cwd` (already spread above and dropped by
1066
+ // `updateConfig`'s allowlist): a stub left behind by THIS
1067
+ // append should resume back to where THIS command ran, not
1068
+ // wherever the original crawl happened to run from.
1069
+ ...buildCreatedCwdPatch(cwd),
674
1070
  };
675
1071
  const backupPath = absFilePath + '.bak';
676
1072
  setupProgress?.onPhase?.(PHASE_BACKING_UP);
@@ -689,22 +1085,52 @@ export class CrawlerOrchestrator extends EventEmitter {
689
1085
  await archive.repromoteExternalPages(scopeMap, archived, setupProgress?.onChunkProgress);
690
1086
  // Seed the sticky set from prior sessions' confirmed traps so
691
1087
  // `--append` does not pay the cost of re-discovering them (see
692
- // `DedupeCapTracker`'s constructor JSDoc).
1088
+ // `DedupeCapTracker`'s constructor JSDoc), and replay every
1089
+ // not-yet-capped shape's prior observations so its counter
1090
+ // does not restart at 0 (see `#preloadDedupeCapObservations`).
693
1091
  setupProgress?.onPhase?.(PHASE_LOADING_DEDUPE_KEYS);
694
- const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
1092
+ // Independent reads (no data dependency) — run concurrently
1093
+ // rather than paying two sequential round-trips, the same
1094
+ // reasoning `resetFailedPages` already applies to its own
1095
+ // unrelated reads.
1096
+ const [preloadedStickyShapeKeys, preloadedDedupeObservations] = await Promise.all([
1097
+ archive.listDedupeCapShapeKeys(),
1098
+ CrawlerOrchestrator.#preloadDedupeCapObservations(archive, options?.dedupeCap ?? null, setupProgress?.onChunkProgress),
1099
+ ]);
695
1100
  const orchestrator = new CrawlerOrchestrator(archive, {
696
1101
  ...mergedConfig,
697
1102
  roots: mergedRoots,
698
1103
  preloadedStickyShapeKeys,
1104
+ preloadedDedupeObservations,
699
1105
  });
700
1106
  setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE);
701
- const { scraped, pending } = await archive.getCrawlingState();
1107
+ // `mergedConfig.recursive` is forced `true` above, so THIS
1108
+ // session's own anchor discovery can only mark an EXTERNAL
1109
+ // anchor as metadata-only. But `pendingMetadataOnly` can also
1110
+ // carry a STALE flag from a PRIOR session — e.g. a URL that
1111
+ // was external (or discovered under a then-non-recursive
1112
+ // config) before this `--append` call promotes it into
1113
+ // `mergedRoots`. `crawler.resume()` below runs BEFORE the new
1114
+ // roots are added via `#crawlUntilPendingClears(newParsed)`
1115
+ // (→ `LinkList#add()`), so `LinkList#add()`'s dedup would
1116
+ // silently let the stale metadataOnly flag win over the
1117
+ // root's full-scrape intent without this exclusion (issue
1118
+ // #369 code review — same class of bug the fix itself
1119
+ // targets). Re-parse `mergedRoots` (`ExURL#withoutHash` form)
1120
+ // to `withoutHashAndAuth` before comparing, matching how
1121
+ // `pendingMetadataOnly` entries are normalised (see the
1122
+ // list-mode `resume`'s identical comment).
1123
+ const { scraped, pending, pendingMetadataOnly = [], } = await archive.getCrawlingState();
1124
+ const mergedRootKeys = new Set(mergedRoots
1125
+ .map((root) => parseUrl(root, mergedConfig)?.withoutHashAndAuth)
1126
+ .filter((root) => root !== undefined));
1127
+ const metadataOnlyUrls = pendingMetadataOnly.filter((url) => !mergedRootKeys.has(url));
702
1128
  setupProgress?.onPhase?.(PHASE_LOADING_RESOURCES);
703
1129
  const resources = await archive.getResourceUrlList(setupProgress?.onChunkProgress);
704
1130
  setupProgress?.onPhase?.(PHASE_LOADING_SCRAPED_COUNT);
705
1131
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
706
1132
  setupProgress?.onPhase?.(PHASE_RESTORING_CRAWL_STATE);
707
- orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
1133
+ orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset, metadataOnlyUrls);
708
1134
  if (initializedCallback) {
709
1135
  await initializedCallback(orchestrator, mergedConfig);
710
1136
  }
@@ -713,13 +1139,17 @@ export class CrawlerOrchestrator extends EventEmitter {
713
1139
  log('New roots %O', newRoots);
714
1140
  log('Merged roots %O', mergedRoots);
715
1141
  await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
716
- await orchestrator.crawling(newParsed);
1142
+ await orchestrator.#crawlUntilPendingClears(newParsed);
717
1143
  CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
718
1144
  await orchestrator.#setUrlOrder();
719
1145
  await ignoreEnoent(unlinkFile(backupPath));
720
1146
  return orchestrator;
721
1147
  }
722
1148
  catch (error) {
1149
+ if (error instanceof PendingUrlsRemainError) {
1150
+ await CrawlerOrchestrator.#abandonBackupOnPendingRemains(setupProgress, backupPath);
1151
+ throw error;
1152
+ }
723
1153
  try {
724
1154
  setupProgress?.onPhase?.(RECOVERY_RESTORE_FROM_BACKUP);
725
1155
  await copyFileWithProgress(backupPath, absFilePath, setupProgress?.onCopyProgress);
@@ -823,6 +1253,7 @@ export class CrawlerOrchestrator extends EventEmitter {
823
1253
  * #294).
824
1254
  * @returns The orchestrator instance after a successful inventory pass.
825
1255
  * @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.
1256
+ * @throws {PendingUrlsRemainError} When the crawl session ends with pages still pending after exhausting auto-retry.
826
1257
  */
827
1258
  static async inventory(archivePath, inventoryUrls, options, initializedCallback, source = null, setupProgress) {
828
1259
  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;
@@ -849,6 +1280,10 @@ export class CrawlerOrchestrator extends EventEmitter {
849
1280
  if (archived.fromList) {
850
1281
  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.');
851
1282
  }
1283
+ // Stamped for `Archive.resume` (issue #350) — a stub left behind
1284
+ // by THIS inventory run should resume back to where THIS command
1285
+ // ran, independent of `--resume`'s own invocation directory.
1286
+ await archive.updateConfig(buildCreatedCwdPatch(cwd));
852
1287
  setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE_PRE);
853
1288
  const { pending } = await archive.getCrawlingState();
854
1289
  if (pending.length > 0) {
@@ -894,84 +1329,16 @@ export class CrawlerOrchestrator extends EventEmitter {
894
1329
  if (source) {
895
1330
  await archive.saveInventorySourceList(source.sha256, source.bytes);
896
1331
  }
897
- // Parse + scope-classify the candidate URLs. sortUrl drops
898
- // unparseable strings; findScopeEntry separates in-scope from
899
- // out-of-scope.
900
- const parsedAll = sortUrl(inventoryUrls, archived);
901
- const scopeMap = new Map();
902
- for (const raw of archived.roots) {
903
- const parsed = parseUrl(raw, archived);
904
- if (!parsed)
905
- continue;
906
- const existing = scopeMap.get(parsed.hostname) ?? [];
907
- scopeMap.set(parsed.hostname, [...existing, parsed]);
908
- }
909
- const inScope = [];
910
- let outOfScope = 0;
911
- for (const url of parsedAll) {
912
- if (findScopeEntry(url, scopeMap, archived) === null) {
913
- outOfScope++;
914
- }
915
- else {
916
- inScope.push(url);
917
- }
918
- }
919
- if (outOfScope > 0) {
920
- log('[inventory] %d URL(s) skipped (outside archived scope: %O)', outOfScope, archived.roots);
921
- }
922
- // Drop URLs that are already represented in the archive (either
923
- // as pages or resources). Comparison key is `withoutHashAndAuth`
924
- // to mirror what `resolveContentItemId` / `insertResource` actually store.
925
- // Two independent reads — Promise.all halves the wait on large
926
- // archives where each `WHERE url IN (?)` chunk costs real I/O.
1332
+ // Parse, scope-classify, and split the candidate URLs into
1333
+ // already-known vs. novel shared with `recrawl`, see
1334
+ // `#classifyInventoryCandidateUrls`.
927
1335
  setupProgress?.onPhase?.(PHASE_CHECKING_KNOWN_URLS);
928
- const candidateUrls = inScope.map((u) => u.withoutHashAndAuth);
929
- const [existingPageUrlList, existingResourceUrlList] = await Promise.all([
930
- archive.getExistingPageUrls(candidateUrls),
931
- archive.getExistingResourceUrls(candidateUrls),
932
- ]);
933
- const existingPageUrls = new Set(existingPageUrlList);
934
- const existingResourceUrls = new Set(existingResourceUrlList);
935
- const novelUrls = inScope.filter((u) => {
936
- const key = u.withoutHashAndAuth;
937
- return !existingPageUrls.has(key) && !existingResourceUrls.has(key);
938
- });
939
- const knownCount = existingPageUrls.size + existingResourceUrls.size;
940
- log('[inventory] %d in-scope, %d already in archive, %d new', inScope.length, knownCount, novelUrls.length);
941
- // Split the novel URLs on the exclusion config BEFORE the
942
- // HTML/non-HTML classification, so an exclude-matched URL is
943
- // recorded as a terminal skipped page instead of being imported
944
- // (issue #260). The inputs mirror the scrape phase's fetch-time
945
- // gate (`shouldSkipUrl` in `crawler.ts` fed by the constructor's
946
- // merge): archived config overlaid with this run's overrides,
947
- // and `DEFAULT_EXCLUDED_EXTERNAL_URLS` merged ahead of the
948
- // user's prefixes — classification and gate must never disagree
949
- // about the same URL. Running this AFTER the known-URL filter is
950
- // deliberate: a previously crawled row that newly matches the
951
- // exclusion config stays untouched (crawled-wins), matching how
952
- // `getExistingPageUrls` shields known rows from re-labelling.
953
- // `excludeKeywords` is deliberately absent: it matches rendered
954
- // page content, which a URL list does not have — HTML seeds
955
- // still get it at render time via the browser verdict.
1336
+ const { outOfScope, novelUrls } = await CrawlerOrchestrator.#classifyInventoryCandidateUrls(inventoryUrls, archived, archive);
1337
+ // Split the novel URLs on the exclusion config — shared with
1338
+ // `recrawl`, see `#classifyExcludedNovelUrls`. `effectiveConfig`
1339
+ // is also used below to build `baseConfig`.
956
1340
  const effectiveConfig = { ...archived, ...cleanObject(options) };
957
- const excludes = normalizeToArray(effectiveConfig.excludes);
958
- const excludeUrls = [
959
- ...DEFAULT_EXCLUDED_EXTERNAL_URLS,
960
- ...normalizeToArray(effectiveConfig.excludeUrls),
961
- ];
962
- const excludedNovelUrls = [];
963
- const importableNovelUrls = [];
964
- for (const url of novelUrls) {
965
- if (shouldSkipUrl({ url, excludes, excludeUrls, options: effectiveConfig })) {
966
- excludedNovelUrls.push(url);
967
- }
968
- else {
969
- importableNovelUrls.push(url);
970
- }
971
- }
972
- if (excludedNovelUrls.length > 0) {
973
- log('[inventory] %d URL(s) recorded as skipped (matched excludes / excludeUrls)', excludedNovelUrls.length);
974
- }
1341
+ const { excludedNovelUrls, importableNovelUrls } = CrawlerOrchestrator.#classifyExcludedNovelUrls(novelUrls, effectiveConfig);
975
1342
  if (novelUrls.length === 0) {
976
1343
  // Nothing to do — release the archive cleanly without taking a
977
1344
  // backup. The orchestrator returned here is empty; the caller
@@ -997,89 +1364,17 @@ export class CrawlerOrchestrator extends EventEmitter {
997
1364
  // clause). This flag steers the catch below.
998
1365
  let ingestionComplete = false;
999
1366
  try {
1000
- // Classify importable novel URLs by URL-extension heuristic (no I/O).
1001
- // Source file lists come from `ls` on the doc-root, so the
1002
- // extension reflects the real file type — a HEAD pre-flight
1003
- // here would be pure wasted I/O. Edge cases:
1004
- //
1005
- // - `.html` returning 404 / 200: the normal crawler HEAD/GET
1006
- // path absorbs this because every HTML-classified URL is
1007
- // fed through the dealer and gets its real HEAD/GET there.
1008
- //
1009
- // - Extensionless API endpoints (e.g. `/api/foo`) that the
1010
- // server returns as `text/html`: `isLikelyHtmlUrl` accepts
1011
- // them as HTML so the dealer's render path runs — the
1012
- // real content-type wins downstream.
1013
- //
1014
- // - `.aspx` / `.do` / `.jsp` / other server-handler
1015
- // extensions that the heuristic does NOT recognise as
1016
- // HTML: these are classified as non-HTML here, recorded
1017
- // as `resources` rows with all-null metadata, and never
1018
- // get a HEAD/GET probe. The accepted trade-off for
1019
- // `--inventory`'s "list of static-looking server files"
1020
- // contract; sites that mix server-handlers into the
1021
- // inventory list will need a follow-up `--retry-failed`
1022
- // pass (or a re-`--inventory` with the corrected list)
1023
- // to populate metadata.
1024
- //
1025
- // non-HTML rows are recorded with null status/content-type
1026
- // which is sufficient for `listUnusedResources` (referrer
1027
- // count = 0) but means downstream consumers must treat
1028
- // null as "not probed" rather than "failed".
1029
- const rawHtmlSeeds = [];
1030
- const nonHtmlSeeds = [];
1031
- for (const url of importableNovelUrls) {
1032
- if (isLikelyHtmlUrl(url)) {
1033
- rawHtmlSeeds.push(url);
1034
- }
1035
- else {
1036
- nonHtmlSeeds.push(url);
1037
- }
1038
- }
1039
- // Dedup HTML seeds by `protocolAgnosticKey` so an inventory
1040
- // list that mixes `http://` and `https://` for the same
1041
- // origin does not produce two `pages` rows that the dealer
1042
- // later collapses to one — the loser would otherwise stay
1043
- // `scraped=0, source='inventory-seed'` forever and look like
1044
- // a real recovery candidate on `--resume`. `getExistingPageUrls`
1045
- // keys on the full URL (with protocol), so it cannot catch
1046
- // the cross-scheme duplicate; this is the dedup boundary.
1047
- const seenKeys = new Set();
1048
- const htmlSeeds = [];
1049
- for (const url of rawHtmlSeeds) {
1050
- const key = protocolAgnosticKey(url.withoutHashAndAuth);
1051
- if (seenKeys.has(key)) {
1052
- continue;
1053
- }
1054
- seenKeys.add(key);
1055
- htmlSeeds.push(url);
1056
- }
1057
- // Bulk-record non-HTML novel URLs in `resources` as
1058
- // `source='inventory-seed'` placeholders. A
1059
- // per-URL `await setResources(...)` loop would spend minutes
1060
- // inside the `.bak`-protected window on large inventory
1061
- // lists; the chunked bulk path collapses N round-trips
1062
- // to N/500.
1063
- setupProgress?.onPhase?.(PHASE_RECORDING_NON_HTML);
1064
- await archive.insertInventoryResources(nonHtmlSeeds);
1065
- // Pre-insert HTML seeds as `scraped = 0`,
1066
- // `source = 'inventory-seed'` placeholders *before* the
1067
- // scrape phase, so a Ctrl+C between here and `setPage`
1068
- // cannot lose the URL. The strict-pending set picks
1069
- // these rows up on the next `--resume` via the
1070
- // `OR p.source != 'crawled'` clause.
1071
- setupProgress?.onPhase?.(PHASE_RECORDING_HTML_SEEDS);
1072
- await archive.insertInventorySeeds(htmlSeeds);
1073
- // Record exclude-matched novel URLs as terminal skipped pages
1074
- // (`is_skipped=1`, `skip_reason='excluded'`,
1075
- // `source='inventory-seed'`) — the same end state the normal
1076
- // crawl's fetch-time gate produces for link-discovered
1077
- // excluded URLs, so the archive looks identical no matter
1078
- // how the URL was discovered. Inside the `.bak` window for
1079
- // the same all-or-nothing reason as the seed inserts above.
1080
- setupProgress?.onPhase?.(PHASE_RECORDING_EXCLUDED);
1081
- await archive.insertInventorySkippedPages(excludedNovelUrls);
1082
- log('[inventory] %d HTML seed(s), %d non-HTML resource(s), %d skipped page(s) recorded', htmlSeeds.length, nonHtmlSeeds.length, excludedNovelUrls.length);
1367
+ // Classify, dedup, and bulk-record the novel URLs shared
1368
+ // with `recrawl`, see `#ingestNovelSeeds`.
1369
+ const { htmlSeeds, nonHtmlSeeds } = await CrawlerOrchestrator.#ingestNovelSeeds({
1370
+ archive,
1371
+ importableNovelUrls,
1372
+ excludedNovelUrls,
1373
+ setupProgress,
1374
+ phaseRecordingNonHtml: PHASE_RECORDING_NON_HTML,
1375
+ phaseRecordingHtmlSeeds: PHASE_RECORDING_HTML_SEEDS,
1376
+ phaseRecordingExcluded: PHASE_RECORDING_EXCLUDED,
1377
+ });
1083
1378
  // Audit row is written *inside* the `.bak` window: a libsql
1084
1379
  // hiccup or transient lock on the INSERT aborts the ingestion
1085
1380
  // and the `.bak` restore wipes the pre-inserted seeds too,
@@ -1087,8 +1382,8 @@ export class CrawlerOrchestrator extends EventEmitter {
1087
1382
  // the ingestion boundary. Audit failures are deliberately
1088
1383
  // NOT swallowed — inside the `.bak` window a restore is
1089
1384
  // safe and useful (see
1090
- // {@link CrawlerOrchestrator.#writeInventoryRunRow}).
1091
- await CrawlerOrchestrator.#writeInventoryRunRow(archive, {
1385
+ // {@link CrawlerOrchestrator.#writeListReconcileRunRow}).
1386
+ await CrawlerOrchestrator.#writeListReconcileRunRow(archive, {
1092
1387
  inventoryUrlsCount: inventoryUrls.length,
1093
1388
  htmlSeedsCount: htmlSeeds.length,
1094
1389
  nonHtmlCount: nonHtmlSeeds.length,
@@ -1129,13 +1424,25 @@ export class CrawlerOrchestrator extends EventEmitter {
1129
1424
  // Seed the sticky set from prior sessions' confirmed traps
1130
1425
  // so `--inventory` does not pay the cost of
1131
1426
  // re-discovering them (see `DedupeCapTracker`'s
1132
- // constructor JSDoc). Scoped to this branch only,
1133
- // matching `#preloadDnsBurnedHostCache`'s scoping below
1134
- // the fallback (non-HTML-only) branch never calls
1427
+ // constructor JSDoc), and replay every not-yet-capped
1428
+ // shape's prior observations so its counter does not
1429
+ // restart at 0 (see `#preloadDedupeCapObservations`).
1430
+ // Scoped to this branch only, matching
1431
+ // `#preloadDnsBurnedHostCache`'s scoping below — the
1432
+ // fallback (non-HTML-only) branch never calls
1135
1433
  // `orchestrator.crawling(...)`, so the tracker is never
1136
- // consulted there.
1137
- orchestratorOptions.preloadedStickyShapeKeys =
1138
- await archive.listDedupeCapShapeKeys();
1434
+ // consulted there. Silent (no `onPhase`/`onProgress`) —
1435
+ // matching the shape-key read's existing silent
1436
+ // behaviour here, unlike `append`/`retryFailed`/`resume`,
1437
+ // which already dedicate a phase to it. Independent reads
1438
+ // — run concurrently (see `append`'s identical pair).
1439
+ [
1440
+ orchestratorOptions.preloadedStickyShapeKeys,
1441
+ orchestratorOptions.preloadedDedupeObservations,
1442
+ ] = await Promise.all([
1443
+ archive.listDedupeCapShapeKeys(),
1444
+ CrawlerOrchestrator.#preloadDedupeCapObservations(archive, orchestratorOptions.dedupeCap ?? null),
1445
+ ]);
1139
1446
  const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
1140
1447
  // Re-read pending *after* the pre-insert so the strict-
1141
1448
  // pending set includes the freshly inserted
@@ -1145,7 +1452,14 @@ export class CrawlerOrchestrator extends EventEmitter {
1145
1452
  // pending set alone (see retryFailed's
1146
1453
  // `crawling([], { recursive })` invocation).
1147
1454
  setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE_POST);
1148
- const { scraped: scrapedAfter, pending: pendingAfter } = await archive.getCrawlingState();
1455
+ // `archived.fromList` is rejected above, so no
1456
+ // root-exclusion is needed here (see the list-mode
1457
+ // `resume`/auto-retry paths' comments for why it matters
1458
+ // there). No `= []` default needed: `pendingMetadataOnlyAfter`
1459
+ // is only ever passed straight through to `Crawler#resume()`,
1460
+ // whose own `metadataOnlyUrls` parameter already defaults
1461
+ // `undefined` to `[]`.
1462
+ const { scraped: scrapedAfter, pending: pendingAfter, pendingMetadataOnly: pendingMetadataOnlyAfter, } = await archive.getCrawlingState();
1149
1463
  setupProgress?.onPhase?.(PHASE_LOADING_RESOURCES);
1150
1464
  const resources = await archive.getResourceUrlList(setupProgress?.onChunkProgress);
1151
1465
  // Pre-existing rendered HTML page count seeds the
@@ -1157,7 +1471,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1157
1471
  setupProgress?.onPhase?.(PHASE_LOADING_SCRAPED_COUNT);
1158
1472
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
1159
1473
  setupProgress?.onPhase?.(PHASE_RESTORING_CRAWL_STATE);
1160
- orchestrator.#crawler.resume(pendingAfter, scrapedAfter, resources, pagesScrapedOffset);
1474
+ orchestrator.#crawler.resume(pendingAfter, scrapedAfter, resources, pagesScrapedOffset, pendingMetadataOnlyAfter);
1161
1475
  if (initializedCallback) {
1162
1476
  await initializedCallback(orchestrator, baseConfig);
1163
1477
  }
@@ -1165,7 +1479,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1165
1479
  log('Archive %s', absFilePath);
1166
1480
  log('HTML seeds %O', htmlSeeds.map((u) => u.href));
1167
1481
  await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
1168
- await orchestrator.crawling([], { recursive: true });
1482
+ await orchestrator.#crawlUntilPendingClears([], { recursive: true });
1169
1483
  CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
1170
1484
  await orchestrator.#setUrlOrder();
1171
1485
  return orchestrator;
@@ -1181,28 +1495,33 @@ export class CrawlerOrchestrator extends EventEmitter {
1181
1495
  }
1182
1496
  catch (error) {
1183
1497
  if (ingestionComplete) {
1184
- // Scrape phase failed; the pre-inserted seeds + audit
1185
- // row are durable inside `tmpDir/db.sqlite` but not yet
1186
- // on disk as a `.nitpicker` tar. The outer catch below
1187
- // runs `archive.close()`, which sees the original
1498
+ // Scrape phase failed — either the auto-retry loop
1499
+ // (`#crawlUntilPendingClears`, issue #350) gave up with
1500
+ // pages still pending, or some other exception. Either
1501
+ // way the pre-inserted seeds + audit row are durable
1502
+ // inside `tmpDir/db.sqlite` but must NOT be packaged: a
1503
+ // `.nitpicker` on disk must imply `pending === 0` (see
1504
+ // that method's JSDoc). The outer catch below runs
1505
+ // `archive.close()`, which sees the original
1188
1506
  // (pre-inventory) `.nitpicker` already on disk and
1189
1507
  // would just `remove(tmpDir)` — silently wiping every
1190
1508
  // `inventory-seed` row and the audit row.
1191
1509
  //
1192
- // Persist the ingested state ourselves before letting
1193
- // the outer catch unwind, then re-throw so the operator
1194
- // learns about the scrape failure (and can recover via
1195
- // `crawl --resume <archive>`). `releaseHandle` shares
1196
- // the orchestrator's `#closeOnce` guard, so the outer
1197
- // catch's `close()` becomes a no-op for the destructive
1198
- // step and only runs `releaseLock` cleanup.
1510
+ // Release the handle ourselves (leaving tmpDir intact)
1511
+ // before letting the outer catch unwind, then re-throw
1512
+ // so the operator learns about the scrape failure and
1513
+ // can recover via `crawl --resume <stub>`. `releaseHandle`
1514
+ // shares the orchestrator's `#closeOnce` guard, so the
1515
+ // outer catch's `close()` becomes a no-op for the
1516
+ // destructive step and only runs `releaseLock` cleanup
1517
+ // — a no-op too when `#crawlUntilPendingClears` already
1518
+ // released it itself before throwing.
1199
1519
  try {
1200
- setupProgress?.onPhase?.(RECOVERY_PERSIST_INGESTED_STATE);
1201
- await archive.write();
1520
+ setupProgress?.onPhase?.(RECOVERY_LEAVE_STATE_FOR_RESUME);
1202
1521
  await archive.releaseHandle();
1203
1522
  }
1204
1523
  catch (persistError) {
1205
- 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.');
1524
+ throw new AggregateError([error, persistError], 'inventory scrape phase failed AND releasing the archive handle also failed. The archive may be in an inconsistent state — check tmpDir.');
1206
1525
  }
1207
1526
  throw error;
1208
1527
  }
@@ -1222,6 +1541,481 @@ export class CrawlerOrchestrator extends EventEmitter {
1222
1541
  throw error;
1223
1542
  }
1224
1543
  }
1544
+ /**
1545
+ * Re-fetch pages named by an operator-supplied URL list, importing any
1546
+ * URL the archive does not yet track as a new inventory seed.
1547
+ *
1548
+ * `recrawl` is `retryFailed`'s un-scrape combined with `inventory`'s
1549
+ * novel-URL ingestion, run inside one `.bak`-protected window: URLs in
1550
+ * `recrawlUrls` that already exist as `content_items` rows are reset back
1551
+ * to pending via {@link Archive.resetPagesByUrls} (see that method for the
1552
+ * conservative exclusion rules — redirect sources, intentionally-skipped
1553
+ * pages, and external pages are matched but never reset), while URLs the
1554
+ * archive has never seen are ingested exactly as `inventory` does (see
1555
+ * {@link CrawlerOrchestrator.inventory}'s JSDoc for that half's contract).
1556
+ * Existing *resources* matched by the list are neither resettable nor
1557
+ * novel — `resource_items` is first-write-wins (a known deviation, see
1558
+ * ARCHITECTURE.md), so a resource re-fetch would not update anything; the
1559
+ * function reports how many list entries fell into this bucket via
1560
+ * `setupProgress.onLog` without acting on them.
1561
+ *
1562
+ * Unlike `inventory`, whose sole early-return condition is "no novel
1563
+ * URLs", `recrawl` also has existing pages to act on — the `.bak` is
1564
+ * skipped only when BOTH `existingPageUrls` (reset candidates) AND
1565
+ * `novelUrls` (ingestion candidates) are empty.
1566
+ *
1567
+ * **Strict-pending gap**: `getCrawlingState()`'s pending set only includes
1568
+ * a `scraped = 0` row that is either anchor-referenced or explicitly
1569
+ * labelled (see that function's JSDoc). When `recrawlUrls` contains pages
1570
+ * that link to each other, resetting one page also deletes its outgoing
1571
+ * `anchor_edges` — so a `source = 'crawled'` sibling that was reset in the
1572
+ * same pass can lose its only anchor referrer and fall out of the strict
1573
+ * pending set, silently skipping its re-fetch. `retryFailed` never hits
1574
+ * this because a failed page's referrers are not themselves reset. The
1575
+ * fix: every URL `Archive.resetPagesByUrls` actually reset is merged into
1576
+ * the pending list handed to `Crawler#resume` regardless of what the
1577
+ * strict scan finds, deduplicated by `LinkList.add`'s `protocolAgnosticKey`
1578
+ * check. A Ctrl+C between the reset and the scrape phase loses this
1579
+ * synthetic merge (it lives only in memory) — `crawl --resume` recovers
1580
+ * whatever the strict-pending scan finds on its own, and re-running
1581
+ * `--recrawl` with the same list recovers the rest, matching the
1582
+ * "un-picked seeds" recovery contract `getCrawlingState`'s JSDoc already
1583
+ * documents for `inventory`.
1584
+ *
1585
+ * **Stale analyze findings**: resetting a page deletes its
1586
+ * `analysis_violations` rows (see {@link resetPagesByUrls}'s JSDoc) so a
1587
+ * re-fetched page never shows findings from HTML that no longer exists,
1588
+ * but other `analyze` outputs (e.g. Discrepancies plugin reports) are not
1589
+ * page-scoped and cannot be selectively invalidated. When at least one
1590
+ * page was reset, a `crawlSessionNotice` is emitted after the crawl
1591
+ * completes recommending `analyze` be re-run before the next `report`.
1592
+ * @param archivePath - Absolute or relative path to the existing `.nitpicker`.
1593
+ * @param recrawlUrls - URLs to match against the archive (existing pages
1594
+ * are reset; unknown URLs are ingested as new inventory seeds).
1595
+ * @param options - Optional config overrides applied on top of the archived config.
1596
+ * @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
1597
+ * @param source - The CLI's already-read URL list source bytes, archived
1598
+ * for audit purposes — see {@link InventorySource}. `null` for
1599
+ * programmatic callers with no source file.
1600
+ * @param setupProgress - Optional progress callbacks for the setup phase
1601
+ * (untar, `.bak` copy, URL classification, reset, seed ingestion, state
1602
+ * rebuild) that runs before `initializedCallback` — see
1603
+ * {@link SetupProgressCallbacks} for why this can't go through the
1604
+ * orchestrator's event emitter (issue #294).
1605
+ * @returns The orchestrator instance after the recrawl completes.
1606
+ * @throws {Error} When `recrawlUrls` is empty or the archive is in list mode.
1607
+ * @throws {PendingUrlsRemainError} When the crawl session ends with pages still pending after exhausting auto-retry.
1608
+ */
1609
+ static async recrawl(archivePath, recrawlUrls, options, initializedCallback, source = null, setupProgress) {
1610
+ const [PHASE_EXTRACTING, PHASE_LOADING_CONFIG, PHASE_LOADING_CRAWL_STATE_PRE, PHASE_CHECKING_KNOWN_URLS, PHASE_BACKING_UP, PHASE_RESETTING_MATCHED, 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,] = RECRAWL_SETUP_PHASES;
1611
+ if (recrawlUrls.length === 0) {
1612
+ throw new Error('recrawl: URL list is empty');
1613
+ }
1614
+ const cwd = options?.cwd ?? process.cwd();
1615
+ const absFilePath = path.isAbsolute(archivePath)
1616
+ ? archivePath
1617
+ : path.resolve(cwd, archivePath);
1618
+ // See `ArchiveOpenOptions.openPluginData` for why this must be `true`
1619
+ // on every writer path that calls `write()`.
1620
+ setupProgress?.onPhase?.(PHASE_EXTRACTING);
1621
+ const archive = await Archive.open({
1622
+ filePath: absFilePath,
1623
+ cwd,
1624
+ openPluginData: true,
1625
+ onExtractProgress: setupProgress?.onExtractProgress,
1626
+ onLog: setupProgress?.onLog,
1627
+ });
1628
+ try {
1629
+ setupProgress?.onPhase?.(PHASE_LOADING_CONFIG);
1630
+ const archived = await archive.getConfig();
1631
+ if (archived.fromList) {
1632
+ throw new Error('Cannot recrawl a list-mode archive: this archive was created with --list/--list-file and contains metadata-only pages. Create a fresh archive instead.');
1633
+ }
1634
+ // Stamped for `Archive.resume` (issue #350) — same rationale as
1635
+ // `inventory`'s identical call.
1636
+ await archive.updateConfig(buildCreatedCwdPatch(cwd));
1637
+ setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE_PRE);
1638
+ const { pending } = await archive.getCrawlingState();
1639
+ if (pending.length > 0) {
1640
+ // Same rationale as `inventory`'s identical warning — routed
1641
+ // through `setupProgress.onLog`, not a bare `console.warn`,
1642
+ // since the `'Loading crawl state'` row is active here.
1643
+ const message = `recrawl: 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.`;
1644
+ if (setupProgress?.onLog) {
1645
+ setupProgress.onLog(message);
1646
+ }
1647
+ else {
1648
+ // eslint-disable-next-line no-console -- --silent has no TaskList row to report through
1649
+ console.warn(message);
1650
+ }
1651
+ }
1652
+ // Archive the exact source bytes before scope classification —
1653
+ // same rationale as `inventory`'s identical call.
1654
+ if (source) {
1655
+ await archive.saveInventorySourceList(source.sha256, source.bytes);
1656
+ }
1657
+ setupProgress?.onPhase?.(PHASE_CHECKING_KNOWN_URLS);
1658
+ const { outOfScope, existingPageUrls, existingResourceUrls, novelUrls } = await CrawlerOrchestrator.#classifyInventoryCandidateUrls(recrawlUrls, archived, archive);
1659
+ if (existingResourceUrls.length > 0) {
1660
+ const message = `recrawl: ${existingResourceUrls.length} URL(s) matched existing resources — not re-fetched (resource rows are first-write-wins; re-fetching would not update them).`;
1661
+ if (setupProgress?.onLog) {
1662
+ setupProgress.onLog(message);
1663
+ }
1664
+ else {
1665
+ // eslint-disable-next-line no-console -- --silent has no TaskList row to report through
1666
+ console.warn(message);
1667
+ }
1668
+ }
1669
+ const effectiveConfig = { ...archived, ...cleanObject(options) };
1670
+ const { excludedNovelUrls, importableNovelUrls } = CrawlerOrchestrator.#classifyExcludedNovelUrls(novelUrls, effectiveConfig);
1671
+ if (existingPageUrls.length === 0 && novelUrls.length === 0) {
1672
+ // Nothing to do — release the archive cleanly without taking a
1673
+ // backup, mirroring `inventory`'s zero-novel early return.
1674
+ const orchestrator = new CrawlerOrchestrator(archive, effectiveConfig);
1675
+ if (initializedCallback) {
1676
+ await initializedCallback(orchestrator, effectiveConfig);
1677
+ }
1678
+ return orchestrator;
1679
+ }
1680
+ const backupPath = absFilePath + '.bak';
1681
+ setupProgress?.onPhase?.(PHASE_BACKING_UP);
1682
+ await copyFileWithProgress(absFilePath, backupPath, setupProgress?.onCopyProgress);
1683
+ let ingestionComplete = false;
1684
+ try {
1685
+ setupProgress?.onPhase?.(PHASE_RESETTING_MATCHED);
1686
+ const resetResult = await archive.resetPagesByUrls(existingPageUrls, setupProgress?.onChunkProgress);
1687
+ const excludedTotal = resetResult.excludedRedirects.length +
1688
+ resetResult.excludedSkipped.length +
1689
+ resetResult.excludedExternal.length;
1690
+ // `existingPageUrls` (from `getExistingPageUrls`) matches by URL
1691
+ // alone, regardless of `scraped` — it can include rows still
1692
+ // pending from an interrupted previous session. Those rows are
1693
+ // absent from every `resetPagesByUrls` array (see that
1694
+ // function's JSDoc: "already pending, nothing to reset"), so
1695
+ // the three counts below alone would not sum back to
1696
+ // `existingPageUrls.length` and the message would look like
1697
+ // pages vanished unexplained. Naming this remainder keeps the
1698
+ // arithmetic honest for an operator auditing the summary.
1699
+ const alreadyPendingCount = existingPageUrls.length - resetResult.resetUrls.length - excludedTotal;
1700
+ const summaryMessage = `recrawl: matched ${existingPageUrls.length} existing page(s) — reset ${resetResult.resetUrls.length}, excluded ${excludedTotal} (${resetResult.excludedRedirects.length} redirect source(s), ${resetResult.excludedSkipped.length} intentionally-skipped, ${resetResult.excludedExternal.length} external), already pending ${alreadyPendingCount}.`;
1701
+ if (setupProgress?.onLog) {
1702
+ setupProgress.onLog(summaryMessage);
1703
+ }
1704
+ else {
1705
+ // eslint-disable-next-line no-console -- --silent has no TaskList row to report through
1706
+ console.warn(summaryMessage);
1707
+ }
1708
+ const { htmlSeeds, nonHtmlSeeds } = await CrawlerOrchestrator.#ingestNovelSeeds({
1709
+ archive,
1710
+ importableNovelUrls,
1711
+ excludedNovelUrls,
1712
+ setupProgress,
1713
+ phaseRecordingNonHtml: PHASE_RECORDING_NON_HTML,
1714
+ phaseRecordingHtmlSeeds: PHASE_RECORDING_HTML_SEEDS,
1715
+ phaseRecordingExcluded: PHASE_RECORDING_EXCLUDED,
1716
+ });
1717
+ // Audit row is written *inside* the `.bak` window — same
1718
+ // all-or-nothing rationale as `inventory`'s identical write.
1719
+ await CrawlerOrchestrator.#writeListReconcileRunRow(archive, {
1720
+ inventoryUrlsCount: recrawlUrls.length,
1721
+ htmlSeedsCount: htmlSeeds.length,
1722
+ nonHtmlCount: nonHtmlSeeds.length,
1723
+ outOfScope,
1724
+ excludeSkipped: excludedNovelUrls.length,
1725
+ sourceFileSha256: source?.sha256 ?? null,
1726
+ invalidSkipped: source?.invalidLineCount ?? null,
1727
+ listLabelPrefix: 'recrawl',
1728
+ notes: `Reset ${resetResult.resetUrls.length} existing page(s) for re-fetch`,
1729
+ });
1730
+ ingestionComplete = true;
1731
+ await ignoreEnoent(unlinkFile(backupPath));
1732
+ const baseConfig = {
1733
+ ...effectiveConfig,
1734
+ recursive: true,
1735
+ fromList: false,
1736
+ };
1737
+ const seedSet = new Set(htmlSeeds.map((u) => u.withoutHashAndAuth));
1738
+ const orchestratorOptions = {
1739
+ ...baseConfig,
1740
+ inventoryMode: { seedUrls: seedSet },
1741
+ };
1742
+ if (resetResult.resetUrls.length > 0 || htmlSeeds.length > 0) {
1743
+ // Same rationale as `inventory`'s identical pair of
1744
+ // preload calls (sticky shapes + prior observation
1745
+ // replay, run concurrently), silent for the same reason
1746
+ // — see that method's comment.
1747
+ [
1748
+ orchestratorOptions.preloadedStickyShapeKeys,
1749
+ orchestratorOptions.preloadedDedupeObservations,
1750
+ ] = await Promise.all([
1751
+ archive.listDedupeCapShapeKeys(),
1752
+ CrawlerOrchestrator.#preloadDedupeCapObservations(archive, orchestratorOptions.dedupeCap ?? null),
1753
+ ]);
1754
+ const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
1755
+ setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE_POST);
1756
+ // `archived.fromList` is rejected above, so no
1757
+ // root-exclusion is needed here (see the list-mode
1758
+ // `resume`/auto-retry paths' comments for why it matters
1759
+ // there). `pendingMetadataOnlyAfter` is not merged with
1760
+ // `resetResult.resetUrls` the way `pending` is below — a
1761
+ // reset row absent from the strict-pending set defaults
1762
+ // to a full re-scrape, the safe direction for a
1763
+ // user-requested recrawl. No `= []` default needed: it is
1764
+ // only ever passed straight through to `Crawler#resume()`,
1765
+ // whose own `metadataOnlyUrls` parameter already defaults
1766
+ // `undefined` to `[]`.
1767
+ const { scraped: scrapedAfter, pending: pendingAfter, pendingMetadataOnly: pendingMetadataOnlyAfter, } = await archive.getCrawlingState();
1768
+ // Merge the reset URLs into the pending set explicitly —
1769
+ // see this method's "Strict-pending gap" JSDoc section.
1770
+ // Deduped by `LinkList.add`'s own key check, so a URL the
1771
+ // strict scan already found is harmless to repeat here.
1772
+ const pendingWithReset = [
1773
+ ...new Set([...pendingAfter, ...resetResult.resetUrls]),
1774
+ ];
1775
+ setupProgress?.onPhase?.(PHASE_LOADING_RESOURCES);
1776
+ const resources = await archive.getResourceUrlList(setupProgress?.onChunkProgress);
1777
+ setupProgress?.onPhase?.(PHASE_LOADING_SCRAPED_COUNT);
1778
+ const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
1779
+ setupProgress?.onPhase?.(PHASE_RESTORING_CRAWL_STATE);
1780
+ orchestrator.#crawler.resume(pendingWithReset, scrapedAfter, resources, pagesScrapedOffset, pendingMetadataOnlyAfter);
1781
+ if (initializedCallback) {
1782
+ await initializedCallback(orchestrator, baseConfig);
1783
+ }
1784
+ log('Start recrawl');
1785
+ log('Archive %s', absFilePath);
1786
+ log('Reset %d page(s), %d new HTML seed(s)', resetResult.resetUrls.length, htmlSeeds.length);
1787
+ await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
1788
+ await orchestrator.#crawlUntilPendingClears([], { recursive: true });
1789
+ CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
1790
+ if (resetResult.resetUrls.length > 0) {
1791
+ void orchestrator.emit('crawlSessionNotice', {
1792
+ message: `[recrawl] Reset ${resetResult.resetUrls.length} page(s) — run \`analyze\` before \`report\` to refresh their findings.`,
1793
+ });
1794
+ }
1795
+ await orchestrator.#setUrlOrder();
1796
+ return orchestrator;
1797
+ }
1798
+ // Only non-HTML URLs were imported and nothing was reset —
1799
+ // nothing left to render, but still update sort order.
1800
+ const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
1801
+ if (initializedCallback) {
1802
+ await initializedCallback(orchestrator, baseConfig);
1803
+ }
1804
+ await orchestrator.#setUrlOrder();
1805
+ return orchestrator;
1806
+ }
1807
+ catch (error) {
1808
+ if (ingestionComplete) {
1809
+ // Same rationale as `inventory`'s identical catch — see
1810
+ // that method's comment. Scrape failure here can equally
1811
+ // be the auto-retry loop (issue #350) giving up.
1812
+ try {
1813
+ setupProgress?.onPhase?.(RECOVERY_LEAVE_STATE_FOR_RESUME);
1814
+ await archive.releaseHandle();
1815
+ }
1816
+ catch (persistError) {
1817
+ throw new AggregateError([error, persistError], 'recrawl scrape phase failed AND releasing the archive handle also failed. The archive may be in an inconsistent state — check tmpDir.');
1818
+ }
1819
+ throw error;
1820
+ }
1821
+ try {
1822
+ setupProgress?.onPhase?.(RECOVERY_RESTORE_FROM_BACKUP);
1823
+ await copyFileWithProgress(backupPath, absFilePath, setupProgress?.onCopyProgress);
1824
+ await ignoreEnoent(unlinkFile(backupPath));
1825
+ }
1826
+ catch (restoreError) {
1827
+ throw new AggregateError([error, restoreError], `recrawl failed AND restore from backup failed. Original archive backup is left at: ${backupPath}`);
1828
+ }
1829
+ throw error;
1830
+ }
1831
+ }
1832
+ catch (error) {
1833
+ await archive.close().catch(() => { });
1834
+ throw error;
1835
+ }
1836
+ }
1837
+ /**
1838
+ * Shared first-stage classification for `inventory` and `recrawl`: parse
1839
+ * the candidate URLs, split them by the archived scope map into
1840
+ * in-scope/out-of-scope, then split the in-scope set into URLs already
1841
+ * represented in the archive (as a page or a resource) vs. novel URLs the
1842
+ * archive has never seen. Comparison key is `withoutHashAndAuth` to
1843
+ * mirror what `resolveContentItemId` / `insertResource` actually store.
1844
+ *
1845
+ * The two existing-URL reads run concurrently via `Promise.all` — halves
1846
+ * the wait on large archives where each `WHERE url IN (?)` chunk costs
1847
+ * real I/O.
1848
+ * @param rawUrls - The operator-supplied URL list, unparsed.
1849
+ * @param archived - The archive's persisted config (`roots` defines scope).
1850
+ * @param archive - The opened archive to query for existing URLs.
1851
+ * @returns `outOfScope` (count dropped by the scope filter),
1852
+ * `existingPageUrls` / `existingResourceUrls` (URLs already known, by
1853
+ * kind), and `novelUrls` (parsed, in-scope URLs matching neither).
1854
+ */
1855
+ static async #classifyInventoryCandidateUrls(rawUrls, archived, archive) {
1856
+ const parsedAll = sortUrl(rawUrls, archived);
1857
+ const scopeMap = new Map();
1858
+ for (const raw of archived.roots) {
1859
+ const parsed = parseUrl(raw, archived);
1860
+ if (!parsed)
1861
+ continue;
1862
+ const existing = scopeMap.get(parsed.hostname) ?? [];
1863
+ scopeMap.set(parsed.hostname, [...existing, parsed]);
1864
+ }
1865
+ const inScope = [];
1866
+ let outOfScope = 0;
1867
+ for (const url of parsedAll) {
1868
+ if (findScopeEntry(url, scopeMap, archived) === null) {
1869
+ outOfScope++;
1870
+ }
1871
+ else {
1872
+ inScope.push(url);
1873
+ }
1874
+ }
1875
+ if (outOfScope > 0) {
1876
+ log('[ingest] %d URL(s) skipped (outside archived scope: %O)', outOfScope, archived.roots);
1877
+ }
1878
+ const candidateUrls = inScope.map((u) => u.withoutHashAndAuth);
1879
+ const [existingPageUrls, existingResourceUrls] = await Promise.all([
1880
+ archive.getExistingPageUrls(candidateUrls),
1881
+ archive.getExistingResourceUrls(candidateUrls),
1882
+ ]);
1883
+ const existingPageUrlSet = new Set(existingPageUrls);
1884
+ const existingResourceUrlSet = new Set(existingResourceUrls);
1885
+ const novelUrls = inScope.filter((u) => {
1886
+ const key = u.withoutHashAndAuth;
1887
+ return !existingPageUrlSet.has(key) && !existingResourceUrlSet.has(key);
1888
+ });
1889
+ log('[ingest] %d in-scope, %d already in archive, %d new', inScope.length, existingPageUrlSet.size + existingResourceUrlSet.size, novelUrls.length);
1890
+ return { outOfScope, existingPageUrls, existingResourceUrls, novelUrls };
1891
+ }
1892
+ /**
1893
+ * Shared second-stage classification for `inventory` and `recrawl`:
1894
+ * splits novel URLs on the exclusion config BEFORE the HTML/non-HTML
1895
+ * classification, so an exclude-matched URL is recorded as a terminal
1896
+ * skipped page instead of being imported (issue #260).
1897
+ *
1898
+ * The inputs mirror the scrape phase's fetch-time gate (`shouldSkipUrl`
1899
+ * in `crawler.ts` fed by the constructor's merge): archived config
1900
+ * overlaid with this run's overrides, and `DEFAULT_EXCLUDED_EXTERNAL_URLS`
1901
+ * merged ahead of the user's prefixes — classification and gate must
1902
+ * never disagree about the same URL. Running this AFTER the known-URL
1903
+ * filter (`#classifyInventoryCandidateUrls`) is deliberate: a previously
1904
+ * crawled row that newly matches the exclusion config stays untouched
1905
+ * (crawled-wins), matching how `getExistingPageUrls` shields known rows
1906
+ * from re-labelling. `excludeKeywords` is deliberately absent: it matches
1907
+ * rendered page content, which a URL list does not have — HTML seeds
1908
+ * still get it at render time via the browser verdict.
1909
+ * @param novelUrls - URLs not yet represented in the archive, from
1910
+ * `#classifyInventoryCandidateUrls`.
1911
+ * @param effectiveConfig - The archived config overlaid with this run's
1912
+ * overrides (the same merge the caller uses to build its own config).
1913
+ * @returns `excludedNovelUrls` (matched `excludes`/`excludeUrls`, to be
1914
+ * recorded as terminal skipped pages) and `importableNovelUrls` (the rest).
1915
+ */
1916
+ static #classifyExcludedNovelUrls(novelUrls, effectiveConfig) {
1917
+ const excludes = normalizeToArray(effectiveConfig.excludes);
1918
+ const excludeUrls = [
1919
+ ...DEFAULT_EXCLUDED_EXTERNAL_URLS,
1920
+ ...normalizeToArray(effectiveConfig.excludeUrls),
1921
+ ];
1922
+ const excludedNovelUrls = [];
1923
+ const importableNovelUrls = [];
1924
+ for (const url of novelUrls) {
1925
+ if (shouldSkipUrl({ url, excludes, excludeUrls, options: effectiveConfig })) {
1926
+ excludedNovelUrls.push(url);
1927
+ }
1928
+ else {
1929
+ importableNovelUrls.push(url);
1930
+ }
1931
+ }
1932
+ if (excludedNovelUrls.length > 0) {
1933
+ log('[ingest] %d URL(s) recorded as skipped (matched excludes / excludeUrls)', excludedNovelUrls.length);
1934
+ }
1935
+ return { excludedNovelUrls, importableNovelUrls };
1936
+ }
1937
+ /**
1938
+ * Shared third-stage ingestion for `inventory` and `recrawl`: classifies
1939
+ * importable novel URLs by URL-extension heuristic (no I/O), dedups HTML
1940
+ * seeds, and bulk-records both kinds into the archive.
1941
+ *
1942
+ * Source file lists come from `ls` on the doc-root, so the extension
1943
+ * reflects the real file type — a HEAD pre-flight here would be pure
1944
+ * wasted I/O. Edge cases:
1945
+ *
1946
+ * - `.html` returning 404 / 200: the normal crawler HEAD/GET path absorbs
1947
+ * this because every HTML-classified URL is fed through the dealer and
1948
+ * gets its real HEAD/GET there.
1949
+ * - Extensionless API endpoints (e.g. `/api/foo`) that the server returns
1950
+ * as `text/html`: `isLikelyHtmlUrl` accepts them as HTML so the
1951
+ * dealer's render path runs — the real content-type wins downstream.
1952
+ * - `.aspx` / `.do` / `.jsp` / other server-handler extensions the
1953
+ * heuristic does NOT recognise as HTML: classified as non-HTML,
1954
+ * recorded as `resources` rows with all-null metadata, never get a
1955
+ * HEAD/GET probe. Sites that mix server-handlers into the list need a
1956
+ * follow-up `--retry-failed` pass (or a re-run with the corrected list)
1957
+ * to populate metadata.
1958
+ *
1959
+ * HTML seeds are deduped by `protocolAgnosticKey` so a list mixing
1960
+ * `http://` and `https://` for the same origin does not produce two rows
1961
+ * that the dealer later collapses to one — the loser would otherwise stay
1962
+ * `scraped=0, source='inventory-seed'` forever and look like a real
1963
+ * recovery candidate on `--resume`. `getExistingPageUrls` keys on the
1964
+ * full URL (with protocol), so it cannot catch the cross-scheme
1965
+ * duplicate; this is the dedup boundary.
1966
+ *
1967
+ * Non-HTML URLs are bulk-recorded via `insertInventoryResources` — a
1968
+ * per-URL loop would spend minutes inside the `.bak`-protected window on
1969
+ * a large list; the chunked bulk path collapses N round-trips to N/500.
1970
+ * HTML seeds are pre-inserted as `scraped = 0`, `source =
1971
+ * 'inventory-seed'` placeholders *before* the scrape phase, so a Ctrl+C
1972
+ * between here and `setPage` cannot lose the URL — the strict-pending set
1973
+ * picks these rows up on the next `--resume` via the `OR p.source !=
1974
+ * 'crawled'` clause. Exclude-matched novel URLs are recorded as terminal
1975
+ * skipped pages (`is_skipped=1`, `skip_reason='excluded'`,
1976
+ * `source='inventory-seed'`) — the same end state the normal crawl's
1977
+ * fetch-time gate produces for link-discovered excluded URLs.
1978
+ * @param options - Named parameters (4+ values).
1979
+ * @param options.archive - The opened archive to write into.
1980
+ * @param options.importableNovelUrls - Novel URLs not matched by excludes.
1981
+ * @param options.excludedNovelUrls - Novel URLs matched by excludes, from `#classifyExcludedNovelUrls`.
1982
+ * @param options.setupProgress - Optional setup progress callbacks.
1983
+ * @param options.phaseRecordingNonHtml - The `onPhase` label to announce before recording non-HTML resources.
1984
+ * @param options.phaseRecordingHtmlSeeds - The `onPhase` label to announce before recording HTML seed pages.
1985
+ * @param options.phaseRecordingExcluded - The `onPhase` label to announce before recording excluded pages.
1986
+ * @returns `htmlSeeds` and `nonHtmlSeeds` — the deduped, classified novel URLs actually recorded.
1987
+ */
1988
+ static async #ingestNovelSeeds(options) {
1989
+ const { archive, importableNovelUrls, excludedNovelUrls, setupProgress, phaseRecordingNonHtml, phaseRecordingHtmlSeeds, phaseRecordingExcluded, } = options;
1990
+ const rawHtmlSeeds = [];
1991
+ const nonHtmlSeeds = [];
1992
+ for (const url of importableNovelUrls) {
1993
+ if (isLikelyHtmlUrl(url)) {
1994
+ rawHtmlSeeds.push(url);
1995
+ }
1996
+ else {
1997
+ nonHtmlSeeds.push(url);
1998
+ }
1999
+ }
2000
+ const seenKeys = new Set();
2001
+ const htmlSeeds = [];
2002
+ for (const url of rawHtmlSeeds) {
2003
+ const key = protocolAgnosticKey(url.withoutHashAndAuth);
2004
+ if (seenKeys.has(key)) {
2005
+ continue;
2006
+ }
2007
+ seenKeys.add(key);
2008
+ htmlSeeds.push(url);
2009
+ }
2010
+ setupProgress?.onPhase?.(phaseRecordingNonHtml);
2011
+ await archive.insertInventoryResources(nonHtmlSeeds);
2012
+ setupProgress?.onPhase?.(phaseRecordingHtmlSeeds);
2013
+ await archive.insertInventorySeeds(htmlSeeds);
2014
+ setupProgress?.onPhase?.(phaseRecordingExcluded);
2015
+ await archive.insertInventorySkippedPages(excludedNovelUrls);
2016
+ log('[ingest] %d HTML seed(s), %d non-HTML resource(s), %d skipped page(s) recorded', htmlSeeds.length, nonHtmlSeeds.length, excludedNovelUrls.length);
2017
+ return { htmlSeeds, nonHtmlSeeds };
2018
+ }
1225
2019
  /**
1226
2020
  * Re-fetch previously-failed pages in an existing `.nitpicker` archive.
1227
2021
  *
@@ -1245,7 +2039,10 @@ export class CrawlerOrchestrator extends EventEmitter {
1245
2039
  *
1246
2040
  * A `<archive>.bak` is created before any DB mutation and removed on success;
1247
2041
  * if the crawl throws, the backup is restored to keep the original archive
1248
- * intact.
2042
+ * intact — except when the crawl ends with {@link PendingUrlsRemainError}
2043
+ * (issue #350), where the un-packaged stub itself is the recovery path and
2044
+ * the backup is instead left untouched (deleted, not restored — see
2045
+ * {@link CrawlerOrchestrator.#abandonBackupOnPendingRemains}).
1249
2046
  *
1250
2047
  * List-mode archives (`info.fromList === true`) are rejected for the same
1251
2048
  * reason as {@link CrawlerOrchestrator.append}: their pages are metadata-only.
@@ -1258,6 +2055,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1258
2055
  * this can't go through the orchestrator's event emitter (issue #294).
1259
2056
  * @returns The orchestrator instance after the retry crawl completes.
1260
2057
  * @throws {Error} When the archive is in list mode or has no parseable roots.
2058
+ * @throws {PendingUrlsRemainError} When the crawl session ends with pages still pending after exhausting auto-retry.
1261
2059
  */
1262
2060
  static async retryFailed(archivePath, options, initializedCallback, setupProgress) {
1263
2061
  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;
@@ -1284,6 +2082,9 @@ export class CrawlerOrchestrator extends EventEmitter {
1284
2082
  if (archived.fromList) {
1285
2083
  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.');
1286
2084
  }
2085
+ // Stamped for `Archive.resume` (issue #350) — same rationale as
2086
+ // `inventory`'s identical call.
2087
+ await archive.updateConfig(buildCreatedCwdPatch(cwd));
1287
2088
  const rootsParsed = sortUrl(archived.roots, archived);
1288
2089
  if (rootsParsed.length === 0) {
1289
2090
  throw new Error('retry: archive has no parseable root URLs');
@@ -1306,32 +2107,60 @@ export class CrawlerOrchestrator extends EventEmitter {
1306
2107
  log('Reset %d failed page(s)', resetUrls.length);
1307
2108
  // Seed the sticky set from prior sessions' confirmed traps so
1308
2109
  // `--retry-failed` does not pay the cost of re-discovering
1309
- // them (see `DedupeCapTracker`'s constructor JSDoc).
2110
+ // them (see `DedupeCapTracker`'s constructor JSDoc), and
2111
+ // replay every not-yet-capped shape's prior observations so
2112
+ // its counter does not restart at 0 (see
2113
+ // `#preloadDedupeCapObservations`). Runs *after*
2114
+ // `resetFailedPages` above, which already excludes
2115
+ // confirmed-capped-shape failures from the reset — so the
2116
+ // still-`scraped=1` rows this reads back never include a page
2117
+ // this same call is about to re-queue, and a page this call
2118
+ // does reset is correctly absent from the replay (its
2119
+ // `page_meta` row — and therefore its `body_hash` — was just
2120
+ // deleted).
1310
2121
  setupProgress?.onPhase?.(PHASE_LOADING_DEDUPE_KEYS);
1311
- const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
2122
+ // Independent reads run concurrently (see `append`'s
2123
+ // identical pair).
2124
+ const [preloadedStickyShapeKeys, preloadedDedupeObservations] = await Promise.all([
2125
+ archive.listDedupeCapShapeKeys(),
2126
+ CrawlerOrchestrator.#preloadDedupeCapObservations(archive, options?.dedupeCap ?? null, setupProgress?.onChunkProgress),
2127
+ ]);
1312
2128
  const orchestrator = new CrawlerOrchestrator(archive, {
1313
2129
  ...config,
1314
2130
  preloadedStickyShapeKeys,
2131
+ preloadedDedupeObservations,
1315
2132
  });
1316
2133
  setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE);
1317
- const { scraped, pending } = await archive.getCrawlingState();
2134
+ // `archived.fromList` is rejected above, so this archive's
2135
+ // `recursive` was never `false` for a list-mode reason — no
2136
+ // root-exclusion needed (see the list-mode `resume`/
2137
+ // auto-retry paths' comments for why it matters there). No
2138
+ // `= []` default needed: `pendingMetadataOnly` is only ever
2139
+ // passed straight through to `Crawler#resume()`, whose own
2140
+ // `metadataOnlyUrls` parameter already defaults `undefined`
2141
+ // to `[]`.
2142
+ const { scraped, pending, pendingMetadataOnly } = await archive.getCrawlingState();
1318
2143
  setupProgress?.onPhase?.(PHASE_LOADING_RESOURCES);
1319
2144
  const resources = await archive.getResourceUrlList(setupProgress?.onChunkProgress);
1320
2145
  setupProgress?.onPhase?.(PHASE_LOADING_SCRAPED_COUNT);
1321
2146
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
1322
2147
  setupProgress?.onPhase?.(PHASE_RESTORING_CRAWL_STATE);
1323
- orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
2148
+ orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset, pendingMetadataOnly);
1324
2149
  if (initializedCallback) {
1325
2150
  await initializedCallback(orchestrator, config);
1326
2151
  }
1327
2152
  await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
1328
- await orchestrator.crawling([], { recursive: config.recursive });
2153
+ await orchestrator.#crawlUntilPendingClears([], { recursive: config.recursive });
1329
2154
  CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
1330
2155
  await orchestrator.#setUrlOrder();
1331
2156
  await ignoreEnoent(unlinkFile(backupPath));
1332
2157
  return orchestrator;
1333
2158
  }
1334
2159
  catch (error) {
2160
+ if (error instanceof PendingUrlsRemainError) {
2161
+ await CrawlerOrchestrator.#abandonBackupOnPendingRemains(setupProgress, backupPath);
2162
+ throw error;
2163
+ }
1335
2164
  try {
1336
2165
  setupProgress?.onPhase?.(RECOVERY_RESTORE_FROM_BACKUP);
1337
2166
  await copyFileWithProgress(backupPath, absFilePath, setupProgress?.onCopyProgress);
@@ -1370,6 +2199,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1370
2199
  * tmpDir IS the source of truth).
1371
2200
  * @returns A promise that resolves to the CrawlerOrchestrator instance after crawling completes.
1372
2201
  * @throws {Error} If the archived URL is invalid.
2202
+ * @throws {PendingUrlsRemainError} When the crawl session ends with pages still pending after exhausting auto-retry.
1373
2203
  */
1374
2204
  static async resume(stubPath, options, initializedCallback, setupProgress) {
1375
2205
  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;
@@ -1379,13 +2209,21 @@ export class CrawlerOrchestrator extends EventEmitter {
1379
2209
  const archivedConfig = await archive.getConfig();
1380
2210
  // Seed the sticky set from prior sessions' confirmed traps so
1381
2211
  // `--resume` does not pay the cost of re-discovering them (see
1382
- // `DedupeCapTracker`'s constructor JSDoc).
2212
+ // `DedupeCapTracker`'s constructor JSDoc), and replay every
2213
+ // not-yet-capped shape's prior observations so its counter does not
2214
+ // restart at 0 (see `#preloadDedupeCapObservations`).
1383
2215
  setupProgress?.onPhase?.(PHASE_LOADING_DEDUPE_KEYS);
1384
- const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
2216
+ // Independent reads run concurrently (see `append`'s identical
2217
+ // pair).
2218
+ const [preloadedStickyShapeKeys, preloadedDedupeObservations] = await Promise.all([
2219
+ archive.listDedupeCapShapeKeys(),
2220
+ CrawlerOrchestrator.#preloadDedupeCapObservations(archive, options?.dedupeCap ?? null, setupProgress?.onChunkProgress),
2221
+ ]);
1385
2222
  const config = {
1386
2223
  ...archivedConfig,
1387
2224
  ...cleanObject(options),
1388
2225
  preloadedStickyShapeKeys,
2226
+ preloadedDedupeObservations,
1389
2227
  };
1390
2228
  const orchestrator = new CrawlerOrchestrator(archive, config);
1391
2229
  const _url = await archive.getUrl();
@@ -1394,13 +2232,31 @@ export class CrawlerOrchestrator extends EventEmitter {
1394
2232
  throw new Error(`URL (${_url}) is invalid`);
1395
2233
  }
1396
2234
  setupProgress?.onPhase?.(PHASE_LOADING_CRAWL_STATE);
1397
- const { scraped, pending } = await archive.getCrawlingState();
2235
+ const { scraped, pending, pendingMetadataOnly = [], } = await archive.getCrawlingState();
2236
+ // Unlike `append`/`inventory`/`recrawl`/`retryFailed`, this path
2237
+ // resumes ANY archive including list-mode ones (`fromList: true`,
2238
+ // `recursive: false`) — exactly the #369 scenario. `config.roots`
2239
+ // are always full-scrape targets regardless of `is_metadata_only`
2240
+ // (see `#crawlUntilPendingClears`'s identical root-exclusion
2241
+ // comment for why a root's row can be wrongly flagged by another
2242
+ // page's anchor). `config.roots` is `ExURL#withoutHash` form (may
2243
+ // keep basic-auth userinfo), but `pendingMetadataOnly` entries are
2244
+ // always `url_refs.url` (`withoutHashAndAuth`, auth stripped —
2245
+ // see `insert-page.ts`/`resolve-content-item-id.ts`) — re-parse
2246
+ // each root through the same normalization before comparing, or a
2247
+ // root URL carrying credentials would never match `rootKeys` and
2248
+ // would wrongly stay in `metadataOnlyUrls` below (issue #369 code
2249
+ // review).
2250
+ const rootKeys = new Set(config.roots
2251
+ .map((root) => parseUrl(root, config)?.withoutHashAndAuth)
2252
+ .filter((root) => root !== undefined));
2253
+ const metadataOnlyUrls = pendingMetadataOnly.filter((u) => !rootKeys.has(u));
1398
2254
  setupProgress?.onPhase?.(PHASE_LOADING_RESOURCES);
1399
2255
  const resources = await archive.getResourceUrlList(setupProgress?.onChunkProgress);
1400
2256
  setupProgress?.onPhase?.(PHASE_LOADING_SCRAPED_COUNT);
1401
2257
  const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
1402
2258
  setupProgress?.onPhase?.(PHASE_RESTORING_CRAWL_STATE);
1403
- orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset);
2259
+ orchestrator.#crawler.resume(pending, scraped, resources, pagesScrapedOffset, metadataOnlyUrls);
1404
2260
  if (initializedCallback) {
1405
2261
  await initializedCallback(orchestrator, config);
1406
2262
  }
@@ -1409,10 +2265,33 @@ export class CrawlerOrchestrator extends EventEmitter {
1409
2265
  log('URL %s', url.href);
1410
2266
  log('Config %O', config);
1411
2267
  await CrawlerOrchestrator.#preloadDnsBurnedHostCache(archive);
1412
- await orchestrator.crawling([url]);
2268
+ await orchestrator.#crawlUntilPendingClears([url]);
1413
2269
  CrawlerOrchestrator.#finalizeCrawlSession(orchestrator);
1414
2270
  return orchestrator;
1415
2271
  }
2272
+ /**
2273
+ * Shared `PendingUrlsRemainError` recovery step for `append` and
2274
+ * `retryFailed`'s catch blocks (issue #350 code review — `inventory` /
2275
+ * `recrawl` reach the same outcome through their own `ingestionComplete`
2276
+ * branch instead, which has no `.bak` left to clean up by the time it
2277
+ * runs, so this helper is specific to the two `.bak`-restore-by-default
2278
+ * catch shapes).
2279
+ *
2280
+ * `#crawlUntilPendingClears` has already released the archive handle
2281
+ * and left the stub intact for `--resume`/`--retry-failed` by the time
2282
+ * this runs; `write()` never ran, so the original archive file was
2283
+ * never touched. Restoring `.bak` over it would be a wasted
2284
+ * full-archive copy (and show a misleading "Restoring from backup"
2285
+ * phase label) — only the now-unnecessary `.bak` needs cleaning up.
2286
+ * @param setupProgress - Forwarded so the recovery phase label still
2287
+ * reaches the caller's setup `TaskList`.
2288
+ * @param backupPath - The `.bak` path to delete (`unlinkFile`, ENOENT
2289
+ * ignored).
2290
+ */
2291
+ static async #abandonBackupOnPendingRemains(setupProgress, backupPath) {
2292
+ setupProgress?.onPhase?.(RECOVERY_LEAVE_STATE_FOR_RESUME);
2293
+ await ignoreEnoent(unlinkFile(backupPath));
2294
+ }
1416
2295
  /**
1417
2296
  * Seeds {@link dnsBurnedHostCache} from `crawl_errors` history at re-open
1418
2297
  * (append / inventory / retryFailed / resume). Called after Archive.open
@@ -1435,8 +2314,40 @@ export class CrawlerOrchestrator extends EventEmitter {
1435
2314
  }
1436
2315
  }
1437
2316
  /**
1438
- * Persist one `inventory_runs` audit row inside the ingestion phase of a
1439
- * `--inventory` invocation, before the `.bak` is released. Lives as a
2317
+ * Reads back this archive's prior `DedupeCapTracker` observations (see
2318
+ * `Archive.listDedupeCapObservations`) and reconstructs them via
2319
+ * `buildDedupeCapObservation`, for the same five resuming-session static
2320
+ * methods that already call `archive.listDedupeCapShapeKeys()` to seed
2321
+ * `CrawlConfig.preloadedStickyShapeKeys`. Unlike that sticky-shape
2322
+ * preload, this read is skipped entirely when `dedupeCap` is `null` — a
2323
+ * full per-page table scan is not worth paying on every resuming session
2324
+ * that has `--dedupe-cap` disabled, whereas the shape-key `DISTINCT`
2325
+ * query stays cheap enough to always run.
2326
+ * @param archive - The opened archive whose scraped pages are read.
2327
+ * @param dedupeCap - The resolved `CrawlConfig.dedupeCap` for this
2328
+ * session. `null` short-circuits to `[]` without touching the archive.
2329
+ * @param onProgress - Forwarded to `archive.listDedupeCapObservations` —
2330
+ * see that method's docs.
2331
+ * @returns Every qualifying page's reconstructed observation (rows with
2332
+ * no usable shape/meta signal are silently dropped — see
2333
+ * `buildDedupeCapObservation`).
2334
+ */
2335
+ static async #preloadDedupeCapObservations(archive, dedupeCap, onProgress) {
2336
+ if (dedupeCap === null)
2337
+ return [];
2338
+ const rows = await archive.listDedupeCapObservations(onProgress);
2339
+ const observations = [];
2340
+ for (const row of rows) {
2341
+ const observation = buildDedupeCapObservation(row);
2342
+ if (observation) {
2343
+ observations.push(observation);
2344
+ }
2345
+ }
2346
+ return observations;
2347
+ }
2348
+ /**
2349
+ * Persist one `list_reconcile_runs` audit row inside the ingestion phase
2350
+ * of a `--inventory` invocation, before the `.bak` is released. Lives as a
1440
2351
  * static helper because the audit-row shape (timestamp stamping + label
1441
2352
  * auto-gen + the privacy-driven path elision documented below) is a
1442
2353
  * cohesive concern that benefits from staying outside the long
@@ -1451,7 +2362,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1451
2362
  * `computeFileSha256` against the bytes it read from the input txt,
1452
2363
  * before the orchestrator was even invoked). The orchestrator boundary
1453
2364
  * deliberately never sees the absolute path — see
1454
- * {@link InventoryRunAggregates} for the privacy rationale.
2365
+ * {@link ListReconcileRunAggregates} for the privacy rationale.
1455
2366
  *
1456
2367
  * **Audit-write failures abort the ingestion phase.** Swallowing them
1457
2368
  * would only be justified if the audit were the last write after the
@@ -1459,20 +2370,20 @@ export class CrawlerOrchestrator extends EventEmitter {
1459
2370
  * `.bak`-protected ingestion phase the
1460
2371
  * trade-off flips. A failed audit row is restorable: the outer catch
1461
2372
  * copies `.bak` back over the archive and the operator reruns the
1462
- * (short) ingestion from scratch. That keeps `inventory_runs` honest
1463
- * (no "ran but unrecorded" rows) at the cost of one rerun.
2373
+ * (short) ingestion from scratch. That keeps `list_reconcile_runs`
2374
+ * honest (no "ran but unrecorded" rows) at the cost of one rerun.
1464
2375
  *
1465
2376
  * Forward-compat: if an explicit `--label` flag is ever added, thread
1466
2377
  * `labelOverride` through {@link inventory} into the `aggregates`
1467
2378
  * shape so the auto-name can be overridden.
1468
2379
  * @param archive - The opened archive to write the audit row into.
1469
- * @param aggregates - The counts captured during the inventory pass; see {@link InventoryRunAggregates}.
2380
+ * @param aggregates - The counts captured during the inventory pass; see {@link ListReconcileRunAggregates}.
1470
2381
  */
1471
- static async #writeInventoryRunRow(archive, aggregates) {
2382
+ static async #writeListReconcileRunRow(archive, aggregates) {
1472
2383
  const ranAt = new Date().toISOString();
1473
- await archive.recordInventoryRun({
2384
+ await archive.recordListReconcileRun({
1474
2385
  ran_at: ranAt,
1475
- list_label: `inventory-${ranAt}`,
2386
+ list_label: `${aggregates.listLabelPrefix ?? 'inventory'}-${ranAt}`,
1476
2387
  source_file_sha256: aggregates.sourceFileSha256,
1477
2388
  total_lines: aggregates.inventoryUrlsCount,
1478
2389
  new_pages: aggregates.htmlSeedsCount,
@@ -1480,6 +2391,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1480
2391
  scope_skipped: aggregates.outOfScope,
1481
2392
  exclude_skipped: aggregates.excludeSkipped,
1482
2393
  invalid_skipped: aggregates.invalidSkipped,
2394
+ notes: aggregates.notes ?? null,
1483
2395
  });
1484
2396
  }
1485
2397
  /**
@@ -1514,6 +2426,37 @@ export class CrawlerOrchestrator extends EventEmitter {
1514
2426
  clearDnsBurnedHostCache();
1515
2427
  }
1516
2428
  }
2429
+ /**
2430
+ * Resolves a `CrawlConfig.cwd` value to an absolute path before it is
2431
+ * stamped as `Config.createdCwd` (issue #350). `cwd` is trusted as an
2432
+ * absolute base everywhere else in this file (`path.resolve(cwd, ...)` for
2433
+ * `absFilePath`/tmpDir), so a caller-supplied relative `cwd` already
2434
+ * resolves against `process.cwd()` implicitly for archive placement — this
2435
+ * makes that same resolution explicit for the value `Archive.resume` will
2436
+ * later read back, so a relative `createdCwd` can never silently
2437
+ * reintroduce the cwd-dependent resume path this column exists to fix.
2438
+ * `path.resolve` is a no-op when `cwd` is already absolute.
2439
+ * @param cwd - The `CrawlConfig.cwd` value (defaults to `process.cwd()` at each call site).
2440
+ * @returns An absolute path.
2441
+ */
2442
+ function resolveAbsoluteCwd(cwd) {
2443
+ return path.resolve(process.cwd(), cwd);
2444
+ }
2445
+ /**
2446
+ * Builds the `Config` patch that stamps `createdCwd` (issue #350) — the one
2447
+ * field every stub-creating static factory (`crawling`/`append`/`inventory`/
2448
+ * `recrawl`/`retryFailed`; `resume` deliberately excluded, it only reads
2449
+ * this value) must set. Centralised so a future stub-creating mode spreads
2450
+ * this into its `setConfig`/`updateConfig` call instead of hand-rolling
2451
+ * `{ createdCwd: resolveAbsoluteCwd(cwd) }` and risking a forgotten
2452
+ * `resolveAbsoluteCwd` wrap (see that function's JSDoc for why the
2453
+ * resolution itself matters).
2454
+ * @param cwd - The `CrawlConfig.cwd` value for this session.
2455
+ * @returns A one-field `Partial<Config>` patch.
2456
+ */
2457
+ function buildCreatedCwdPatch(cwd) {
2458
+ return { createdCwd: resolveAbsoluteCwd(cwd) };
2459
+ }
1517
2460
  /**
1518
2461
  * Await a filesystem promise but silently swallow only `ENOENT` errors. Any
1519
2462
  * other failure (permissions, disk full, etc.) propagates so the caller can