@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.
- package/lib/append-retry-failed-common-setup-phases.d.ts +1 -1
- package/lib/append-retry-failed-common-setup-phases.js +5 -1
- package/lib/append-setup-phases.d.ts +1 -1
- package/lib/archive/archive.d.ts +57 -8
- package/lib/archive/archive.js +83 -12
- package/lib/archive/create-adjunct-tables.d.ts +3 -2
- package/lib/archive/create-adjunct-tables.js +12 -9
- package/lib/archive/create-entity-tables.js +3 -0
- package/lib/archive/database.d.ts +39 -7
- package/lib/archive/database.js +49 -9
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.d.ts +49 -0
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.js +69 -0
- package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +13 -12
- package/lib/archive/db-ops/_shared/resolve-content-item-id.js +63 -31
- package/lib/archive/db-ops/_shared/types.d.ts +39 -0
- package/lib/archive/db-ops/analysis/replace-analysis-violations.js +7 -4
- package/lib/archive/db-ops/config/get-created-cwd.d.ts +11 -0
- package/lib/archive/db-ops/config/get-created-cwd.js +13 -0
- package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.d.ts +57 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.js +87 -0
- package/lib/archive/db-ops/errors/insert-page-error.js +3 -1
- package/lib/archive/db-ops/lifecycle/init.d.ts +13 -6
- package/lib/archive/db-ops/lifecycle/init.js +30 -10
- package/lib/archive/db-ops/list-reconcile/record-list-reconcile-run.d.ts +22 -0
- package/lib/archive/db-ops/{inventory/record-inventory-run.js → list-reconcile/record-list-reconcile-run.js} +11 -10
- package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
- package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +9 -2
- package/lib/archive/db-ops/pages/read/get-crawling-state.js +11 -3
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.d.ts +20 -0
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.js +37 -0
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +3 -2
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +17 -37
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +22 -1
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +79 -33
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.d.ts +78 -0
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.js +146 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.d.ts +24 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.js +24 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +2 -1
- package/lib/archive/db-ops/pages/write/insert-page.js +4 -2
- package/lib/archive/db-ops/pages/write/link-redirect-sources.js +3 -1
- package/lib/archive/db-ops/pages/write/record-redirect.js +1 -1
- package/lib/archive/db-ops/pages/write/set-skipped-page.js +3 -1
- package/lib/archive/db-ops/pages/write/update-page.d.ts +9 -1
- package/lib/archive/db-ops/pages/write/update-page.js +28 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +3 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.js +10 -35
- package/lib/archive/init-schema.d.ts +1 -1
- package/lib/archive/init-schema.js +3 -2
- package/lib/archive/meta/compute-main-contents-denormalized.d.ts +11 -3
- package/lib/archive/meta/compute-main-contents-denormalized.js +14 -2
- package/lib/archive/meta/types.d.ts +24 -0
- package/lib/archive/migrate-content-items-is-metadata-only.d.ts +44 -0
- package/lib/archive/migrate-content-items-is-metadata-only.js +60 -0
- package/lib/archive/migrate-info-created-cwd.d.ts +17 -0
- package/lib/archive/migrate-info-created-cwd.js +36 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.d.ts +34 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.js +51 -0
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.d.ts → migrate-list-reconcile-runs-exclude-skipped.d.ts} +8 -8
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.js → migrate-list-reconcile-runs-exclude-skipped.js} +12 -12
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.d.ts +16 -0
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.js +35 -0
- package/lib/archive/migrate-page-meta-image-scan.d.ts +27 -0
- package/lib/archive/migrate-page-meta-image-scan.js +52 -0
- package/lib/archive/page.d.ts +12 -0
- package/lib/archive/page.js +16 -0
- package/lib/archive/types.d.ts +97 -10
- package/lib/compute-auto-retry-backoff-delay.d.ts +30 -0
- package/lib/compute-auto-retry-backoff-delay.js +36 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.d.ts +32 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.js +129 -0
- package/lib/crawler/crawler.d.ts +35 -3
- package/lib/crawler/crawler.js +486 -338
- package/lib/crawler/dedupe/build-dedupe-cap-observation.d.ts +74 -0
- package/lib/crawler/dedupe/build-dedupe-cap-observation.js +97 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +7 -5
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +20 -0
- package/lib/crawler/dedupe/compute-shape-key.js +73 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +6 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +6 -1
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +7 -5
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +5 -3
- package/lib/crawler/dedupe/types.d.ts +18 -0
- package/lib/crawler/fetch-destination.js +1 -0
- package/lib/crawler/link-list.d.ts +9 -1
- package/lib/crawler/link-list.js +12 -2
- package/lib/crawler/link-to-page-data.js +1 -0
- package/lib/crawler/resource-to-page-data.js +1 -0
- package/lib/crawler/types.d.ts +75 -0
- package/lib/crawler-orchestrator.d.ts +171 -8
- package/lib/crawler-orchestrator.js +1177 -234
- package/lib/crawler.d.ts +3 -0
- package/lib/crawler.js +3 -0
- package/lib/delay-or-abort.d.ts +20 -0
- package/lib/delay-or-abort.js +35 -0
- package/lib/pending-urls-remain-error.d.ts +35 -0
- package/lib/pending-urls-remain-error.js +43 -0
- package/lib/recrawl-setup-phases.d.ts +38 -0
- package/lib/recrawl-setup-phases.js +52 -0
- package/lib/resume-setup-phases.d.ts +1 -1
- package/lib/resume-setup-phases.js +5 -1
- package/lib/retry-failed-setup-phases.d.ts +1 -1
- package/lib/setup-recovery-phase-labels.d.ts +17 -8
- package/lib/setup-recovery-phase-labels.js +17 -8
- package/lib/types.d.ts +62 -12
- package/lib/utils/compute-file-sha256.d.ts +1 -1
- package/lib/utils/compute-file-sha256.js +1 -1
- package/package.json +4 -4
- package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +0 -21
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +0 -16
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +0 -35
package/lib/archive/types.d.ts
CHANGED
|
@@ -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 `
|
|
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 `
|
|
114
|
-
* here. Only `ran_at` is required — every other field is
|
|
115
|
-
* raw-SQL backfill (a one-off `sqlite3 INSERT` recording
|
|
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.
|
|
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
|
|
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 `
|
|
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
|
|
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
|
+
}
|
package/lib/crawler/crawler.d.ts
CHANGED
|
@@ -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
|
|
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
|
*
|