@nitpicker/crawler 0.9.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-accessor.d.ts +87 -14
- package/lib/archive/archive-accessor.js +162 -36
- package/lib/archive/archive.d.ts +262 -24
- package/lib/archive/archive.js +397 -86
- 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 +411 -26
- package/lib/archive/database.js +1881 -540
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
- package/lib/archive/filesystem/peek-tar-top-dir.js +65 -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.d.ts +53 -2
- package/lib/archive/init-schema.js +399 -15
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
- package/lib/archive/meta/assert-compatible-version.js +72 -0
- package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
- package/lib/archive/meta/classify-jsonld-type.js +43 -0
- package/lib/archive/meta/compare-semver.d.ts +23 -0
- package/lib/archive/meta/compare-semver.js +51 -0
- package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
- package/lib/archive/meta/compute-page-denormalized.js +35 -0
- package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
- package/lib/archive/meta/derive-flat-from-meta.js +158 -0
- package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
- package/lib/archive/meta/derive-meta-extras.js +23 -0
- package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
- package/lib/archive/meta/extract-tags-for-archive.js +36 -0
- package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
- package/lib/archive/meta/summarize-jsonld.js +29 -0
- package/lib/archive/meta/summarize-tags.d.ts +16 -0
- package/lib/archive/meta/summarize-tags.js +33 -0
- package/lib/archive/meta/types.d.ts +207 -0
- package/lib/archive/meta/types.js +33 -0
- package/lib/archive/migrate-crawl-errors.d.ts +20 -0
- package/lib/archive/migrate-crawl-errors.js +38 -0
- package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
- package/lib/archive/migrate-html-blob-tables.js +53 -0
- package/lib/archive/migrate-inventory-runs.d.ts +29 -0
- package/lib/archive/migrate-inventory-runs.js +52 -0
- package/lib/archive/migrate-page-errors.d.ts +16 -0
- package/lib/archive/migrate-page-errors.js +35 -0
- package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
- package/lib/archive/migrate-pages-resources-source.js +46 -0
- package/lib/archive/page.d.ts +187 -49
- package/lib/archive/page.js +258 -63
- package/lib/archive/peek-archive-lock.d.ts +40 -0
- package/lib/archive/peek-archive-lock.js +62 -0
- package/lib/archive/resolve-redirect-chain.d.ts +33 -0
- package/lib/archive/resolve-redirect-chain.js +27 -0
- package/lib/archive/types.d.ts +168 -26
- 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/close-browser-safely.d.ts +64 -0
- package/lib/crawler/close-browser-safely.js +73 -0
- package/lib/crawler/crawler.d.ts +7 -2
- package/lib/crawler/crawler.js +919 -113
- package/lib/crawler/create-change-phase-handler.d.ts +54 -0
- package/lib/crawler/create-change-phase-handler.js +44 -0
- 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-page-source.d.ts +23 -0
- package/lib/crawler/derive-page-source.js +28 -0
- package/lib/crawler/derive-resource-source.d.ts +33 -0
- package/lib/crawler/derive-resource-source.js +37 -0
- 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/drain-phase-errors.d.ts +48 -0
- package/lib/crawler/drain-phase-errors.js +35 -0
- package/lib/crawler/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +132 -18
- package/lib/crawler/format-crawl-progress.d.ts +12 -3
- package/lib/crawler/format-crawl-progress.js +14 -6
- package/lib/crawler/handle-browser-close.d.ts +29 -0
- package/lib/crawler/handle-browser-close.js +28 -0
- package/lib/crawler/is-html-content-type.d.ts +17 -0
- package/lib/crawler/is-html-content-type.js +19 -0
- 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-likely-html-url.d.ts +22 -0
- package/lib/crawler/is-likely-html-url.js +65 -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/kill-process-tree.d.ts +94 -0
- package/lib/crawler/kill-process-tree.js +178 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +25 -4
- package/lib/crawler/link-to-page-data.d.ts +13 -5
- package/lib/crawler/link-to-page-data.js +26 -5
- package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
- package/lib/crawler/log-undrained-phase-errors.js +34 -0
- package/lib/crawler/normalize-content-type.d.ts +14 -0
- package/lib/crawler/normalize-content-type.js +20 -0
- package/lib/crawler/partition-urls-by-html.d.ts +16 -0
- package/lib/crawler/partition-urls-by-html.js +23 -0
- 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/redirect-dest-key.d.ts +19 -0
- package/lib/crawler/redirect-dest-key.js +27 -0
- package/lib/crawler/resource-to-page-data.d.ts +28 -0
- package/lib/crawler/resource-to-page-data.js +59 -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 +229 -1
- package/lib/crawler-orchestrator.d.ts +105 -3
- package/lib/crawler-orchestrator.js +620 -20
- package/lib/crawler.d.ts +8 -2
- package/lib/crawler.js +6 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/resource-row-to-lookup-result.d.ts +13 -0
- package/lib/resource-row-to-lookup-result.js +20 -0
- package/lib/types.d.ts +95 -1
- 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/lib/utils/object/parse-response-headers.d.ts +12 -0
- package/lib/utils/object/parse-response-headers.js +26 -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,28 +3,58 @@ 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';
|
|
16
|
+
import { createChangePhaseHandler } from './create-change-phase-handler.js';
|
|
17
|
+
import { derivePageSource } from './derive-page-source.js';
|
|
12
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';
|
|
21
|
+
import { drainPhaseErrors } from './drain-phase-errors.js';
|
|
13
22
|
import { fetchDestination } from './fetch-destination.js';
|
|
14
23
|
import { findScopeEntry } from './find-scope-entry.js';
|
|
15
24
|
import { formatCrawlProgress } from './format-crawl-progress.js';
|
|
16
25
|
import { generatePredictedUrls } from './generate-predicted-urls.js';
|
|
26
|
+
import { handleBrowserClose } from './handle-browser-close.js';
|
|
17
27
|
import { handleIgnoreAndSkip } from './handle-ignore-and-skip.js';
|
|
18
|
-
import { handleResourceResponse } from './handle-resource-response.js';
|
|
19
28
|
import { handleScrapeEnd } from './handle-scrape-end.js';
|
|
20
29
|
import { handleScrapeError } from './handle-scrape-error.js';
|
|
21
30
|
import { injectScopeAuth } from './inject-scope-auth.js';
|
|
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';
|
|
22
34
|
import LinkList from './link-list.js';
|
|
23
35
|
import { linkToPageData } from './link-to-page-data.js';
|
|
36
|
+
import { logUndrainedPhaseErrors } from './log-undrained-phase-errors.js';
|
|
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';
|
|
24
40
|
import { protocolAgnosticKey } from './protocol-agnostic-key.js';
|
|
41
|
+
import { redirectDestKey } from './redirect-dest-key.js';
|
|
42
|
+
import { resourceToPageData } from './resource-to-page-data.js';
|
|
25
43
|
import { RobotsChecker } from './robots-checker.js';
|
|
44
|
+
import { shouldBurnHost } from './should-burn-host.js';
|
|
26
45
|
import { shouldDiscardPredicted } from './should-discard-predicted.js';
|
|
27
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];
|
|
28
58
|
/**
|
|
29
59
|
* The core crawler engine that discovers and scrapes web pages.
|
|
30
60
|
*
|
|
@@ -43,8 +73,18 @@ class Crawler extends EventEmitter {
|
|
|
43
73
|
#linkList = new LinkList();
|
|
44
74
|
/** Merged crawler configuration (user overrides + defaults). */
|
|
45
75
|
#options;
|
|
76
|
+
/**
|
|
77
|
+
* Phase errors observed during {@link Crawler._launchBrowserAndScrape},
|
|
78
|
+
* buffered per URL href so they can be emitted as `pageError` events
|
|
79
|
+
* AFTER the corresponding `page` / `externalPage` event. This ordering
|
|
80
|
+
* lets the orchestrator's WriteQueue serialise `setPage` before
|
|
81
|
+
* `insertPageError`, so the FK resolution via URL always finds the row.
|
|
82
|
+
*/
|
|
83
|
+
#pendingPhaseErrors = new Map();
|
|
46
84
|
/** Set of resource URLs (without hash) already captured, for deduplication. */
|
|
47
85
|
#resources = new Set();
|
|
86
|
+
/** Number of HTML pages (isTarget=1) scraped in previous sessions, used to seed the progress counter on resume. */
|
|
87
|
+
#resumedPagesScraped = 0;
|
|
48
88
|
/** URLs restored from a previous session that still need to be scraped. */
|
|
49
89
|
#resumedPending = [];
|
|
50
90
|
/** URLs already scraped in a previous session, used to populate the `seen` set in {@link #runDeal}. */
|
|
@@ -53,6 +93,27 @@ class Crawler extends EventEmitter {
|
|
|
53
93
|
#robotsChecker;
|
|
54
94
|
/** Maps hostnames to their scope URLs. Defines the crawl boundary for internal/external classification. */
|
|
55
95
|
#scope = new Map();
|
|
96
|
+
/**
|
|
97
|
+
* Protocol-agnostic keys of redirect destinations already rendered (and stored)
|
|
98
|
+
* during this crawl. When many URLs redirect to one destination, only the first
|
|
99
|
+
* renders it; the rest record the redirect edge and skip the browser (#73).
|
|
100
|
+
* Keyed by {@link redirectDestKey}. Reset at the start of {@link #runDeal}.
|
|
101
|
+
*/
|
|
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();
|
|
56
117
|
/**
|
|
57
118
|
* The AbortSignal associated with this crawler's AbortController.
|
|
58
119
|
*
|
|
@@ -87,6 +148,9 @@ class Crawler extends EventEmitter {
|
|
|
87
148
|
verbose: options?.verbose ?? false,
|
|
88
149
|
userAgent: options?.userAgent || `Nitpicker/${pkg.version}`,
|
|
89
150
|
ignoreRobots: options?.ignoreRobots ?? false,
|
|
151
|
+
lookupResource: options?.lookupResource ?? null,
|
|
152
|
+
lookupPageSource: options?.lookupPageSource ?? null,
|
|
153
|
+
inventoryMode: options?.inventoryMode ?? null,
|
|
90
154
|
};
|
|
91
155
|
this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
|
|
92
156
|
for (const urlStr of this.#options.roots) {
|
|
@@ -126,10 +190,14 @@ class Crawler extends EventEmitter {
|
|
|
126
190
|
* @param pending - URLs that were pending (not yet scraped) in the previous session.
|
|
127
191
|
* @param scraped - URLs that were already scraped in the previous session.
|
|
128
192
|
* @param resources - Resource URLs that were already captured in the previous session.
|
|
193
|
+
* @param pagesScrapedOffset - Number of HTML pages already rendered in previous
|
|
194
|
+
* sessions, used to seed the session-spanning progress counter. Defaults to 0
|
|
195
|
+
* for callers that don't need cross-session accuracy in the progress display.
|
|
129
196
|
*/
|
|
130
|
-
resume(pending, scraped, resources) {
|
|
197
|
+
resume(pending, scraped, resources, pagesScrapedOffset = 0) {
|
|
131
198
|
this.#resumedPending = this.#linkList.resume(pending, scraped, this.#options);
|
|
132
199
|
this.#resumedScraped = scraped;
|
|
200
|
+
this.#resumedPagesScraped = pagesScrapedOffset;
|
|
133
201
|
for (const resource of resources) {
|
|
134
202
|
this.#resources.add(resource);
|
|
135
203
|
}
|
|
@@ -147,21 +215,27 @@ class Crawler extends EventEmitter {
|
|
|
147
215
|
* before reaching the dealer so a URL that exists in both sources — which
|
|
148
216
|
* is common in append-mode when a new root coincides with a repromoted
|
|
149
217
|
* previously-external page — does not race on two parallel slots.
|
|
150
|
-
* @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`).
|
|
151
220
|
* @param opts - Optional overrides; currently only `recursive` is honoured.
|
|
152
221
|
* @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
|
|
153
222
|
* Defaults to the constructor option's `recursive` value.
|
|
154
223
|
* @throws {Error} If the URL list is empty.
|
|
155
224
|
*/
|
|
156
225
|
start(urls, opts) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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;
|
|
161
233
|
for (const url of urls) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
}
|
|
165
239
|
}
|
|
166
240
|
this.#linkList.add(url);
|
|
167
241
|
}
|
|
@@ -170,7 +244,12 @@ class Crawler extends EventEmitter {
|
|
|
170
244
|
this.#options.recursive = false;
|
|
171
245
|
this.#options.fromList = true;
|
|
172
246
|
}
|
|
173
|
-
|
|
247
|
+
// A resume can have an empty scraped set — e.g. a crawl interrupted before
|
|
248
|
+
// any page finished, or a `--retry-failed` run where every page in the
|
|
249
|
+
// archive was a failure and got reset to pending. Keying purely on
|
|
250
|
+
// `#resumedScraped` would then mistake the session for a fresh crawl and
|
|
251
|
+
// drop every resumed pending URL, so honour the pending set too.
|
|
252
|
+
const isResuming = this.#resumedScraped.length > 0 || this.#resumedPending.length > 0;
|
|
174
253
|
// Dedupe by the same protocol-agnostic key the dealer uses internally.
|
|
175
254
|
// Append-mode in particular can put the same URL into both
|
|
176
255
|
// `#resumedPending` (via `repromoteExternalPages`) and `urls` (the
|
|
@@ -185,18 +264,43 @@ class Crawler extends EventEmitter {
|
|
|
185
264
|
seenInitial.add(key);
|
|
186
265
|
initialUrls.push(url);
|
|
187
266
|
}
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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');
|
|
193
275
|
}
|
|
194
|
-
|
|
276
|
+
const resumeOffset = this.#resumedScraped.length;
|
|
277
|
+
const pagesScrapedOffset = this.#resumedPagesScraped;
|
|
278
|
+
void this.#runDeal(initialUrls, resumeOffset, pagesScrapedOffset).catch((error) => {
|
|
195
279
|
crawlerLog('runDeal error: %O', error);
|
|
196
280
|
this.#emitDealErrors(error, root.href);
|
|
197
281
|
void this.emit('crawlEnd', {});
|
|
198
282
|
});
|
|
199
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Thin instance-bound adapter over {@link drainPhaseErrors}. Flushes
|
|
286
|
+
* `#pendingPhaseErrors` for `url` as `pageError` events. Idempotent.
|
|
287
|
+
*
|
|
288
|
+
* **Test gap (known)**: this adapter is invoked from the worker body in
|
|
289
|
+
* {@link Crawler.#runDeal} at three call sites — after `#handleResult`,
|
|
290
|
+
* inside the worker's `catch`, and via `logUndrainedPhaseErrors` in
|
|
291
|
+
* `finally`. The drain logic itself is unit-tested in
|
|
292
|
+
* `drain-phase-errors.spec.ts`; the wiring (whether the worker actually
|
|
293
|
+
* calls it on each path) is verified by code review only, because
|
|
294
|
+
* driving the worker requires a Puppeteer + beholder mock stack whose
|
|
295
|
+
* cost outweighs the regression it would catch.
|
|
296
|
+
* @param url - URL whose buffered errors should be flushed.
|
|
297
|
+
* @param isExternal - Whether the URL is external to the crawl scope.
|
|
298
|
+
*/
|
|
299
|
+
#drainPhaseErrors(url, isExternal) {
|
|
300
|
+
drainPhaseErrors(this.#pendingPhaseErrors, url.href, isExternal, (payload) => {
|
|
301
|
+
void this.emit('pageError', payload);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
200
304
|
/**
|
|
201
305
|
* Emits error events for a deal-level failure.
|
|
202
306
|
*
|
|
@@ -222,19 +326,22 @@ class Crawler extends EventEmitter {
|
|
|
222
326
|
* Processes captured sub-resources from a page scrape, deduplicates them,
|
|
223
327
|
* and emits `response` / `responseReferrers` events for new resources.
|
|
224
328
|
* @param resources - Sub-resource entries captured during the page load
|
|
329
|
+
* @param parentSource
|
|
225
330
|
*/
|
|
226
|
-
#handleResources(resources) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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);
|
|
238
345
|
}
|
|
239
346
|
}
|
|
240
347
|
/**
|
|
@@ -246,20 +353,22 @@ class Crawler extends EventEmitter {
|
|
|
246
353
|
* - `error`: Creates a fallback PageData, marks as done, and emits `error`.
|
|
247
354
|
* @param result - The scrape result from beholder
|
|
248
355
|
* @param url - The URL that was scraped
|
|
249
|
-
* @param
|
|
356
|
+
* @param enqueue - Callback to enqueue newly discovered URLs into the dealer
|
|
357
|
+
* queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}).
|
|
358
|
+
* Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order.
|
|
250
359
|
* @param paginationState - Mutable state for predicted pagination cascade prevention
|
|
251
360
|
* @param paginationState.lastPushedUrl
|
|
252
361
|
* @param paginationState.lastPushedWasPredicted
|
|
253
362
|
* @param concurrency - Current concurrency level, used to determine predicted URL count
|
|
254
363
|
*/
|
|
255
|
-
#handleResult(result, url,
|
|
364
|
+
#handleResult(result, url, enqueue, paginationState, concurrency) {
|
|
256
365
|
switch (result.type) {
|
|
257
366
|
case 'success': {
|
|
258
367
|
if (!result.pageData)
|
|
259
368
|
break;
|
|
260
369
|
handleScrapeEnd(result.pageData, this.#linkList, this.#scope, this.#options, (newUrl, opts) => {
|
|
261
370
|
this.#linkList.add(newUrl, opts);
|
|
262
|
-
void
|
|
371
|
+
void enqueue(newUrl);
|
|
263
372
|
// Predicted pagination detection
|
|
264
373
|
if (!paginationState || !concurrency)
|
|
265
374
|
return;
|
|
@@ -276,13 +385,18 @@ class Crawler extends EventEmitter {
|
|
|
276
385
|
const pattern = detectPaginationPattern(paginationState.lastPushedUrl, newUrl.withoutHashAndAuth);
|
|
277
386
|
if (pattern) {
|
|
278
387
|
const urls = generatePredictedUrls(pattern, newUrl.withoutHashAndAuth, concurrency);
|
|
388
|
+
const specUrls = [];
|
|
279
389
|
for (const specUrlStr of urls) {
|
|
280
390
|
const specUrl = parseUrl(specUrlStr, this.#options);
|
|
281
391
|
if (specUrl) {
|
|
282
392
|
this.#linkList.add(specUrl, { predicted: true });
|
|
283
|
-
|
|
393
|
+
specUrls.push(specUrl);
|
|
284
394
|
}
|
|
285
395
|
}
|
|
396
|
+
// Enqueue as one batch so ascending page order is kept
|
|
397
|
+
// at the front of the queue (see enqueue in #runDeal).
|
|
398
|
+
if (specUrls.length > 0)
|
|
399
|
+
void enqueue(...specUrls);
|
|
286
400
|
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
287
401
|
paginationState.lastPushedWasPredicted = true;
|
|
288
402
|
return;
|
|
@@ -291,11 +405,20 @@ class Crawler extends EventEmitter {
|
|
|
291
405
|
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
292
406
|
paginationState.lastPushedWasPredicted = false;
|
|
293
407
|
});
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
408
|
+
{
|
|
409
|
+
const pageSource = derivePageSource(this.#options.inventoryMode, result.pageData.url.withoutHashAndAuth);
|
|
410
|
+
if (result.pageData.isExternal) {
|
|
411
|
+
void this.emit('externalPage', {
|
|
412
|
+
result: result.pageData,
|
|
413
|
+
source: pageSource,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
void this.emit('page', {
|
|
418
|
+
result: result.pageData,
|
|
419
|
+
source: pageSource,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
299
422
|
}
|
|
300
423
|
break;
|
|
301
424
|
}
|
|
@@ -324,11 +447,15 @@ class Crawler extends EventEmitter {
|
|
|
324
447
|
}, this.#linkList, this.#scope, this.#options);
|
|
325
448
|
const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
|
|
326
449
|
if (pageResult) {
|
|
450
|
+
const pageSource = derivePageSource(this.#options.inventoryMode, pageResult.url.withoutHashAndAuth);
|
|
327
451
|
if (pageResult.isExternal) {
|
|
328
|
-
void this.emit('externalPage', {
|
|
452
|
+
void this.emit('externalPage', {
|
|
453
|
+
result: pageResult,
|
|
454
|
+
source: pageSource,
|
|
455
|
+
});
|
|
329
456
|
}
|
|
330
457
|
else {
|
|
331
|
-
void this.emit('page', { result: pageResult });
|
|
458
|
+
void this.emit('page', { result: pageResult, source: pageSource });
|
|
332
459
|
}
|
|
333
460
|
}
|
|
334
461
|
void this.emit('error', {
|
|
@@ -355,68 +482,60 @@ class Crawler extends EventEmitter {
|
|
|
355
482
|
* @param headCheckResult - Optional HEAD result to pass to the scraper, avoiding a redundant request
|
|
356
483
|
* @returns The scrape result from beholder
|
|
357
484
|
*/
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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;
|
|
365
529
|
}
|
|
366
|
-
const puppeteer = await import('puppeteer');
|
|
367
|
-
const browser = await puppeteer.launch({
|
|
368
|
-
headless: true,
|
|
369
|
-
...(this.#options.executablePath
|
|
370
|
-
? { executablePath: this.#options.executablePath }
|
|
371
|
-
: {}),
|
|
372
|
-
});
|
|
373
530
|
try {
|
|
374
|
-
|
|
375
|
-
const page = await browser.newPage();
|
|
376
|
-
await page.setUserAgent(this.#options.userAgent);
|
|
377
|
-
// Defence-in-depth: beholder sets Authorization via setExtraHTTPHeaders,
|
|
378
|
-
// but page.authenticate() handles Chromium-level HTTP auth challenges
|
|
379
|
-
// (401 + WWW-Authenticate) that setExtraHTTPHeaders cannot cover.
|
|
380
|
-
if (url.username && url.password) {
|
|
381
|
-
await page.authenticate({
|
|
382
|
-
username: url.username,
|
|
383
|
-
password: url.password,
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
const scraper = new Scraper();
|
|
387
|
-
scraper.on('changePhase', (e) => {
|
|
388
|
-
const msg = formatPhaseLog(e);
|
|
389
|
-
if (msg) {
|
|
390
|
-
update(msg);
|
|
391
|
-
}
|
|
392
|
-
void this.emit('changePhase', e);
|
|
393
|
-
});
|
|
394
|
-
const result = await scraper.scrapeStart(page, url, {
|
|
395
|
-
isExternal,
|
|
396
|
-
captureImages: !isExternal && this.#options.captureImages,
|
|
397
|
-
excludeKeywords: this.#options.excludeKeywords,
|
|
398
|
-
disableQueries: this.#options.disableQueries,
|
|
399
|
-
metadataOnly,
|
|
400
|
-
retries: this.#options.retry,
|
|
401
|
-
headCheckResult,
|
|
402
|
-
});
|
|
403
|
-
update('Closing browser%dots%');
|
|
404
|
-
return result;
|
|
531
|
+
return await lookupPageSource(url.withoutHashAndAuth);
|
|
405
532
|
}
|
|
406
533
|
catch (error) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
message: error instanceof Error ? error.message : String(error),
|
|
413
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
414
|
-
shutdown: true,
|
|
415
|
-
},
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
finally {
|
|
419
|
-
await browser.close().catch(() => { });
|
|
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;
|
|
420
539
|
}
|
|
421
540
|
}
|
|
422
541
|
/**
|
|
@@ -429,16 +548,29 @@ class Crawler extends EventEmitter {
|
|
|
429
548
|
* @param initialUrls - Starting URLs to seed the deal queue
|
|
430
549
|
* @param resumeOffset - Number of URLs already scraped in a previous session,
|
|
431
550
|
* added to the progress counter for accurate display
|
|
551
|
+
* @param pagesScrapedOffset - Number of HTML pages already rendered in previous
|
|
552
|
+
* sessions, used to seed the per-session HTML-pages counter so the display
|
|
553
|
+
* remains accurate across resumes
|
|
432
554
|
*/
|
|
433
|
-
async #runDeal(initialUrls, resumeOffset = 0) {
|
|
555
|
+
async #runDeal(initialUrls, resumeOffset = 0, pagesScrapedOffset = 0) {
|
|
434
556
|
const seen = new Set(initialUrls.map((u) => protocolAgnosticKey(u.withoutHashAndAuth)));
|
|
435
557
|
// Add scraped URLs to seen to prevent re-processing during resume
|
|
436
558
|
for (const url of this.#resumedScraped) {
|
|
437
559
|
seen.add(protocolAgnosticKey(url));
|
|
438
560
|
}
|
|
561
|
+
// Redirect-destination dedup is per-crawl; clear any state from a prior run.
|
|
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();
|
|
439
567
|
// external URL の追跡(target は deal の total/done から導出)
|
|
440
568
|
const externalUrls = new Set();
|
|
441
569
|
const externalDoneUrls = new Set();
|
|
570
|
+
// HTML ページとしてブラウザでレンダリングし、かつアーカイブに保存されたページ数。
|
|
571
|
+
// HEAD のみ・title 取得のみ・skip・ブラウザ起動失敗・predicted-discard は含まない。
|
|
572
|
+
// 過去セッションぶんは pagesScrapedOffset として init される。
|
|
573
|
+
let pagesScraped = pagesScrapedOffset;
|
|
442
574
|
// 初期 URL を分類(onPush を通らないため)
|
|
443
575
|
for (const url of initialUrls) {
|
|
444
576
|
if (findScopeEntry(url, this.#scope, this.#options) === null) {
|
|
@@ -453,7 +585,7 @@ class Crawler extends EventEmitter {
|
|
|
453
585
|
lastPushedUrl: null,
|
|
454
586
|
lastPushedWasPredicted: false,
|
|
455
587
|
};
|
|
456
|
-
await deal(initialUrls, (url, update, _index, setLineHeader, push) => {
|
|
588
|
+
await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
|
|
457
589
|
const matchedScope = findScopeEntry(url, this.#scope, this.#options);
|
|
458
590
|
const isExternal = matchedScope === null;
|
|
459
591
|
const urlText = isExternal ? c.dim(url.href) : c.cyan(url.href);
|
|
@@ -463,8 +595,44 @@ class Crawler extends EventEmitter {
|
|
|
463
595
|
}
|
|
464
596
|
this.#linkList.add(url);
|
|
465
597
|
this.#linkList.progress(url);
|
|
598
|
+
// Likely-HTML URLs jump to the front of the queue (unshift) so page
|
|
599
|
+
// crawling advances ahead of asset/document fetches; everything else
|
|
600
|
+
// is appended (push). partitionUrlsByHtml splits the batch by the
|
|
601
|
+
// URL-only heuristic. Variadic so a batch (e.g. predicted pagination)
|
|
602
|
+
// keeps its order: a single unshift(...html) preserves ascending order
|
|
603
|
+
// at the front, whereas unshifting one-by-one would reverse it.
|
|
604
|
+
const enqueue = (...newUrls) => {
|
|
605
|
+
const [html, other] = partitionUrlsByHtml(newUrls);
|
|
606
|
+
const ops = [];
|
|
607
|
+
if (html.length > 0)
|
|
608
|
+
ops.push(unshift(...html));
|
|
609
|
+
if (other.length > 0)
|
|
610
|
+
ops.push(push(...other));
|
|
611
|
+
return Promise.all(ops).then(() => { });
|
|
612
|
+
};
|
|
466
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
|
+
}
|
|
467
628
|
const log = createTimedUpdate(update, this.#options.verbose);
|
|
629
|
+
// `#scrapePage` 内のブラウザ HTML レンダーが成功したかをマークするフラグ。
|
|
630
|
+
// 成功時のみ #scrapePage 側で true に設定される。
|
|
631
|
+
// discard 判定後にこのフラグを見てカウントするので、launch 失敗や predicted-discard は除外される。
|
|
632
|
+
let renderedInBrowser = false;
|
|
633
|
+
const markBrowserScrape = () => {
|
|
634
|
+
renderedInBrowser = true;
|
|
635
|
+
};
|
|
468
636
|
try {
|
|
469
637
|
const robotsAllowed = await this.#robotsChecker.isAllowed(url);
|
|
470
638
|
if (!robotsAllowed) {
|
|
@@ -496,24 +664,112 @@ class Crawler extends EventEmitter {
|
|
|
496
664
|
isLowerLayer: false,
|
|
497
665
|
});
|
|
498
666
|
this.#linkList.done(url, this.#scope, { page: pageData }, this.#options);
|
|
499
|
-
void this.emit('externalPage', {
|
|
667
|
+
void this.emit('externalPage', {
|
|
668
|
+
result: pageData,
|
|
669
|
+
source: derivePageSource(this.#options.inventoryMode, url.withoutHashAndAuth),
|
|
670
|
+
});
|
|
500
671
|
log(c.dim('External (skip fetch)'));
|
|
501
672
|
return;
|
|
502
673
|
}
|
|
503
674
|
const metadataOnly = this.#linkList.isMetadataOnly(url.withoutHash);
|
|
504
675
|
const isPredicted = this.#linkList.isPredicted(url.withoutHashAndAuth);
|
|
505
676
|
log('Scraping%dots%');
|
|
506
|
-
const result = await this.#scrapePage(url, log, metadataOnly, _index);
|
|
677
|
+
const result = await this.#scrapePage(url, log, metadataOnly, _index, markBrowserScrape);
|
|
678
|
+
// Redirect convergence (#73): the destination was already
|
|
679
|
+
// rendered during this crawl, so only the redirect edge is
|
|
680
|
+
// recorded and the browser was never launched. Mark the URL
|
|
681
|
+
// done and emit `redirect` (routed to `Archive.setRedirect`,
|
|
682
|
+
// which writes the edge without touching the destination's
|
|
683
|
+
// content). This URL does not count toward pagesScraped.
|
|
684
|
+
if (result.type === 'redirect-edge') {
|
|
685
|
+
// Note: a predicted (speculative) URL that reaches here genuinely
|
|
686
|
+
// redirects (the server returned 3xx), so it is a real URL — we
|
|
687
|
+
// record its edge rather than discard it. This matches the render
|
|
688
|
+
// path, where the first predicted source to a destination renders
|
|
689
|
+
// it and is recorded as a redirect source the same way; only 404 /
|
|
690
|
+
// error predicted URLs are dropped (by `shouldDiscardPredicted`).
|
|
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));
|
|
748
|
+
log(c.dim('Redirect (dest already scraped)'));
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
507
751
|
// Discard predicted URLs that failed (404, error, etc.)
|
|
508
752
|
if (isPredicted && shouldDiscardPredicted(result)) {
|
|
509
753
|
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
510
754
|
log(c.dim('Predicted (discarded)'));
|
|
511
755
|
return;
|
|
512
756
|
}
|
|
757
|
+
// Count only after discard check: rendered HTML pages that
|
|
758
|
+
// will be persisted to the archive. Launch failures bypass
|
|
759
|
+
// this point via the catch block; discarded predicted URLs
|
|
760
|
+
// return above without reaching here.
|
|
761
|
+
if (renderedInBrowser) {
|
|
762
|
+
pagesScraped++;
|
|
763
|
+
}
|
|
513
764
|
log('Saving results%dots%');
|
|
514
|
-
this.#handleResult(result, url,
|
|
515
|
-
this.#
|
|
765
|
+
this.#handleResult(result, url, enqueue, paginationState, concurrency);
|
|
766
|
+
const parentSource = await this.#resolveParentSource(url);
|
|
767
|
+
this.#handleResources(result.resources, parentSource);
|
|
516
768
|
log(formatResultSummary(result));
|
|
769
|
+
// Phase errors must be emitted AFTER 'page' / 'externalPage'
|
|
770
|
+
// so the orchestrator's WriteQueue sees `setPage` before
|
|
771
|
+
// `insertPageError` and the URL→pageId resolution succeeds.
|
|
772
|
+
this.#drainPhaseErrors(url, isExternal);
|
|
517
773
|
}
|
|
518
774
|
catch (error) {
|
|
519
775
|
crawlerLog('Worker error for %s: %O', url.href, error);
|
|
@@ -532,16 +788,29 @@ class Crawler extends EventEmitter {
|
|
|
532
788
|
isExternal,
|
|
533
789
|
error: workerError,
|
|
534
790
|
});
|
|
791
|
+
// Hard-error path: persist whatever phase errors we have
|
|
792
|
+
// already buffered so they are not lost.
|
|
793
|
+
this.#drainPhaseErrors(url, isExternal);
|
|
535
794
|
}
|
|
536
795
|
finally {
|
|
537
796
|
if (isExternal) {
|
|
538
797
|
externalDoneUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
|
|
539
798
|
}
|
|
799
|
+
// Phase errors still in the buffer here were not drained
|
|
800
|
+
// by the success or catch paths — typically because a
|
|
801
|
+
// predicted URL was discarded before reaching the drain
|
|
802
|
+
// point. The helper logs the drop (observable via
|
|
803
|
+
// DEBUG=Nitpicker:Crawler) and removes the entry so the
|
|
804
|
+
// Map cannot leak across crawls.
|
|
805
|
+
logUndrainedPhaseErrors(this.#pendingPhaseErrors, url.href, crawlerLog);
|
|
540
806
|
}
|
|
541
807
|
};
|
|
542
808
|
}, {
|
|
543
809
|
limit: concurrency,
|
|
544
|
-
|
|
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,
|
|
545
814
|
verbose: this.#options.verbose || !process.stdout.isTTY,
|
|
546
815
|
signal: this.#abortController.signal,
|
|
547
816
|
header: (_progress, done, total, limit) => {
|
|
@@ -551,6 +820,7 @@ class Crawler extends EventEmitter {
|
|
|
551
820
|
resumeOffset,
|
|
552
821
|
externalTotal: externalUrls.size,
|
|
553
822
|
externalDone: externalDoneUrls.size,
|
|
823
|
+
pagesScraped,
|
|
554
824
|
limit,
|
|
555
825
|
});
|
|
556
826
|
},
|
|
@@ -581,13 +851,55 @@ class Crawler extends EventEmitter {
|
|
|
581
851
|
* @param update - Callback for progress messages
|
|
582
852
|
* @param metadataOnly - When true, only extract title metadata without full browser scraping
|
|
583
853
|
* @param laneIndex - The dealer lane index, used to create unique countdown IDs
|
|
854
|
+
* @param markBrowserScrape - Called once **after** the browser successfully
|
|
855
|
+
* renders an HTML page (i.e. `_launchBrowserAndScrape` resolved with
|
|
856
|
+
* `type: 'success'`). Not called for HEAD-only, title-only, captured-resource
|
|
857
|
+
* reuse, non-HTML responses, non-HTTP protocols (mailto:, tel:), browser
|
|
858
|
+
* launch throws (e.g. invalid executablePath), or scraper-returned
|
|
859
|
+
* `type: 'error'` results. The caller is responsible for further filtering
|
|
860
|
+
* (e.g. predicted-discard).
|
|
584
861
|
* @returns The scrape result
|
|
585
862
|
*/
|
|
586
|
-
async #scrapePage(url, update, metadataOnly, laneIndex) {
|
|
863
|
+
async #scrapePage(url, update, metadataOnly, laneIndex, markBrowserScrape) {
|
|
587
864
|
const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
|
|
588
865
|
// Non-HTTP protocols (mailto:, tel:, etc.) — let the scraper handle early return
|
|
589
866
|
if (!url.isHTTP) {
|
|
590
|
-
return this
|
|
867
|
+
return this._launchBrowserAndScrape(url, update, isExternal, metadataOnly);
|
|
868
|
+
}
|
|
869
|
+
// Reuse captured resource data — when this URL was already observed as a
|
|
870
|
+
// sub-resource during page rendering, its response data is recorded and
|
|
871
|
+
// the HEAD pre-flight is redundant. Only 2xx non-HTML rows are eligible
|
|
872
|
+
// (see resourceToPageData); anything else falls through to the pre-flight.
|
|
873
|
+
// Both URL variants are checked because scope-auth injection adds
|
|
874
|
+
// credentials to queued URLs while browser-captured resource URLs have none.
|
|
875
|
+
// The result is deliberately NOT written to destinationCache: a queued URL
|
|
876
|
+
// is processed at most once (the dealer dedupes by protocol-agnostic key),
|
|
877
|
+
// so a URL that takes this path never reaches fetchDestination again.
|
|
878
|
+
const lookupResource = this.#options.lookupResource;
|
|
879
|
+
if (lookupResource &&
|
|
880
|
+
(this.#resources.has(url.withoutHash) ||
|
|
881
|
+
this.#resources.has(url.withoutHashAndAuth))) {
|
|
882
|
+
update('Checking captured resource%dots%');
|
|
883
|
+
let resource = null;
|
|
884
|
+
try {
|
|
885
|
+
resource = await lookupResource([url.withoutHash, url.withoutHashAndAuth]);
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
// A lookup failure must never be worse than not having the
|
|
889
|
+
// optimization — fall back to the HEAD pre-flight below.
|
|
890
|
+
crawlerLog('Resource lookup failed for %s, falling back: %O', url.href, error);
|
|
891
|
+
}
|
|
892
|
+
const pageData = resource
|
|
893
|
+
? resourceToPageData({ url, isExternal, resource })
|
|
894
|
+
: null;
|
|
895
|
+
if (pageData) {
|
|
896
|
+
crawlerLog('Reused captured resource for %s', url.href);
|
|
897
|
+
return {
|
|
898
|
+
type: 'success',
|
|
899
|
+
pageData: metadataOnly ? { ...pageData, isTarget: false } : pageData,
|
|
900
|
+
resources: [],
|
|
901
|
+
};
|
|
902
|
+
}
|
|
591
903
|
}
|
|
592
904
|
// Pre-flight: lightweight HEAD request to check server availability
|
|
593
905
|
update('HEAD request%dots%');
|
|
@@ -596,6 +908,131 @@ class Crawler extends EventEmitter {
|
|
|
596
908
|
headCheckResult = await this.#sendHeadRequest(url, isExternal, update, laneIndex);
|
|
597
909
|
}
|
|
598
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
|
+
}
|
|
599
1036
|
// Server unreachable — skip browser launch entirely
|
|
600
1037
|
update(c.red('Unreachable'));
|
|
601
1038
|
return {
|
|
@@ -603,16 +1040,38 @@ class Crawler extends EventEmitter {
|
|
|
603
1040
|
resources: [],
|
|
604
1041
|
error: {
|
|
605
1042
|
name: error instanceof Error ? error.name : 'Error',
|
|
606
|
-
message:
|
|
1043
|
+
message: errorMessage,
|
|
607
1044
|
stack: error instanceof Error ? error.stack : undefined,
|
|
608
1045
|
shutdown: false,
|
|
609
1046
|
},
|
|
610
1047
|
};
|
|
611
1048
|
}
|
|
1049
|
+
// Redirect convergence (#73): `finalKey` is the destination this URL lands
|
|
1050
|
+
// on after following its redirect chain (or the URL itself when it does not
|
|
1051
|
+
// redirect). When that destination has already been rendered and stored
|
|
1052
|
+
// during this crawl, do NOT process this URL further — record the redirect
|
|
1053
|
+
// edge only and skip everything below, regardless of content type. This is
|
|
1054
|
+
// the root fix for the many-to-one redirect duplication (#70): every source
|
|
1055
|
+
// URL that 301s to one destination otherwise re-renders/re-stores it. The
|
|
1056
|
+
// check sits ABOVE the metadata-only and non-HTML branches on purpose — both
|
|
1057
|
+
// route their HEAD/title result through `updatePage`, which would funnel a
|
|
1058
|
+
// content-less result into `#insertPage` and overwrite the already-rendered
|
|
1059
|
+
// destination's title / meta / isExternal. The edge-only path leaves the
|
|
1060
|
+
// destination row intact.
|
|
1061
|
+
//
|
|
1062
|
+
// `finalKey` is also claimed for destinations reached directly (no redirect;
|
|
1063
|
+
// see the claim after a successful render below), so a destination that is
|
|
1064
|
+
// both linked directly and arrived at via a redirect is rendered by whichever
|
|
1065
|
+
// path wins the race, not both.
|
|
1066
|
+
const finalKey = redirectDestKey(url, headCheckResult.redirectPaths);
|
|
1067
|
+
if (this.#scrapedDestinations.has(finalKey)) {
|
|
1068
|
+
crawlerLog('Redirect dest already rendered, edge only: %s', url.href);
|
|
1069
|
+
return { type: 'redirect-edge', source: 'http-chain', pageData: headCheckResult };
|
|
1070
|
+
}
|
|
612
1071
|
// Title-only mode — extract <title> via partial GET for HTML, skip browser
|
|
613
1072
|
if (metadataOnly) {
|
|
614
1073
|
if (headCheckResult.contentType === null ||
|
|
615
|
-
headCheckResult.contentType
|
|
1074
|
+
isHtmlContentType(headCheckResult.contentType)) {
|
|
616
1075
|
update('Fetching title%dots%');
|
|
617
1076
|
try {
|
|
618
1077
|
const titleResult = await fetchDestination({
|
|
@@ -640,15 +1099,135 @@ class Crawler extends EventEmitter {
|
|
|
640
1099
|
}
|
|
641
1100
|
// Non-HTML content — skip browser
|
|
642
1101
|
if (headCheckResult.contentType !== null &&
|
|
643
|
-
headCheckResult.contentType
|
|
1102
|
+
!isHtmlContentType(headCheckResult.contentType)) {
|
|
644
1103
|
return {
|
|
645
1104
|
type: 'success',
|
|
646
1105
|
pageData: headCheckResult,
|
|
647
1106
|
resources: [],
|
|
648
1107
|
};
|
|
649
1108
|
}
|
|
650
|
-
// HTML or unknown content type — launch browser with preflight result
|
|
651
|
-
|
|
1109
|
+
// HTML or unknown content type — launch browser with preflight result.
|
|
1110
|
+
// markBrowserScrape() fires only when the result is `success`.
|
|
1111
|
+
// `_launchBrowserAndScrape` catches internal errors and returns
|
|
1112
|
+
// `{ type: 'error', ... }` instead of throwing (see its catch block),
|
|
1113
|
+
// so awaiting alone does NOT prove the page was rendered. The explicit
|
|
1114
|
+
// success check excludes navigation failures, scraper exceptions, and
|
|
1115
|
+
// shutdown-class errors from the pages-rendered count.
|
|
1116
|
+
const browserResult = await this._launchBrowserAndScrape(url, update, isExternal, metadataOnly, headCheckResult);
|
|
1117
|
+
if (browserResult.type === 'success') {
|
|
1118
|
+
markBrowserScrape();
|
|
1119
|
+
// Claim the destination that was ACTUALLY rendered, keyed off the
|
|
1120
|
+
// browser's own redirect resolution rather than the HEAD pre-flight's
|
|
1121
|
+
// guess (`finalKey`). The browser is authoritative for what got stored;
|
|
1122
|
+
// if HEAD and the browser disagree on the final URL (method-conditional
|
|
1123
|
+
// / JS / meta-refresh redirects), keying the claim off the HEAD guess
|
|
1124
|
+
// would route a sibling source to an edge pointing at a never-rendered
|
|
1125
|
+
// phantom row. By claiming the rendered URL, a divergent sibling simply
|
|
1126
|
+
// re-renders (dedup misses) instead — correct, just less optimal. In the
|
|
1127
|
+
// common case HEAD and the browser agree, so the keys are identical.
|
|
1128
|
+
//
|
|
1129
|
+
// Claimed only after a successful render, so a failed render leaves the
|
|
1130
|
+
// destination unclaimed and a later source retries it. Concurrent
|
|
1131
|
+
// in-flight sources to the same destination (bounded by the concurrency
|
|
1132
|
+
// limit) may still each render before any claim lands; the storage-layer
|
|
1133
|
+
// replace in `updatePage` (#70) keeps the resulting anchors / images
|
|
1134
|
+
// correct (sub-resources may briefly duplicate, far below the pre-#73
|
|
1135
|
+
// once-per-source blow-up).
|
|
1136
|
+
const renderedKey = browserResult.pageData
|
|
1137
|
+
? redirectDestKey(url, browserResult.pageData.redirectPaths)
|
|
1138
|
+
: finalKey;
|
|
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
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return browserResult;
|
|
652
1231
|
}
|
|
653
1232
|
/**
|
|
654
1233
|
* Performs a pre-flight HTTP HEAD request with retry logic.
|
|
@@ -663,17 +1242,244 @@ class Crawler extends EventEmitter {
|
|
|
663
1242
|
* @returns Lightweight page data from the HEAD response
|
|
664
1243
|
*/
|
|
665
1244
|
async #sendHeadRequest(url, isExternal, update, laneIndex) {
|
|
666
|
-
|
|
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
|
+
}, {
|
|
667
1289
|
retries: this.#options.retry,
|
|
668
1290
|
label: 'HEAD request',
|
|
669
1291
|
onWait: (determinedInterval, retryCount, label, error) => {
|
|
670
1292
|
update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
|
|
671
1293
|
},
|
|
672
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
|
+
}
|
|
673
1311
|
update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
|
|
674
1312
|
},
|
|
675
1313
|
});
|
|
676
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
|
+
}
|
|
677
1483
|
/**
|
|
678
1484
|
* The default maximum number of concurrent scraping processes.
|
|
679
1485
|
*
|