@nitpicker/crawler 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/append-retry-failed-common-setup-phases.d.ts +1 -1
- package/lib/append-retry-failed-common-setup-phases.js +5 -1
- package/lib/append-setup-phases.d.ts +1 -1
- package/lib/archive/archive.d.ts +57 -8
- package/lib/archive/archive.js +83 -12
- package/lib/archive/create-adjunct-tables.d.ts +3 -2
- package/lib/archive/create-adjunct-tables.js +12 -9
- package/lib/archive/create-entity-tables.js +3 -0
- package/lib/archive/database.d.ts +39 -7
- package/lib/archive/database.js +49 -9
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.d.ts +49 -0
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.js +69 -0
- package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +13 -12
- package/lib/archive/db-ops/_shared/resolve-content-item-id.js +63 -31
- package/lib/archive/db-ops/_shared/types.d.ts +39 -0
- package/lib/archive/db-ops/analysis/replace-analysis-violations.js +7 -4
- package/lib/archive/db-ops/config/get-created-cwd.d.ts +11 -0
- package/lib/archive/db-ops/config/get-created-cwd.js +13 -0
- package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.d.ts +57 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.js +87 -0
- package/lib/archive/db-ops/errors/insert-page-error.js +3 -1
- package/lib/archive/db-ops/lifecycle/init.d.ts +13 -6
- package/lib/archive/db-ops/lifecycle/init.js +30 -10
- package/lib/archive/db-ops/list-reconcile/record-list-reconcile-run.d.ts +22 -0
- package/lib/archive/db-ops/{inventory/record-inventory-run.js → list-reconcile/record-list-reconcile-run.js} +11 -10
- package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
- package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +9 -2
- package/lib/archive/db-ops/pages/read/get-crawling-state.js +11 -3
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.d.ts +20 -0
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.js +37 -0
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +3 -2
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +17 -37
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +22 -1
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +79 -33
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.d.ts +78 -0
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.js +146 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.d.ts +24 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.js +24 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +2 -1
- package/lib/archive/db-ops/pages/write/insert-page.js +4 -2
- package/lib/archive/db-ops/pages/write/link-redirect-sources.js +3 -1
- package/lib/archive/db-ops/pages/write/record-redirect.js +1 -1
- package/lib/archive/db-ops/pages/write/set-skipped-page.js +3 -1
- package/lib/archive/db-ops/pages/write/update-page.d.ts +9 -1
- package/lib/archive/db-ops/pages/write/update-page.js +28 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +3 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.js +10 -35
- package/lib/archive/init-schema.d.ts +1 -1
- package/lib/archive/init-schema.js +3 -2
- package/lib/archive/meta/compute-main-contents-denormalized.d.ts +11 -3
- package/lib/archive/meta/compute-main-contents-denormalized.js +14 -2
- package/lib/archive/meta/types.d.ts +24 -0
- package/lib/archive/migrate-content-items-is-metadata-only.d.ts +44 -0
- package/lib/archive/migrate-content-items-is-metadata-only.js +60 -0
- package/lib/archive/migrate-info-created-cwd.d.ts +17 -0
- package/lib/archive/migrate-info-created-cwd.js +36 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.d.ts +34 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.js +51 -0
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.d.ts → migrate-list-reconcile-runs-exclude-skipped.d.ts} +8 -8
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.js → migrate-list-reconcile-runs-exclude-skipped.js} +12 -12
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.d.ts +16 -0
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.js +35 -0
- package/lib/archive/migrate-page-meta-image-scan.d.ts +27 -0
- package/lib/archive/migrate-page-meta-image-scan.js +52 -0
- package/lib/archive/page.d.ts +12 -0
- package/lib/archive/page.js +16 -0
- package/lib/archive/types.d.ts +97 -10
- package/lib/compute-auto-retry-backoff-delay.d.ts +30 -0
- package/lib/compute-auto-retry-backoff-delay.js +36 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.d.ts +32 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.js +129 -0
- package/lib/crawler/crawler.d.ts +35 -3
- package/lib/crawler/crawler.js +486 -338
- package/lib/crawler/dedupe/build-dedupe-cap-observation.d.ts +74 -0
- package/lib/crawler/dedupe/build-dedupe-cap-observation.js +97 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +7 -5
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +20 -0
- package/lib/crawler/dedupe/compute-shape-key.js +73 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +6 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +6 -1
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +7 -5
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +5 -3
- package/lib/crawler/dedupe/types.d.ts +18 -0
- package/lib/crawler/fetch-destination.js +1 -0
- package/lib/crawler/link-list.d.ts +9 -1
- package/lib/crawler/link-list.js +12 -2
- package/lib/crawler/link-to-page-data.js +1 -0
- package/lib/crawler/resource-to-page-data.js +1 -0
- package/lib/crawler/types.d.ts +75 -0
- package/lib/crawler-orchestrator.d.ts +171 -8
- package/lib/crawler-orchestrator.js +1177 -234
- package/lib/crawler.d.ts +3 -0
- package/lib/crawler.js +3 -0
- package/lib/delay-or-abort.d.ts +20 -0
- package/lib/delay-or-abort.js +35 -0
- package/lib/pending-urls-remain-error.d.ts +35 -0
- package/lib/pending-urls-remain-error.js +43 -0
- package/lib/recrawl-setup-phases.d.ts +38 -0
- package/lib/recrawl-setup-phases.js +52 -0
- package/lib/resume-setup-phases.d.ts +1 -1
- package/lib/resume-setup-phases.js +5 -1
- package/lib/retry-failed-setup-phases.d.ts +1 -1
- package/lib/setup-recovery-phase-labels.d.ts +17 -8
- package/lib/setup-recovery-phase-labels.js +17 -8
- package/lib/types.d.ts +62 -12
- package/lib/utils/compute-file-sha256.d.ts +1 -1
- package/lib/utils/compute-file-sha256.js +1 -1
- package/package.json +4 -4
- package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +0 -21
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +0 -16
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +0 -35
package/lib/crawler/crawler.js
CHANGED
|
@@ -11,6 +11,7 @@ import pkg from '../../package.json' with { type: 'json' };
|
|
|
11
11
|
import { computeBodyHash } from '../archive/body-hash/compute-body-hash.js';
|
|
12
12
|
import { classifyErrorKind } from '../classify-error-kind.js';
|
|
13
13
|
import { crawlerLog } from '../debug.js';
|
|
14
|
+
import { applyCrawlRuntimeOptionsPatch } from './apply-crawl-runtime-options-patch.js';
|
|
14
15
|
import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
|
|
15
16
|
import { buildRedirectEvent } from './build-redirect-event.js';
|
|
16
17
|
import { captureCustomElements } from './capture-custom-elements.js';
|
|
@@ -96,6 +97,17 @@ const DEFAULT_DEDUPE_MAP_CAP = 100_000;
|
|
|
96
97
|
export default class Crawler extends EventEmitter {
|
|
97
98
|
/** Controller used to cancel the deal-based crawl via its AbortSignal. */
|
|
98
99
|
#abortController = new AbortController();
|
|
100
|
+
/**
|
|
101
|
+
* Handle into the currently-running `deal()` call's `Dealer`, letting
|
|
102
|
+
* {@link updateRuntimeOptions} apply a `parallels` change immediately via
|
|
103
|
+
* `setLimit()`. `null` outside of an active `deal()` call — auto-retry
|
|
104
|
+
* (`CrawlerOrchestrator#crawlUntilPendingClears`) runs `deal()` in
|
|
105
|
+
* separate rounds, and this is only valid for the round currently in
|
|
106
|
+
* flight (`#runDeal`'s `onStart`/after-`deal()` reset it each round).
|
|
107
|
+
* When `null`, a `parallels` patch still updates `#options.parallels` so
|
|
108
|
+
* the *next* round picks it up.
|
|
109
|
+
*/
|
|
110
|
+
#dealController = null;
|
|
99
111
|
/**
|
|
100
112
|
* Per-shape count of anchors rejected by the dedupe-cap enqueue gates
|
|
101
113
|
* after that shape capped. Read by {@link getDedupeCapRejections} at
|
|
@@ -105,6 +117,13 @@ export default class Crawler extends EventEmitter {
|
|
|
105
117
|
* to the archive incrementally).
|
|
106
118
|
*/
|
|
107
119
|
#dedupeCapRejectionCounts = new Map();
|
|
120
|
+
/**
|
|
121
|
+
* Guards {@link #pendingReplayCapEvents}' drain so a second {@link start}
|
|
122
|
+
* call on the same instance — the auto-retry continuation path
|
|
123
|
+
* (`opts.isRetryContinuation`, issue #350) — does not re-emit the first
|
|
124
|
+
* call's already-drained events.
|
|
125
|
+
*/
|
|
126
|
+
#dedupeCapReplayDrained = false;
|
|
108
127
|
/**
|
|
109
128
|
* Opt-in (`--dedupe-cap`) same-cluster soft cap. Always constructed
|
|
110
129
|
* (Misra-Gries state stays empty when {@link CrawlerOptions.dedupeCap} is
|
|
@@ -157,6 +176,17 @@ export default class Crawler extends EventEmitter {
|
|
|
157
176
|
* `insertPageError`, so the FK resolution via URL always finds the row.
|
|
158
177
|
*/
|
|
159
178
|
#pendingPhaseErrors = new Map();
|
|
179
|
+
/**
|
|
180
|
+
* `DedupeCapEvent`s produced by replaying
|
|
181
|
+
* {@link CrawlerOptions.preloadedDedupeObservations} into
|
|
182
|
+
* {@link #dedupeCapTracker} in the constructor. Buffered here (rather
|
|
183
|
+
* than emitted immediately) because the constructor runs before
|
|
184
|
+
* `CrawlerOrchestrator.crawling()` registers its `dedupeCap` listener —
|
|
185
|
+
* emitting eagerly would be silently dropped. Drained once, at the top
|
|
186
|
+
* of {@link start}, by which point the caller has always finished
|
|
187
|
+
* registering listeners (see `start`'s own JSDoc on this point).
|
|
188
|
+
*/
|
|
189
|
+
#pendingReplayCapEvents = [];
|
|
160
190
|
/**
|
|
161
191
|
* Predicted-pagination body-hash tracking (always-on — independent of
|
|
162
192
|
* the opt-in `--dedupe-cap` tracker). Maps a URL shape key
|
|
@@ -253,6 +283,8 @@ export default class Crawler extends EventEmitter {
|
|
|
253
283
|
dedupeCap: options?.dedupeCap ?? null,
|
|
254
284
|
dedupeMapCap: options?.dedupeMapCap ?? DEFAULT_DEDUPE_MAP_CAP,
|
|
255
285
|
preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
|
|
286
|
+
preloadedDedupeObservations: options?.preloadedDedupeObservations ?? [],
|
|
287
|
+
lanes: options?.lanes,
|
|
256
288
|
};
|
|
257
289
|
this.#networkOutageDetector = new NetworkOutageDetector({
|
|
258
290
|
windowMs: this.#options.networkOutageWindowMs,
|
|
@@ -260,6 +292,27 @@ export default class Crawler extends EventEmitter {
|
|
|
260
292
|
hostThreshold: this.#options.networkOutageHostThreshold,
|
|
261
293
|
});
|
|
262
294
|
this.#dedupeCapTracker = new DedupeCapTracker({ cap: this.#options.dedupeCap ?? 0, mapCap: this.#options.dedupeMapCap }, this.#options.preloadedStickyShapeKeys);
|
|
295
|
+
// Replay a prior session's observations synchronously, right here —
|
|
296
|
+
// this is the entire burst-prevention fix for a `--retry-failed`
|
|
297
|
+
// remaining-count spike: by the time the first real anchor reaches
|
|
298
|
+
// gate 1 in `#handleResult`, `#dedupeCapTracker`'s Misra-Gries
|
|
299
|
+
// counters already reflect every qualifying page this archive has
|
|
300
|
+
// ever scraped, not just the shapes that were already confirmed
|
|
301
|
+
// capped (`preloadedStickyShapeKeys`). A shape one observation short
|
|
302
|
+
// of its threshold when the previous session ended resumes from
|
|
303
|
+
// that count instead of 0. Any event this replay newly crosses the
|
|
304
|
+
// threshold for is buffered, not emitted — see
|
|
305
|
+
// `#pendingReplayCapEvents`'s JSDoc — the drain in `start()` is only
|
|
306
|
+
// about the `dedupe_cap_events` audit row / post-hoc marking /
|
|
307
|
+
// `rejected_count`, not this gating effect. `preloadedDedupeObservations`
|
|
308
|
+
// is empty for a fresh (non-resuming) crawl, so this loop is a no-op
|
|
309
|
+
// there.
|
|
310
|
+
for (const observation of this.#options.preloadedDedupeObservations) {
|
|
311
|
+
const event = this.#dedupeCapTracker.observe(observation);
|
|
312
|
+
if (event) {
|
|
313
|
+
this.#pendingReplayCapEvents.push(event);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
263
316
|
this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
|
|
264
317
|
for (const urlStr of this.#options.roots) {
|
|
265
318
|
const url = parseUrl(urlStr, this.#options);
|
|
@@ -315,9 +368,12 @@ export default class Crawler extends EventEmitter {
|
|
|
315
368
|
* @param pagesScrapedOffset - Number of HTML pages already rendered in previous
|
|
316
369
|
* sessions, used to seed the session-spanning progress counter. Defaults to 0
|
|
317
370
|
* for callers that don't need cross-session accuracy in the progress display.
|
|
371
|
+
* @param metadataOnlyUrls - The subset of `pending` fated for a
|
|
372
|
+
* metadata-only scrape — see `LinkList#resume`'s `metadataOnlyUrls`
|
|
373
|
+
* doc (#369).
|
|
318
374
|
*/
|
|
319
|
-
resume(pending, scraped, resources, pagesScrapedOffset = 0) {
|
|
320
|
-
this.#resumedPending = this.#linkList.resume(pending, scraped, this.#options);
|
|
375
|
+
resume(pending, scraped, resources, pagesScrapedOffset = 0, metadataOnlyUrls = []) {
|
|
376
|
+
this.#resumedPending = this.#linkList.resume(pending, scraped, this.#options, metadataOnlyUrls);
|
|
321
377
|
this.#resumedScraped = scraped;
|
|
322
378
|
this.#resumedPagesScraped = pagesScrapedOffset;
|
|
323
379
|
for (const resource of resources) {
|
|
@@ -339,12 +395,32 @@ export default class Crawler extends EventEmitter {
|
|
|
339
395
|
* previously-external page — does not race on two parallel slots.
|
|
340
396
|
* @param urls - The list of root URLs to begin crawling from. May be empty
|
|
341
397
|
* when resumed pending URLs already exist (for example `--retry-failed`).
|
|
342
|
-
* @param opts - Optional overrides
|
|
398
|
+
* @param opts - Optional overrides.
|
|
343
399
|
* @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
|
|
344
400
|
* Defaults to the constructor option's `recursive` value.
|
|
401
|
+
* @param opts.isRetryContinuation - Forwarded to `#runDeal` (issue #350):
|
|
402
|
+
* when `true`, this is `CrawlerOrchestrator`'s auto-retry loop
|
|
403
|
+
* re-invoking `start()` on the same `Crawler` instance after a prior
|
|
404
|
+
* pass ended with pages still pending, not an unrelated fresh
|
|
405
|
+
* session — see `#runDeal`'s JSDoc for what that changes.
|
|
345
406
|
* @throws {Error} If the URL list is empty.
|
|
346
407
|
*/
|
|
347
408
|
start(urls, opts) {
|
|
409
|
+
// Drain constructor-time replay cap events (see
|
|
410
|
+
// `#pendingReplayCapEvents`'s JSDoc) exactly once, on this instance's
|
|
411
|
+
// first `start()` call. `CrawlerOrchestrator.crawling()` always
|
|
412
|
+
// registers its `dedupeCap` listener before calling `start()`, so by
|
|
413
|
+
// this point the emit is guaranteed to reach it — emitting from the
|
|
414
|
+
// constructor instead would be silently dropped (no listener exists
|
|
415
|
+
// yet at that point). The auto-retry continuation path re-invokes
|
|
416
|
+
// `start()` on the same instance; `#dedupeCapReplayDrained` stops
|
|
417
|
+
// this from re-emitting the same events on that second call.
|
|
418
|
+
if (!this.#dedupeCapReplayDrained) {
|
|
419
|
+
this.#dedupeCapReplayDrained = true;
|
|
420
|
+
for (const event of this.#pendingReplayCapEvents) {
|
|
421
|
+
void this.emit('dedupeCap', event);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
348
424
|
// Inventory mode pre-loads tens of thousands of seed URLs that all
|
|
349
425
|
// fall under archived `roots` (already populated into `#scope` by
|
|
350
426
|
// the constructor). Adding each seed as its own scope entry was
|
|
@@ -397,12 +473,57 @@ export default class Crawler extends EventEmitter {
|
|
|
397
473
|
}
|
|
398
474
|
const resumeOffset = this.#resumedScraped.length;
|
|
399
475
|
const pagesScrapedOffset = this.#resumedPagesScraped;
|
|
400
|
-
void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset).catch((error) => {
|
|
476
|
+
void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset, opts?.isRetryContinuation ?? false).catch((error) => {
|
|
401
477
|
crawlerLog('runDeal error: %O', error);
|
|
402
478
|
this.#emitDealErrors(error, root.href);
|
|
403
479
|
void this.emit('crawlEnd', {});
|
|
404
480
|
});
|
|
405
481
|
}
|
|
482
|
+
/**
|
|
483
|
+
* Applies a runtime change to this crawl's tunable options
|
|
484
|
+
* (`parallels`/`interval`/the three exclude arrays) while it is in
|
|
485
|
+
* progress. See {@link applyCrawlRuntimeOptionsPatch} for merge
|
|
486
|
+
* semantics and validation.
|
|
487
|
+
*
|
|
488
|
+
* A `parallels` change takes effect immediately (via the live
|
|
489
|
+
* `Dealer`'s `setLimit()`) when this is called while a `deal()` round
|
|
490
|
+
* is actually in flight ({@link #dealController} non-`null`). Between
|
|
491
|
+
* auto-retry rounds ({@link #dealController} `null`), only
|
|
492
|
+
* `#options.parallels` is updated — the next round's `#runDeal` reads it
|
|
493
|
+
* when computing that round's `concurrency`.
|
|
494
|
+
* @param patch - The runtime change to apply.
|
|
495
|
+
* @returns A snapshot of the tunable options after applying `patch`.
|
|
496
|
+
* @throws {RangeError} If `parallels` is present and not an integer `>= 1`, or `interval` is present and not an integer `>= 0`.
|
|
497
|
+
* @throws {TypeError} If any exclude entry is present and not a non-empty string.
|
|
498
|
+
* @example
|
|
499
|
+
* ```ts
|
|
500
|
+
* const snapshot = crawler.updateRuntimeOptions({ parallels: 4 });
|
|
501
|
+
* console.log(snapshot.parallels); // 4
|
|
502
|
+
* ```
|
|
503
|
+
*/
|
|
504
|
+
updateRuntimeOptions(patch) {
|
|
505
|
+
const snapshot = applyCrawlRuntimeOptionsPatch(this.#options, patch);
|
|
506
|
+
if (patch.parallels !== undefined) {
|
|
507
|
+
this.#dealController?.setLimit(patch.parallels);
|
|
508
|
+
}
|
|
509
|
+
return snapshot;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* The current effective concurrency: `this.#options.parallels` (at least
|
|
513
|
+
* 1) if set, else the historical default {@link Crawler.MAX_PROCESS_LENGTH}.
|
|
514
|
+
* A method, not a value `#runDeal` captures once per round, so a
|
|
515
|
+
* mid-round `updateRuntimeOptions({ parallels })` is reflected
|
|
516
|
+
* immediately wherever this is read afterwards (`#handleResult`'s
|
|
517
|
+
* `concurrency` param, which sizes predicted-pagination batches) —
|
|
518
|
+
* unlike the live `Dealer`'s own worker slot count, this has no reactive
|
|
519
|
+
* setter to piggyback on.
|
|
520
|
+
* @returns The current concurrency value.
|
|
521
|
+
*/
|
|
522
|
+
#currentConcurrency() {
|
|
523
|
+
return this.#options.parallels
|
|
524
|
+
? Math.max(this.#options.parallels, 1)
|
|
525
|
+
: Crawler.MAX_PROCESS_LENGTH;
|
|
526
|
+
}
|
|
406
527
|
/**
|
|
407
528
|
* Thin instance-bound adapter over {@link drainPhaseErrors}. Flushes
|
|
408
529
|
* `#pendingPhaseErrors` for `url` as `pageError` events. Idempotent.
|
|
@@ -895,25 +1016,37 @@ export default class Crawler extends EventEmitter {
|
|
|
895
1016
|
* @param pagesScrapedOffset - Number of HTML pages already rendered in previous
|
|
896
1017
|
* sessions, used to seed the per-session HTML-pages counter so the display
|
|
897
1018
|
* remains accurate across resumes
|
|
1019
|
+
* @param isRetryContinuation - `true` when `CrawlerOrchestrator`'s
|
|
1020
|
+
* auto-retry loop (issue #350) is re-invoking `start()` on this same
|
|
1021
|
+
* `Crawler` instance after a prior pass ended with pages still
|
|
1022
|
+
* pending, rather than an unrelated fresh session (a plain resume,
|
|
1023
|
+
* append, etc.). Skips the four per-session resets below: retrying is
|
|
1024
|
+
* specifically trying to avoid re-paying network-outage/host-burn
|
|
1025
|
+
* detection cost against the same underlying network condition, so
|
|
1026
|
+
* discarding what the prior attempt already learned would defeat the
|
|
1027
|
+
* point. Defaults to `false` — every other caller of `start()` keeps
|
|
1028
|
+
* the pre-#350 "always reset" behaviour.
|
|
898
1029
|
*/
|
|
899
|
-
async #runDeal(initialUrls, resumeOffset = 0, pagesScrapedOffset = 0) {
|
|
1030
|
+
async #runDeal(initialUrls, resumeOffset = 0, pagesScrapedOffset = 0, isRetryContinuation = false) {
|
|
900
1031
|
const seen = new Set(initialUrls.map((u) => protocolAgnosticKey(u.withoutHashAndAuth)));
|
|
901
1032
|
// Add scraped URLs to seen to prevent re-processing during resume
|
|
902
1033
|
for (const url of this.#resumedScraped) {
|
|
903
1034
|
seen.add(protocolAgnosticKey(url));
|
|
904
1035
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1036
|
+
if (!isRetryContinuation) {
|
|
1037
|
+
// Redirect-destination dedup is per-crawl; clear any state from a prior run.
|
|
1038
|
+
this.#scrapedDestinations.clear();
|
|
1039
|
+
// Session-liveness signal is per-crawl too; clear so a fresh session
|
|
1040
|
+
// does not inherit "host alive" claims from a prior run that may have
|
|
1041
|
+
// happened on an entirely different network.
|
|
1042
|
+
this.#successfulHosts.clear();
|
|
1043
|
+
// Network-outage state is per-crawl too: a sliding window of errors
|
|
1044
|
+
// (or a gate left closed) from a prior run on this same `Crawler`
|
|
1045
|
+
// instance must not leak into a fresh session. `#networkGate.open()`
|
|
1046
|
+
// is a no-op if already open.
|
|
1047
|
+
this.#networkOutageDetector.reset();
|
|
1048
|
+
this.#networkGate.open();
|
|
1049
|
+
}
|
|
917
1050
|
// external URL の追跡(target は deal の total/done から導出)
|
|
918
1051
|
const externalUrls = new Set();
|
|
919
1052
|
const externalDoneUrls = new Set();
|
|
@@ -927,352 +1060,367 @@ export default class Crawler extends EventEmitter {
|
|
|
927
1060
|
externalUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
|
|
928
1061
|
}
|
|
929
1062
|
}
|
|
930
|
-
const concurrency = this.#
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
injectScopeAuth(url, matchedScope);
|
|
940
|
-
}
|
|
941
|
-
this.#linkList.add(url);
|
|
942
|
-
this.#linkList.progress(url);
|
|
943
|
-
// Likely-HTML URLs jump to the front of the queue (unshift) so page
|
|
944
|
-
// crawling advances ahead of asset/document fetches; everything else
|
|
945
|
-
// is appended (push). partitionUrlsByHtml splits the batch by the
|
|
946
|
-
// URL-only heuristic. Variadic so a batch (e.g. predicted pagination)
|
|
947
|
-
// keeps its order: a single unshift(...html) preserves ascending order
|
|
948
|
-
// at the front, whereas unshifting one-by-one would reverse it.
|
|
949
|
-
const enqueue = (...newUrls) => {
|
|
950
|
-
const [html, other] = partitionUrlsByHtml(newUrls);
|
|
951
|
-
const ops = [];
|
|
952
|
-
if (html.length > 0)
|
|
953
|
-
ops.push(unshift(...html));
|
|
954
|
-
if (other.length > 0)
|
|
955
|
-
ops.push(push(...other));
|
|
956
|
-
return Promise.all(ops).then(() => { });
|
|
957
|
-
};
|
|
958
|
-
return async () => {
|
|
959
|
-
// Pause here, not inside `fetchDestination` or deeper, so a
|
|
960
|
-
// paused worker shows as a long-running dealer task instead
|
|
961
|
-
// of requiring any change to `@d-zero/dealer` itself — a
|
|
962
|
-
// closed gate resolves the instant `#handleOutageSuspect`'s
|
|
963
|
-
// recovery probe succeeds (see `network-gate.ts`).
|
|
964
|
-
await this.#networkGate.wait();
|
|
965
|
-
// Interval delay is handled here instead of by dealer because
|
|
966
|
-
// DNS-burned hosts must skip the wait entirely. Spending the
|
|
967
|
-
// per-URL interval on a host the cache already knows is dead
|
|
968
|
-
// just slows the crawl down for zero benefit — the HEAD won't
|
|
969
|
-
// be fired and `Crawler.#sendHeadRequest` will throw the
|
|
970
|
-
// preload short-circuit immediately. For all other URLs, run
|
|
971
|
-
// the same `delay()` + `%countdown(...)` log that dealer would
|
|
972
|
-
// have emitted, so the dealer display reads identically.
|
|
973
|
-
const burned = dnsBurnedHostCache.has(url.hostname.toLowerCase());
|
|
974
|
-
if (!burned && this.#options.interval && this.#options.interval > 0) {
|
|
975
|
-
await delay(this.#options.interval, (determinedInterval) => {
|
|
976
|
-
update(`Waiting interval: %countdown(${determinedInterval},${_index}_interval)%ms`);
|
|
977
|
-
});
|
|
1063
|
+
const concurrency = this.#currentConcurrency();
|
|
1064
|
+
try {
|
|
1065
|
+
await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
|
|
1066
|
+
const matchedScope = findScopeEntry(url, this.#scope, this.#options);
|
|
1067
|
+
const isExternal = matchedScope === null;
|
|
1068
|
+
const urlText = isExternal ? c.dim(url.href) : c.cyan(url.href);
|
|
1069
|
+
setLineHeader(`%braille% ${urlText}: `);
|
|
1070
|
+
if (matchedScope) {
|
|
1071
|
+
injectScopeAuth(url, matchedScope);
|
|
978
1072
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1073
|
+
this.#linkList.add(url);
|
|
1074
|
+
this.#linkList.progress(url);
|
|
1075
|
+
// Likely-HTML URLs jump to the front of the queue (unshift) so page
|
|
1076
|
+
// crawling advances ahead of asset/document fetches; everything else
|
|
1077
|
+
// is appended (push). partitionUrlsByHtml splits the batch by the
|
|
1078
|
+
// URL-only heuristic. Variadic so a batch (e.g. predicted pagination)
|
|
1079
|
+
// keeps its order: a single unshift(...html) preserves ascending order
|
|
1080
|
+
// at the front, whereas unshifting one-by-one would reverse it.
|
|
1081
|
+
const enqueue = (...newUrls) => {
|
|
1082
|
+
const [html, other] = partitionUrlsByHtml(newUrls);
|
|
1083
|
+
const ops = [];
|
|
1084
|
+
if (html.length > 0)
|
|
1085
|
+
ops.push(unshift(...html));
|
|
1086
|
+
if (other.length > 0)
|
|
1087
|
+
ops.push(push(...other));
|
|
1088
|
+
return Promise.all(ops).then(() => { });
|
|
986
1089
|
};
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1090
|
+
return async () => {
|
|
1091
|
+
// Pause here, not inside `fetchDestination` or deeper, so a
|
|
1092
|
+
// paused worker shows as a long-running dealer task instead
|
|
1093
|
+
// of requiring any change to `@d-zero/dealer` itself — a
|
|
1094
|
+
// closed gate resolves the instant `#handleOutageSuspect`'s
|
|
1095
|
+
// recovery probe succeeds (see `network-gate.ts`).
|
|
1096
|
+
await this.#networkGate.wait();
|
|
1097
|
+
// Interval delay is handled here instead of by dealer because
|
|
1098
|
+
// DNS-burned hosts must skip the wait entirely. Spending the
|
|
1099
|
+
// per-URL interval on a host the cache already knows is dead
|
|
1100
|
+
// just slows the crawl down for zero benefit — the HEAD won't
|
|
1101
|
+
// be fired and `Crawler.#sendHeadRequest` will throw the
|
|
1102
|
+
// preload short-circuit immediately. For all other URLs, run
|
|
1103
|
+
// the same `delay()` + `%countdown(...)` log that dealer would
|
|
1104
|
+
// have emitted, so the dealer display reads identically.
|
|
1105
|
+
const burned = dnsBurnedHostCache.has(url.hostname.toLowerCase());
|
|
1106
|
+
if (!burned && this.#options.interval && this.#options.interval > 0) {
|
|
1107
|
+
await delay(this.#options.interval, (determinedInterval) => {
|
|
1108
|
+
update(`Waiting interval: %countdown(${determinedInterval},${_index}_interval)%ms`);
|
|
1002
1109
|
});
|
|
1003
|
-
log(c.gray('Blocked by robots.txt'));
|
|
1004
|
-
return;
|
|
1005
1110
|
}
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1111
|
+
const log = createTimedUpdate(update, this.#options.verbose);
|
|
1112
|
+
// `#scrapePage` 内のブラウザ HTML レンダーが成功したかをマークするフラグ。
|
|
1113
|
+
// 成功時のみ #scrapePage 側で true に設定される。
|
|
1114
|
+
// discard 判定後にこのフラグを見てカウントするので、launch 失敗や predicted-discard は除外される。
|
|
1115
|
+
let renderedInBrowser = false;
|
|
1116
|
+
const markBrowserScrape = () => {
|
|
1117
|
+
renderedInBrowser = true;
|
|
1118
|
+
};
|
|
1119
|
+
// Set below for every internal page with a rendered HTML body
|
|
1120
|
+
// (not just predicted ones — see the computation site's comment),
|
|
1121
|
+
// so both `#handleResult`'s dedupe-cap observation and the `page`
|
|
1122
|
+
// event's `bodyHash` payload (ultimately consumed by
|
|
1123
|
+
// `update-page.ts`'s `page_meta.body_hash` write) reuse this one
|
|
1124
|
+
// value instead of each hashing the same html again.
|
|
1125
|
+
let precomputedBodyHash = null;
|
|
1126
|
+
try {
|
|
1127
|
+
const robotsAllowed = await this.#robotsChecker.isAllowed(url);
|
|
1128
|
+
if (!robotsAllowed) {
|
|
1129
|
+
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
1130
|
+
void this.emit('skip', {
|
|
1131
|
+
url: url.href,
|
|
1132
|
+
reason: 'blocked by robots.txt',
|
|
1133
|
+
isExternal,
|
|
1134
|
+
});
|
|
1135
|
+
log(c.gray('Blocked by robots.txt'));
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const isSkip = shouldSkipUrl({
|
|
1020
1139
|
url,
|
|
1021
|
-
|
|
1022
|
-
|
|
1140
|
+
excludes: this.#options.excludes,
|
|
1141
|
+
excludeUrls: this.#options.excludeUrls,
|
|
1142
|
+
options: this.#options,
|
|
1023
1143
|
});
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
if (
|
|
1083
|
-
|
|
1084
|
-
|
|
1144
|
+
if (isSkip) {
|
|
1145
|
+
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
1146
|
+
void this.emit('skip', { url: url.href, reason: 'excluded', isExternal });
|
|
1147
|
+
log(c.gray('Skipped'));
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (!this.#options.fetchExternal && isExternal) {
|
|
1151
|
+
const pageData = linkToPageData({
|
|
1152
|
+
url,
|
|
1153
|
+
isExternal,
|
|
1154
|
+
isLowerLayer: false,
|
|
1155
|
+
});
|
|
1156
|
+
this.#linkList.done(url, this.#scope, { page: pageData }, this.#options);
|
|
1157
|
+
void this.emit('externalPage', {
|
|
1158
|
+
result: pageData,
|
|
1159
|
+
source: derivePageSource(this.#options.inventoryMode, url.withoutHashAndAuth),
|
|
1160
|
+
});
|
|
1161
|
+
log(c.dim('External (skip fetch)'));
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
const metadataOnly = this.#linkList.isMetadataOnly(url.withoutHash);
|
|
1165
|
+
const isPredicted = this.#linkList.isPredicted(url.withoutHashAndAuth);
|
|
1166
|
+
log('Scraping%dots%');
|
|
1167
|
+
const result = await this.#scrapePage(url, log, metadataOnly, _index, markBrowserScrape);
|
|
1168
|
+
// Redirect convergence (#73): the destination was already
|
|
1169
|
+
// rendered during this crawl, so only the redirect edge is
|
|
1170
|
+
// recorded and the browser was never launched. Mark the URL
|
|
1171
|
+
// done and emit `redirect` (routed to `Archive.setRedirect`,
|
|
1172
|
+
// which writes the edge without touching the destination's
|
|
1173
|
+
// content). This URL does not count toward pagesScraped.
|
|
1174
|
+
if (result.type === 'redirect-edge') {
|
|
1175
|
+
// Note: a predicted (speculative) URL that reaches here genuinely
|
|
1176
|
+
// redirects (the server returned 3xx), so it is a real URL — we
|
|
1177
|
+
// record its edge rather than discard it. This matches the render
|
|
1178
|
+
// path, where the first predicted source to a destination renders
|
|
1179
|
+
// it and is recorded as a redirect source the same way; only 404 /
|
|
1180
|
+
// error predicted URLs are dropped (by `shouldDiscardPredicted`).
|
|
1181
|
+
//
|
|
1182
|
+
// The `source` discriminator divides this branch in two:
|
|
1183
|
+
//
|
|
1184
|
+
// - `'http-chain'` — the HEAD pre-flight resolved a real 3xx chain
|
|
1185
|
+
// and the destination is already rendered (`#scrapedDestinations`
|
|
1186
|
+
// claim). Every URL in `redirectPaths` is intermediate / known,
|
|
1187
|
+
// so the existing behaviour applies: `linkList.done` folds the
|
|
1188
|
+
// whole chain into the done-set so later references skip cleanly.
|
|
1189
|
+
//
|
|
1190
|
+
// - `'js-redirect'` — `scraper.scrapeStart` threw because
|
|
1191
|
+
// `page.goto()` returned null (`window.location.replace()` /
|
|
1192
|
+
// meta-refresh fired mid-navigation), and `redirectPaths`
|
|
1193
|
+
// carries the single JS target Chromium ended up on. That target
|
|
1194
|
+
// has NOT been rendered yet — it must enter the crawl queue, and
|
|
1195
|
+
// `linkList.done` MUST NOT fold it into the done-set (otherwise
|
|
1196
|
+
// the dealer's `seen` rejects the push and the destination is
|
|
1197
|
+
// silently lost from the archive).
|
|
1198
|
+
if (result.source === 'js-redirect') {
|
|
1199
|
+
const destination = result.pageData.redirectPaths.at(-1);
|
|
1200
|
+
if (destination) {
|
|
1201
|
+
const destinationUrl = parseUrl(destination, this.#options);
|
|
1202
|
+
if (destinationUrl) {
|
|
1203
|
+
// Gate 2: this direct enqueue does not go through
|
|
1204
|
+
// `#handleResult`'s addUrl closure (gate 1), so it needs
|
|
1205
|
+
// its own same-cluster-cap check — a JS-redirect trap
|
|
1206
|
+
// that advances a parameter via `location.replace()`
|
|
1207
|
+
// would otherwise keep re-entering the queue here.
|
|
1208
|
+
const gateShapeKey = computeShapeKey(destinationUrl.withoutHashAndAuth);
|
|
1209
|
+
const isCapped = this.#options.dedupeCap !== null &&
|
|
1210
|
+
gateShapeKey !== null &&
|
|
1211
|
+
findScopeEntry(destinationUrl, this.#scope, this.#options) !==
|
|
1212
|
+
null &&
|
|
1213
|
+
this.#dedupeCapTracker.isCapped(gateShapeKey);
|
|
1214
|
+
if (isCapped) {
|
|
1215
|
+
if (gateShapeKey)
|
|
1216
|
+
this.#recordDedupeCapRejection(gateShapeKey);
|
|
1217
|
+
}
|
|
1218
|
+
else {
|
|
1219
|
+
this.#linkList.add(destinationUrl);
|
|
1220
|
+
void enqueue(destinationUrl);
|
|
1221
|
+
}
|
|
1085
1222
|
}
|
|
1086
1223
|
else {
|
|
1087
|
-
|
|
1088
|
-
|
|
1224
|
+
// `deriveJsRedirectTarget` already canonicalises
|
|
1225
|
+
// via WHATWG URL parsing, so reaching the
|
|
1226
|
+
// `parseUrl === null` branch here would mean
|
|
1227
|
+
// `@d-zero/shared/parse-url` rejected what
|
|
1228
|
+
// WHATWG accepted — unexpected, and silently
|
|
1229
|
+
// dropping the destination would be a silent
|
|
1230
|
+
// archive loss. Log it so DEBUG=Nitpicker:Crawler
|
|
1231
|
+
// catches the case.
|
|
1232
|
+
crawlerLog('JS-redirect destination %s failed to parse — dropping enqueue', destination);
|
|
1089
1233
|
}
|
|
1090
1234
|
}
|
|
1091
1235
|
else {
|
|
1092
|
-
|
|
1093
|
-
// via WHATWG URL parsing, so reaching the
|
|
1094
|
-
// `parseUrl === null` branch here would mean
|
|
1095
|
-
// `@d-zero/shared/parse-url` rejected what
|
|
1096
|
-
// WHATWG accepted — unexpected, and silently
|
|
1097
|
-
// dropping the destination would be a silent
|
|
1098
|
-
// archive loss. Log it so DEBUG=Nitpicker:Crawler
|
|
1099
|
-
// catches the case.
|
|
1100
|
-
crawlerLog('JS-redirect destination %s failed to parse — dropping enqueue', destination);
|
|
1236
|
+
crawlerLog('JS-redirect result for %s had no redirectPaths destination — dropping enqueue', url.href);
|
|
1101
1237
|
}
|
|
1238
|
+
this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options, { includeRedirectPaths: false });
|
|
1102
1239
|
}
|
|
1103
1240
|
else {
|
|
1104
|
-
|
|
1241
|
+
this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options);
|
|
1105
1242
|
}
|
|
1106
|
-
|
|
1243
|
+
// The redirect-edge call path may INSERT a brand-new
|
|
1244
|
+
// destination row (js-redirect rescue, #73
|
|
1245
|
+
// convergence on first sight). Forward the
|
|
1246
|
+
// originating page's inventory provenance so the
|
|
1247
|
+
// destination + intermediate hops inherit the
|
|
1248
|
+
// chain's lineage instead of laundering to DB
|
|
1249
|
+
// DEFAULT `'crawled'`. `inventoryMode === null`
|
|
1250
|
+
// (resume / retry-failed) yields `undefined`,
|
|
1251
|
+
// which is correct: the DB-side lookup in
|
|
1252
|
+
// `#linkRedirectSources` reads the destination's
|
|
1253
|
+
// stored source for those sessions.
|
|
1254
|
+
void this.emit('redirect', buildRedirectEvent(result.pageData, this.#options.inventoryMode, url.withoutHashAndAuth));
|
|
1255
|
+
log(c.dim('Redirect (dest already scraped)'));
|
|
1256
|
+
return;
|
|
1107
1257
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1258
|
+
// Discard predicted URLs that failed (404, error, etc.)
|
|
1259
|
+
if (isPredicted && shouldDiscardPredicted(result)) {
|
|
1260
|
+
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
1261
|
+
log(c.dim('Predicted (discarded)'));
|
|
1262
|
+
return;
|
|
1110
1263
|
}
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
precomputedBodyHash = computeBodyHash(result.pageData.html);
|
|
1146
|
-
// Discard a predicted URL whose rendered body is a
|
|
1147
|
-
// byte-for-byte duplicate of the previous predicted page of
|
|
1148
|
-
// the same shape, and stop generating further predictions for
|
|
1149
|
-
// that shape (checked above, in the pagination-pattern
|
|
1150
|
-
// branch). This is the always-on backstop against a site
|
|
1151
|
-
// that returns 2xx for any extrapolated token but ignores it
|
|
1152
|
-
// entirely (e.g. always serving the same "no results"
|
|
1153
|
-
// template) — `shouldDiscardPredicted` alone cannot see
|
|
1154
|
-
// this, since it only inspects HTTP status.
|
|
1155
|
-
if (isPredicted) {
|
|
1156
|
-
const shapeKey = computeShapeKey(url.withoutHashAndAuth);
|
|
1157
|
-
if (shapeKey) {
|
|
1158
|
-
const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
|
|
1159
|
-
if (isPredictedContentDuplicate(precomputedBodyHash, lastBodyHash)) {
|
|
1160
|
-
this.#predictedShapeStopped.add(shapeKey);
|
|
1161
|
-
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
1162
|
-
log(c.dim('Predicted (content duplicate, discarded)'));
|
|
1163
|
-
return;
|
|
1264
|
+
// Compute this page's body hash once, up front, for every
|
|
1265
|
+
// internal page with a rendered HTML body — not just predicted
|
|
1266
|
+
// ones. This condition intentionally mirrors `update-page.ts`'s
|
|
1267
|
+
// `writeHtml && page.html.length > 0` write gate (internal pages
|
|
1268
|
+
// are exactly the ones `setPage` — as opposed to
|
|
1269
|
+
// `setExternalPage` — writes a body through), so the value
|
|
1270
|
+
// computed here can be forwarded through the `page` event all
|
|
1271
|
+
// the way to that write and reused there instead of hashing the
|
|
1272
|
+
// same html a second time.
|
|
1273
|
+
if (result.type === 'success' &&
|
|
1274
|
+
result.pageData &&
|
|
1275
|
+
!result.pageData.isExternal &&
|
|
1276
|
+
result.pageData.html.length > 0) {
|
|
1277
|
+
precomputedBodyHash = computeBodyHash(result.pageData.html);
|
|
1278
|
+
// Discard a predicted URL whose rendered body is a
|
|
1279
|
+
// byte-for-byte duplicate of the previous predicted page of
|
|
1280
|
+
// the same shape, and stop generating further predictions for
|
|
1281
|
+
// that shape (checked above, in the pagination-pattern
|
|
1282
|
+
// branch). This is the always-on backstop against a site
|
|
1283
|
+
// that returns 2xx for any extrapolated token but ignores it
|
|
1284
|
+
// entirely (e.g. always serving the same "no results"
|
|
1285
|
+
// template) — `shouldDiscardPredicted` alone cannot see
|
|
1286
|
+
// this, since it only inspects HTTP status.
|
|
1287
|
+
if (isPredicted) {
|
|
1288
|
+
const shapeKey = computeShapeKey(url.withoutHashAndAuth);
|
|
1289
|
+
if (shapeKey) {
|
|
1290
|
+
const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
|
|
1291
|
+
if (isPredictedContentDuplicate(precomputedBodyHash, lastBodyHash)) {
|
|
1292
|
+
this.#predictedShapeStopped.add(shapeKey);
|
|
1293
|
+
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
1294
|
+
log(c.dim('Predicted (content duplicate, discarded)'));
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
this.#predictedShapeBodyHashes.set(shapeKey, precomputedBodyHash);
|
|
1164
1298
|
}
|
|
1165
|
-
this.#predictedShapeBodyHashes.set(shapeKey, precomputedBodyHash);
|
|
1166
1299
|
}
|
|
1167
1300
|
}
|
|
1301
|
+
// Count only after discard check: rendered HTML pages that
|
|
1302
|
+
// will be persisted to the archive. Launch failures bypass
|
|
1303
|
+
// this point via the catch block; discarded predicted URLs
|
|
1304
|
+
// return above without reaching here.
|
|
1305
|
+
if (renderedInBrowser) {
|
|
1306
|
+
pagesScraped++;
|
|
1307
|
+
}
|
|
1308
|
+
log('Saving results%dots%');
|
|
1309
|
+
this.#handleResult(result, url, enqueue, this.#currentConcurrency(), precomputedBodyHash);
|
|
1310
|
+
// Skip sub-resources / console logs for a result that turned out
|
|
1311
|
+
// external — NOT the same as this worker's own `isExternal`
|
|
1312
|
+
// (computed from `url` before navigation). Beholder decides
|
|
1313
|
+
// `isExternal: false` before navigating and only flips it to
|
|
1314
|
+
// `true` after seeing the destination's hostname, so a
|
|
1315
|
+
// same-host source that redirects cross-host still has its
|
|
1316
|
+
// request/response/console listeners attached under the
|
|
1317
|
+
// pre-navigation `isExternal: false` for the whole trip. Those
|
|
1318
|
+
// listeners keep capturing the destination's sub-resources and
|
|
1319
|
+
// console output even after the flip, so without this guard a
|
|
1320
|
+
// cross-host redirect leaks the OFF-SCOPE destination's data
|
|
1321
|
+
// into this archive: its console output would be recorded as
|
|
1322
|
+
// this page's quality signal, and its resources would leave a
|
|
1323
|
+
// `resource_ref_edges` row on the (now content-less) redirect
|
|
1324
|
+
// SOURCE — `linkRedirectSources` deletes that source's
|
|
1325
|
+
// `anchor_edges` / `image_items` but not `resource_ref_edges`.
|
|
1326
|
+
// A genuinely external URL never reaches this branch with
|
|
1327
|
+
// non-empty `resources` / `consoleLogs` in the first place —
|
|
1328
|
+
// beholder never attaches these listeners for one, per the
|
|
1329
|
+
// `isExternal` gate in `#fetchData` — so this guard is a no-op
|
|
1330
|
+
// outside the cross-host-redirect case. See
|
|
1331
|
+
// `resolveResultWentOffHost`'s JSDoc for how it answers this for
|
|
1332
|
+
// a `type: 'error'` result, which has no `pageData` to read.
|
|
1333
|
+
if (!resolveResultWentOffHost(result, url)) {
|
|
1334
|
+
const parentSource = await this.#resolveParentSource(url);
|
|
1335
|
+
this.#handleResources(result.resources, parentSource);
|
|
1336
|
+
this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
|
|
1337
|
+
}
|
|
1338
|
+
log(formatResultSummary(result));
|
|
1339
|
+
// Phase errors must be emitted AFTER 'page' / 'externalPage'
|
|
1340
|
+
// so the orchestrator's WriteQueue sees `setPage` before
|
|
1341
|
+
// `insertPageError` and the URL→pageId resolution succeeds.
|
|
1342
|
+
this.#drainPhaseErrors(url, isExternal);
|
|
1168
1343
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1344
|
+
catch (error) {
|
|
1345
|
+
crawlerLog('Worker error for %s: %O', url.href, error);
|
|
1346
|
+
log(c.red('Error'));
|
|
1347
|
+
const workerError = error instanceof Error ? error : new Error(String(error));
|
|
1348
|
+
handleScrapeError({
|
|
1349
|
+
url,
|
|
1350
|
+
error: workerError,
|
|
1351
|
+
shutdown: false,
|
|
1352
|
+
pid: process.pid,
|
|
1353
|
+
}, this.#linkList, this.#scope, this.#options);
|
|
1354
|
+
void this.emit('error', {
|
|
1355
|
+
pid: process.pid,
|
|
1356
|
+
isMainProcess: true,
|
|
1357
|
+
url: url.href,
|
|
1358
|
+
isExternal,
|
|
1359
|
+
error: workerError,
|
|
1360
|
+
});
|
|
1361
|
+
// Hard-error path: persist whatever phase errors we have
|
|
1362
|
+
// already buffered so they are not lost.
|
|
1363
|
+
this.#drainPhaseErrors(url, isExternal);
|
|
1175
1364
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
// console output even after the flip, so without this guard a
|
|
1188
|
-
// cross-host redirect leaks the OFF-SCOPE destination's data
|
|
1189
|
-
// into this archive: its console output would be recorded as
|
|
1190
|
-
// this page's quality signal, and its resources would leave a
|
|
1191
|
-
// `resource_ref_edges` row on the (now content-less) redirect
|
|
1192
|
-
// SOURCE — `linkRedirectSources` deletes that source's
|
|
1193
|
-
// `anchor_edges` / `image_items` but not `resource_ref_edges`.
|
|
1194
|
-
// A genuinely external URL never reaches this branch with
|
|
1195
|
-
// non-empty `resources` / `consoleLogs` in the first place —
|
|
1196
|
-
// beholder never attaches these listeners for one, per the
|
|
1197
|
-
// `isExternal` gate in `#fetchData` — so this guard is a no-op
|
|
1198
|
-
// outside the cross-host-redirect case. See
|
|
1199
|
-
// `resolveResultWentOffHost`'s JSDoc for how it answers this for
|
|
1200
|
-
// a `type: 'error'` result, which has no `pageData` to read.
|
|
1201
|
-
if (!resolveResultWentOffHost(result, url)) {
|
|
1202
|
-
const parentSource = await this.#resolveParentSource(url);
|
|
1203
|
-
this.#handleResources(result.resources, parentSource);
|
|
1204
|
-
this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
|
|
1365
|
+
finally {
|
|
1366
|
+
if (isExternal) {
|
|
1367
|
+
externalDoneUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
|
|
1368
|
+
}
|
|
1369
|
+
// Phase errors still in the buffer here were not drained
|
|
1370
|
+
// by the success or catch paths — typically because a
|
|
1371
|
+
// predicted URL was discarded before reaching the drain
|
|
1372
|
+
// point. The helper logs the drop (observable via
|
|
1373
|
+
// DEBUG=Nitpicker:Crawler) and removes the entry so the
|
|
1374
|
+
// Map cannot leak across crawls.
|
|
1375
|
+
logUndrainedPhaseErrors(this.#pendingPhaseErrors, url.href, crawlerLog);
|
|
1205
1376
|
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1377
|
+
};
|
|
1378
|
+
}, {
|
|
1379
|
+
limit: concurrency,
|
|
1380
|
+
// Interval is applied per-URL inside the worker callback above so
|
|
1381
|
+
// DNS-burned hosts can skip it. Letting dealer handle interval
|
|
1382
|
+
// would run the wait before our short-circuit check fires.
|
|
1383
|
+
interval: 0,
|
|
1384
|
+
verbose: this.#options.verbose || !process.stdout.isTTY,
|
|
1385
|
+
signal: this.#abortController.signal,
|
|
1386
|
+
// `undefined` falls back to deal() building its own Lanes off
|
|
1387
|
+
// `verbose` above; a caller-supplied Lanes (e.g. the CLI's,
|
|
1388
|
+
// carrying its runtime-input footer) is reused as-is instead.
|
|
1389
|
+
lanes: this.#options.lanes,
|
|
1390
|
+
onStart: (controller) => {
|
|
1391
|
+
this.#dealController = controller;
|
|
1392
|
+
},
|
|
1393
|
+
header: (_progress, done, total, limit) => {
|
|
1394
|
+
return formatCrawlProgress({
|
|
1395
|
+
done,
|
|
1396
|
+
total,
|
|
1397
|
+
resumeOffset,
|
|
1398
|
+
externalTotal: externalUrls.size,
|
|
1399
|
+
externalDone: externalDoneUrls.size,
|
|
1400
|
+
pagesScraped,
|
|
1401
|
+
limit,
|
|
1228
1402
|
});
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1403
|
+
},
|
|
1404
|
+
onPush: (url) => {
|
|
1405
|
+
const key = protocolAgnosticKey(url.withoutHashAndAuth);
|
|
1406
|
+
if (seen.has(key))
|
|
1407
|
+
return false;
|
|
1408
|
+
seen.add(key);
|
|
1409
|
+
if (findScopeEntry(url, this.#scope, this.#options) === null) {
|
|
1410
|
+
externalUrls.add(key);
|
|
1236
1411
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
// DNS-burned hosts can skip it. Letting dealer handle interval
|
|
1250
|
-
// would run the wait before our short-circuit check fires.
|
|
1251
|
-
interval: 0,
|
|
1252
|
-
verbose: this.#options.verbose || !process.stdout.isTTY,
|
|
1253
|
-
signal: this.#abortController.signal,
|
|
1254
|
-
header: (_progress, done, total, limit) => {
|
|
1255
|
-
return formatCrawlProgress({
|
|
1256
|
-
done,
|
|
1257
|
-
total,
|
|
1258
|
-
resumeOffset,
|
|
1259
|
-
externalTotal: externalUrls.size,
|
|
1260
|
-
externalDone: externalDoneUrls.size,
|
|
1261
|
-
pagesScraped,
|
|
1262
|
-
limit,
|
|
1263
|
-
});
|
|
1264
|
-
},
|
|
1265
|
-
onPush: (url) => {
|
|
1266
|
-
const key = protocolAgnosticKey(url.withoutHashAndAuth);
|
|
1267
|
-
if (seen.has(key))
|
|
1268
|
-
return false;
|
|
1269
|
-
seen.add(key);
|
|
1270
|
-
if (findScopeEntry(url, this.#scope, this.#options) === null) {
|
|
1271
|
-
externalUrls.add(key);
|
|
1272
|
-
}
|
|
1273
|
-
return true;
|
|
1274
|
-
},
|
|
1275
|
-
});
|
|
1412
|
+
return true;
|
|
1413
|
+
},
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
finally {
|
|
1417
|
+
// Reset even when `deal()` rejects (propagating out of this method
|
|
1418
|
+
// to `start()`/`resume()`'s `.catch()`) — leaving a stale
|
|
1419
|
+
// controller pointed at a dead round would let a later
|
|
1420
|
+
// `updateRuntimeOptions({ parallels })` call `setLimit()` on a
|
|
1421
|
+
// `Dealer` instance that has already finished.
|
|
1422
|
+
this.#dealController = null;
|
|
1423
|
+
}
|
|
1276
1424
|
crawlerLog('Crawl End');
|
|
1277
1425
|
void this.emit('crawlEnd', {});
|
|
1278
1426
|
}
|