@nitpicker/crawler 0.14.0 → 0.16.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 +136 -10
- package/lib/archive/archive.js +147 -9
- package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
- package/lib/archive/body-hash/compute-body-hash.js +31 -0
- package/lib/archive/body-hash/extract-body.d.ts +18 -0
- package/lib/archive/body-hash/extract-body.js +29 -0
- package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
- package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
- package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
- package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
- package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
- package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
- package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
- package/lib/archive/cache/clear-archive-cache-root.js +32 -0
- package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
- package/lib/archive/cache/list-archive-cache-entries.js +125 -0
- package/lib/archive/cache/path-exists.d.ts +9 -0
- package/lib/archive/cache/path-exists.js +18 -0
- package/lib/archive/cache/types.d.ts +33 -0
- package/lib/archive/cache/types.js +1 -0
- package/lib/archive/create-adjunct-tables.d.ts +14 -0
- package/lib/archive/create-adjunct-tables.js +169 -0
- package/lib/archive/create-entity-tables.js +21 -1
- package/lib/archive/database.d.ts +67 -2
- package/lib/archive/database.js +92 -3
- package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
- package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
- package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
- package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
- package/lib/archive/db-ops/_shared/types.d.ts +2 -0
- package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
- package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
- package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
- package/lib/archive/db-ops/analysis/types.d.ts +61 -0
- package/lib/archive/db-ops/analysis/types.js +1 -0
- package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
- package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
- package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
- package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
- package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
- package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
- package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
- package/lib/archive/db-ops/console-logs/types.js +1 -0
- package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
- package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
- package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
- package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
- package/lib/archive/db-ops/lifecycle/init.js +21 -2
- package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
- package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
- package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
- package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
- package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
- package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
- package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
- package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
- package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
- package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
- package/lib/archive/db-ops/pages/write/update-page.js +12 -0
- package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
- package/lib/archive/filesystem/output-binary.d.ts +12 -0
- package/lib/archive/filesystem/output-binary.js +17 -0
- package/lib/archive/get-failed-page-messages.d.ts +20 -8
- package/lib/archive/get-failed-page-messages.js +29 -15
- package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
- package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
- package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
- package/lib/archive/migrate-page-meta-body-hash.js +52 -0
- package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
- package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
- package/lib/archive/page.d.ts +1 -1
- package/lib/archive/types.d.ts +60 -2
- package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
- package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
- package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
- package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
- package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
- package/lib/archive/url-alias/format-alias-key.js +14 -0
- package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
- package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
- package/lib/archive/url-alias/types.d.ts +7 -0
- package/lib/archive/url-alias/types.js +1 -0
- package/lib/classify-error-kind.d.ts +1 -0
- package/lib/classify-error-kind.js +14 -0
- package/lib/crawler/assert-chrome-installed.d.ts +24 -0
- package/lib/crawler/assert-chrome-installed.js +43 -0
- package/lib/crawler/choose-probe-host.d.ts +24 -0
- package/lib/crawler/choose-probe-host.js +38 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
- package/lib/crawler/crawler.d.ts +12 -0
- package/lib/crawler/crawler.js +496 -29
- package/lib/crawler/decode-auth-credential.d.ts +29 -0
- package/lib/crawler/decode-auth-credential.js +39 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
- package/lib/crawler/dedupe/compute-shape-key.js +56 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
- package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
- package/lib/crawler/dedupe/is-shape-capped.js +12 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
- package/lib/crawler/dedupe/types.d.ts +42 -0
- package/lib/crawler/dedupe/types.js +1 -0
- package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
- package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
- package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
- package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
- package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
- package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
- package/lib/crawler/fetch-destination.js +14 -2
- package/lib/crawler/generate-predicted-urls.d.ts +12 -0
- package/lib/crawler/generate-predicted-urls.js +33 -2
- package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
- package/lib/crawler/network-gate.d.ts +49 -0
- package/lib/crawler/network-gate.js +78 -0
- package/lib/crawler/network-outage-detector.d.ts +51 -0
- package/lib/crawler/network-outage-detector.js +81 -0
- package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
- package/lib/crawler/network-outage-summary-counter.js +18 -0
- package/lib/crawler/probe-network.d.ts +26 -0
- package/lib/crawler/probe-network.js +21 -0
- package/lib/crawler/types.d.ts +185 -1
- package/lib/crawler-orchestrator.d.ts +74 -18
- package/lib/crawler-orchestrator.js +267 -27
- package/lib/crawler.d.ts +22 -0
- package/lib/crawler.js +18 -0
- package/lib/is-within-outage-window.d.ts +49 -0
- package/lib/is-within-outage-window.js +33 -0
- package/lib/network-related-error-kinds.d.ts +23 -0
- package/lib/network-related-error-kinds.js +28 -0
- package/lib/permanent-error-kinds.d.ts +9 -4
- package/lib/permanent-error-kinds.js +10 -4
- package/lib/types.d.ts +11 -2
- package/lib/utils/array/each-splitted.d.ts +1 -1
- package/lib/utils/compute-file-sha256.d.ts +17 -17
- package/lib/utils/compute-file-sha256.js +18 -47
- package/package.json +3 -3
package/lib/crawler/crawler.js
CHANGED
|
@@ -9,17 +9,29 @@ import { retryCall } from '@d-zero/shared/retry';
|
|
|
9
9
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
10
10
|
import c from 'ansi-colors';
|
|
11
11
|
import pkg from '../../package.json' with { type: 'json' };
|
|
12
|
+
import { computeBodyHash } from '../archive/body-hash/compute-body-hash.js';
|
|
12
13
|
import { classifyErrorKind } from '../classify-error-kind.js';
|
|
13
14
|
import { crawlerLog } from '../debug.js';
|
|
14
15
|
import { buildJsRedirectEdge } from './build-js-redirect-edge.js';
|
|
15
16
|
import { buildRedirectEvent } from './build-redirect-event.js';
|
|
16
17
|
import { captureImageDomPaths } from './capture-image-dom-paths.js';
|
|
18
|
+
import { chooseProbeHost } from './choose-probe-host.js';
|
|
17
19
|
import { createChangePhaseHandler } from './create-change-phase-handler.js';
|
|
20
|
+
import { decodeAuthCredential } from './decode-auth-credential.js';
|
|
21
|
+
import { computeMetaSignature } from './dedupe/compute-meta-signature.js';
|
|
22
|
+
import { computeShapeKey } from './dedupe/compute-shape-key.js';
|
|
23
|
+
import DedupeCapTracker from './dedupe/dedupe-cap-tracker.js';
|
|
24
|
+
import { isPredictedContentDuplicate } from './dedupe/is-predicted-content-duplicate.js';
|
|
25
|
+
import { resolveOgUrlMismatch } from './dedupe/resolve-og-url-mismatch.js';
|
|
18
26
|
import { derivePageSource } from './derive-page-source.js';
|
|
27
|
+
import { destinationCache } from './destination-cache.js';
|
|
19
28
|
import { detectPaginationPattern } from './detect-pagination-pattern.js';
|
|
29
|
+
import { dnsBurnedHostBurnTimestamps } from './dns-burned-host-burn-timestamps.js';
|
|
20
30
|
import { dnsBurnedHostCache } from './dns-burned-host-cache.js';
|
|
21
31
|
import { dnsBurnedHostShortCircuitCounter } from './dns-burned-host-short-circuit-counter.js';
|
|
22
32
|
import { drainPhaseErrors } from './drain-phase-errors.js';
|
|
33
|
+
import { evictNetworkClassifiedDestinationCacheEntries } from './evict-network-classified-destination-cache-entries.js';
|
|
34
|
+
import { evictOutageTaintedDnsBurns } from './evict-outage-tainted-dns-burns.js';
|
|
23
35
|
import { fetchDestination } from './fetch-destination.js';
|
|
24
36
|
import { findScopeEntry } from './find-scope-entry.js';
|
|
25
37
|
import { formatCrawlProgress } from './format-crawl-progress.js';
|
|
@@ -35,9 +47,12 @@ import { isPuppeteerFallbackCandidate } from './is-puppeteer-fallback-candidate.
|
|
|
35
47
|
import LinkList from './link-list.js';
|
|
36
48
|
import { linkToPageData } from './link-to-page-data.js';
|
|
37
49
|
import { logUndrainedPhaseErrors } from './log-undrained-phase-errors.js';
|
|
50
|
+
import NetworkGate from './network-gate.js';
|
|
51
|
+
import NetworkOutageDetector from './network-outage-detector.js';
|
|
38
52
|
import { partitionUrlsByHtml } from './partition-urls-by-html.js';
|
|
39
53
|
import { planSubResourceEmits } from './plan-sub-resource-emits.js';
|
|
40
54
|
import { PreloadShortCircuitError } from './preload-short-circuit-error.js';
|
|
55
|
+
import { probeNetwork } from './probe-network.js';
|
|
41
56
|
import { protocolAgnosticKey } from './protocol-agnostic-key.js';
|
|
42
57
|
import { redirectDestKey } from './redirect-dest-key.js';
|
|
43
58
|
import { resourceToPageData } from './resource-to-page-data.js';
|
|
@@ -56,6 +71,16 @@ import { shouldSkipUrl } from './should-skip-url.js';
|
|
|
56
71
|
* stay on the final (longest) timeout for any additional attempts.
|
|
57
72
|
*/
|
|
58
73
|
const HEAD_TIMEOUT_ESCALATION_MS = [10_000, 30_000, 60_000];
|
|
74
|
+
/** Default {@link CrawlerOptions.networkOutageWindowMs}. */
|
|
75
|
+
const DEFAULT_NETWORK_OUTAGE_WINDOW_MS = 10_000;
|
|
76
|
+
/** Default {@link CrawlerOptions.networkOutageErrorThreshold}. */
|
|
77
|
+
const DEFAULT_NETWORK_OUTAGE_ERROR_THRESHOLD = 5;
|
|
78
|
+
/** Default {@link CrawlerOptions.networkOutageHostThreshold}. */
|
|
79
|
+
const DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD = 2;
|
|
80
|
+
/** Default {@link CrawlerOptions.networkOutageProbeIntervalMs}. */
|
|
81
|
+
const DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS = 10_000;
|
|
82
|
+
/** Default {@link CrawlerOptions.dedupeMapCap}. */
|
|
83
|
+
const DEFAULT_DEDUPE_MAP_CAP = 100_000;
|
|
59
84
|
/**
|
|
60
85
|
* The core crawler engine that discovers and scrapes web pages.
|
|
61
86
|
*
|
|
@@ -70,10 +95,59 @@ const HEAD_TIMEOUT_ESCALATION_MS = [10_000, 30_000, 60_000];
|
|
|
70
95
|
class Crawler extends EventEmitter {
|
|
71
96
|
/** Controller used to cancel the deal-based crawl via its AbortSignal. */
|
|
72
97
|
#abortController = new AbortController();
|
|
98
|
+
/**
|
|
99
|
+
* Per-shape count of anchors rejected by the dedupe-cap enqueue gates
|
|
100
|
+
* after that shape capped. Read by {@link getDedupeCapRejections} at
|
|
101
|
+
* `crawlEnd` so the orchestrator can finalize each
|
|
102
|
+
* `dedupe_cap_events.rejected_count` exactly once (see
|
|
103
|
+
* `Crawler#getDedupeCapRejections`'s JSDoc for why this is not written
|
|
104
|
+
* to the archive incrementally).
|
|
105
|
+
*/
|
|
106
|
+
#dedupeCapRejectionCounts = new Map();
|
|
107
|
+
/**
|
|
108
|
+
* Opt-in (`--dedupe-cap`) same-cluster soft cap. Always constructed
|
|
109
|
+
* (Misra-Gries state stays empty when {@link CrawlerOptions.dedupeCap} is
|
|
110
|
+
* `null`), gated on by `#options.dedupeCap !== null` at each call site
|
|
111
|
+
* rather than being conditionally `undefined`, so the two enqueue gates
|
|
112
|
+
* and the observation call in {@link #handleResult} do not need to
|
|
113
|
+
* null-check a class field.
|
|
114
|
+
*/
|
|
115
|
+
#dedupeCapTracker;
|
|
73
116
|
/** Tracks discovered URLs, their scrape status, and deduplication. */
|
|
74
117
|
#linkList = new LinkList();
|
|
118
|
+
/**
|
|
119
|
+
* Gate every worker callback awaits before doing network work (see the
|
|
120
|
+
* worker body inside {@link #runDeal}). Open by default; closed by
|
|
121
|
+
* {@link #handleOutageSuspect} once a recovery probe confirms a suspect
|
|
122
|
+
* outage, reopened once a later probe succeeds. Re-opened defensively at
|
|
123
|
+
* the start of {@link #runDeal} (a no-op if already open) so a fresh
|
|
124
|
+
* session never inherits a closed gate from a prior anomalous one.
|
|
125
|
+
*/
|
|
126
|
+
#networkGate = new NetworkGate();
|
|
127
|
+
/**
|
|
128
|
+
* Sliding-window detector for "the operator's own network, not the
|
|
129
|
+
* target sites, looks like it is down". Fed from {@link #sendHeadRequest}'s
|
|
130
|
+
* `onWait` / `onGiveUp`; a non-null {@link OutageSuspect} triggers
|
|
131
|
+
* {@link #handleOutageSuspect}. Reset at the start of {@link #runDeal}.
|
|
132
|
+
* Assigned in the constructor (not a field initializer) because it
|
|
133
|
+
* needs `this.#options`'s network-outage tunables.
|
|
134
|
+
*/
|
|
135
|
+
#networkOutageDetector;
|
|
75
136
|
/** Merged crawler configuration (user overrides + defaults). */
|
|
76
137
|
#options;
|
|
138
|
+
/**
|
|
139
|
+
* Synchronous claim flag guarding the async gap between "a suspect
|
|
140
|
+
* outage arrived" and "the confirming probe settled" in
|
|
141
|
+
* {@link #handleOutageSuspect}. Without it, two workers whose HEAD
|
|
142
|
+
* requests both exhaust retries in quick succession could each start
|
|
143
|
+
* their own confirming probe while the gate is still open, and if both
|
|
144
|
+
* probes fail, both would close the gate and emit
|
|
145
|
+
* `networkOutageConfirmed` — creating two simultaneously-open
|
|
146
|
+
* `network_outages` rows for one ongoing outage. Checked and set
|
|
147
|
+
* synchronously (no `await` between the check and the set), which is
|
|
148
|
+
* race-free because JS has no thread-level interleaving.
|
|
149
|
+
*/
|
|
150
|
+
#outageHandlingInProgress = false;
|
|
77
151
|
/**
|
|
78
152
|
* Phase errors observed during {@link Crawler._launchBrowserAndScrape},
|
|
79
153
|
* buffered per URL href so they can be emitted as `pageError` events
|
|
@@ -82,6 +156,23 @@ class Crawler extends EventEmitter {
|
|
|
82
156
|
* `insertPageError`, so the FK resolution via URL always finds the row.
|
|
83
157
|
*/
|
|
84
158
|
#pendingPhaseErrors = new Map();
|
|
159
|
+
/**
|
|
160
|
+
* Predicted-pagination body-hash tracking (always-on — independent of
|
|
161
|
+
* the opt-in `--dedupe-cap` tracker). Maps a URL shape key
|
|
162
|
+
* ({@link computeShapeKey}) to the {@link computeBodyHash} of the most
|
|
163
|
+
* recently scraped *predicted* page of that shape. Never reset mid-crawl
|
|
164
|
+
* (persists for the whole session, like {@link #scrapedDestinations}).
|
|
165
|
+
*/
|
|
166
|
+
#predictedShapeBodyHashes = new Map();
|
|
167
|
+
/**
|
|
168
|
+
* Shapes for which {@link #predictedShapeBodyHashes} detected a
|
|
169
|
+
* content-duplicate predicted page (see {@link isPredictedContentDuplicate}).
|
|
170
|
+
* Once a shape lands here, no further predicted URLs are generated for it
|
|
171
|
+
* (checked in {@link #handleResult}'s pagination-pattern branch) — the
|
|
172
|
+
* cheapest possible way to stop a self-generating trap without needing
|
|
173
|
+
* the opt-in dedupe-cap machinery.
|
|
174
|
+
*/
|
|
175
|
+
#predictedShapeStopped = new Set();
|
|
85
176
|
/** Set of resource URLs (without hash) already captured, for deduplication. */
|
|
86
177
|
#resources = new Set();
|
|
87
178
|
/** Number of HTML pages (isTarget=1) scraped in previous sessions, used to seed the progress counter on resume. */
|
|
@@ -153,7 +244,21 @@ class Crawler extends EventEmitter {
|
|
|
153
244
|
lookupResource: options?.lookupResource ?? null,
|
|
154
245
|
lookupPageSource: options?.lookupPageSource ?? null,
|
|
155
246
|
inventoryMode: options?.inventoryMode ?? null,
|
|
247
|
+
networkOutageWindowMs: options?.networkOutageWindowMs ?? DEFAULT_NETWORK_OUTAGE_WINDOW_MS,
|
|
248
|
+
networkOutageErrorThreshold: options?.networkOutageErrorThreshold ?? DEFAULT_NETWORK_OUTAGE_ERROR_THRESHOLD,
|
|
249
|
+
networkOutageHostThreshold: options?.networkOutageHostThreshold ?? DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD,
|
|
250
|
+
networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs ?? DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS,
|
|
251
|
+
networkProbe: options?.networkProbe ?? null,
|
|
252
|
+
dedupeCap: options?.dedupeCap ?? null,
|
|
253
|
+
dedupeMapCap: options?.dedupeMapCap ?? DEFAULT_DEDUPE_MAP_CAP,
|
|
254
|
+
preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
|
|
156
255
|
};
|
|
256
|
+
this.#networkOutageDetector = new NetworkOutageDetector({
|
|
257
|
+
windowMs: this.#options.networkOutageWindowMs,
|
|
258
|
+
errorThreshold: this.#options.networkOutageErrorThreshold,
|
|
259
|
+
hostThreshold: this.#options.networkOutageHostThreshold,
|
|
260
|
+
});
|
|
261
|
+
this.#dedupeCapTracker = new DedupeCapTracker({ cap: this.#options.dedupeCap ?? 0, mapCap: this.#options.dedupeMapCap }, this.#options.preloadedStickyShapeKeys);
|
|
157
262
|
this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
|
|
158
263
|
for (const urlStr of this.#options.roots) {
|
|
159
264
|
const url = parseUrl(urlStr, this.#options);
|
|
@@ -174,6 +279,20 @@ class Crawler extends EventEmitter {
|
|
|
174
279
|
abort() {
|
|
175
280
|
this.#abortController.abort();
|
|
176
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Per-shape count of anchors the dedupe-cap enqueue gates rejected after
|
|
284
|
+
* that shape capped (opt-in `--dedupe-cap`). Read by
|
|
285
|
+
* `CrawlerOrchestrator` at `crawlEnd` to finalize each
|
|
286
|
+
* `dedupe_cap_events.rejected_count` exactly once — rejections are
|
|
287
|
+
* accumulated in memory rather than written to the archive per-rejection
|
|
288
|
+
* to avoid write amplification (a capped trap can generate an unbounded
|
|
289
|
+
* number of rejected anchors).
|
|
290
|
+
* @returns A snapshot of the per-shape rejection counts. Empty when
|
|
291
|
+
* `--dedupe-cap` was not enabled or no shape has capped yet.
|
|
292
|
+
*/
|
|
293
|
+
getDedupeCapRejections() {
|
|
294
|
+
return this.#dedupeCapRejectionCounts;
|
|
295
|
+
}
|
|
177
296
|
/**
|
|
178
297
|
* Retrieve the list of Chromium process IDs that are still running.
|
|
179
298
|
*
|
|
@@ -324,6 +443,84 @@ class Crawler extends EventEmitter {
|
|
|
324
443
|
});
|
|
325
444
|
}
|
|
326
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Emits captured console messages / page errors for a scrape (issue
|
|
448
|
+
* #228), skipping the emit entirely when `entries` is empty.
|
|
449
|
+
*
|
|
450
|
+
* The empty-skip is deliberate, not an optimization: `replaceConsoleLogs`
|
|
451
|
+
* replaces a page's rows wholesale (Scoped-Replace, like
|
|
452
|
+
* `anchor_edges` / `image_items`), so emitting on an empty capture would
|
|
453
|
+
* wipe out a prior good result on a degraded re-scrape (navigation
|
|
454
|
+
* timeout, partial render) that legitimately produced nothing this
|
|
455
|
+
* time — the same trade-off `updatePage` documents for anchors/images.
|
|
456
|
+
* @param entries - Console log entries captured during the page load.
|
|
457
|
+
* @param url - The originally-requested URL (not necessarily the page
|
|
458
|
+
* that ends up holding the content — see `CrawlerEventTypes.consoleLogs`).
|
|
459
|
+
* @param redirectPaths - The redirect chain hops captured during fetch,
|
|
460
|
+
* in order. Empty when the scrape produced no `pageData` (a
|
|
461
|
+
* `'skipped'` / `'error'` result).
|
|
462
|
+
*/
|
|
463
|
+
#handleConsoleLogs(entries, url, redirectPaths) {
|
|
464
|
+
if (entries.length === 0) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
void this.emit('consoleLogs', {
|
|
468
|
+
pageUrl: url.withoutHashAndAuth,
|
|
469
|
+
redirectPaths,
|
|
470
|
+
entries,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Confirm a sliding-window suspect via an active probe, and if
|
|
475
|
+
* confirmed, close {@link #networkGate} and start
|
|
476
|
+
* {@link #runRecoveryProbeLoop}.
|
|
477
|
+
*
|
|
478
|
+
* Guarded by {@link #outageHandlingInProgress} (a synchronous
|
|
479
|
+
* check-then-set, race-free under JS's single-threaded execution) AND
|
|
480
|
+
* by `#networkGate.isOpen` — the latter covers the entire duration a
|
|
481
|
+
* recovery loop is running (no new suspect should re-confirm or
|
|
482
|
+
* re-probe while one outage is already open), the former covers only
|
|
483
|
+
* the narrow async gap between "decided to investigate" and "the
|
|
484
|
+
* confirming probe settled", which the gate-open check alone cannot see
|
|
485
|
+
* since the gate has not closed yet at that point.
|
|
486
|
+
* @param suspect - The trigger emitted by {@link NetworkOutageDetector.record}.
|
|
487
|
+
*/
|
|
488
|
+
async #handleOutageSuspect(suspect) {
|
|
489
|
+
if (!this.#networkGate.isOpen || this.#outageHandlingInProgress) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
this.#outageHandlingInProgress = true;
|
|
493
|
+
try {
|
|
494
|
+
// No usable probe target at all (no session successes yet AND no
|
|
495
|
+
// parseable root URL) — cannot confirm, and cannot ever detect
|
|
496
|
+
// recovery either, so there is nothing safe to do but leave the
|
|
497
|
+
// gate open and treat this as inconclusive.
|
|
498
|
+
const probeHost = chooseProbeHost(this.#successfulHosts, this.#options.roots);
|
|
499
|
+
if (probeHost === null) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const probe = this.#options.networkProbe ?? probeNetwork;
|
|
503
|
+
const initiallyReachable = await probe(probeHost);
|
|
504
|
+
if (initiallyReachable) {
|
|
505
|
+
// False alarm: the sliding window tripped (e.g. several
|
|
506
|
+
// unrelated hosts happened to fail close together) but the
|
|
507
|
+
// probe host answers fine. Leave the gate open.
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.#networkGate.close();
|
|
511
|
+
void this.emit('networkOutageConfirmed', {
|
|
512
|
+
startedAt: suspect.startedAt,
|
|
513
|
+
detectedAt: suspect.detectedAt,
|
|
514
|
+
probeHost,
|
|
515
|
+
triggerErrorCount: suspect.triggerErrorCount,
|
|
516
|
+
triggerHostCount: suspect.triggerHostCount,
|
|
517
|
+
});
|
|
518
|
+
void this.#runRecoveryProbeLoop(probeHost, suspect.startedAt);
|
|
519
|
+
}
|
|
520
|
+
finally {
|
|
521
|
+
this.#outageHandlingInProgress = false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
327
524
|
/**
|
|
328
525
|
* Processes captured sub-resources from a page scrape, deduplicates them,
|
|
329
526
|
* and emits `response` / `responseReferrers` events for new resources.
|
|
@@ -358,21 +555,92 @@ class Crawler extends EventEmitter {
|
|
|
358
555
|
* @param enqueue - Callback to enqueue newly discovered URLs into the dealer
|
|
359
556
|
* queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}).
|
|
360
557
|
* Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order.
|
|
361
|
-
* @param paginationState - Mutable state for predicted pagination cascade prevention
|
|
362
|
-
* @param paginationState.lastPushedUrl
|
|
363
|
-
* @param paginationState.lastPushedWasPredicted
|
|
364
558
|
* @param concurrency - Current concurrency level, used to determine predicted URL count
|
|
559
|
+
* @param precomputedBodyHash - This page's body hash, if the caller already
|
|
560
|
+
* computed it (the predicted-content-duplicate check, A-3, computes it for
|
|
561
|
+
* every predicted page regardless of `--dedupe-cap`) — reused for the
|
|
562
|
+
* dedupe-cap observation below instead of hashing the same html twice.
|
|
365
563
|
*/
|
|
366
|
-
#handleResult(result, url, enqueue,
|
|
564
|
+
#handleResult(result, url, enqueue, concurrency, precomputedBodyHash) {
|
|
367
565
|
switch (result.type) {
|
|
368
566
|
case 'success': {
|
|
369
567
|
if (!result.pageData)
|
|
370
568
|
break;
|
|
569
|
+
// Scoped to this one page's anchor list (fresh per `#handleResult`
|
|
570
|
+
// call, not shared across pages): pagination-pattern detection
|
|
571
|
+
// compares consecutive anchors as they are discovered by
|
|
572
|
+
// `processAnchors`'s single synchronous loop below, so "consecutive"
|
|
573
|
+
// must mean "adjacent in this document", not "adjacent in whatever
|
|
574
|
+
// order the crawl's workers happened to finish". Sharing this state
|
|
575
|
+
// across pages/workers let `step` be computed from two unrelated
|
|
576
|
+
// URLs, compounding across rounds until a `/news/date/{year}/`
|
|
577
|
+
// pager's predicted token overflowed into scientific notation
|
|
578
|
+
// (`1.7715854126052197e+120`, observed in production).
|
|
579
|
+
const paginationState = {
|
|
580
|
+
lastPushedUrl: null,
|
|
581
|
+
lastPushedWasPredicted: false,
|
|
582
|
+
};
|
|
583
|
+
// Feed this page's own signature into the same-cluster tracker
|
|
584
|
+
// (opt-in via `--dedupe-cap`). This is deliberately separate
|
|
585
|
+
// from the enqueue gates below: gating decides whether to
|
|
586
|
+
// admit a not-yet-scraped anchor based on shape alone; this
|
|
587
|
+
// observes the page that was JUST scraped, using its actual
|
|
588
|
+
// meta/body content. External and metadata-only pages carry no
|
|
589
|
+
// useful signal for this feature and are skipped, matching the
|
|
590
|
+
// signature-scope exclusions in `computeMetaSignature`'s design.
|
|
591
|
+
if (this.#options.dedupeCap !== null &&
|
|
592
|
+
!result.pageData.isExternal &&
|
|
593
|
+
!this.#linkList.isMetadataOnly(result.pageData.url.withoutHash) &&
|
|
594
|
+
result.pageData.html.length > 0) {
|
|
595
|
+
const shapeKey = computeShapeKey(result.pageData.url.withoutHashAndAuth);
|
|
596
|
+
const metaSig = computeMetaSignature(result.pageData.meta);
|
|
597
|
+
if (shapeKey && metaSig) {
|
|
598
|
+
const bodyHash = precomputedBodyHash ?? computeBodyHash(result.pageData.html);
|
|
599
|
+
const ogUrlMismatch = resolveOgUrlMismatch(result.pageData.meta, result.pageData.url.href);
|
|
600
|
+
const event = this.#dedupeCapTracker.observe({
|
|
601
|
+
shapeKey,
|
|
602
|
+
metaSig,
|
|
603
|
+
bodyHash,
|
|
604
|
+
ogUrlMismatch,
|
|
605
|
+
url: result.pageData.url.href,
|
|
606
|
+
});
|
|
607
|
+
if (event) {
|
|
608
|
+
void this.emit('dedupeCap', event);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
371
612
|
handleScrapeEnd(result.pageData, this.#linkList, this.#scope, this.#options, (newUrl, opts) => {
|
|
613
|
+
// Gate 1: blocks real anchors discovered on this page whose
|
|
614
|
+
// shape is already confirmed as a trap. This does NOT cover
|
|
615
|
+
// predicted URLs — `generatePredictedUrls`'s output is
|
|
616
|
+
// pushed directly below (`this.#linkList.add(specUrl, ...)`),
|
|
617
|
+
// bypassing this closure entirely — so the predicted-URL
|
|
618
|
+
// generation site below has its own equivalent check
|
|
619
|
+
// (`shapeIsStopped`, combined with `#predictedShapeStopped`).
|
|
620
|
+
// External anchors are out of scope for the cap (issue #208:
|
|
621
|
+
// "cap 適用は internal only"), enforced by the scope check
|
|
622
|
+
// below. Deliberately NOT also excluding `opts?.metadataOnly`
|
|
623
|
+
// (unlike the tracker's observation side, which does skip
|
|
624
|
+
// metadata-only pages — they carry no reliable signature): with
|
|
625
|
+
// `--recursive=false`, `handle-scrape-end.ts` marks EVERY anchor
|
|
626
|
+
// metadata-only, internal or not, so excluding them here would
|
|
627
|
+
// silently disable `--dedupe-cap` for anchor discovery whenever
|
|
628
|
+
// `--recursive=false` is set — while gate 2 (the JS-redirect
|
|
629
|
+
// direct enqueue below) has no such exclusion and would still
|
|
630
|
+
// cap the very same shape, an inconsistency between the two
|
|
631
|
+
// discovery paths.
|
|
632
|
+
if (this.#options.dedupeCap !== null &&
|
|
633
|
+
findScopeEntry(newUrl, this.#scope, this.#options) !== null) {
|
|
634
|
+
const gateShapeKey = computeShapeKey(newUrl.withoutHashAndAuth);
|
|
635
|
+
if (gateShapeKey && this.#dedupeCapTracker.isCapped(gateShapeKey)) {
|
|
636
|
+
this.#recordDedupeCapRejection(gateShapeKey);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
372
640
|
this.#linkList.add(newUrl, opts);
|
|
373
641
|
void enqueue(newUrl);
|
|
374
642
|
// Predicted pagination detection
|
|
375
|
-
if (!
|
|
643
|
+
if (!concurrency)
|
|
376
644
|
return;
|
|
377
645
|
// metadataOnly / external: update tracking but skip pattern detection
|
|
378
646
|
if (opts?.metadataOnly ||
|
|
@@ -386,22 +654,37 @@ class Crawler extends EventEmitter {
|
|
|
386
654
|
!paginationState.lastPushedWasPredicted) {
|
|
387
655
|
const pattern = detectPaginationPattern(paginationState.lastPushedUrl, newUrl.withoutHashAndAuth);
|
|
388
656
|
if (pattern) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
657
|
+
// Stop generating further predicted URLs for this shape
|
|
658
|
+
// once EITHER confirmation mechanism has fired — the
|
|
659
|
+
// always-on content-duplication check
|
|
660
|
+
// (`#predictedShapeStopped`), or the opt-in
|
|
661
|
+
// `--dedupe-cap` tracker (`#dedupeCapTracker.isCapped`,
|
|
662
|
+
// only consulted when the flag is set). Falls through to
|
|
663
|
+
// the plain (non-predicted) bookkeeping below instead of
|
|
664
|
+
// returning, since the anchor itself is still real.
|
|
665
|
+
const shapeKey = computeShapeKey(newUrl.withoutHashAndAuth);
|
|
666
|
+
const shapeIsStopped = shapeKey !== null &&
|
|
667
|
+
(this.#predictedShapeStopped.has(shapeKey) ||
|
|
668
|
+
(this.#options.dedupeCap !== null &&
|
|
669
|
+
this.#dedupeCapTracker.isCapped(shapeKey)));
|
|
670
|
+
if (!shapeIsStopped) {
|
|
671
|
+
const urls = generatePredictedUrls(pattern, newUrl.withoutHashAndAuth, concurrency);
|
|
672
|
+
const specUrls = [];
|
|
673
|
+
for (const specUrlStr of urls) {
|
|
674
|
+
const specUrl = parseUrl(specUrlStr, this.#options);
|
|
675
|
+
if (specUrl) {
|
|
676
|
+
this.#linkList.add(specUrl, { predicted: true });
|
|
677
|
+
specUrls.push(specUrl);
|
|
678
|
+
}
|
|
396
679
|
}
|
|
680
|
+
// Enqueue as one batch so ascending page order is kept
|
|
681
|
+
// at the front of the queue (see enqueue in #runDeal).
|
|
682
|
+
if (specUrls.length > 0)
|
|
683
|
+
void enqueue(...specUrls);
|
|
684
|
+
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
685
|
+
paginationState.lastPushedWasPredicted = true;
|
|
686
|
+
return;
|
|
397
687
|
}
|
|
398
|
-
// Enqueue as one batch so ascending page order is kept
|
|
399
|
-
// at the front of the queue (see enqueue in #runDeal).
|
|
400
|
-
if (specUrls.length > 0)
|
|
401
|
-
void enqueue(...specUrls);
|
|
402
|
-
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
403
|
-
paginationState.lastPushedWasPredicted = true;
|
|
404
|
-
return;
|
|
405
688
|
}
|
|
406
689
|
}
|
|
407
690
|
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
@@ -471,6 +754,67 @@ class Crawler extends EventEmitter {
|
|
|
471
754
|
}
|
|
472
755
|
}
|
|
473
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* Undo cache damage from the outage window `[startedAt, endedAt]`:
|
|
759
|
+
* evict `destinationCache` entries whose cached error looks
|
|
760
|
+
* network-related (any such entry may be stale evidence about the
|
|
761
|
+
* operator's network, not the target site), and un-burn any
|
|
762
|
+
* `dnsBurnedHostCache` host THIS session burned during that window
|
|
763
|
+
* (preload-seeded burns are structurally immune — see
|
|
764
|
+
* `evict-outage-tainted-dns-burns.ts`).
|
|
765
|
+
*
|
|
766
|
+
* Called on every closed→open gate transition, whether triggered by a
|
|
767
|
+
* successful recovery probe or by an abort — the cached failures are
|
|
768
|
+
* stale either way, and the eviction itself has no failure mode that
|
|
769
|
+
* depends on why the gate reopened.
|
|
770
|
+
* @param startedAt - The outage's `startedAt` (from the triggering `OutageSuspect`).
|
|
771
|
+
* @param endedAt - The moment the gate is reopening.
|
|
772
|
+
*/
|
|
773
|
+
#onGateReopened(startedAt, endedAt) {
|
|
774
|
+
evictNetworkClassifiedDestinationCacheEntries(destinationCache);
|
|
775
|
+
evictOutageTaintedDnsBurns({
|
|
776
|
+
cache: dnsBurnedHostCache,
|
|
777
|
+
burnTimestamps: dnsBurnedHostBurnTimestamps,
|
|
778
|
+
window: { startedAt, endedAt },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Increments {@link #dedupeCapRejectionCounts} for one shape. Scoped to
|
|
783
|
+
* the two concrete enqueue-time rejections (a real anchor or a
|
|
784
|
+
* JS-redirect destination that was discovered but blocked) — it does
|
|
785
|
+
* NOT count predicted URLs that were never generated at all because
|
|
786
|
+
* their shape was already stopped (see the `shapeIsStopped` check in
|
|
787
|
+
* {@link #handleResult}), since nothing concrete existed there to
|
|
788
|
+
* reject.
|
|
789
|
+
* @param shapeKey - The capped shape a rejection is being recorded for.
|
|
790
|
+
*/
|
|
791
|
+
#recordDedupeCapRejection(shapeKey) {
|
|
792
|
+
this.#dedupeCapRejectionCounts.set(shapeKey, (this.#dedupeCapRejectionCounts.get(shapeKey) ?? 0) + 1);
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Feed one observed network-layer error into
|
|
796
|
+
* {@link #networkOutageDetector} and hand off to
|
|
797
|
+
* {@link #handleOutageSuspect} the instant its sliding window trips.
|
|
798
|
+
*
|
|
799
|
+
* Called from BOTH `onWait` (every non-final retry attempt) and
|
|
800
|
+
* `onGiveUp` (the final attempt) inside {@link #sendHeadRequest}, so a
|
|
801
|
+
* single URL's retry storm contributes every attempt's error, not just
|
|
802
|
+
* its terminal one — a real network-wide outage is expected to trip the
|
|
803
|
+
* `hostThreshold` gate from many DIFFERENT hosts' attempts arriving in
|
|
804
|
+
* the same short window, not from one URL retrying against one host.
|
|
805
|
+
* @param message - The raw error message to classify.
|
|
806
|
+
* @param host - Lower-cased hostname the error occurred on.
|
|
807
|
+
*/
|
|
808
|
+
#recordNetworkError(message, host) {
|
|
809
|
+
const suspect = this.#networkOutageDetector.record({
|
|
810
|
+
kind: classifyErrorKind(message),
|
|
811
|
+
host,
|
|
812
|
+
at: Date.now(),
|
|
813
|
+
});
|
|
814
|
+
if (suspect) {
|
|
815
|
+
void this.#handleOutageSuspect(suspect);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
474
818
|
/**
|
|
475
819
|
* Resolve the source label of the page being scraped so sub-resources
|
|
476
820
|
* captured during its render can inherit the correct lineage label
|
|
@@ -541,6 +885,12 @@ class Crawler extends EventEmitter {
|
|
|
541
885
|
// does not inherit "host alive" claims from a prior run that may have
|
|
542
886
|
// happened on an entirely different network.
|
|
543
887
|
this.#successfulHosts.clear();
|
|
888
|
+
// Network-outage state is per-crawl too: a sliding window of errors
|
|
889
|
+
// (or a gate left closed) from a prior run on this same `Crawler`
|
|
890
|
+
// instance must not leak into a fresh session. `#networkGate.open()`
|
|
891
|
+
// is a no-op if already open.
|
|
892
|
+
this.#networkOutageDetector.reset();
|
|
893
|
+
this.#networkGate.open();
|
|
544
894
|
// external URL の追跡(target は deal の total/done から導出)
|
|
545
895
|
const externalUrls = new Set();
|
|
546
896
|
const externalDoneUrls = new Set();
|
|
@@ -557,11 +907,6 @@ class Crawler extends EventEmitter {
|
|
|
557
907
|
const concurrency = this.#options.parallels
|
|
558
908
|
? Math.max(this.#options.parallels, 1)
|
|
559
909
|
: _a.MAX_PROCESS_LENGTH;
|
|
560
|
-
// Predicted pagination state
|
|
561
|
-
const paginationState = {
|
|
562
|
-
lastPushedUrl: null,
|
|
563
|
-
lastPushedWasPredicted: false,
|
|
564
|
-
};
|
|
565
910
|
await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
|
|
566
911
|
const matchedScope = findScopeEntry(url, this.#scope, this.#options);
|
|
567
912
|
const isExternal = matchedScope === null;
|
|
@@ -588,6 +933,12 @@ class Crawler extends EventEmitter {
|
|
|
588
933
|
return Promise.all(ops).then(() => { });
|
|
589
934
|
};
|
|
590
935
|
return async () => {
|
|
936
|
+
// Pause here, not inside `fetchDestination` or deeper, so a
|
|
937
|
+
// paused worker shows as a long-running dealer task instead
|
|
938
|
+
// of requiring any change to `@d-zero/dealer` itself — a
|
|
939
|
+
// closed gate resolves the instant `#handleOutageSuspect`'s
|
|
940
|
+
// recovery probe succeeds (see `network-gate.ts`).
|
|
941
|
+
await this.#networkGate.wait();
|
|
591
942
|
// Interval delay is handled here instead of by dealer because
|
|
592
943
|
// DNS-burned hosts must skip the wait entirely. Spending the
|
|
593
944
|
// per-URL interval on a host the cache already knows is dead
|
|
@@ -610,6 +961,11 @@ class Crawler extends EventEmitter {
|
|
|
610
961
|
const markBrowserScrape = () => {
|
|
611
962
|
renderedInBrowser = true;
|
|
612
963
|
};
|
|
964
|
+
// Set by the predicted-content-duplicate check below (A-3) when it
|
|
965
|
+
// computes this page's body hash, so `#handleResult`'s dedupe-cap
|
|
966
|
+
// observation (also gated on this page's html) can reuse it instead
|
|
967
|
+
// of hashing the same html a second time.
|
|
968
|
+
let precomputedBodyHash = null;
|
|
613
969
|
try {
|
|
614
970
|
const robotsAllowed = await this.#robotsChecker.isAllowed(url);
|
|
615
971
|
if (!robotsAllowed) {
|
|
@@ -687,8 +1043,25 @@ class Crawler extends EventEmitter {
|
|
|
687
1043
|
if (destination) {
|
|
688
1044
|
const destinationUrl = parseUrl(destination, this.#options);
|
|
689
1045
|
if (destinationUrl) {
|
|
690
|
-
this
|
|
691
|
-
|
|
1046
|
+
// Gate 2: this direct enqueue does not go through
|
|
1047
|
+
// `#handleResult`'s addUrl closure (gate 1), so it needs
|
|
1048
|
+
// its own same-cluster-cap check — a JS-redirect trap
|
|
1049
|
+
// that advances a parameter via `location.replace()`
|
|
1050
|
+
// would otherwise keep re-entering the queue here.
|
|
1051
|
+
const gateShapeKey = computeShapeKey(destinationUrl.withoutHashAndAuth);
|
|
1052
|
+
const isCapped = this.#options.dedupeCap !== null &&
|
|
1053
|
+
gateShapeKey !== null &&
|
|
1054
|
+
findScopeEntry(destinationUrl, this.#scope, this.#options) !==
|
|
1055
|
+
null &&
|
|
1056
|
+
this.#dedupeCapTracker.isCapped(gateShapeKey);
|
|
1057
|
+
if (isCapped) {
|
|
1058
|
+
if (gateShapeKey)
|
|
1059
|
+
this.#recordDedupeCapRejection(gateShapeKey);
|
|
1060
|
+
}
|
|
1061
|
+
else {
|
|
1062
|
+
this.#linkList.add(destinationUrl);
|
|
1063
|
+
void enqueue(destinationUrl);
|
|
1064
|
+
}
|
|
692
1065
|
}
|
|
693
1066
|
else {
|
|
694
1067
|
// `deriveJsRedirectTarget` already canonicalises
|
|
@@ -731,6 +1104,32 @@ class Crawler extends EventEmitter {
|
|
|
731
1104
|
log(c.dim('Predicted (discarded)'));
|
|
732
1105
|
return;
|
|
733
1106
|
}
|
|
1107
|
+
// Discard a predicted URL whose rendered body is a
|
|
1108
|
+
// byte-for-byte duplicate of the previous predicted page of the
|
|
1109
|
+
// same shape, and stop generating further predictions for that
|
|
1110
|
+
// shape (checked above, in the pagination-pattern branch). This
|
|
1111
|
+
// is the always-on backstop against a site that returns 2xx for
|
|
1112
|
+
// any extrapolated token but ignores it entirely (e.g. always
|
|
1113
|
+
// serving the same "no results" template) — `shouldDiscardPredicted`
|
|
1114
|
+
// alone cannot see this, since it only inspects HTTP status.
|
|
1115
|
+
if (isPredicted &&
|
|
1116
|
+
result.type === 'success' &&
|
|
1117
|
+
result.pageData &&
|
|
1118
|
+
result.pageData.html.length > 0) {
|
|
1119
|
+
const shapeKey = computeShapeKey(url.withoutHashAndAuth);
|
|
1120
|
+
if (shapeKey) {
|
|
1121
|
+
const bodyHash = computeBodyHash(result.pageData.html);
|
|
1122
|
+
precomputedBodyHash = bodyHash;
|
|
1123
|
+
const lastBodyHash = this.#predictedShapeBodyHashes.get(shapeKey) ?? null;
|
|
1124
|
+
if (isPredictedContentDuplicate(bodyHash, lastBodyHash)) {
|
|
1125
|
+
this.#predictedShapeStopped.add(shapeKey);
|
|
1126
|
+
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
1127
|
+
log(c.dim('Predicted (content duplicate, discarded)'));
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
this.#predictedShapeBodyHashes.set(shapeKey, bodyHash);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
734
1133
|
// Count only after discard check: rendered HTML pages that
|
|
735
1134
|
// will be persisted to the archive. Launch failures bypass
|
|
736
1135
|
// this point via the catch block; discarded predicted URLs
|
|
@@ -739,9 +1138,10 @@ class Crawler extends EventEmitter {
|
|
|
739
1138
|
pagesScraped++;
|
|
740
1139
|
}
|
|
741
1140
|
log('Saving results%dots%');
|
|
742
|
-
this.#handleResult(result, url, enqueue,
|
|
1141
|
+
this.#handleResult(result, url, enqueue, concurrency, precomputedBodyHash);
|
|
743
1142
|
const parentSource = await this.#resolveParentSource(url);
|
|
744
1143
|
this.#handleResources(result.resources, parentSource);
|
|
1144
|
+
this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
|
|
745
1145
|
log(formatResultSummary(result));
|
|
746
1146
|
// Phase errors must be emitted AFTER 'page' / 'externalPage'
|
|
747
1147
|
// so the orchestrator's WriteQueue sees `setPage` before
|
|
@@ -815,6 +1215,52 @@ class Crawler extends EventEmitter {
|
|
|
815
1215
|
crawlerLog('Crawl End');
|
|
816
1216
|
void this.emit('crawlEnd', {});
|
|
817
1217
|
}
|
|
1218
|
+
/**
|
|
1219
|
+
* While {@link #networkGate} is closed, probe every
|
|
1220
|
+
* `networkOutageProbeIntervalMs` until one succeeds, then reopen the
|
|
1221
|
+
* gate and emit `networkOutageRecovered`.
|
|
1222
|
+
*
|
|
1223
|
+
* If the crawl is aborted while this loop is running, the gate is
|
|
1224
|
+
* opened anyway (so any worker stuck on `#networkGate.wait()` can
|
|
1225
|
+
* unblock and `deal()` can resolve) but `networkOutageRecovered` is NOT
|
|
1226
|
+
* emitted — an abort says nothing about whether the network actually
|
|
1227
|
+
* recovered, so the `network_outages` row is deliberately left open for
|
|
1228
|
+
* the next writer session's boot-time finalizer
|
|
1229
|
+
* (`close-stale-open-network-outages.ts`) to resolve. Either way,
|
|
1230
|
+
* {@link #onGateReopened} still runs — the cached failures are stale
|
|
1231
|
+
* regardless of why the gate reopened.
|
|
1232
|
+
* @param probeHost - The hostname to probe, chosen once by
|
|
1233
|
+
* {@link #handleOutageSuspect} and reused for every attempt in this loop.
|
|
1234
|
+
* @param startedAt - The outage's `startedAt`, forwarded to {@link #onGateReopened}.
|
|
1235
|
+
*/
|
|
1236
|
+
async #runRecoveryProbeLoop(probeHost, startedAt) {
|
|
1237
|
+
const probe = this.#options.networkProbe ?? probeNetwork;
|
|
1238
|
+
const bailIfAborted = () => {
|
|
1239
|
+
if (!this.#abortController.signal.aborted) {
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
this.#networkGate.open();
|
|
1243
|
+
this.#onGateReopened(startedAt, Date.now());
|
|
1244
|
+
return true;
|
|
1245
|
+
};
|
|
1246
|
+
if (bailIfAborted()) {
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
for (;;) {
|
|
1250
|
+
await delay(this.#options.networkOutageProbeIntervalMs);
|
|
1251
|
+
if (bailIfAborted()) {
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const recovered = await probe(probeHost);
|
|
1255
|
+
if (recovered) {
|
|
1256
|
+
const endedAt = Date.now();
|
|
1257
|
+
this.#networkGate.open();
|
|
1258
|
+
this.#onGateReopened(startedAt, endedAt);
|
|
1259
|
+
void this.emit('networkOutageRecovered', { endedAt });
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
818
1264
|
/**
|
|
819
1265
|
* Orchestrates the full scrape pipeline for a single URL.
|
|
820
1266
|
*
|
|
@@ -875,6 +1321,7 @@ class Crawler extends EventEmitter {
|
|
|
875
1321
|
type: 'success',
|
|
876
1322
|
pageData: metadataOnly ? { ...pageData, isTarget: false } : pageData,
|
|
877
1323
|
resources: [],
|
|
1324
|
+
consoleLogs: [],
|
|
878
1325
|
};
|
|
879
1326
|
}
|
|
880
1327
|
}
|
|
@@ -1002,6 +1449,7 @@ class Crawler extends EventEmitter {
|
|
|
1002
1449
|
return {
|
|
1003
1450
|
type: 'error',
|
|
1004
1451
|
resources: [],
|
|
1452
|
+
consoleLogs: [],
|
|
1005
1453
|
error: {
|
|
1006
1454
|
name: error instanceof Error ? error.name : 'Error',
|
|
1007
1455
|
message: errorMessage,
|
|
@@ -1015,6 +1463,7 @@ class Crawler extends EventEmitter {
|
|
|
1015
1463
|
return {
|
|
1016
1464
|
type: 'error',
|
|
1017
1465
|
resources: [],
|
|
1466
|
+
consoleLogs: [],
|
|
1018
1467
|
error: {
|
|
1019
1468
|
name: error instanceof Error ? error.name : 'Error',
|
|
1020
1469
|
message: errorMessage,
|
|
@@ -1062,6 +1511,7 @@ class Crawler extends EventEmitter {
|
|
|
1062
1511
|
type: 'success',
|
|
1063
1512
|
pageData: { ...titleResult, isTarget: false },
|
|
1064
1513
|
resources: [],
|
|
1514
|
+
consoleLogs: [],
|
|
1065
1515
|
};
|
|
1066
1516
|
}
|
|
1067
1517
|
catch (error) {
|
|
@@ -1072,6 +1522,7 @@ class Crawler extends EventEmitter {
|
|
|
1072
1522
|
type: 'success',
|
|
1073
1523
|
pageData: { ...headCheckResult, isTarget: false },
|
|
1074
1524
|
resources: [],
|
|
1525
|
+
consoleLogs: [],
|
|
1075
1526
|
};
|
|
1076
1527
|
}
|
|
1077
1528
|
// Non-HTML content — skip browser
|
|
@@ -1081,6 +1532,7 @@ class Crawler extends EventEmitter {
|
|
|
1081
1532
|
type: 'success',
|
|
1082
1533
|
pageData: headCheckResult,
|
|
1083
1534
|
resources: [],
|
|
1535
|
+
consoleLogs: [],
|
|
1084
1536
|
};
|
|
1085
1537
|
}
|
|
1086
1538
|
// HTML or unknown content type — launch browser with preflight result.
|
|
@@ -1266,9 +1718,11 @@ class Crawler extends EventEmitter {
|
|
|
1266
1718
|
retries: this.#options.retry,
|
|
1267
1719
|
label: 'HEAD request',
|
|
1268
1720
|
onWait: (determinedInterval, retryCount, label, error) => {
|
|
1721
|
+
this.#recordNetworkError(error.message, host);
|
|
1269
1722
|
update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
|
|
1270
1723
|
},
|
|
1271
1724
|
onGiveUp: (retryCount, error, label) => {
|
|
1725
|
+
this.#recordNetworkError(error.message, host);
|
|
1272
1726
|
// Burn the host so subsequent URLs short-circuit — but ONLY
|
|
1273
1727
|
// when this is the first time we've ever seen the host fail
|
|
1274
1728
|
// in this session. A host that responded earlier is treated
|
|
@@ -1284,6 +1738,12 @@ class Crawler extends EventEmitter {
|
|
|
1284
1738
|
successfulHosts: this.#successfulHosts,
|
|
1285
1739
|
})) {
|
|
1286
1740
|
dnsBurnedHostCache.set(host, 'dns');
|
|
1741
|
+
// Recorded so a later outage recovery can tell THIS
|
|
1742
|
+
// burn (possibly outage-caused) apart from a
|
|
1743
|
+
// preload-seeded one (a cross-session, confirmed-dead
|
|
1744
|
+
// verdict that must never be undone by an in-session
|
|
1745
|
+
// recovery) — see `evict-outage-tainted-dns-burns.ts`.
|
|
1746
|
+
dnsBurnedHostBurnTimestamps.set(host, Date.now());
|
|
1287
1747
|
}
|
|
1288
1748
|
update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
|
|
1289
1749
|
},
|
|
@@ -1369,9 +1829,15 @@ class Crawler extends EventEmitter {
|
|
|
1369
1829
|
// Verified by `scope-auth-leak.e2e.ts`: removing either piece
|
|
1370
1830
|
// causes that test to fail (without auth → main 401 hangs;
|
|
1371
1831
|
// without strip → scope cred leaks to off-scope sub-resource).
|
|
1832
|
+
//
|
|
1833
|
+
// The ExURL fields keep the WHATWG percent-encoded form, but
|
|
1834
|
+
// `page.authenticate` sends its arguments verbatim — decode
|
|
1835
|
+
// first or a password containing `[`/`]`/`{`/`}`/`=` etc.
|
|
1836
|
+
// authenticates with the wrong literal (see
|
|
1837
|
+
// `decode-auth-credential.ts`).
|
|
1372
1838
|
await page.authenticate({
|
|
1373
|
-
username: url.username
|
|
1374
|
-
password: url.password
|
|
1839
|
+
username: decodeAuthCredential(url.username),
|
|
1840
|
+
password: decodeAuthCredential(url.password),
|
|
1375
1841
|
});
|
|
1376
1842
|
// Re-parse from `withoutHashAndAuth` rather than mutating the
|
|
1377
1843
|
// re-parsed `url.href` object: ExURL pre-computes `href`,
|
|
@@ -1479,6 +1945,7 @@ class Crawler extends EventEmitter {
|
|
|
1479
1945
|
return {
|
|
1480
1946
|
type: 'error',
|
|
1481
1947
|
resources: [],
|
|
1948
|
+
consoleLogs: [],
|
|
1482
1949
|
error: {
|
|
1483
1950
|
name: error instanceof Error ? error.name : 'Error',
|
|
1484
1951
|
message: error instanceof Error ? error.message : String(error),
|