@nitpicker/crawler 0.15.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 +39 -1
- package/lib/archive/archive.js +49 -0
- package/lib/archive/create-adjunct-tables.d.ts +3 -0
- package/lib/archive/create-adjunct-tables.js +42 -0
- package/lib/archive/database.d.ts +31 -1
- package/lib/archive/database.js +42 -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/types.d.ts +13 -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/crawler.d.ts +12 -0
- package/lib/crawler/crawler.js +239 -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/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/types.d.ts +38 -0
- package/lib/crawler-orchestrator.d.ts +12 -0
- package/lib/crawler-orchestrator.js +106 -1
- package/lib/crawler.d.ts +1 -0
- package/lib/crawler.js +1 -0
- package/lib/permanent-error-kinds.d.ts +9 -4
- package/lib/permanent-error-kinds.js +10 -4
- package/lib/types.d.ts +2 -1
- package/package.json +2 -2
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
4
|
+
/**
|
|
5
|
+
* Verifies that Puppeteer can resolve an installed Chrome/Chromium executable
|
|
6
|
+
* before a crawl starts.
|
|
7
|
+
*
|
|
8
|
+
* A crawl otherwise only discovers a missing browser deep inside the
|
|
9
|
+
* per-URL scrape loop (`Crawler#_launchBrowserAndScrape`), where it surfaces
|
|
10
|
+
* as one more scrape error among many — the CLI still prints "Crawl
|
|
11
|
+
* completed" and writes an archive, so a missing Chrome (a fatal
|
|
12
|
+
* precondition, not a per-page failure) is easy to miss. Calling this once,
|
|
13
|
+
* before any archive I/O begins, turns it into an immediate, actionable
|
|
14
|
+
* failure instead.
|
|
15
|
+
* @param executablePath - Explicit override, matching
|
|
16
|
+
* {@link CrawlerOptions.executablePath}. Pass `null` (or omit) to check
|
|
17
|
+
* Puppeteer's own pinned Chrome resolution instead.
|
|
18
|
+
* @throws {Error} When the resolved executable path does not exist on disk.
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { assertChromeIsInstalled } from '@nitpicker/crawler';
|
|
22
|
+
*
|
|
23
|
+
* // Throws with install instructions before any crawl work starts.
|
|
24
|
+
* await assertChromeIsInstalled();
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export async function assertChromeIsInstalled(executablePath) {
|
|
28
|
+
if (executablePath) {
|
|
29
|
+
const execPath = path.resolve(executablePath);
|
|
30
|
+
if (existsSync(execPath)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`Executable path does not exist: ${execPath}`);
|
|
34
|
+
}
|
|
35
|
+
const puppeteer = await import('puppeteer');
|
|
36
|
+
const resolvedPath = await puppeteer.executablePath();
|
|
37
|
+
if (existsSync(resolvedPath)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const puppeteerVersion = pkg.dependencies.puppeteer;
|
|
41
|
+
throw new Error(`Chrome executable not found at: ${resolvedPath}\n` +
|
|
42
|
+
`Run \`npx puppeteer@${puppeteerVersion} browsers install chrome\` to install the Chrome build Puppeteer expects, then retry.`);
|
|
43
|
+
}
|
package/lib/crawler/crawler.d.ts
CHANGED
|
@@ -37,6 +37,18 @@ export default class Crawler extends EventEmitter<CrawlerEventTypes> {
|
|
|
37
37
|
* {@link #runDeal}.
|
|
38
38
|
*/
|
|
39
39
|
abort(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Per-shape count of anchors the dedupe-cap enqueue gates rejected after
|
|
42
|
+
* that shape capped (opt-in `--dedupe-cap`). Read by
|
|
43
|
+
* `CrawlerOrchestrator` at `crawlEnd` to finalize each
|
|
44
|
+
* `dedupe_cap_events.rejected_count` exactly once — rejections are
|
|
45
|
+
* accumulated in memory rather than written to the archive per-rejection
|
|
46
|
+
* to avoid write amplification (a capped trap can generate an unbounded
|
|
47
|
+
* number of rejected anchors).
|
|
48
|
+
* @returns A snapshot of the per-shape rejection counts. Empty when
|
|
49
|
+
* `--dedupe-cap` was not enabled or no shape has capped yet.
|
|
50
|
+
*/
|
|
51
|
+
getDedupeCapRejections(): ReadonlyMap<string, number>;
|
|
40
52
|
/**
|
|
41
53
|
* Retrieve the list of Chromium process IDs that are still running.
|
|
42
54
|
*
|
package/lib/crawler/crawler.js
CHANGED
|
@@ -9,6 +9,7 @@ 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';
|
|
@@ -16,6 +17,12 @@ import { buildRedirectEvent } from './build-redirect-event.js';
|
|
|
16
17
|
import { captureImageDomPaths } from './capture-image-dom-paths.js';
|
|
17
18
|
import { chooseProbeHost } from './choose-probe-host.js';
|
|
18
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';
|
|
19
26
|
import { derivePageSource } from './derive-page-source.js';
|
|
20
27
|
import { destinationCache } from './destination-cache.js';
|
|
21
28
|
import { detectPaginationPattern } from './detect-pagination-pattern.js';
|
|
@@ -72,6 +79,8 @@ const DEFAULT_NETWORK_OUTAGE_ERROR_THRESHOLD = 5;
|
|
|
72
79
|
const DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD = 2;
|
|
73
80
|
/** Default {@link CrawlerOptions.networkOutageProbeIntervalMs}. */
|
|
74
81
|
const DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS = 10_000;
|
|
82
|
+
/** Default {@link CrawlerOptions.dedupeMapCap}. */
|
|
83
|
+
const DEFAULT_DEDUPE_MAP_CAP = 100_000;
|
|
75
84
|
/**
|
|
76
85
|
* The core crawler engine that discovers and scrapes web pages.
|
|
77
86
|
*
|
|
@@ -86,6 +95,24 @@ const DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS = 10_000;
|
|
|
86
95
|
class Crawler extends EventEmitter {
|
|
87
96
|
/** Controller used to cancel the deal-based crawl via its AbortSignal. */
|
|
88
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;
|
|
89
116
|
/** Tracks discovered URLs, their scrape status, and deduplication. */
|
|
90
117
|
#linkList = new LinkList();
|
|
91
118
|
/**
|
|
@@ -129,6 +156,23 @@ class Crawler extends EventEmitter {
|
|
|
129
156
|
* `insertPageError`, so the FK resolution via URL always finds the row.
|
|
130
157
|
*/
|
|
131
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();
|
|
132
176
|
/** Set of resource URLs (without hash) already captured, for deduplication. */
|
|
133
177
|
#resources = new Set();
|
|
134
178
|
/** Number of HTML pages (isTarget=1) scraped in previous sessions, used to seed the progress counter on resume. */
|
|
@@ -205,12 +249,16 @@ class Crawler extends EventEmitter {
|
|
|
205
249
|
networkOutageHostThreshold: options?.networkOutageHostThreshold ?? DEFAULT_NETWORK_OUTAGE_HOST_THRESHOLD,
|
|
206
250
|
networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs ?? DEFAULT_NETWORK_OUTAGE_PROBE_INTERVAL_MS,
|
|
207
251
|
networkProbe: options?.networkProbe ?? null,
|
|
252
|
+
dedupeCap: options?.dedupeCap ?? null,
|
|
253
|
+
dedupeMapCap: options?.dedupeMapCap ?? DEFAULT_DEDUPE_MAP_CAP,
|
|
254
|
+
preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
|
|
208
255
|
};
|
|
209
256
|
this.#networkOutageDetector = new NetworkOutageDetector({
|
|
210
257
|
windowMs: this.#options.networkOutageWindowMs,
|
|
211
258
|
errorThreshold: this.#options.networkOutageErrorThreshold,
|
|
212
259
|
hostThreshold: this.#options.networkOutageHostThreshold,
|
|
213
260
|
});
|
|
261
|
+
this.#dedupeCapTracker = new DedupeCapTracker({ cap: this.#options.dedupeCap ?? 0, mapCap: this.#options.dedupeMapCap }, this.#options.preloadedStickyShapeKeys);
|
|
214
262
|
this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
|
|
215
263
|
for (const urlStr of this.#options.roots) {
|
|
216
264
|
const url = parseUrl(urlStr, this.#options);
|
|
@@ -231,6 +279,20 @@ class Crawler extends EventEmitter {
|
|
|
231
279
|
abort() {
|
|
232
280
|
this.#abortController.abort();
|
|
233
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
|
+
}
|
|
234
296
|
/**
|
|
235
297
|
* Retrieve the list of Chromium process IDs that are still running.
|
|
236
298
|
*
|
|
@@ -493,21 +555,92 @@ class Crawler extends EventEmitter {
|
|
|
493
555
|
* @param enqueue - Callback to enqueue newly discovered URLs into the dealer
|
|
494
556
|
* queue, prioritising likely-HTML URLs to the front (see {@link partitionUrlsByHtml}).
|
|
495
557
|
* Accepts a batch so a group of URLs (e.g. predicted pagination) keeps its order.
|
|
496
|
-
* @param paginationState - Mutable state for predicted pagination cascade prevention
|
|
497
|
-
* @param paginationState.lastPushedUrl
|
|
498
|
-
* @param paginationState.lastPushedWasPredicted
|
|
499
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.
|
|
500
563
|
*/
|
|
501
|
-
#handleResult(result, url, enqueue,
|
|
564
|
+
#handleResult(result, url, enqueue, concurrency, precomputedBodyHash) {
|
|
502
565
|
switch (result.type) {
|
|
503
566
|
case 'success': {
|
|
504
567
|
if (!result.pageData)
|
|
505
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
|
+
}
|
|
506
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
|
+
}
|
|
507
640
|
this.#linkList.add(newUrl, opts);
|
|
508
641
|
void enqueue(newUrl);
|
|
509
642
|
// Predicted pagination detection
|
|
510
|
-
if (!
|
|
643
|
+
if (!concurrency)
|
|
511
644
|
return;
|
|
512
645
|
// metadataOnly / external: update tracking but skip pattern detection
|
|
513
646
|
if (opts?.metadataOnly ||
|
|
@@ -521,22 +654,37 @@ class Crawler extends EventEmitter {
|
|
|
521
654
|
!paginationState.lastPushedWasPredicted) {
|
|
522
655
|
const pattern = detectPaginationPattern(paginationState.lastPushedUrl, newUrl.withoutHashAndAuth);
|
|
523
656
|
if (pattern) {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
+
}
|
|
531
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;
|
|
532
687
|
}
|
|
533
|
-
// Enqueue as one batch so ascending page order is kept
|
|
534
|
-
// at the front of the queue (see enqueue in #runDeal).
|
|
535
|
-
if (specUrls.length > 0)
|
|
536
|
-
void enqueue(...specUrls);
|
|
537
|
-
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
538
|
-
paginationState.lastPushedWasPredicted = true;
|
|
539
|
-
return;
|
|
540
688
|
}
|
|
541
689
|
}
|
|
542
690
|
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
@@ -630,6 +778,19 @@ class Crawler extends EventEmitter {
|
|
|
630
778
|
window: { startedAt, endedAt },
|
|
631
779
|
});
|
|
632
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
|
+
}
|
|
633
794
|
/**
|
|
634
795
|
* Feed one observed network-layer error into
|
|
635
796
|
* {@link #networkOutageDetector} and hand off to
|
|
@@ -746,11 +907,6 @@ class Crawler extends EventEmitter {
|
|
|
746
907
|
const concurrency = this.#options.parallels
|
|
747
908
|
? Math.max(this.#options.parallels, 1)
|
|
748
909
|
: _a.MAX_PROCESS_LENGTH;
|
|
749
|
-
// Predicted pagination state
|
|
750
|
-
const paginationState = {
|
|
751
|
-
lastPushedUrl: null,
|
|
752
|
-
lastPushedWasPredicted: false,
|
|
753
|
-
};
|
|
754
910
|
await deal(initialUrls, (url, update, _index, setLineHeader, push, unshift) => {
|
|
755
911
|
const matchedScope = findScopeEntry(url, this.#scope, this.#options);
|
|
756
912
|
const isExternal = matchedScope === null;
|
|
@@ -805,6 +961,11 @@ class Crawler extends EventEmitter {
|
|
|
805
961
|
const markBrowserScrape = () => {
|
|
806
962
|
renderedInBrowser = true;
|
|
807
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;
|
|
808
969
|
try {
|
|
809
970
|
const robotsAllowed = await this.#robotsChecker.isAllowed(url);
|
|
810
971
|
if (!robotsAllowed) {
|
|
@@ -882,8 +1043,25 @@ class Crawler extends EventEmitter {
|
|
|
882
1043
|
if (destination) {
|
|
883
1044
|
const destinationUrl = parseUrl(destination, this.#options);
|
|
884
1045
|
if (destinationUrl) {
|
|
885
|
-
this
|
|
886
|
-
|
|
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
|
+
}
|
|
887
1065
|
}
|
|
888
1066
|
else {
|
|
889
1067
|
// `deriveJsRedirectTarget` already canonicalises
|
|
@@ -926,6 +1104,32 @@ class Crawler extends EventEmitter {
|
|
|
926
1104
|
log(c.dim('Predicted (discarded)'));
|
|
927
1105
|
return;
|
|
928
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
|
+
}
|
|
929
1133
|
// Count only after discard check: rendered HTML pages that
|
|
930
1134
|
// will be persisted to the archive. Launch failures bypass
|
|
931
1135
|
// this point via the catch block; discarded predicted URLs
|
|
@@ -934,7 +1138,7 @@ class Crawler extends EventEmitter {
|
|
|
934
1138
|
pagesScraped++;
|
|
935
1139
|
}
|
|
936
1140
|
log('Saving results%dots%');
|
|
937
|
-
this.#handleResult(result, url, enqueue,
|
|
1141
|
+
this.#handleResult(result, url, enqueue, concurrency, precomputedBodyHash);
|
|
938
1142
|
const parentSource = await this.#resolveParentSource(url);
|
|
939
1143
|
this.#handleResources(result.resources, parentSource);
|
|
940
1144
|
this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
|
|
@@ -1625,9 +1829,15 @@ class Crawler extends EventEmitter {
|
|
|
1625
1829
|
// Verified by `scope-auth-leak.e2e.ts`: removing either piece
|
|
1626
1830
|
// causes that test to fail (without auth → main 401 hangs;
|
|
1627
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`).
|
|
1628
1838
|
await page.authenticate({
|
|
1629
|
-
username: url.username
|
|
1630
|
-
password: url.password
|
|
1839
|
+
username: decodeAuthCredential(url.username),
|
|
1840
|
+
password: decodeAuthCredential(url.password),
|
|
1631
1841
|
});
|
|
1632
1842
|
// Re-parse from `withoutHashAndAuth` rather than mutating the
|
|
1633
1843
|
// re-parsed `url.href` object: ExURL pre-computes `href`,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode a percent-encoded userinfo credential (username or password)
|
|
3
|
+
* parsed out of a URL into the literal string the origin server expects.
|
|
4
|
+
*
|
|
5
|
+
* The WHATWG URL parser percent-encodes characters outside the userinfo
|
|
6
|
+
* set (`[`, `]`, `{`, `}`, `=`, `:`, `@`, non-ASCII, …) and keeps the
|
|
7
|
+
* `username` / `password` fields in that encoded form. Consumers that
|
|
8
|
+
* forward credentials out-of-band — `page.authenticate()` for the
|
|
9
|
+
* browser session, the `auth` request option for the HEAD pre-flight —
|
|
10
|
+
* must send the decoded literal, or any credential containing such a
|
|
11
|
+
* character silently authenticates with the wrong string and the server
|
|
12
|
+
* answers 401. Node's own `urlToOptions` applies the same
|
|
13
|
+
* `decodeURIComponent` step for `http.request(url)`.
|
|
14
|
+
*
|
|
15
|
+
* A malformed sequence (a literal `%` the parser left untouched, e.g. a
|
|
16
|
+
* user typing `pa%ssword` without encoding it) would make
|
|
17
|
+
* `decodeURIComponent` throw, so the raw value is returned as a
|
|
18
|
+
* fallback — identical to the pre-decode behavior for that input.
|
|
19
|
+
* @param value - The raw (possibly percent-encoded) credential field, or
|
|
20
|
+
* `null` when the URL carries no userinfo.
|
|
21
|
+
* @returns The decoded credential, or an empty string for `null` input.
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const url = parseUrl('https://user:pa%5Dss%7Bword%3D@example.com/')!;
|
|
25
|
+
* decodeAuthCredential(url.password); // => 'pa]ss{word='
|
|
26
|
+
* decodeAuthCredential(null); // => ''
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare function decodeAuthCredential(value: string | null): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode a percent-encoded userinfo credential (username or password)
|
|
3
|
+
* parsed out of a URL into the literal string the origin server expects.
|
|
4
|
+
*
|
|
5
|
+
* The WHATWG URL parser percent-encodes characters outside the userinfo
|
|
6
|
+
* set (`[`, `]`, `{`, `}`, `=`, `:`, `@`, non-ASCII, …) and keeps the
|
|
7
|
+
* `username` / `password` fields in that encoded form. Consumers that
|
|
8
|
+
* forward credentials out-of-band — `page.authenticate()` for the
|
|
9
|
+
* browser session, the `auth` request option for the HEAD pre-flight —
|
|
10
|
+
* must send the decoded literal, or any credential containing such a
|
|
11
|
+
* character silently authenticates with the wrong string and the server
|
|
12
|
+
* answers 401. Node's own `urlToOptions` applies the same
|
|
13
|
+
* `decodeURIComponent` step for `http.request(url)`.
|
|
14
|
+
*
|
|
15
|
+
* A malformed sequence (a literal `%` the parser left untouched, e.g. a
|
|
16
|
+
* user typing `pa%ssword` without encoding it) would make
|
|
17
|
+
* `decodeURIComponent` throw, so the raw value is returned as a
|
|
18
|
+
* fallback — identical to the pre-decode behavior for that input.
|
|
19
|
+
* @param value - The raw (possibly percent-encoded) credential field, or
|
|
20
|
+
* `null` when the URL carries no userinfo.
|
|
21
|
+
* @returns The decoded credential, or an empty string for `null` input.
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const url = parseUrl('https://user:pa%5Dss%7Bword%3D@example.com/')!;
|
|
25
|
+
* decodeAuthCredential(url.password); // => 'pa]ss{word='
|
|
26
|
+
* decodeAuthCredential(null); // => ''
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function decodeAuthCredential(value) {
|
|
30
|
+
if (!value) {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return decodeURIComponent(value);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Meta } from '@d-zero/beholder';
|
|
2
|
+
/**
|
|
3
|
+
* Computes a signature of the four meta fields most likely to be identical
|
|
4
|
+
* across a self-generating pager/cluster trap: `<title>`, `<meta
|
|
5
|
+
* name="description">`, `og:title`, `og:url`.
|
|
6
|
+
*
|
|
7
|
+
* Returns `null` when the page carries no useful signal — an empty `<title>`
|
|
8
|
+
* with `og.title` and `og.url` both absent (the two Open Graph fields this
|
|
9
|
+
* signature actually hashes) is typical of a non-content page (e.g. a bare
|
|
10
|
+
* external redirect stub), and counting it would let a `null`-ish signature
|
|
11
|
+
* accidentally look like a majority match against unrelated pages.
|
|
12
|
+
*
|
|
13
|
+
* Uses the four fields as-written (no URL absolutisation for `og.url`): all
|
|
14
|
+
* pages on one site are rendered by the same template, so `og:url` is either
|
|
15
|
+
* consistently relative or consistently absolute across a trap's pages, and
|
|
16
|
+
* hashing the raw value keeps this computation a pure string op with no need
|
|
17
|
+
* for a page-URL argument. Absolutisation only matters when *comparing*
|
|
18
|
+
* `og:url` against the page's own URL (a different signal, computed
|
|
19
|
+
* separately at cap-scoring time).
|
|
20
|
+
* @param meta - Beholder-derived metadata for the page.
|
|
21
|
+
* @returns A hex-encoded SHA-1 signature, or `null` if the page has no title
|
|
22
|
+
* and no Open Graph tags.
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* computeMetaSignature({ title: 'お知らせ', og: { url: '/news' } } as Meta);
|
|
26
|
+
* // => a stable hex digest
|
|
27
|
+
* computeMetaSignature({ title: '', og: {} } as Meta); // => null
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function computeMetaSignature(meta: Meta): string | null;
|
|
Binary file
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computes a URL "shape" key: the host plus path/query with every path
|
|
3
|
+
* segment that contains a digit collapsed to a fixed placeholder, and every
|
|
4
|
+
* query value (regardless of content) collapsed to a fixed placeholder.
|
|
5
|
+
*
|
|
6
|
+
* This absorbs both the "numeric pager" trap shape (`/news/date/2024/` and
|
|
7
|
+
* `/news/date/1.5e+32/` collapse to the same key) and the "query trap" shape
|
|
8
|
+
* (`?page=1` / `?page=2` / `?session=ab12cd` all collapse to the same key),
|
|
9
|
+
* without needing two separate `parentPath` definitions the way issue
|
|
10
|
+
* #208's original proposal did.
|
|
11
|
+
*
|
|
12
|
+
* Uses `../decompose-url.ts` (the pagination-detection one) — NOT
|
|
13
|
+
* `../../archive/populate-ref-tables/decompose-url.ts`, an unrelated same-named
|
|
14
|
+
* module with a different `DecomposedUrl` shape used for ref-table population.
|
|
15
|
+
*
|
|
16
|
+
* The masking rule here is the deliberate inverse of
|
|
17
|
+
* `../../archive/body-hash/mask-dynamic-ids.ts`: that module leaves
|
|
18
|
+
* pure-digit tokens untouched (they are more likely stable content than a
|
|
19
|
+
* dynamic id) and only masks mixed alphanumeric runs. A shape key needs the
|
|
20
|
+
* opposite: ANY digit inside a path segment marks it as "probably a
|
|
21
|
+
* pagination/date/id token", so the whole segment is collapsed. Do not share
|
|
22
|
+
* masking logic between the two — they classify the same kind of text for
|
|
23
|
+
* opposite purposes.
|
|
24
|
+
* @param url - A URL string (protocol-agnostic `//host/...` or full
|
|
25
|
+
* `https://host/...`), typically `ExURL.withoutHashAndAuth`.
|
|
26
|
+
* @returns The shape key, or `null` if `url` cannot be decomposed.
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* computeShapeKey('//example.com/news/date/2024/');
|
|
30
|
+
* // => 'example.com/news/date/{n}/'
|
|
31
|
+
* computeShapeKey('//example.com/news/date/1.5e+32/');
|
|
32
|
+
* // => 'example.com/news/date/{n}/' — same shape
|
|
33
|
+
* computeShapeKey('//example.com/list?page=1');
|
|
34
|
+
* // => 'example.com/list?page={v}'
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export declare function computeShapeKey(url: string): string | null;
|