@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
@@ -80,6 +80,28 @@ export interface Config extends Required<Pick<ParseURLOptions, 'disableQueries'>
80
80
  * detection, or `null`/omitted to use the automatic heuristic.
81
81
  */
82
82
  mainContentSelector?: string | null;
83
+ /**
84
+ * The `process.cwd()` recorded when this session's stub (tmpDir) was
85
+ * created by `CrawlerOrchestrator.crawling` / `.append` / `.inventory` /
86
+ * `.recrawl` / `.retryFailed` — NOT written by `.resume` itself, which
87
+ * only reads it. `Archive.resume` uses this (falling back to its own
88
+ * `process.cwd()` for a stub that predates this column) to reconstruct
89
+ * the completed archive's output path independent of the directory
90
+ * `crawl --resume` happens to be invoked from.
91
+ *
92
+ * `.nitpicker` files are routinely shared between users, and this local
93
+ * absolute path (which can embed a username / project layout) has no
94
+ * business surviving into a shared archive — `Archive.write` scrubs the
95
+ * column back to `null` immediately before packaging, so it only ever
96
+ * exists in an unpackaged stub.
97
+ *
98
+ * WHY (for future stub-limited fields): if you add another column meant
99
+ * to live only in the unpackaged stub, pair it with a matching scrub in
100
+ * `Archive.write` (`archive.ts`) — nothing enforces this automatically,
101
+ * and a forgotten scrub silently leaks the field into every shared
102
+ * `.nitpicker` from then on.
103
+ */
104
+ createdCwd?: string | null;
83
105
  }
84
106
  /**
85
107
  * Provenance of a page or resource row — which crawler channel originally
@@ -107,13 +129,13 @@ export interface Config extends Required<Pick<ParseURLOptions, 'disableQueries'>
107
129
  */
108
130
  export type PageSource = 'crawled' | 'inventory-seed' | 'inventory-discovered';
109
131
  /**
110
- * One row written to the `inventory_runs` audit table on each successful
111
- * `--inventory <list>` invocation.
132
+ * One row written to the `list_reconcile_runs` audit table on each
133
+ * successful `--inventory <list>` or `--recrawl <list>` invocation.
112
134
  *
113
- * Schema-mirror interface: every column on `inventory_runs` is represented
114
- * here. Only `ran_at` is required — every other field is nullable so a
115
- * raw-SQL backfill (a one-off `sqlite3 INSERT` recording an inventory
116
- * pass that predates this table) can omit summary
135
+ * Schema-mirror interface: every column on `list_reconcile_runs` is
136
+ * represented here. Only `ran_at` is required — every other field is
137
+ * nullable so a raw-SQL backfill (a one-off `sqlite3 INSERT` recording a
138
+ * reconcile pass that predates this table) can omit summary
117
139
  * stats it cannot reconstruct.
118
140
  *
119
141
  * The audit log is append-only: there is intentionally no UPDATE path,
@@ -122,7 +144,7 @@ export type PageSource = 'crawled' | 'inventory-seed' | 'inventory-discovered';
122
144
  * detection is a read-side concern; `source_file_sha256` is recorded as
123
145
  * the content-identity key it would use.
124
146
  * @example
125
- * await archive.recordInventoryRun({
147
+ * await archive.recordListReconcileRun({
126
148
  * ran_at: new Date().toISOString(),
127
149
  * list_label: 'prod-2026-06',
128
150
  * total_lines: 113_268,
@@ -132,7 +154,7 @@ export type PageSource = 'crawled' | 'inventory-seed' | 'inventory-discovered';
132
154
  * exclude_skipped: 3,
133
155
  * });
134
156
  */
135
- export interface InventoryRunMeta {
157
+ export interface ListReconcileRunMeta {
136
158
  /** ISO 8601 timestamp at which the run completed (e.g. `'2026-06-21T11:30:00+09:00'`). */
137
159
  ran_at: string;
138
160
  /** Human-readable identifier (e.g. `'prod-2026-06-21'`). `null` when the caller did not supply one. */
@@ -147,13 +169,36 @@ export interface InventoryRunMeta {
147
169
  new_resources?: number | null;
148
170
  /** Number of input URLs dropped because they fell outside the archived scope. */
149
171
  scope_skipped?: number | null;
150
- /** Number of novel in-scope input URLs recorded as terminal skipped pages (`is_skipped=1`, `skip_reason='excluded'`) instead of being imported, because they matched the effective `excludes` / `excludeUrls` config. Pure audit output like every other count on this row — written once per run, read back only by `listInventoryRuns` display surfaces, never consumed by any runtime decision. `null` on rows written before the column existed (those runs predate ingestion-side exclusion — their excluded URLs were imported as real pages/resources, not counted). */
172
+ /** Number of novel in-scope input URLs recorded as terminal skipped pages (`is_skipped=1`, `skip_reason='excluded'`) instead of being imported, because they matched the effective `excludes` / `excludeUrls` config. Pure audit output like every other count on this row — written once per run, read back only by `listReconcileRuns` display surfaces, never consumed by any runtime decision. `null` on rows written before the column existed (those runs predate ingestion-side exclusion — their excluded URLs were imported as real pages/resources, not counted). */
151
173
  exclude_skipped?: number | null;
152
174
  /** Number of source-file lines dropped by the CLI for failing URL validation, before this row's `total_lines` was counted. `null` for programmatic callers that built the URL list in-memory (no source file to have invalid lines). */
153
175
  invalid_skipped?: number | null;
154
176
  /** Free-form text for backfill annotations or operator notes. */
155
177
  notes?: string | null;
156
178
  }
179
+ /**
180
+ * Result of {@link Database.resetPagesByUrls}: the URLs actually reset back
181
+ * to pending, plus the URLs excluded from reset grouped by why — a caller
182
+ * (`CrawlerOrchestrator.recrawl`) reports each exclusion category to the
183
+ * operator separately rather than lumping them into one opaque count.
184
+ *
185
+ * A URL can appear in at most one of these four arrays: `resetUrls` when it
186
+ * matched the reset candidate criteria, or exactly one exclusion array when
187
+ * it matched an existing page row but failed one of the conservative guards.
188
+ * A URL with no matching row in the archive at all (not yet known) appears in
189
+ * none of them — that case is the caller's "novel URL" handling, not this
190
+ * function's concern.
191
+ */
192
+ export interface ResetPagesByUrlsResult {
193
+ /** URLs whose row was reset to `scraped = 0` and had its derived data cleared. */
194
+ resetUrls: string[];
195
+ /** URLs excluded because the matching row is a redirect source (`redirect_dest_id` is set) — resetting it would discard a real 3xx observation. */
196
+ excludedRedirects: string[];
197
+ /** URLs excluded because the matching row was intentionally skipped (`is_skipped = 1`, e.g. `excludes`/`excludeUrls`) — the exclusion still applies. */
198
+ excludedSkipped: string[];
199
+ /** URLs excluded because the matching row is external (`is_external = 1`) — bringing a scope-external URL back into scope is `--append`'s job, not a URL-list re-fetch. */
200
+ excludedExternal: string[];
201
+ }
157
202
  /**
158
203
  * A row in `network_outages` — one detected operator-network outage.
159
204
  *
@@ -183,7 +228,7 @@ export interface NetworkOutageRow {
183
228
  /**
184
229
  * Fields required to record a newly-detected outage via
185
230
  * `Database.insertNetworkOutage`. camelCase (unlike {@link NetworkOutageRow}
186
- * / {@link InventoryRunMeta}) because callers build this directly from
231
+ * / {@link ListReconcileRunMeta}) because callers build this directly from
187
232
  * `NetworkOutageDetector`'s camelCase `OutageSuspect` plus a probe host —
188
233
  * the db-op does the camelCase → snake_case column mapping on write.
189
234
  */
@@ -210,6 +255,44 @@ export interface InsertDedupeCapEventParams {
210
255
  observedCount: number;
211
256
  detectedAt: number;
212
257
  }
258
+ /**
259
+ * One previously-scraped internal page's raw fields, read back from
260
+ * `content_items` / `page_meta` / `url_refs` / `text_refs`, in the exact
261
+ * shape `buildDedupeCapObservation` needs to reconstruct the
262
+ * `DedupeCapObservation` that page would have produced during a live crawl.
263
+ * Produced by `listDedupeCapObservations` — see that function for the row
264
+ * selection criteria (which pages qualify, and why).
265
+ *
266
+ * Nullable fields mirror the LEFT JOINed `text_refs` / `url_refs` columns
267
+ * they come from (a page with no `<title>`, no meta description, or no
268
+ * `og:title`/`og:url` leaves the corresponding ref column NULL).
269
+ */
270
+ export interface DedupeCapObservationRow {
271
+ /**
272
+ * `url_refs.url`, in `withoutHashAndAuth` form — matches how
273
+ * `computeShapeKey`'s live callers key their gate lookups. No hash
274
+ * fragment survives here even if the original page URL had one (the
275
+ * archive never stores it) — see `buildDedupeCapObservation`'s JSDoc
276
+ * for why this makes its `ogUrlMismatch` reconstruction an
277
+ * approximation for hash-carrying URLs.
278
+ */
279
+ url: string;
280
+ /** `page_meta.title_text_id` → `text_refs.text`. Never NULL in practice (beholder always returns at least `''`), but read as nullable since the join can't guarantee it. */
281
+ title: string | null;
282
+ /** `page_meta.description_text_id` → `text_refs.text`. */
283
+ description: string | null;
284
+ /** `page_meta.og_title_text_id` → `text_refs.text`. */
285
+ ogTitle: string | null;
286
+ /**
287
+ * `page_meta.og_url_id` → `url_refs.url` — already absolutised at write
288
+ * time (`derive-flat-from-meta.ts`), not the raw `og:url` attribute
289
+ * value. See `buildDedupeCapObservation`'s JSDoc for why this makes its
290
+ * `metaSig` component an approximation for relative-`og:url` templates.
291
+ */
292
+ ogUrl: string | null;
293
+ /** `page_meta.body_hash`. Rows with a NULL body hash are excluded by `listDedupeCapObservations` itself, so this is always present by the time a row reaches this type — kept non-nullable here for that reason. */
294
+ bodyHash: Buffer;
295
+ }
213
296
  /**
214
297
  * Filter type for querying pages from the database.
215
298
  *
@@ -402,6 +485,10 @@ export interface DB_Page {
402
485
  scroll_height_desktop: number | null;
403
486
  /** `document.body.scrollHeight` at the mobile-small preset, or null. */
404
487
  scroll_height_mobile: number | null;
488
+ /** `@d-zero/beholder`'s `IMAGE_SCAN_CODE` outcome for the desktop-compact `<img>` scan, or null when not attempted. */
489
+ image_scan_desktop: number | null;
490
+ /** `@d-zero/beholder`'s `IMAGE_SCAN_CODE` outcome for the mobile-small `<img>` scan, or null when not attempted. */
491
+ image_scan_mobile: number | null;
405
492
  /** JSON-serialised nested Meta sub-objects not flattened above. */
406
493
  meta_extras: string | null;
407
494
  /** JSON-serialized network logs captured during scraping, or null if not collected. */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Computes the exponential backoff delay before an auto-retry attempt
3
+ * (issue #350): 30s, 60s, 120s, … doubling each attempt, capped at 5
4
+ * minutes so a long run of retries against a persistently slow site does
5
+ * not stall for arbitrarily long between attempts.
6
+ *
7
+ * WHY NOT `@d-zero/shared/retry`'s `retryCall`: that helper retries a
8
+ * single async call until it stops throwing, driven entirely by
9
+ * exceptions — this loop instead re-runs a whole crawl pass and decides
10
+ * whether to continue by reading `getCrawlingState().pending` afterward
11
+ * (no exception involved on the "still pending" path) and can abandon
12
+ * early on a no-progress attempt, neither of which `retryCall`'s
13
+ * catch-and-retry model expresses. `@d-zero/shared/retry` also does not
14
+ * export its interval math as a standalone function (only the
15
+ * `retryCall`/`retry` entry points, which own the whole wait+retry loop
16
+ * internally), so there is nothing smaller to reuse for just the delay
17
+ * calculation either.
18
+ * @param attempt - The 1-indexed attempt number about to run.
19
+ * @returns The delay in milliseconds to wait before that attempt.
20
+ * @example
21
+ * ```ts
22
+ * computeAutoRetryBackoffDelayMs(1);
23
+ * // => 30_000 (30s)
24
+ * computeAutoRetryBackoffDelayMs(2);
25
+ * // => 60_000 (60s)
26
+ * computeAutoRetryBackoffDelayMs(10);
27
+ * // => 300_000 (5min) — capped
28
+ * ```
29
+ */
30
+ export declare function computeAutoRetryBackoffDelayMs(attempt: number): number;
@@ -0,0 +1,36 @@
1
+ /** Initial wait before the first auto-retry attempt, in milliseconds. */
2
+ const INITIAL_DELAY_MS = 30_000;
3
+ /** Upper bound on the wait between auto-retry attempts, in milliseconds. */
4
+ const MAX_DELAY_MS = 300_000;
5
+ /**
6
+ * Computes the exponential backoff delay before an auto-retry attempt
7
+ * (issue #350): 30s, 60s, 120s, … doubling each attempt, capped at 5
8
+ * minutes so a long run of retries against a persistently slow site does
9
+ * not stall for arbitrarily long between attempts.
10
+ *
11
+ * WHY NOT `@d-zero/shared/retry`'s `retryCall`: that helper retries a
12
+ * single async call until it stops throwing, driven entirely by
13
+ * exceptions — this loop instead re-runs a whole crawl pass and decides
14
+ * whether to continue by reading `getCrawlingState().pending` afterward
15
+ * (no exception involved on the "still pending" path) and can abandon
16
+ * early on a no-progress attempt, neither of which `retryCall`'s
17
+ * catch-and-retry model expresses. `@d-zero/shared/retry` also does not
18
+ * export its interval math as a standalone function (only the
19
+ * `retryCall`/`retry` entry points, which own the whole wait+retry loop
20
+ * internally), so there is nothing smaller to reuse for just the delay
21
+ * calculation either.
22
+ * @param attempt - The 1-indexed attempt number about to run.
23
+ * @returns The delay in milliseconds to wait before that attempt.
24
+ * @example
25
+ * ```ts
26
+ * computeAutoRetryBackoffDelayMs(1);
27
+ * // => 30_000 (30s)
28
+ * computeAutoRetryBackoffDelayMs(2);
29
+ * // => 60_000 (60s)
30
+ * computeAutoRetryBackoffDelayMs(10);
31
+ * // => 300_000 (5min) — capped
32
+ * ```
33
+ */
34
+ export function computeAutoRetryBackoffDelayMs(attempt) {
35
+ return Math.min(INITIAL_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
36
+ }
@@ -0,0 +1,32 @@
1
+ import type { CrawlerOptions, CrawlRuntimeOptions, CrawlRuntimeOptionsPatch } from './types.js';
2
+ /**
3
+ * Applies a runtime patch to a live {@link CrawlerOptions} object in place
4
+ * and returns a snapshot of the affected fields.
5
+ *
6
+ * `parallels`/`interval` overwrite the current value. The three exclude
7
+ * arrays are additive only — new entries are appended (duplicates against
8
+ * the existing array dropped); there is no way to remove an already-set
9
+ * exclude pattern through this function. `Crawler#updateRuntimeOptions`
10
+ * (the only caller) reads `options.excludes`/`.excludeUrls`/
11
+ * `.excludeKeywords`/`.parallels`/`.interval` fresh on every use — see
12
+ * `shouldSkipUrl`'s call site in `crawler.ts` and the per-URL interval
13
+ * check — so replacing these fields with new arrays/values here takes
14
+ * effect starting with the next URL a worker picks up, without any cache
15
+ * to invalidate.
16
+ * @param options - The live options object to mutate.
17
+ * @param patch - The runtime change to apply.
18
+ * @returns A snapshot of `parallels`/`interval`/the three exclude arrays after
19
+ * applying `patch`, plus which of `patch`'s exclude entries (if any) were
20
+ * genuinely new (see {@link CrawlRuntimeOptions.addedExcludes}).
21
+ * @throws {RangeError} If `parallels` is present and not an integer `>= 1`, or `interval` is present and not an integer `>= 0`.
22
+ * @throws {TypeError} If any exclude entry is present and not a non-empty string.
23
+ * @example
24
+ * ```ts
25
+ * const snapshot = applyCrawlRuntimeOptionsPatch(options, {
26
+ * parallels: 4,
27
+ * excludes: ['/admin/**'],
28
+ * });
29
+ * console.log(snapshot.parallels); // 4
30
+ * ```
31
+ */
32
+ export declare function applyCrawlRuntimeOptionsPatch(options: CrawlerOptions, patch: CrawlRuntimeOptionsPatch): CrawlRuntimeOptions;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Merges `additions` into `existing`, appending only entries not already
3
+ * present (order-preserving, first occurrence wins) — the same
4
+ * `[...new Set([...a, ...b])]` idiom `crawler-orchestrator.ts` already uses
5
+ * for root/URL merges (`mergedRoots`, `shapeKeysToFinalize`).
6
+ * @param existing - The current array.
7
+ * @param additions - Entries to add, or `undefined` to leave `existing` unchanged.
8
+ * @returns A new array — `existing` itself when `additions` is `undefined` or empty.
9
+ */
10
+ function mergeUnique(existing, additions) {
11
+ if (!additions || additions.length === 0) {
12
+ return existing;
13
+ }
14
+ return [...new Set([...existing, ...additions])];
15
+ }
16
+ /**
17
+ * The subset of `additions` not already present in `existing` — order
18
+ * preserved, and a duplicate within `additions` itself counted only once.
19
+ * Distinct from {@link mergeUnique}'s result (which is `existing` plus these
20
+ * entries): a caller reporting "what was newly added" needs just this part,
21
+ * not the merged array — see {@link CrawlRuntimeOptions.addedExcludes}'s
22
+ * JSDoc for why echoing `additions` itself back as "added" is wrong when
23
+ * every entry in it was already present.
24
+ * @param existing - The current array.
25
+ * @param additions - Entries the patch requested adding, or `undefined`.
26
+ * @returns The entries from `additions` that are genuinely new.
27
+ */
28
+ function pickNewEntries(existing, additions) {
29
+ if (!additions || additions.length === 0) {
30
+ return [];
31
+ }
32
+ const existingSet = new Set(existing);
33
+ const added = [];
34
+ for (const entry of additions) {
35
+ if (!existingSet.has(entry) && !added.includes(entry)) {
36
+ added.push(entry);
37
+ }
38
+ }
39
+ return added;
40
+ }
41
+ /**
42
+ * Validates every field of `patch` before any mutation, so a single invalid
43
+ * field (e.g. `parallels: 0`) cannot leave `options` half-updated.
44
+ * @param patch - The patch to validate.
45
+ * @throws {RangeError} If `parallels` is present and not an integer `>= 1`, or `interval` is present and not an integer `>= 0`.
46
+ * @throws {TypeError} If any exclude entry is present and not a non-empty string.
47
+ */
48
+ function assertValidPatch(patch) {
49
+ if (patch.parallels !== undefined &&
50
+ (!Number.isSafeInteger(patch.parallels) || patch.parallels < 1)) {
51
+ throw new RangeError(`parallels must be an integer >= 1, got ${patch.parallels}`);
52
+ }
53
+ if (patch.interval !== undefined &&
54
+ (!Number.isSafeInteger(patch.interval) || patch.interval < 0)) {
55
+ throw new RangeError(`interval must be an integer >= 0, got ${patch.interval}`);
56
+ }
57
+ for (const [field, entries] of [
58
+ ['excludes', patch.excludes],
59
+ ['excludeUrls', patch.excludeUrls],
60
+ ['excludeKeywords', patch.excludeKeywords],
61
+ ]) {
62
+ if (!entries)
63
+ continue;
64
+ for (const entry of entries) {
65
+ if (typeof entry !== 'string' || entry.length === 0) {
66
+ throw new TypeError(`${field} entries must be non-empty strings, got ${JSON.stringify(entry)}`);
67
+ }
68
+ }
69
+ }
70
+ }
71
+ /**
72
+ * Applies a runtime patch to a live {@link CrawlerOptions} object in place
73
+ * and returns a snapshot of the affected fields.
74
+ *
75
+ * `parallels`/`interval` overwrite the current value. The three exclude
76
+ * arrays are additive only — new entries are appended (duplicates against
77
+ * the existing array dropped); there is no way to remove an already-set
78
+ * exclude pattern through this function. `Crawler#updateRuntimeOptions`
79
+ * (the only caller) reads `options.excludes`/`.excludeUrls`/
80
+ * `.excludeKeywords`/`.parallels`/`.interval` fresh on every use — see
81
+ * `shouldSkipUrl`'s call site in `crawler.ts` and the per-URL interval
82
+ * check — so replacing these fields with new arrays/values here takes
83
+ * effect starting with the next URL a worker picks up, without any cache
84
+ * to invalidate.
85
+ * @param options - The live options object to mutate.
86
+ * @param patch - The runtime change to apply.
87
+ * @returns A snapshot of `parallels`/`interval`/the three exclude arrays after
88
+ * applying `patch`, plus which of `patch`'s exclude entries (if any) were
89
+ * genuinely new (see {@link CrawlRuntimeOptions.addedExcludes}).
90
+ * @throws {RangeError} If `parallels` is present and not an integer `>= 1`, or `interval` is present and not an integer `>= 0`.
91
+ * @throws {TypeError} If any exclude entry is present and not a non-empty string.
92
+ * @example
93
+ * ```ts
94
+ * const snapshot = applyCrawlRuntimeOptionsPatch(options, {
95
+ * parallels: 4,
96
+ * excludes: ['/admin/**'],
97
+ * });
98
+ * console.log(snapshot.parallels); // 4
99
+ * ```
100
+ */
101
+ export function applyCrawlRuntimeOptionsPatch(options, patch) {
102
+ assertValidPatch(patch);
103
+ // Computed before `options` is mutated below — `pickNewEntries` needs the
104
+ // pre-patch arrays to tell "new" from "already present".
105
+ const addedExcludes = pickNewEntries(options.excludes, patch.excludes);
106
+ const addedExcludeUrls = pickNewEntries(options.excludeUrls, patch.excludeUrls);
107
+ const addedExcludeKeywords = pickNewEntries(options.excludeKeywords, patch.excludeKeywords);
108
+ if (patch.parallels !== undefined) {
109
+ options.parallels = patch.parallels;
110
+ }
111
+ if (patch.interval !== undefined) {
112
+ options.interval = patch.interval;
113
+ }
114
+ options.excludes = [...mergeUnique(options.excludes, patch.excludes)];
115
+ options.excludeUrls = [...mergeUnique(options.excludeUrls, patch.excludeUrls)];
116
+ options.excludeKeywords = [
117
+ ...mergeUnique(options.excludeKeywords, patch.excludeKeywords),
118
+ ];
119
+ return {
120
+ parallels: options.parallels,
121
+ interval: options.interval,
122
+ excludes: options.excludes,
123
+ excludeUrls: options.excludeUrls,
124
+ excludeKeywords: options.excludeKeywords,
125
+ addedExcludes,
126
+ addedExcludeUrls,
127
+ addedExcludeKeywords,
128
+ };
129
+ }
@@ -1,4 +1,4 @@
1
- import type { CrawlerEventTypes, CrawlerOptions } from './types.js';
1
+ import type { CrawlerEventTypes, CrawlerOptions, CrawlRuntimeOptions, CrawlRuntimeOptionsPatch } from './types.js';
2
2
  import type { ExURL } from '@d-zero/shared/parse-url';
3
3
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
4
4
  export type { CrawlerOptions } from './types.js';
@@ -68,8 +68,11 @@ export default class Crawler extends EventEmitter<CrawlerEventTypes> {
68
68
  * @param pagesScrapedOffset - Number of HTML pages already rendered in previous
69
69
  * sessions, used to seed the session-spanning progress counter. Defaults to 0
70
70
  * for callers that don't need cross-session accuracy in the progress display.
71
+ * @param metadataOnlyUrls - The subset of `pending` fated for a
72
+ * metadata-only scrape — see `LinkList#resume`'s `metadataOnlyUrls`
73
+ * doc (#369).
71
74
  */
72
- resume(pending: string[], scraped: string[], resources: string[], pagesScrapedOffset?: number): void;
75
+ resume(pending: string[], scraped: string[], resources: string[], pagesScrapedOffset?: number, metadataOnlyUrls?: readonly string[]): void;
73
76
  /**
74
77
  * Start crawling from one or more root URLs.
75
78
  *
@@ -85,14 +88,43 @@ export default class Crawler extends EventEmitter<CrawlerEventTypes> {
85
88
  * previously-external page — does not race on two parallel slots.
86
89
  * @param urls - The list of root URLs to begin crawling from. May be empty
87
90
  * when resumed pending URLs already exist (for example `--retry-failed`).
88
- * @param opts - Optional overrides; currently only `recursive` is honoured.
91
+ * @param opts - Optional overrides.
89
92
  * @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
90
93
  * Defaults to the constructor option's `recursive` value.
94
+ * @param opts.isRetryContinuation - Forwarded to `#runDeal` (issue #350):
95
+ * when `true`, this is `CrawlerOrchestrator`'s auto-retry loop
96
+ * re-invoking `start()` on the same `Crawler` instance after a prior
97
+ * pass ended with pages still pending, not an unrelated fresh
98
+ * session — see `#runDeal`'s JSDoc for what that changes.
91
99
  * @throws {Error} If the URL list is empty.
92
100
  */
93
101
  start(urls: ExURL[], opts?: {
94
102
  recursive?: boolean;
103
+ isRetryContinuation?: boolean;
95
104
  }): void;
105
+ /**
106
+ * Applies a runtime change to this crawl's tunable options
107
+ * (`parallels`/`interval`/the three exclude arrays) while it is in
108
+ * progress. See {@link applyCrawlRuntimeOptionsPatch} for merge
109
+ * semantics and validation.
110
+ *
111
+ * A `parallels` change takes effect immediately (via the live
112
+ * `Dealer`'s `setLimit()`) when this is called while a `deal()` round
113
+ * is actually in flight ({@link #dealController} non-`null`). Between
114
+ * auto-retry rounds ({@link #dealController} `null`), only
115
+ * `#options.parallels` is updated — the next round's `#runDeal` reads it
116
+ * when computing that round's `concurrency`.
117
+ * @param patch - The runtime change to apply.
118
+ * @returns A snapshot of the tunable options after applying `patch`.
119
+ * @throws {RangeError} If `parallels` is present and not an integer `>= 1`, or `interval` is present and not an integer `>= 0`.
120
+ * @throws {TypeError} If any exclude entry is present and not a non-empty string.
121
+ * @example
122
+ * ```ts
123
+ * const snapshot = crawler.updateRuntimeOptions({ parallels: 4 });
124
+ * console.log(snapshot.parallels); // 4
125
+ * ```
126
+ */
127
+ updateRuntimeOptions(patch: CrawlRuntimeOptionsPatch): CrawlRuntimeOptions;
96
128
  /**
97
129
  * Launches a fresh Puppeteer browser, runs the beholder scraper, and cleans up.
98
130
  *