@nitpicker/crawler 0.11.0 → 0.12.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/archive/archive.d.ts +117 -2
- package/lib/archive/archive.js +147 -2
- package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
- package/lib/archive/cache/compute-archive-cache-key.js +95 -0
- package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
- package/lib/archive/cache/extract-archive-to-cache.js +309 -0
- package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
- package/lib/archive/cache/get-archive-cache-root.js +53 -0
- package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
- package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
- package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
- package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
- package/lib/archive/database.d.ts +216 -15
- package/lib/archive/database.js +1459 -938
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- package/lib/archive/get-failed-page-messages.d.ts +43 -0
- package/lib/archive/get-failed-page-messages.js +131 -0
- package/lib/archive/init-schema.js +153 -1
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/migrate-inventory-runs.d.ts +29 -0
- package/lib/archive/migrate-inventory-runs.js +52 -0
- package/lib/archive/types.d.ts +33 -0
- package/lib/classify-error-kind.d.ts +19 -0
- package/lib/classify-error-kind.js +122 -0
- package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
- package/lib/crawler/build-js-redirect-edge.js +57 -0
- package/lib/crawler/build-redirect-event.d.ts +24 -0
- package/lib/crawler/build-redirect-event.js +28 -0
- package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
- package/lib/crawler/crawler.d.ts +3 -1
- package/lib/crawler/crawler.js +655 -107
- package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
- package/lib/crawler/derive-js-redirect-target.js +129 -0
- package/lib/crawler/derive-resource-source.d.ts +25 -15
- package/lib/crawler/derive-resource-source.js +28 -17
- package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
- package/lib/crawler/dns-burned-host-cache.js +25 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
- package/lib/crawler/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +94 -16
- package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
- package/lib/crawler/is-js-redirect-error-shape.js +53 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +23 -3
- package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
- package/lib/crawler/plan-sub-resource-emits.js +44 -0
- package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
- package/lib/crawler/preload-short-circuit-error.js +25 -0
- package/lib/crawler/should-burn-host.d.ts +78 -0
- package/lib/crawler/should-burn-host.js +61 -0
- package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
- package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
- package/lib/crawler/types.d.ts +107 -0
- package/lib/crawler-orchestrator.d.ts +13 -3
- package/lib/crawler-orchestrator.js +292 -69
- package/lib/crawler.d.ts +3 -2
- package/lib/crawler.js +3 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/types.d.ts +84 -0
- package/lib/utils/compute-file-sha256.d.ts +23 -0
- package/lib/utils/compute-file-sha256.js +55 -0
- package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
- package/lib/utils/error/emit-error-with-retry.js +44 -0
- package/lib/utils/error/emit-error.d.ts +39 -0
- package/lib/utils/error/emit-error.js +41 -0
- package/package.json +11 -11
- package/lib/utils/error/error-emitter.d.ts +0 -18
- package/lib/utils/error/error-emitter.js +0 -29
package/lib/crawler/crawler.js
CHANGED
|
@@ -3,16 +3,21 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import Scraper from '@d-zero/beholder';
|
|
5
5
|
import { deal } from '@d-zero/dealer';
|
|
6
|
+
import { delay } from '@d-zero/shared/delay';
|
|
6
7
|
import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
|
|
7
8
|
import { retryCall } from '@d-zero/shared/retry';
|
|
8
9
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
9
10
|
import c from 'ansi-colors';
|
|
10
11
|
import pkg from '../../package.json' with { type: 'json' };
|
|
12
|
+
import { classifyErrorKind } from '../classify-error-kind.js';
|
|
11
13
|
import { crawlerLog } from '../debug.js';
|
|
14
|
+
import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
|
|
15
|
+
import { buildRedirectEvent } from './build-redirect-event.js';
|
|
12
16
|
import { createChangePhaseHandler } from './create-change-phase-handler.js';
|
|
13
17
|
import { derivePageSource } from './derive-page-source.js';
|
|
14
|
-
import { deriveResourceSource } from './derive-resource-source.js';
|
|
15
18
|
import { detectPaginationPattern } from './detect-pagination-pattern.js';
|
|
19
|
+
import { dnsBurnedHostCache } from './dns-burned-host-cache.js';
|
|
20
|
+
import { dnsBurnedHostShortCircuitCounter } from './dns-burned-host-short-circuit-counter.js';
|
|
16
21
|
import { drainPhaseErrors } from './drain-phase-errors.js';
|
|
17
22
|
import { fetchDestination } from './fetch-destination.js';
|
|
18
23
|
import { findScopeEntry } from './find-scope-entry.js';
|
|
@@ -20,21 +25,36 @@ import { formatCrawlProgress } from './format-crawl-progress.js';
|
|
|
20
25
|
import { generatePredictedUrls } from './generate-predicted-urls.js';
|
|
21
26
|
import { handleBrowserClose } from './handle-browser-close.js';
|
|
22
27
|
import { handleIgnoreAndSkip } from './handle-ignore-and-skip.js';
|
|
23
|
-
import { handleResourceResponse } from './handle-resource-response.js';
|
|
24
28
|
import { handleScrapeEnd } from './handle-scrape-end.js';
|
|
25
29
|
import { handleScrapeError } from './handle-scrape-error.js';
|
|
26
30
|
import { injectScopeAuth } from './inject-scope-auth.js';
|
|
27
31
|
import { isHtmlContentType } from './is-html-content-type.js';
|
|
32
|
+
import { isLikelyHtmlUrl } from './is-likely-html-url.js';
|
|
33
|
+
import { isPuppeteerFallbackCandidate } from './is-puppeteer-fallback-candidate.js';
|
|
28
34
|
import LinkList from './link-list.js';
|
|
29
35
|
import { linkToPageData } from './link-to-page-data.js';
|
|
30
36
|
import { logUndrainedPhaseErrors } from './log-undrained-phase-errors.js';
|
|
31
37
|
import { partitionUrlsByHtml } from './partition-urls-by-html.js';
|
|
38
|
+
import { planSubResourceEmits } from './plan-sub-resource-emits.js';
|
|
39
|
+
import { PreloadShortCircuitError } from './preload-short-circuit-error.js';
|
|
32
40
|
import { protocolAgnosticKey } from './protocol-agnostic-key.js';
|
|
33
41
|
import { redirectDestKey } from './redirect-dest-key.js';
|
|
34
42
|
import { resourceToPageData } from './resource-to-page-data.js';
|
|
35
43
|
import { RobotsChecker } from './robots-checker.js';
|
|
44
|
+
import { shouldBurnHost } from './should-burn-host.js';
|
|
36
45
|
import { shouldDiscardPredicted } from './should-discard-predicted.js';
|
|
37
46
|
import { shouldSkipUrl } from './should-skip-url.js';
|
|
47
|
+
/**
|
|
48
|
+
* Per-attempt HEAD pre-flight timeouts in milliseconds.
|
|
49
|
+
*
|
|
50
|
+
* `retryCall` re-invokes the work function up to `retry + 1` times; we keep
|
|
51
|
+
* the first attempt short so a fast healthy site never pays the slow-server
|
|
52
|
+
* tax, then escalate so that a slow-but-eventually-responsive host gets a
|
|
53
|
+
* larger budget on retry. The attempt index is clamped to the last element
|
|
54
|
+
* of the array, so configurations with `retry > escalation.length - 1` just
|
|
55
|
+
* stay on the final (longest) timeout for any additional attempts.
|
|
56
|
+
*/
|
|
57
|
+
const HEAD_TIMEOUT_ESCALATION_MS = [10_000, 30_000, 60_000];
|
|
38
58
|
/**
|
|
39
59
|
* The core crawler engine that discovers and scrapes web pages.
|
|
40
60
|
*
|
|
@@ -54,7 +74,7 @@ class Crawler extends EventEmitter {
|
|
|
54
74
|
/** Merged crawler configuration (user overrides + defaults). */
|
|
55
75
|
#options;
|
|
56
76
|
/**
|
|
57
|
-
* Phase errors observed during {@link Crawler
|
|
77
|
+
* Phase errors observed during {@link Crawler._launchBrowserAndScrape},
|
|
58
78
|
* buffered per URL href so they can be emitted as `pageError` events
|
|
59
79
|
* AFTER the corresponding `page` / `externalPage` event. This ordering
|
|
60
80
|
* lets the orchestrator's WriteQueue serialise `setPage` before
|
|
@@ -80,6 +100,20 @@ class Crawler extends EventEmitter {
|
|
|
80
100
|
* Keyed by {@link redirectDestKey}. Reset at the start of {@link #runDeal}.
|
|
81
101
|
*/
|
|
82
102
|
#scrapedDestinations = new Set();
|
|
103
|
+
/**
|
|
104
|
+
* Lower-cased hostnames for which at least one URL has returned an
|
|
105
|
+
* HTTP response (any status) via `fetchDestination` in this session.
|
|
106
|
+
* Consulted by {@link shouldBurnHost} as the cascade guard against
|
|
107
|
+
* "transient local DNS hiccup wipes out a healthy host": a host that
|
|
108
|
+
* responded earlier is treated as still alive even when the next URL on
|
|
109
|
+
* it exhausts retries with a `getaddrinfo ENOTFOUND`, since the most
|
|
110
|
+
* likely cause is the operator's resolver flipping mid-crawl rather than
|
|
111
|
+
* the host suddenly disappearing. Populated by {@link #sendHeadRequest}
|
|
112
|
+
* on the success path; reset at the start of {@link #runDeal} alongside
|
|
113
|
+
* {@link #scrapedDestinations} so a fresh session does not inherit
|
|
114
|
+
* stale liveness assumptions.
|
|
115
|
+
*/
|
|
116
|
+
#successfulHosts = new Set();
|
|
83
117
|
/**
|
|
84
118
|
* The AbortSignal associated with this crawler's AbortController.
|
|
85
119
|
*
|
|
@@ -115,6 +149,7 @@ class Crawler extends EventEmitter {
|
|
|
115
149
|
userAgent: options?.userAgent || `Nitpicker/${pkg.version}`,
|
|
116
150
|
ignoreRobots: options?.ignoreRobots ?? false,
|
|
117
151
|
lookupResource: options?.lookupResource ?? null,
|
|
152
|
+
lookupPageSource: options?.lookupPageSource ?? null,
|
|
118
153
|
inventoryMode: options?.inventoryMode ?? null,
|
|
119
154
|
};
|
|
120
155
|
this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
|
|
@@ -180,21 +215,27 @@ class Crawler extends EventEmitter {
|
|
|
180
215
|
* before reaching the dealer so a URL that exists in both sources — which
|
|
181
216
|
* is common in append-mode when a new root coincides with a repromoted
|
|
182
217
|
* previously-external page — does not race on two parallel slots.
|
|
183
|
-
* @param urls - The list of root URLs to begin crawling from.
|
|
218
|
+
* @param urls - The list of root URLs to begin crawling from. May be empty
|
|
219
|
+
* when resumed pending URLs already exist (for example `--retry-failed`).
|
|
184
220
|
* @param opts - Optional overrides; currently only `recursive` is honoured.
|
|
185
221
|
* @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
|
|
186
222
|
* Defaults to the constructor option's `recursive` value.
|
|
187
223
|
* @throws {Error} If the URL list is empty.
|
|
188
224
|
*/
|
|
189
225
|
start(urls, opts) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
226
|
+
// Inventory mode pre-loads tens of thousands of seed URLs that all
|
|
227
|
+
// fall under archived `roots` (already populated into `#scope` by
|
|
228
|
+
// the constructor). Adding each seed as its own scope entry was
|
|
229
|
+
// O(N²) on build (per-host `existing.some` + array spread) AND
|
|
230
|
+
// turned every later `findScopeEntry` into a 70k linear scan. Skip
|
|
231
|
+
// the scope add — seeds remain entry points via `#linkList`.
|
|
232
|
+
const skipScopeAdd = this.#options.inventoryMode != null;
|
|
194
233
|
for (const url of urls) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
234
|
+
if (!skipScopeAdd) {
|
|
235
|
+
const existing = this.#scope.get(url.hostname) || [];
|
|
236
|
+
if (!existing.some((u) => u.href === url.href)) {
|
|
237
|
+
this.#scope.set(url.hostname, [...existing, url]);
|
|
238
|
+
}
|
|
198
239
|
}
|
|
199
240
|
this.#linkList.add(url);
|
|
200
241
|
}
|
|
@@ -223,13 +264,17 @@ class Crawler extends EventEmitter {
|
|
|
223
264
|
seenInitial.add(key);
|
|
224
265
|
initialUrls.push(url);
|
|
225
266
|
}
|
|
267
|
+
const root = initialUrls[0];
|
|
268
|
+
if (!root) {
|
|
269
|
+
if (isResuming) {
|
|
270
|
+
crawlerLog('Crawl End (nothing to resume)');
|
|
271
|
+
void this.emit('crawlEnd', {});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
throw new Error('urls is empty');
|
|
275
|
+
}
|
|
226
276
|
const resumeOffset = this.#resumedScraped.length;
|
|
227
277
|
const pagesScrapedOffset = this.#resumedPagesScraped;
|
|
228
|
-
if (initialUrls.length === 0) {
|
|
229
|
-
crawlerLog('Crawl End (nothing to resume)');
|
|
230
|
-
void this.emit('crawlEnd', {});
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
278
|
void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset).catch((error) => {
|
|
234
279
|
crawlerLog('runDeal error: %O', error);
|
|
235
280
|
this.#emitDealErrors(error, root.href);
|
|
@@ -281,25 +326,22 @@ class Crawler extends EventEmitter {
|
|
|
281
326
|
* Processes captured sub-resources from a page scrape, deduplicates them,
|
|
282
327
|
* and emits `response` / `responseReferrers` events for new resources.
|
|
283
328
|
* @param resources - Sub-resource entries captured during the page load
|
|
329
|
+
* @param parentSource
|
|
284
330
|
*/
|
|
285
|
-
#handleResources(resources) {
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
void this.emit('responseReferrers', {
|
|
300
|
-
url: pageUrl,
|
|
301
|
-
src: resource.url.withoutHash,
|
|
302
|
-
});
|
|
331
|
+
#handleResources(resources, parentSource) {
|
|
332
|
+
// Decide the full emit plan first via the pure planner — that lets
|
|
333
|
+
// the lineage propagation contract (parent source → sub-resource
|
|
334
|
+
// `source`) be unit-tested in `plan-sub-resource-emits.spec.ts`
|
|
335
|
+
// without spinning up the puppeteer stack here. The previous
|
|
336
|
+
// inline shape made the `source` value invisible to tests because
|
|
337
|
+
// emit() side effects were only observable via a full scrape run
|
|
338
|
+
// that requires a mocked Chromium instance.
|
|
339
|
+
const { responseEmits, referrerEmits } = planSubResourceEmits(resources, parentSource, this.#resources);
|
|
340
|
+
for (const payload of responseEmits) {
|
|
341
|
+
void this.emit('response', payload);
|
|
342
|
+
}
|
|
343
|
+
for (const payload of referrerEmits) {
|
|
344
|
+
void this.emit('responseReferrers', payload);
|
|
303
345
|
}
|
|
304
346
|
}
|
|
305
347
|
/**
|
|
@@ -440,72 +482,60 @@ class Crawler extends EventEmitter {
|
|
|
440
482
|
* @param headCheckResult - Optional HEAD result to pass to the scraper, avoiding a redundant request
|
|
441
483
|
* @returns The scrape result from beholder
|
|
442
484
|
*/
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
485
|
+
/**
|
|
486
|
+
* @param url
|
|
487
|
+
* @param update
|
|
488
|
+
* @param isExternal
|
|
489
|
+
* @param metadataOnly
|
|
490
|
+
* @param headCheckResult
|
|
491
|
+
* @internal
|
|
492
|
+
* cascade-guard contract for the puppeteer-fallback success / skipped
|
|
493
|
+
* branches can be exercised via `vi.spyOn(Crawler.prototype,
|
|
494
|
+
* '_launchBrowserAndScrape')` in unit tests. There is no production
|
|
495
|
+
* consumer outside this class.
|
|
496
|
+
*/
|
|
497
|
+
/**
|
|
498
|
+
* Resolve the source label of the page being scraped so sub-resources
|
|
499
|
+
* captured during its render can inherit the correct lineage label
|
|
500
|
+
* (`'inventory-discovered'` when the parent is in the inventory chain,
|
|
501
|
+
* `undefined` otherwise so the DB DEFAULT `'crawled'` lands).
|
|
502
|
+
*
|
|
503
|
+
* Two-stage resolution:
|
|
504
|
+
*
|
|
505
|
+
* 1. If `inventoryMode` is active (live `--inventory` session), use
|
|
506
|
+
* `derivePageSource` directly — the in-memory seed set is the
|
|
507
|
+
* authoritative answer and no DB round-trip is needed.
|
|
508
|
+
*
|
|
509
|
+
* 2. Otherwise (`--resume`, `--retry-failed`, `--append`, or a normal
|
|
510
|
+
* `crawl` of a previously-inventoried archive), ask the injected
|
|
511
|
+
* `lookupPageSource` callback. The orchestrator wires that callback
|
|
512
|
+
* to `Archive.getPageSourceByUrl` so the parent's lineage from
|
|
513
|
+
* earlier sessions survives across sessions.
|
|
514
|
+
*
|
|
515
|
+
* One round-trip per page render at most — the result is not memoised
|
|
516
|
+
* because each worker scrapes a single page per `#scrapePage` call
|
|
517
|
+
* and the cost is amortised across every sub-resource of that page.
|
|
518
|
+
* @param url - The URL of the page being scraped.
|
|
519
|
+
* @returns The parent page's source, or `undefined` when none applies.
|
|
520
|
+
*/
|
|
521
|
+
async #resolveParentSource(url) {
|
|
522
|
+
const fromInventoryMode = derivePageSource(this.#options.inventoryMode, url.withoutHashAndAuth);
|
|
523
|
+
if (fromInventoryMode !== undefined) {
|
|
524
|
+
return fromInventoryMode;
|
|
525
|
+
}
|
|
526
|
+
const lookupPageSource = this.#options.lookupPageSource;
|
|
527
|
+
if (!lookupPageSource) {
|
|
528
|
+
return undefined;
|
|
450
529
|
}
|
|
451
|
-
const puppeteer = await import('puppeteer');
|
|
452
|
-
const browser = await puppeteer.launch({
|
|
453
|
-
headless: true,
|
|
454
|
-
...(this.#options.executablePath
|
|
455
|
-
? { executablePath: this.#options.executablePath }
|
|
456
|
-
: {}),
|
|
457
|
-
});
|
|
458
530
|
try {
|
|
459
|
-
|
|
460
|
-
const page = await browser.newPage();
|
|
461
|
-
await page.setUserAgent(this.#options.userAgent);
|
|
462
|
-
// Defence-in-depth: beholder sets Authorization via setExtraHTTPHeaders,
|
|
463
|
-
// but page.authenticate() handles Chromium-level HTTP auth challenges
|
|
464
|
-
// (401 + WWW-Authenticate) that setExtraHTTPHeaders cannot cover.
|
|
465
|
-
if (url.username && url.password) {
|
|
466
|
-
await page.authenticate({
|
|
467
|
-
username: url.username,
|
|
468
|
-
password: url.password,
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
const scraper = new Scraper();
|
|
472
|
-
scraper.on('changePhase', createChangePhaseHandler({
|
|
473
|
-
emit: (event) => void this.emit('changePhase', event),
|
|
474
|
-
update,
|
|
475
|
-
formatLog: formatPhaseLog,
|
|
476
|
-
buffer: this.#pendingPhaseErrors,
|
|
477
|
-
urlHref: url.href,
|
|
478
|
-
}));
|
|
479
|
-
const result = await scraper.scrapeStart(page, url, {
|
|
480
|
-
isExternal,
|
|
481
|
-
captureImages: !isExternal && this.#options.captureImages,
|
|
482
|
-
excludeKeywords: this.#options.excludeKeywords,
|
|
483
|
-
disableQueries: this.#options.disableQueries,
|
|
484
|
-
metadataOnly,
|
|
485
|
-
retries: this.#options.retry,
|
|
486
|
-
headCheckResult,
|
|
487
|
-
});
|
|
488
|
-
update('Closing browser%dots%');
|
|
489
|
-
return result;
|
|
531
|
+
return await lookupPageSource(url.withoutHashAndAuth);
|
|
490
532
|
}
|
|
491
533
|
catch (error) {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
message: error instanceof Error ? error.message : String(error),
|
|
498
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
499
|
-
shutdown: true,
|
|
500
|
-
},
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
finally {
|
|
504
|
-
// handleBrowserClose force-kills the underlying Chromium when a
|
|
505
|
-
// graceful close() hangs (e.g. the session died mid-scrape) and
|
|
506
|
-
// guarantees the finally never throws, so the try-block's return
|
|
507
|
-
// value or caught error is never masked.
|
|
508
|
-
await handleBrowserClose(browser, url.href, crawlerLog);
|
|
534
|
+
// A lookup failure must never be worse than not having lineage
|
|
535
|
+
// — fall back to undefined so the sub-resources land at the DB
|
|
536
|
+
// DEFAULT `'crawled'` rather than crashing the whole worker.
|
|
537
|
+
crawlerLog('Parent source lookup failed for %s: %O', url.href, error);
|
|
538
|
+
return undefined;
|
|
509
539
|
}
|
|
510
540
|
}
|
|
511
541
|
/**
|
|
@@ -530,6 +560,10 @@ class Crawler extends EventEmitter {
|
|
|
530
560
|
}
|
|
531
561
|
// Redirect-destination dedup is per-crawl; clear any state from a prior run.
|
|
532
562
|
this.#scrapedDestinations.clear();
|
|
563
|
+
// Session-liveness signal is per-crawl too; clear so a fresh session
|
|
564
|
+
// does not inherit "host alive" claims from a prior run that may have
|
|
565
|
+
// happened on an entirely different network.
|
|
566
|
+
this.#successfulHosts.clear();
|
|
533
567
|
// external URL の追跡(target は deal の total/done から導出)
|
|
534
568
|
const externalUrls = new Set();
|
|
535
569
|
const externalDoneUrls = new Set();
|
|
@@ -577,6 +611,20 @@ class Crawler extends EventEmitter {
|
|
|
577
611
|
return Promise.all(ops).then(() => { });
|
|
578
612
|
};
|
|
579
613
|
return async () => {
|
|
614
|
+
// Interval delay is handled here instead of by dealer because
|
|
615
|
+
// DNS-burned hosts must skip the wait entirely. Spending the
|
|
616
|
+
// per-URL interval on a host the cache already knows is dead
|
|
617
|
+
// just slows the crawl down for zero benefit — the HEAD won't
|
|
618
|
+
// be fired and `Crawler.#sendHeadRequest` will throw the
|
|
619
|
+
// preload short-circuit immediately. For all other URLs, run
|
|
620
|
+
// the same `delay()` + `%countdown(...)` log that dealer would
|
|
621
|
+
// have emitted, so the dealer display reads identically.
|
|
622
|
+
const burned = dnsBurnedHostCache.has(url.hostname.toLowerCase());
|
|
623
|
+
if (!burned && this.#options.interval && this.#options.interval > 0) {
|
|
624
|
+
await delay(this.#options.interval, (determinedInterval) => {
|
|
625
|
+
update(`Waiting interval: %countdown(${determinedInterval},${_index}_interval)%ms`);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
580
628
|
const log = createTimedUpdate(update, this.#options.verbose);
|
|
581
629
|
// `#scrapePage` 内のブラウザ HTML レンダーが成功したかをマークするフラグ。
|
|
582
630
|
// 成功時のみ #scrapePage 側で true に設定される。
|
|
@@ -640,8 +688,63 @@ class Crawler extends EventEmitter {
|
|
|
640
688
|
// path, where the first predicted source to a destination renders
|
|
641
689
|
// it and is recorded as a redirect source the same way; only 404 /
|
|
642
690
|
// error predicted URLs are dropped (by `shouldDiscardPredicted`).
|
|
643
|
-
|
|
644
|
-
|
|
691
|
+
//
|
|
692
|
+
// The `source` discriminator divides this branch in two:
|
|
693
|
+
//
|
|
694
|
+
// - `'http-chain'` — the HEAD pre-flight resolved a real 3xx chain
|
|
695
|
+
// and the destination is already rendered (`#scrapedDestinations`
|
|
696
|
+
// claim). Every URL in `redirectPaths` is intermediate / known,
|
|
697
|
+
// so the existing behaviour applies: `linkList.done` folds the
|
|
698
|
+
// whole chain into the done-set so later references skip cleanly.
|
|
699
|
+
//
|
|
700
|
+
// - `'js-redirect'` — `scraper.scrapeStart` threw because
|
|
701
|
+
// `page.goto()` returned null (`window.location.replace()` /
|
|
702
|
+
// meta-refresh fired mid-navigation), and `redirectPaths`
|
|
703
|
+
// carries the single JS target Chromium ended up on. That target
|
|
704
|
+
// has NOT been rendered yet — it must enter the crawl queue, and
|
|
705
|
+
// `linkList.done` MUST NOT fold it into the done-set (otherwise
|
|
706
|
+
// the dealer's `seen` rejects the push and the destination is
|
|
707
|
+
// silently lost from the archive).
|
|
708
|
+
if (result.source === 'js-redirect') {
|
|
709
|
+
const destination = result.pageData.redirectPaths.at(-1);
|
|
710
|
+
if (destination) {
|
|
711
|
+
const destinationUrl = parseUrl(destination, this.#options);
|
|
712
|
+
if (destinationUrl) {
|
|
713
|
+
this.#linkList.add(destinationUrl);
|
|
714
|
+
void enqueue(destinationUrl);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
// `deriveJsRedirectTarget` already canonicalises
|
|
718
|
+
// via WHATWG URL parsing, so reaching the
|
|
719
|
+
// `parseUrl === null` branch here would mean
|
|
720
|
+
// `@d-zero/shared/parse-url` rejected what
|
|
721
|
+
// WHATWG accepted — unexpected, and silently
|
|
722
|
+
// dropping the destination would be a silent
|
|
723
|
+
// archive loss. Log it so DEBUG=Nitpicker:Crawler
|
|
724
|
+
// catches the case.
|
|
725
|
+
crawlerLog('JS-redirect destination %s failed to parse — dropping enqueue', destination);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
crawlerLog('JS-redirect result for %s had no redirectPaths destination — dropping enqueue', url.href);
|
|
730
|
+
}
|
|
731
|
+
this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options, { includeRedirectPaths: false });
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
this.#linkList.done(url, this.#scope, { page: result.pageData }, this.#options);
|
|
735
|
+
}
|
|
736
|
+
// The redirect-edge call path may INSERT a brand-new
|
|
737
|
+
// destination row (js-redirect rescue, #73
|
|
738
|
+
// convergence on first sight). Forward the
|
|
739
|
+
// originating page's inventory provenance so the
|
|
740
|
+
// destination + intermediate hops inherit the
|
|
741
|
+
// chain's lineage instead of laundering to DB
|
|
742
|
+
// DEFAULT `'crawled'`. `inventoryMode === null`
|
|
743
|
+
// (resume / retry-failed) yields `undefined`,
|
|
744
|
+
// which is correct: the DB-side lookup in
|
|
745
|
+
// `#linkRedirectSources` reads the destination's
|
|
746
|
+
// stored source for those sessions.
|
|
747
|
+
void this.emit('redirect', buildRedirectEvent(result.pageData, this.#options.inventoryMode, url.withoutHashAndAuth));
|
|
645
748
|
log(c.dim('Redirect (dest already scraped)'));
|
|
646
749
|
return;
|
|
647
750
|
}
|
|
@@ -660,7 +763,8 @@ class Crawler extends EventEmitter {
|
|
|
660
763
|
}
|
|
661
764
|
log('Saving results%dots%');
|
|
662
765
|
this.#handleResult(result, url, enqueue, paginationState, concurrency);
|
|
663
|
-
this.#
|
|
766
|
+
const parentSource = await this.#resolveParentSource(url);
|
|
767
|
+
this.#handleResources(result.resources, parentSource);
|
|
664
768
|
log(formatResultSummary(result));
|
|
665
769
|
// Phase errors must be emitted AFTER 'page' / 'externalPage'
|
|
666
770
|
// so the orchestrator's WriteQueue sees `setPage` before
|
|
@@ -703,7 +807,10 @@ class Crawler extends EventEmitter {
|
|
|
703
807
|
};
|
|
704
808
|
}, {
|
|
705
809
|
limit: concurrency,
|
|
706
|
-
|
|
810
|
+
// Interval is applied per-URL inside the worker callback above so
|
|
811
|
+
// DNS-burned hosts can skip it. Letting dealer handle interval
|
|
812
|
+
// would run the wait before our short-circuit check fires.
|
|
813
|
+
interval: 0,
|
|
707
814
|
verbose: this.#options.verbose || !process.stdout.isTTY,
|
|
708
815
|
signal: this.#abortController.signal,
|
|
709
816
|
header: (_progress, done, total, limit) => {
|
|
@@ -745,7 +852,7 @@ class Crawler extends EventEmitter {
|
|
|
745
852
|
* @param metadataOnly - When true, only extract title metadata without full browser scraping
|
|
746
853
|
* @param laneIndex - The dealer lane index, used to create unique countdown IDs
|
|
747
854
|
* @param markBrowserScrape - Called once **after** the browser successfully
|
|
748
|
-
* renders an HTML page (i.e.
|
|
855
|
+
* renders an HTML page (i.e. `_launchBrowserAndScrape` resolved with
|
|
749
856
|
* `type: 'success'`). Not called for HEAD-only, title-only, captured-resource
|
|
750
857
|
* reuse, non-HTML responses, non-HTTP protocols (mailto:, tel:), browser
|
|
751
858
|
* launch throws (e.g. invalid executablePath), or scraper-returned
|
|
@@ -757,7 +864,7 @@ class Crawler extends EventEmitter {
|
|
|
757
864
|
const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
|
|
758
865
|
// Non-HTTP protocols (mailto:, tel:, etc.) — let the scraper handle early return
|
|
759
866
|
if (!url.isHTTP) {
|
|
760
|
-
return this
|
|
867
|
+
return this._launchBrowserAndScrape(url, update, isExternal, metadataOnly);
|
|
761
868
|
}
|
|
762
869
|
// Reuse captured resource data — when this URL was already observed as a
|
|
763
870
|
// sub-resource during page rendering, its response data is recorded and
|
|
@@ -801,6 +908,131 @@ class Crawler extends EventEmitter {
|
|
|
801
908
|
headCheckResult = await this.#sendHeadRequest(url, isExternal, update, laneIndex);
|
|
802
909
|
}
|
|
803
910
|
catch (error) {
|
|
911
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
912
|
+
// Puppeteer-only fallback: when the HEAD pre-flight (and its GET
|
|
913
|
+
// companion inside `fetchDestination`) exhaust retries on what
|
|
914
|
+
// looks like an HTML URL, give the browser exactly one chance
|
|
915
|
+
// before recording the page as `status = -1`. Some middleboxes /
|
|
916
|
+
// WAF configurations drop bare HEAD/GET probes (parse-error,
|
|
917
|
+
// reset, silent timeout) while still answering a real puppeteer
|
|
918
|
+
// navigation; those URLs would otherwise be permanently lost.
|
|
919
|
+
//
|
|
920
|
+
// Restricted to non-metadataOnly scrapes because metadata-only
|
|
921
|
+
// mode is a bandwidth-saving path for external pages — there is
|
|
922
|
+
// no payoff in spinning up puppeteer when the row was never
|
|
923
|
+
// going to be fully rendered. `isPuppeteerFallbackCandidate`
|
|
924
|
+
// filters PreloadShortCircuitError automatically via its
|
|
925
|
+
// classifier check (its synthesised message classifies as `dns`).
|
|
926
|
+
if (!metadataOnly &&
|
|
927
|
+
isLikelyHtmlUrl(url) &&
|
|
928
|
+
isPuppeteerFallbackCandidate(errorMessage)) {
|
|
929
|
+
update(c.yellow('HEAD/GET unreachable — trying puppeteer once'));
|
|
930
|
+
try {
|
|
931
|
+
const fallback = await this._launchBrowserAndScrape(url, update, isExternal, metadataOnly);
|
|
932
|
+
if (fallback.type === 'success') {
|
|
933
|
+
if (fallback.pageData) {
|
|
934
|
+
const renderedKey = redirectDestKey(url, fallback.pageData.redirectPaths);
|
|
935
|
+
this.#scrapedDestinations.add(renderedKey);
|
|
936
|
+
}
|
|
937
|
+
// Puppeteer fallback proved the host is reachable
|
|
938
|
+
// (HEAD/GET probes died at a middlebox / WAF but the
|
|
939
|
+
// real browser navigation got a response). Mark the
|
|
940
|
+
// host alive for the cascade guard — without this, a
|
|
941
|
+
// host whose first URL only succeeded via the
|
|
942
|
+
// browser-rescue path would still be vulnerable to
|
|
943
|
+
// the next URL's HEAD failure burning it.
|
|
944
|
+
this.#successfulHosts.add(url.hostname.toLowerCase());
|
|
945
|
+
markBrowserScrape();
|
|
946
|
+
return fallback;
|
|
947
|
+
}
|
|
948
|
+
if (fallback.type === 'skipped') {
|
|
949
|
+
// Puppeteer rendered the page far enough for the scraper
|
|
950
|
+
// to match an `excludeKeywords` rule. That is a definitive
|
|
951
|
+
// "skip" verdict from the browser, NOT an unreachable
|
|
952
|
+
// host — surface the skip so downstream handling (skip
|
|
953
|
+
// counter, anchor-extraction suppression, `setSkippedPage`
|
|
954
|
+
// in the archive) behaves identically to the case where
|
|
955
|
+
// HEAD had succeeded. Without this branch, the page would
|
|
956
|
+
// be recorded as `status = -1` with the HEAD timeout
|
|
957
|
+
// message — a misleading entry that conflates
|
|
958
|
+
// "operator-intended skip" with "network failure".
|
|
959
|
+
//
|
|
960
|
+
// Skipped also counts as proof-of-life: the browser
|
|
961
|
+
// reached the page far enough to match exclude rules,
|
|
962
|
+
// so the host was clearly responding.
|
|
963
|
+
this.#successfulHosts.add(url.hostname.toLowerCase());
|
|
964
|
+
return fallback;
|
|
965
|
+
}
|
|
966
|
+
// `fallback.type === 'error'`. `_launchBrowserAndScrape`
|
|
967
|
+
// catches its own exceptions and returns
|
|
968
|
+
// `{type:'error', shutdown:...}` rather than throwing, so
|
|
969
|
+
// the `catch` arm below would NOT see this branch. Log
|
|
970
|
+
// the puppeteer-side cause (and any `shutdown` flag the
|
|
971
|
+
// scraper attached) so operators have a breadcrumb that
|
|
972
|
+
// the safety net actually fired and lost — otherwise
|
|
973
|
+
// only the HEAD error reaches `crawl_errors` and the
|
|
974
|
+
// browser failure mode is invisible.
|
|
975
|
+
crawlerLog('Puppeteer fallback returned error for %s: %s (shutdown=%s)', url.href, fallback.error?.message ?? '(no message)', fallback.error?.shutdown ?? false);
|
|
976
|
+
// JS-redirect rescue on the puppeteer-fallback branch:
|
|
977
|
+
// the HEAD/GET probes died (the kind set in
|
|
978
|
+
// `isPuppeteerFallbackCandidate` — middlebox / WAF
|
|
979
|
+
// shapes), the one-shot puppeteer attempt also threw,
|
|
980
|
+
// but `page.url()` reported a different post-navigation
|
|
981
|
+
// URL. This is the same WAF-+-JS-redirect shape the
|
|
982
|
+
// HEAD-success rescue handles one branch below, applied
|
|
983
|
+
// to the prior failure layer. Without this, a URL whose
|
|
984
|
+
// only sin is "HEAD blocked + JS-redirected body" falls
|
|
985
|
+
// to `status = -1` and joins the retry-forever loop the
|
|
986
|
+
// rescue is supposed to break. The trigger is the same
|
|
987
|
+
// narrow `Page.goto returned null` shape — anything
|
|
988
|
+
// else (TLS failure inside puppeteer, target crash, …)
|
|
989
|
+
// must fall through to the unreachable path so the real
|
|
990
|
+
// failure surfaces. We synthesise the redirect-edge
|
|
991
|
+
// PageData from the HEAD error (status = -1) instead of
|
|
992
|
+
// from a HEAD success, so `#linkRedirectSources` still
|
|
993
|
+
// stamps the source as 301 and the edge wires the dest
|
|
994
|
+
// in.
|
|
995
|
+
const fallbackRescue = buildJsRedirectEdge({
|
|
996
|
+
url,
|
|
997
|
+
isExternal,
|
|
998
|
+
errorMessage: fallback.error?.message,
|
|
999
|
+
postNavigationUrl: fallback.postNavigationUrl,
|
|
1000
|
+
// No `headCheckResult`: HEAD itself died on this
|
|
1001
|
+
// path, so the synthesised PageData starts from a
|
|
1002
|
+
// `linkToPageData` placeholder with `status = -1`
|
|
1003
|
+
// carrying the original HEAD error message.
|
|
1004
|
+
// `#linkRedirectSources` still flips the source row
|
|
1005
|
+
// to 301 because NULL/-1 satisfies its conditional
|
|
1006
|
+
// stamp predicate.
|
|
1007
|
+
});
|
|
1008
|
+
if (fallbackRescue !== null) {
|
|
1009
|
+
return fallbackRescue;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
catch (browserError) {
|
|
1013
|
+
// Browser launch / runtime crash — fall through to the
|
|
1014
|
+
// unreachable path below. The original HEAD error is more
|
|
1015
|
+
// informative about WHY the URL wasn't reachable, so it
|
|
1016
|
+
// (not the puppeteer noise) is what we surface in
|
|
1017
|
+
// `crawl_errors`. The lane display flag below (
|
|
1018
|
+
// "Unreachable (fallback failed)") preserves the fact
|
|
1019
|
+
// that puppeteer also tried, so operators reading the
|
|
1020
|
+
// progress log can tell this URL got the safety-net
|
|
1021
|
+
// attempt versus the cheap-probe-only path.
|
|
1022
|
+
crawlerLog('Puppeteer fallback also failed for %s: %O', url.href, browserError);
|
|
1023
|
+
}
|
|
1024
|
+
update(c.red('Unreachable (fallback failed)'));
|
|
1025
|
+
return {
|
|
1026
|
+
type: 'error',
|
|
1027
|
+
resources: [],
|
|
1028
|
+
error: {
|
|
1029
|
+
name: error instanceof Error ? error.name : 'Error',
|
|
1030
|
+
message: errorMessage,
|
|
1031
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1032
|
+
shutdown: false,
|
|
1033
|
+
},
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
804
1036
|
// Server unreachable — skip browser launch entirely
|
|
805
1037
|
update(c.red('Unreachable'));
|
|
806
1038
|
return {
|
|
@@ -808,7 +1040,7 @@ class Crawler extends EventEmitter {
|
|
|
808
1040
|
resources: [],
|
|
809
1041
|
error: {
|
|
810
1042
|
name: error instanceof Error ? error.name : 'Error',
|
|
811
|
-
message:
|
|
1043
|
+
message: errorMessage,
|
|
812
1044
|
stack: error instanceof Error ? error.stack : undefined,
|
|
813
1045
|
shutdown: false,
|
|
814
1046
|
},
|
|
@@ -834,7 +1066,7 @@ class Crawler extends EventEmitter {
|
|
|
834
1066
|
const finalKey = redirectDestKey(url, headCheckResult.redirectPaths);
|
|
835
1067
|
if (this.#scrapedDestinations.has(finalKey)) {
|
|
836
1068
|
crawlerLog('Redirect dest already rendered, edge only: %s', url.href);
|
|
837
|
-
return { type: 'redirect-edge', pageData: headCheckResult };
|
|
1069
|
+
return { type: 'redirect-edge', source: 'http-chain', pageData: headCheckResult };
|
|
838
1070
|
}
|
|
839
1071
|
// Title-only mode — extract <title> via partial GET for HTML, skip browser
|
|
840
1072
|
if (metadataOnly) {
|
|
@@ -876,12 +1108,12 @@ class Crawler extends EventEmitter {
|
|
|
876
1108
|
}
|
|
877
1109
|
// HTML or unknown content type — launch browser with preflight result.
|
|
878
1110
|
// markBrowserScrape() fires only when the result is `success`.
|
|
879
|
-
//
|
|
1111
|
+
// `_launchBrowserAndScrape` catches internal errors and returns
|
|
880
1112
|
// `{ type: 'error', ... }` instead of throwing (see its catch block),
|
|
881
1113
|
// so awaiting alone does NOT prove the page was rendered. The explicit
|
|
882
1114
|
// success check excludes navigation failures, scraper exceptions, and
|
|
883
1115
|
// shutdown-class errors from the pages-rendered count.
|
|
884
|
-
const browserResult = await this
|
|
1116
|
+
const browserResult = await this._launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
|
|
885
1117
|
if (browserResult.type === 'success') {
|
|
886
1118
|
markBrowserScrape();
|
|
887
1119
|
// Claim the destination that was ACTUALLY rendered, keyed off the
|
|
@@ -905,6 +1137,95 @@ class Crawler extends EventEmitter {
|
|
|
905
1137
|
? redirectDestKey(url, browserResult.pageData.redirectPaths)
|
|
906
1138
|
: finalKey;
|
|
907
1139
|
this.#scrapedDestinations.add(renderedKey);
|
|
1140
|
+
return browserResult;
|
|
1141
|
+
}
|
|
1142
|
+
// Browser scrape failed but the HEAD pre-flight already resolved a
|
|
1143
|
+
// redirect chain — fall back to the redirect-edge path so the chain
|
|
1144
|
+
// is not lost. Without this, a URL whose final destination is on
|
|
1145
|
+
// HTTPS→HTTP downgrade (or any other navigation Chromium refuses
|
|
1146
|
+
// to complete while the underlying redirect was a normal 301/302)
|
|
1147
|
+
// would be persisted as `status = -1` with NULL `redirectDestId`,
|
|
1148
|
+
// then re-picked up by every `--retry-failed` pass forever — the
|
|
1149
|
+
// HEAD answer is the authoritative truth and the browser cannot
|
|
1150
|
+
// invalidate it.
|
|
1151
|
+
//
|
|
1152
|
+
// Restricted to `type === 'error'` because:
|
|
1153
|
+
// - `'skipped'` is an `excludeKeywords` verdict from the browser
|
|
1154
|
+
// on the rendered URL and is its own definitive outcome —
|
|
1155
|
+
// surfacing it as a redirect-edge would lose the skip signal.
|
|
1156
|
+
// - `'success'` is handled above.
|
|
1157
|
+
//
|
|
1158
|
+
// The destination is claimed even though no row was rendered for
|
|
1159
|
+
// it: subsequent siblings on the same chain should also fold into
|
|
1160
|
+
// the same edge instead of re-firing the same failing browser
|
|
1161
|
+
// attempt. If the destination URL itself reaches the queue later,
|
|
1162
|
+
// it goes through the normal `#scrapePage` path (the claim only
|
|
1163
|
+
// short-circuits sibling redirect SOURCES, not the destination
|
|
1164
|
+
// itself).
|
|
1165
|
+
if (browserResult.type === 'error' && headCheckResult.redirectPaths.length > 0) {
|
|
1166
|
+
this.#scrapedDestinations.add(finalKey);
|
|
1167
|
+
crawlerLog('Browser scrape failed for %s but HEAD resolved a redirect chain — recording as edge', url.href);
|
|
1168
|
+
return { type: 'redirect-edge', source: 'http-chain', pageData: headCheckResult };
|
|
1169
|
+
}
|
|
1170
|
+
// JS-redirect rescue: HEAD returned a definitive response (no chain),
|
|
1171
|
+
// the browser scrape threw with the specific `Page.goto returned null`
|
|
1172
|
+
// shape (gated by `isJsRedirectErrorShape` below), and puppeteer
|
|
1173
|
+
// reports a different post-navigation URL via `page.url()`. The
|
|
1174
|
+
// motivating case is a server returning `200 OK` whose body contains
|
|
1175
|
+
// `window.location.replace(...)` or `<meta http-equiv="refresh">` —
|
|
1176
|
+
// `page.goto()` resolves to `null` once the JS-driven navigation
|
|
1177
|
+
// supersedes the original, and the scraper throws
|
|
1178
|
+
// `The method Page.goto returned null`. Recording the edge preserves
|
|
1179
|
+
// the link from the source to the JS-redirect target, removes the
|
|
1180
|
+
// page from `--retry-failed`'s candidate pool (the SQL filter
|
|
1181
|
+
// excludes rows with a non-null `redirectDestId`), and matches what
|
|
1182
|
+
// a real browser shows the user.
|
|
1183
|
+
//
|
|
1184
|
+
// What the source row reads as:
|
|
1185
|
+
// - the source is not committed via `setPage`/`updatePage` on this
|
|
1186
|
+
// path (the redirect-edge handler in `#runDeal` only calls
|
|
1187
|
+
// `linkList.done` + `emit('redirect', ...)` → `Archive.setRedirect`),
|
|
1188
|
+
// so `recordRedirect` → `#getIdByUrl` creates a NULL-status
|
|
1189
|
+
// placeholder row for the source if it did not already exist;
|
|
1190
|
+
// - `#linkRedirectSources` then stamps `status = 301
|
|
1191
|
+
// statusText='Moved Permanently'` because NULL satisfies its
|
|
1192
|
+
// conditional-update predicate.
|
|
1193
|
+
// That is the same shape an HTTP 301 source ends up with — the
|
|
1194
|
+
// truthful HTTP layer (the upstream's 200) is lost on this path, but
|
|
1195
|
+
// the alternative (status=-1 retry-forever) is strictly worse. A
|
|
1196
|
+
// future refinement could keep the HEAD-derived status by routing
|
|
1197
|
+
// the source through `setPage` before `setRedirect`; intentionally
|
|
1198
|
+
// deferred to keep this rescue minimal.
|
|
1199
|
+
//
|
|
1200
|
+
// Pre-claiming the destination in `#scrapedDestinations` would
|
|
1201
|
+
// short-circuit the freshly-enqueued destination at the top of
|
|
1202
|
+
// `#scrapePage` (the `if (#scrapedDestinations.has(finalKey))` guard
|
|
1203
|
+
// at line 1213), leaving the dest row as a content-less HEAD edge
|
|
1204
|
+
// instead of a fully rendered page. So we *do not* claim here — the
|
|
1205
|
+
// destination renders normally via the queue, and `#scrapedDestinations`
|
|
1206
|
+
// is populated at line ~1322 of the render-success path the way every
|
|
1207
|
+
// other URL is. Sibling JS-redirect sources to the same destination
|
|
1208
|
+
// still converge: the second sibling enters this branch, observes its
|
|
1209
|
+
// own `page.url()` landing on the same target, records its own
|
|
1210
|
+
// redirect-edge, and re-enqueues — the dealer's `seen` dedup absorbs
|
|
1211
|
+
// the duplicate push, so the destination renders exactly once.
|
|
1212
|
+
if (browserResult.type === 'error') {
|
|
1213
|
+
const headSuccessRescue = buildJsRedirectEdge({
|
|
1214
|
+
url,
|
|
1215
|
+
isExternal,
|
|
1216
|
+
errorMessage: browserResult.error?.message,
|
|
1217
|
+
postNavigationUrl: browserResult.postNavigationUrl,
|
|
1218
|
+
// `headCheckResult` is supplied here so the synthesised
|
|
1219
|
+
// PageData carries the real HTTP-level status / content
|
|
1220
|
+
// type from the HEAD pre-flight. `#linkRedirectSources`
|
|
1221
|
+
// only stamps 301 onto NULL/-1 status rows, so the
|
|
1222
|
+
// HEAD-derived status DOES survive on this path — the
|
|
1223
|
+
// truthful HTTP 200 is preserved.
|
|
1224
|
+
headCheckResult,
|
|
1225
|
+
});
|
|
1226
|
+
if (headSuccessRescue !== null) {
|
|
1227
|
+
return headSuccessRescue;
|
|
1228
|
+
}
|
|
908
1229
|
}
|
|
909
1230
|
return browserResult;
|
|
910
1231
|
}
|
|
@@ -921,17 +1242,244 @@ class Crawler extends EventEmitter {
|
|
|
921
1242
|
* @returns Lightweight page data from the HEAD response
|
|
922
1243
|
*/
|
|
923
1244
|
async #sendHeadRequest(url, isExternal, update, laneIndex) {
|
|
924
|
-
|
|
1245
|
+
const host = url.hostname.toLowerCase();
|
|
1246
|
+
if (dnsBurnedHostCache.has(host)) {
|
|
1247
|
+
// Either session-learned earlier in this crawl (one URL on this host
|
|
1248
|
+
// already exhausted retries with a DNS error) or preload-seeded from
|
|
1249
|
+
// `crawl_errors` on archive open. Either way: skip the HEAD entirely.
|
|
1250
|
+
// The orchestrator's error-channel listener detects
|
|
1251
|
+
// PreloadShortCircuitError via instanceof and refuses to write it to
|
|
1252
|
+
// `crawl_errors`, preventing self-amplification across crawls.
|
|
1253
|
+
dnsBurnedHostShortCircuitCounter.count++;
|
|
1254
|
+
update(c.red(`HEAD request: host ${host} DNS-burned — skipping`));
|
|
1255
|
+
throw new PreloadShortCircuitError(host);
|
|
1256
|
+
}
|
|
1257
|
+
// Escalating per-attempt timeout: a slow-but-reachable server (e.g. some
|
|
1258
|
+
// government sites under load) often answers in 20-40 s but is missed by
|
|
1259
|
+
// a flat 10 s race on every retry. Start short to keep crawl throughput
|
|
1260
|
+
// up on healthy URLs, then back off so the last attempt is generous
|
|
1261
|
+
// enough that "really slow" gets a fair shot before we give up.
|
|
1262
|
+
let attempt = 0;
|
|
1263
|
+
return retryCall(async () => {
|
|
1264
|
+
// Clamp the attempt index to the last entry of the escalation array
|
|
1265
|
+
// so retry counts past the array length keep using the longest
|
|
1266
|
+
// budget instead of falling off into `undefined`. `as number`
|
|
1267
|
+
// only because TS can't see that a positive-length readonly array
|
|
1268
|
+
// always has a defined last element.
|
|
1269
|
+
const escalationIndex = Math.min(attempt, HEAD_TIMEOUT_ESCALATION_MS.length - 1);
|
|
1270
|
+
const timeoutMs = HEAD_TIMEOUT_ESCALATION_MS[escalationIndex];
|
|
1271
|
+
attempt += 1;
|
|
1272
|
+
const headResult = await fetchDestination({
|
|
1273
|
+
url,
|
|
1274
|
+
isExternal,
|
|
1275
|
+
userAgent: this.#options.userAgent,
|
|
1276
|
+
timeout: timeoutMs,
|
|
1277
|
+
});
|
|
1278
|
+
// Mark host alive the MOMENT an HTTP response is observed,
|
|
1279
|
+
// before retryCall's outer resolution settles. A later attempt
|
|
1280
|
+
// (or a sibling worker's onGiveUp) racing this success would
|
|
1281
|
+
// otherwise see an empty `#successfulHosts` and burn the host
|
|
1282
|
+
// — exactly the cascade the guard is here to prevent. Any HTTP
|
|
1283
|
+
// status counts: the guard cares about DNS-and-TCP reachability,
|
|
1284
|
+
// not application-level success, and `fetchDestination` only
|
|
1285
|
+
// resolves when an HTTP response was actually received.
|
|
1286
|
+
this.#successfulHosts.add(host);
|
|
1287
|
+
return headResult;
|
|
1288
|
+
}, {
|
|
925
1289
|
retries: this.#options.retry,
|
|
926
1290
|
label: 'HEAD request',
|
|
927
1291
|
onWait: (determinedInterval, retryCount, label, error) => {
|
|
928
1292
|
update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
|
|
929
1293
|
},
|
|
930
1294
|
onGiveUp: (retryCount, error, label) => {
|
|
1295
|
+
// Burn the host so subsequent URLs short-circuit — but ONLY
|
|
1296
|
+
// when this is the first time we've ever seen the host fail
|
|
1297
|
+
// in this session. A host that responded earlier is treated
|
|
1298
|
+
// as transiently unreachable (operator's resolver flipped
|
|
1299
|
+
// mid-crawl etc.), not a dead domain. `shouldBurnHost`
|
|
1300
|
+
// encapsulates this decision so the cascade guard is
|
|
1301
|
+
// independently testable. Also gated to `onGiveUp` rather
|
|
1302
|
+
// than `onWait` so an `EAI_AGAIN` that recovers on retry
|
|
1303
|
+
// doesn't trip the guard prematurely.
|
|
1304
|
+
if (shouldBurnHost({
|
|
1305
|
+
errorKind: classifyErrorKind(error.message),
|
|
1306
|
+
host,
|
|
1307
|
+
successfulHosts: this.#successfulHosts,
|
|
1308
|
+
})) {
|
|
1309
|
+
dnsBurnedHostCache.set(host, 'dns');
|
|
1310
|
+
}
|
|
931
1311
|
update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
|
|
932
1312
|
},
|
|
933
1313
|
});
|
|
934
1314
|
}
|
|
1315
|
+
// eslint-disable-next-line no-restricted-syntax -- intentional `private` (vs `#`) so tests can spyOn the prototype to drive the puppeteer-fallback cascade-guard branches without a full browser mock; see JSDoc above.
|
|
1316
|
+
async _launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult) {
|
|
1317
|
+
update('Launching browser%dots%');
|
|
1318
|
+
if (this.#options.executablePath) {
|
|
1319
|
+
const execPath = path.resolve(this.#options.executablePath);
|
|
1320
|
+
if (!existsSync(execPath)) {
|
|
1321
|
+
throw new Error(`Executable path does not exist: ${execPath}`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
const puppeteer = await import('puppeteer');
|
|
1325
|
+
const browser = await puppeteer.launch({
|
|
1326
|
+
headless: true,
|
|
1327
|
+
...(this.#options.executablePath
|
|
1328
|
+
? { executablePath: this.#options.executablePath }
|
|
1329
|
+
: {}),
|
|
1330
|
+
});
|
|
1331
|
+
// `page` is hoisted out of the try-block so the catch arm can read
|
|
1332
|
+
// `page.url()` for JS-redirect detection. See `BrowserScrapeResult`
|
|
1333
|
+
// JSDoc for the full why; in short, when `scrapeStart` throws because
|
|
1334
|
+
// `page.goto()` returned `null`, the puppeteer page object still
|
|
1335
|
+
// holds the URL Chromium actually navigated to via the offending
|
|
1336
|
+
// `window.location.replace()` / meta-refresh, and that is the only
|
|
1337
|
+
// authoritative source for the JS-redirect destination.
|
|
1338
|
+
let page = null;
|
|
1339
|
+
try {
|
|
1340
|
+
update('Creating page%dots%');
|
|
1341
|
+
page = await browser.newPage();
|
|
1342
|
+
await page.setUserAgent(this.#options.userAgent);
|
|
1343
|
+
// HTTP-auth handling — two cooperating pieces, BOTH required:
|
|
1344
|
+
//
|
|
1345
|
+
// 1. `page.authenticate({user, pass})` (always, even with empty
|
|
1346
|
+
// strings) registers a Fetch-domain auth handler with
|
|
1347
|
+
// Chromium. With empty credentials it ALSO drains Chromium's
|
|
1348
|
+
// native HTTP-auth dialog without sending anything
|
|
1349
|
+
// privileged — the dialog cannot be captured by
|
|
1350
|
+
// `page.on('dialog')` (HTTP-auth is not a JS dialog) and
|
|
1351
|
+
// would otherwise hang the navigation until puppeteer's
|
|
1352
|
+
// timeout fires. With non-empty credentials it provides the
|
|
1353
|
+
// scope's auth so the in-scope navigation succeeds.
|
|
1354
|
+
//
|
|
1355
|
+
// 2. Stripping URL-embedded credentials from the navigation
|
|
1356
|
+
// target. **This is the credential-leak guard.** When the
|
|
1357
|
+
// URL we hand puppeteer carries `user:pass@host`, Chromium
|
|
1358
|
+
// promotes those credentials into its HTTP-auth cache
|
|
1359
|
+
// keyed by (scheme, host, port, realm). Subsequent
|
|
1360
|
+
// sub-resource requests issued from the same page —
|
|
1361
|
+
// including cross-origin requests to a different hostname
|
|
1362
|
+
// sharing the same IP / port (e.g. an embedded
|
|
1363
|
+
// `<img src="http://127.0.0.1:8010/…">` loaded from a
|
|
1364
|
+
// `localhost:8010` page) — get the cached `Authorization`
|
|
1365
|
+
// header re-attached by the network stack. The
|
|
1366
|
+
// `Fetch.authRequired` event never fires for these
|
|
1367
|
+
// pre-emptive attachments, so neither `page.authenticate`
|
|
1368
|
+
// nor any custom Fetch listener can filter them. The only
|
|
1369
|
+
// way to keep the cred out of the cross-origin request is
|
|
1370
|
+
// to make sure it never enters the cache in the first
|
|
1371
|
+
// place — hence stripping the URL before navigation.
|
|
1372
|
+
//
|
|
1373
|
+
// Verified by `scope-auth-leak.e2e.ts`: removing either piece
|
|
1374
|
+
// causes that test to fail (without auth → main 401 hangs;
|
|
1375
|
+
// without strip → scope cred leaks to off-scope sub-resource).
|
|
1376
|
+
await page.authenticate({
|
|
1377
|
+
username: url.username ?? '',
|
|
1378
|
+
password: url.password ?? '',
|
|
1379
|
+
});
|
|
1380
|
+
// Re-parse from `withoutHashAndAuth` rather than mutating the
|
|
1381
|
+
// re-parsed `url.href` object: ExURL pre-computes `href`,
|
|
1382
|
+
// `withoutHash` and other derived strings at parse time, and
|
|
1383
|
+
// post-hoc field assignment (`navigateUrl.username = ''`)
|
|
1384
|
+
// leaves those derived strings stale. Anything downstream that
|
|
1385
|
+
// reads `navigateUrl.href` (e.g. a future beholder bump that
|
|
1386
|
+
// switches `page.goto` from `withoutHashAndAuth` to `href`)
|
|
1387
|
+
// would silently get back the credentialed string — defeating
|
|
1388
|
+
// the leak guard. Building the navigation URL from a known
|
|
1389
|
+
// credential-free string guarantees every field is consistent.
|
|
1390
|
+
const navigateUrl = parseUrl(url.withoutHashAndAuth) ?? url;
|
|
1391
|
+
const scraper = new Scraper();
|
|
1392
|
+
scraper.on('changePhase', createChangePhaseHandler({
|
|
1393
|
+
emit: (event) => void this.emit('changePhase', event),
|
|
1394
|
+
update,
|
|
1395
|
+
formatLog: formatPhaseLog,
|
|
1396
|
+
buffer: this.#pendingPhaseErrors,
|
|
1397
|
+
urlHref: url.href,
|
|
1398
|
+
}));
|
|
1399
|
+
const result = await scraper.scrapeStart(page, navigateUrl, {
|
|
1400
|
+
isExternal,
|
|
1401
|
+
captureImages: !isExternal && this.#options.captureImages,
|
|
1402
|
+
excludeKeywords: this.#options.excludeKeywords,
|
|
1403
|
+
disableQueries: this.#options.disableQueries,
|
|
1404
|
+
metadataOnly,
|
|
1405
|
+
retries: this.#options.retry,
|
|
1406
|
+
headCheckResult,
|
|
1407
|
+
});
|
|
1408
|
+
update('Closing browser%dots%');
|
|
1409
|
+
// JS-redirect rescue capture: when `scrapeStart` catches a
|
|
1410
|
+
// `#fetchData` throw internally (e.g. `Page.goto returned null`
|
|
1411
|
+
// because a client-side `window.location.replace()` /
|
|
1412
|
+
// meta-refresh fired), it returns `{ type: 'error', ... }`
|
|
1413
|
+
// instead of re-throwing — so the `catch` arm below never
|
|
1414
|
+
// sees those cases. Read `page.url()` here while `page` is
|
|
1415
|
+
// still alive (finally still hasn't called `handleBrowserClose`)
|
|
1416
|
+
// and attach it to the result so `#scrapePage` can fold the
|
|
1417
|
+
// source into a redirect edge. Without this capture, the
|
|
1418
|
+
// rescue path is dead for the most common failure shape it
|
|
1419
|
+
// was designed to handle.
|
|
1420
|
+
//
|
|
1421
|
+
// `page.url()` itself can throw when the browser context died
|
|
1422
|
+
// mid-scrape (target crashed, session killed). On failure we
|
|
1423
|
+
// fall through with `postNavigationUrl` unset so the existing
|
|
1424
|
+
// HEAD-chain rescue / normal error path takes over.
|
|
1425
|
+
if (result.type === 'error') {
|
|
1426
|
+
try {
|
|
1427
|
+
const postNavigationUrl = page.url();
|
|
1428
|
+
return { ...result, postNavigationUrl };
|
|
1429
|
+
}
|
|
1430
|
+
catch (urlReadError) {
|
|
1431
|
+
crawlerLog('Reading page.url() for JS-redirect detection failed on %s: %O', url.href, urlReadError);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return result;
|
|
1435
|
+
}
|
|
1436
|
+
catch (error) {
|
|
1437
|
+
// JS-redirect rescue: when `scrapeStart` throws because
|
|
1438
|
+
// `page.goto()` returned `null` (the symptom of a client-side
|
|
1439
|
+
// `window.location.replace()` / meta-refresh navigating away
|
|
1440
|
+
// before the original response materialised), `page.url()` still
|
|
1441
|
+
// reports the destination Chromium ended up on. Capturing it
|
|
1442
|
+
// here lets `#scrapePage` fold the source into a redirect edge
|
|
1443
|
+
// instead of recording a hard `status = -1` — `Page.goto returned
|
|
1444
|
+
// null` classifies as `protocol`, which is neither permanent nor
|
|
1445
|
+
// a puppeteer-fallback kind, so without this rescue the page
|
|
1446
|
+
// loops through `--retry-failed` forever with the same failure.
|
|
1447
|
+
//
|
|
1448
|
+
// `page.url()` itself can throw when the browser context is
|
|
1449
|
+
// already torn down (target closed, session killed). Treat any
|
|
1450
|
+
// such failure as "no extra information" and fall back to the
|
|
1451
|
+
// normal error path — the existing redirect-edge fallback that
|
|
1452
|
+
// keys off `headCheckResult.redirectPaths` may still rescue the
|
|
1453
|
+
// page when the HEAD pre-flight resolved a chain.
|
|
1454
|
+
let postNavigationUrl;
|
|
1455
|
+
if (page) {
|
|
1456
|
+
try {
|
|
1457
|
+
postNavigationUrl = page.url();
|
|
1458
|
+
}
|
|
1459
|
+
catch (urlReadError) {
|
|
1460
|
+
crawlerLog('Reading page.url() for JS-redirect detection failed on %s: %O', url.href, urlReadError);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
return {
|
|
1464
|
+
type: 'error',
|
|
1465
|
+
resources: [],
|
|
1466
|
+
error: {
|
|
1467
|
+
name: error instanceof Error ? error.name : 'Error',
|
|
1468
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1469
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1470
|
+
shutdown: true,
|
|
1471
|
+
},
|
|
1472
|
+
...(postNavigationUrl === undefined ? {} : { postNavigationUrl }),
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
finally {
|
|
1476
|
+
// handleBrowserClose force-kills the underlying Chromium when a
|
|
1477
|
+
// graceful close() hangs (e.g. the session died mid-scrape) and
|
|
1478
|
+
// guarantees the finally never throws, so the try-block's return
|
|
1479
|
+
// value or caught error is never masked.
|
|
1480
|
+
await handleBrowserClose(browser, url.href, crawlerLog);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
935
1483
|
/**
|
|
936
1484
|
* The default maximum number of concurrent scraping processes.
|
|
937
1485
|
*
|