@nitpicker/crawler 0.21.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
|
@@ -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,
|
|
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:
|
|
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
|
|
184
|
-
// (`append`/`inventory`/`retryFailed`/`resume`) pass
|
|
185
|
-
// fresh `crawling()` has no archive history to seed from
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
+
registerGuarded('responseReferrers', (resource) => {
|
|
386
525
|
writeQueue
|
|
387
526
|
.enqueue(() => this.#archive.setResourcesReferrers(resource))
|
|
388
527
|
.catch((error) => reject(error));
|
|
389
528
|
});
|
|
390
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
898
|
-
//
|
|
899
|
-
//
|
|
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
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
|
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
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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.#
|
|
1091
|
-
await CrawlerOrchestrator.#
|
|
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)
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
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
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
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
|
-
//
|
|
1193
|
-
// the outer catch unwind, then re-throw
|
|
1194
|
-
// learns about the scrape failure
|
|
1195
|
-
// `crawl --resume <
|
|
1196
|
-
// the orchestrator's `#closeOnce` guard, so the
|
|
1197
|
-
// catch's `close()` becomes a no-op for the
|
|
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?.(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
1439
|
-
*
|
|
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
|
|
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 `
|
|
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
|
|
2380
|
+
* @param aggregates - The counts captured during the inventory pass; see {@link ListReconcileRunAggregates}.
|
|
1470
2381
|
*/
|
|
1471
|
-
static async #
|
|
2382
|
+
static async #writeListReconcileRunRow(archive, aggregates) {
|
|
1472
2383
|
const ranAt = new Date().toISOString();
|
|
1473
|
-
await archive.
|
|
2384
|
+
await archive.recordListReconcileRun({
|
|
1474
2385
|
ran_at: ranAt,
|
|
1475
|
-
list_label:
|
|
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
|