@nitpicker/crawler 0.15.0 → 0.17.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.
Files changed (69) hide show
  1. package/lib/archive/archive.d.ts +52 -1
  2. package/lib/archive/archive.js +68 -0
  3. package/lib/archive/create-adjunct-tables.d.ts +3 -0
  4. package/lib/archive/create-adjunct-tables.js +43 -0
  5. package/lib/archive/create-entity-tables.js +10 -0
  6. package/lib/archive/database.d.ts +40 -1
  7. package/lib/archive/database.js +54 -0
  8. package/lib/archive/db-ops/config/get-config.js +1 -0
  9. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
  10. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
  11. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
  12. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
  13. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
  14. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
  15. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
  16. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
  17. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  18. package/lib/archive/db-ops/lifecycle/init.d.ts +4 -2
  19. package/lib/archive/db-ops/lifecycle/init.js +12 -2
  20. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.d.ts +38 -0
  21. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +59 -0
  22. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +5 -6
  23. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +17 -41
  24. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.d.ts +42 -0
  25. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.js +56 -0
  26. package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +41 -0
  27. package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +51 -0
  28. package/lib/archive/migrate-entity-tables.d.ts +10 -0
  29. package/lib/archive/migrate-entity-tables.js +10 -0
  30. package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +20 -0
  31. package/lib/archive/migrate-inventory-runs-exclude-skipped.js +33 -0
  32. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +7 -0
  33. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +9 -0
  34. package/lib/archive/types.d.ts +16 -0
  35. package/lib/classify-error-kind.d.ts +1 -0
  36. package/lib/classify-error-kind.js +14 -0
  37. package/lib/crawler/assert-chrome-installed.d.ts +24 -0
  38. package/lib/crawler/assert-chrome-installed.js +43 -0
  39. package/lib/crawler/crawler.d.ts +12 -0
  40. package/lib/crawler/crawler.js +239 -29
  41. package/lib/crawler/decode-auth-credential.d.ts +29 -0
  42. package/lib/crawler/decode-auth-credential.js +39 -0
  43. package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
  44. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  45. package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
  46. package/lib/crawler/dedupe/compute-shape-key.js +56 -0
  47. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
  48. package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
  49. package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
  50. package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
  51. package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
  52. package/lib/crawler/dedupe/is-shape-capped.js +12 -0
  53. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
  54. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
  55. package/lib/crawler/dedupe/types.d.ts +42 -0
  56. package/lib/crawler/dedupe/types.js +1 -0
  57. package/lib/crawler/fetch-destination.js +14 -2
  58. package/lib/crawler/generate-predicted-urls.d.ts +12 -0
  59. package/lib/crawler/generate-predicted-urls.js +33 -2
  60. package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
  61. package/lib/crawler/types.d.ts +38 -0
  62. package/lib/crawler-orchestrator.d.ts +39 -10
  63. package/lib/crawler-orchestrator.js +187 -23
  64. package/lib/crawler.d.ts +2 -0
  65. package/lib/crawler.js +2 -0
  66. package/lib/permanent-error-kinds.d.ts +9 -4
  67. package/lib/permanent-error-kinds.js +10 -4
  68. package/lib/types.d.ts +4 -1
  69. package/package.json +3 -3
@@ -121,6 +121,7 @@ export type PageSource = 'crawled' | 'inventory-seed' | 'inventory-discovered';
121
121
  * new_pages: 1234,
122
122
  * new_resources: 56,
123
123
  * scope_skipped: 7,
124
+ * exclude_skipped: 3,
124
125
  * });
125
126
  */
126
127
  export interface InventoryRunMeta {
@@ -138,6 +139,8 @@ export interface InventoryRunMeta {
138
139
  new_resources?: number | null;
139
140
  /** Number of input URLs dropped because they fell outside the archived scope. */
140
141
  scope_skipped?: number | null;
142
+ /** Number of novel in-scope input URLs recorded as terminal skipped pages (`is_skipped=1`, `skip_reason='excluded'`) instead of being imported, because they matched the effective `excludes` / `excludeUrls` config. Pure audit output like every other count on this row — written once per run, read back only by `listInventoryRuns` display surfaces, never consumed by any runtime decision. `null` on rows written before the column existed (those runs predate ingestion-side exclusion — their excluded URLs were imported as real pages/resources, not counted). */
143
+ exclude_skipped?: number | null;
141
144
  /** Number of source-file lines dropped by the CLI for failing URL validation, before this row's `total_lines` was counted. `null` for programmatic callers that built the URL list in-memory (no source file to have invalid lines). */
142
145
  invalid_skipped?: number | null;
143
146
  /** Free-form text for backfill annotations or operator notes. */
@@ -186,6 +189,19 @@ export interface InsertNetworkOutageParams {
186
189
  triggerErrorCount: number;
187
190
  triggerHostCount: number;
188
191
  }
192
+ /**
193
+ * Fields required to record a newly-capped URL shape via
194
+ * `Database.insertDedupeCapEvent`. camelCase, mapped to snake_case columns
195
+ * on write — same convention as {@link InsertNetworkOutageParams}.
196
+ */
197
+ export interface InsertDedupeCapEventParams {
198
+ shapeKey: string;
199
+ sampleUrl: string;
200
+ bodyHash: Buffer;
201
+ effectiveThreshold: number;
202
+ observedCount: number;
203
+ detectedAt: number;
204
+ }
189
205
  /**
190
206
  * Filter type for querying pages from the database.
191
207
  *
@@ -14,6 +14,7 @@ import type { ErrorKind } from './types.js';
14
14
  * classifyErrorKind('getaddrinfo ENOTFOUND www.example.com'); // 'dns'
15
15
  * classifyErrorKind('gave up after 3 retries — Race 180,000ms'); // 'timeout'
16
16
  * classifyErrorKind('Protocol error (Page.reload): Target closed'); // 'protocol'
17
+ * classifyErrorKind('Maximum number of redirects exceeded'); // 'redirect-loop'
17
18
  * ```
18
19
  */
19
20
  export declare function classifyErrorKind(message: string): ErrorKind;
@@ -53,6 +53,19 @@ const MATCHERS = [
53
53
  kind: 'parse-error',
54
54
  pattern: /Parse Error|Expected HTTP\/|Unexpected end of stream/i,
55
55
  },
56
+ // `follow-redirects` (pinned at 1.16.0, `fetch-destination.ts`'s HEAD/GET
57
+ // pre-flight) throws exactly "Maximum number of redirects exceeded" —
58
+ // no cause token, no URL — when a chain never terminates within its
59
+ // `maxRedirects` budget. `ERR_TOO_MANY_REDIRECTS` is the equivalent
60
+ // Chromium net-error code, covering the same symptom surfaced through a
61
+ // puppeteer navigation instead of the Node HTTP client. Both mean the
62
+ // SAME thing: the site's own redirect configuration loops and will loop
63
+ // again on any future fetch, which is why this is deterministic (not a
64
+ // transient network condition) — see `PERMANENT_ERROR_KINDS`.
65
+ {
66
+ kind: 'redirect-loop',
67
+ pattern: /Maximum number of redirects exceeded|ERR_TOO_MANY_REDIRECTS/i,
68
+ },
56
69
  // `client-blocked` covers Chromium's ERR_BLOCKED_* family — the browser
57
70
  // actively decided to reject the request (ad/tracker heuristics, CSP,
58
71
  // CORB / ORB, administrator block list, fingerprinting protection,
@@ -110,6 +123,7 @@ const MATCHERS = [
110
123
  * classifyErrorKind('getaddrinfo ENOTFOUND www.example.com'); // 'dns'
111
124
  * classifyErrorKind('gave up after 3 retries — Race 180,000ms'); // 'timeout'
112
125
  * classifyErrorKind('Protocol error (Page.reload): Target closed'); // 'protocol'
126
+ * classifyErrorKind('Maximum number of redirects exceeded'); // 'redirect-loop'
113
127
  * ```
114
128
  */
115
129
  export function classifyErrorKind(message) {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Verifies that Puppeteer can resolve an installed Chrome/Chromium executable
3
+ * before a crawl starts.
4
+ *
5
+ * A crawl otherwise only discovers a missing browser deep inside the
6
+ * per-URL scrape loop (`Crawler#_launchBrowserAndScrape`), where it surfaces
7
+ * as one more scrape error among many — the CLI still prints "Crawl
8
+ * completed" and writes an archive, so a missing Chrome (a fatal
9
+ * precondition, not a per-page failure) is easy to miss. Calling this once,
10
+ * before any archive I/O begins, turns it into an immediate, actionable
11
+ * failure instead.
12
+ * @param executablePath - Explicit override, matching
13
+ * {@link CrawlerOptions.executablePath}. Pass `null` (or omit) to check
14
+ * Puppeteer's own pinned Chrome resolution instead.
15
+ * @throws {Error} When the resolved executable path does not exist on disk.
16
+ * @example
17
+ * ```ts
18
+ * import { assertChromeIsInstalled } from '@nitpicker/crawler';
19
+ *
20
+ * // Throws with install instructions before any crawl work starts.
21
+ * await assertChromeIsInstalled();
22
+ * ```
23
+ */
24
+ export declare function assertChromeIsInstalled(executablePath?: string | null): Promise<void>;
@@ -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
+ }
@@ -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
  *
@@ -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, paginationState, concurrency) {
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 (!paginationState || !concurrency)
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
- const urls = generatePredictedUrls(pattern, newUrl.withoutHashAndAuth, concurrency);
525
- const specUrls = [];
526
- for (const specUrlStr of urls) {
527
- const specUrl = parseUrl(specUrlStr, this.#options);
528
- if (specUrl) {
529
- this.#linkList.add(specUrl, { predicted: true });
530
- specUrls.push(specUrl);
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.#linkList.add(destinationUrl);
886
- void enqueue(destinationUrl);
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, paginationState, concurrency);
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
+ }