@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.
Files changed (155) hide show
  1. package/lib/archive/archive.d.ts +136 -10
  2. package/lib/archive/archive.js +147 -9
  3. package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
  4. package/lib/archive/body-hash/compute-body-hash.js +31 -0
  5. package/lib/archive/body-hash/extract-body.d.ts +18 -0
  6. package/lib/archive/body-hash/extract-body.js +29 -0
  7. package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
  8. package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
  9. package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
  10. package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
  11. package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
  12. package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
  13. package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
  14. package/lib/archive/cache/clear-archive-cache-root.js +32 -0
  15. package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
  16. package/lib/archive/cache/list-archive-cache-entries.js +125 -0
  17. package/lib/archive/cache/path-exists.d.ts +9 -0
  18. package/lib/archive/cache/path-exists.js +18 -0
  19. package/lib/archive/cache/types.d.ts +33 -0
  20. package/lib/archive/cache/types.js +1 -0
  21. package/lib/archive/create-adjunct-tables.d.ts +14 -0
  22. package/lib/archive/create-adjunct-tables.js +169 -0
  23. package/lib/archive/create-entity-tables.js +21 -1
  24. package/lib/archive/database.d.ts +67 -2
  25. package/lib/archive/database.js +92 -3
  26. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
  27. package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
  28. package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
  29. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
  30. package/lib/archive/db-ops/_shared/types.d.ts +2 -0
  31. package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
  32. package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
  33. package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
  34. package/lib/archive/db-ops/analysis/types.d.ts +61 -0
  35. package/lib/archive/db-ops/analysis/types.js +1 -0
  36. package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
  37. package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
  38. package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
  39. package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
  40. package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
  41. package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
  42. package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
  43. package/lib/archive/db-ops/console-logs/types.js +1 -0
  44. package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
  45. package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
  46. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
  47. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
  48. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
  49. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
  50. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
  51. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
  52. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
  53. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
  54. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  55. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  56. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  57. package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
  58. package/lib/archive/db-ops/lifecycle/init.js +21 -2
  59. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  60. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  61. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  62. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  63. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  64. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  65. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  66. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  67. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  68. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  69. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
  70. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
  71. package/lib/archive/db-ops/pages/write/update-page.js +12 -0
  72. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  73. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  74. package/lib/archive/filesystem/output-binary.js +17 -0
  75. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  76. package/lib/archive/get-failed-page-messages.js +29 -15
  77. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  78. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  79. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  80. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  81. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  82. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  83. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  84. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  85. package/lib/archive/page.d.ts +1 -1
  86. package/lib/archive/types.d.ts +60 -2
  87. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  88. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  89. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  90. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  91. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  92. package/lib/archive/url-alias/format-alias-key.js +14 -0
  93. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  94. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  95. package/lib/archive/url-alias/types.d.ts +7 -0
  96. package/lib/archive/url-alias/types.js +1 -0
  97. package/lib/classify-error-kind.d.ts +1 -0
  98. package/lib/classify-error-kind.js +14 -0
  99. package/lib/crawler/assert-chrome-installed.d.ts +24 -0
  100. package/lib/crawler/assert-chrome-installed.js +43 -0
  101. package/lib/crawler/choose-probe-host.d.ts +24 -0
  102. package/lib/crawler/choose-probe-host.js +38 -0
  103. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  104. package/lib/crawler/crawler.d.ts +12 -0
  105. package/lib/crawler/crawler.js +496 -29
  106. package/lib/crawler/decode-auth-credential.d.ts +29 -0
  107. package/lib/crawler/decode-auth-credential.js +39 -0
  108. package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
  109. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  110. package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
  111. package/lib/crawler/dedupe/compute-shape-key.js +56 -0
  112. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
  113. package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
  114. package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
  115. package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
  116. package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
  117. package/lib/crawler/dedupe/is-shape-capped.js +12 -0
  118. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
  119. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
  120. package/lib/crawler/dedupe/types.d.ts +42 -0
  121. package/lib/crawler/dedupe/types.js +1 -0
  122. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  123. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  124. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  125. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  126. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  127. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  128. package/lib/crawler/fetch-destination.js +14 -2
  129. package/lib/crawler/generate-predicted-urls.d.ts +12 -0
  130. package/lib/crawler/generate-predicted-urls.js +33 -2
  131. package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
  132. package/lib/crawler/network-gate.d.ts +49 -0
  133. package/lib/crawler/network-gate.js +78 -0
  134. package/lib/crawler/network-outage-detector.d.ts +51 -0
  135. package/lib/crawler/network-outage-detector.js +81 -0
  136. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  137. package/lib/crawler/network-outage-summary-counter.js +18 -0
  138. package/lib/crawler/probe-network.d.ts +26 -0
  139. package/lib/crawler/probe-network.js +21 -0
  140. package/lib/crawler/types.d.ts +185 -1
  141. package/lib/crawler-orchestrator.d.ts +74 -18
  142. package/lib/crawler-orchestrator.js +267 -27
  143. package/lib/crawler.d.ts +22 -0
  144. package/lib/crawler.js +18 -0
  145. package/lib/is-within-outage-window.d.ts +49 -0
  146. package/lib/is-within-outage-window.js +33 -0
  147. package/lib/network-related-error-kinds.d.ts +23 -0
  148. package/lib/network-related-error-kinds.js +28 -0
  149. package/lib/permanent-error-kinds.d.ts +9 -4
  150. package/lib/permanent-error-kinds.js +10 -4
  151. package/lib/types.d.ts +11 -2
  152. package/lib/utils/array/each-splitted.d.ts +1 -1
  153. package/lib/utils/compute-file-sha256.d.ts +17 -17
  154. package/lib/utils/compute-file-sha256.js +18 -47
  155. package/package.json +3 -3
@@ -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;
@@ -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;
@@ -0,0 +1,56 @@
1
+ import { decomposeUrl } from '../decompose-url.js';
2
+ const DIGIT_CONTAINING_SEGMENT_PATTERN = /\d/;
3
+ const SEGMENT_PLACEHOLDER = '{n}';
4
+ const VALUE_PLACEHOLDER = '{v}';
5
+ /**
6
+ * Computes a URL "shape" key: the host plus path/query with every path
7
+ * segment that contains a digit collapsed to a fixed placeholder, and every
8
+ * query value (regardless of content) collapsed to a fixed placeholder.
9
+ *
10
+ * This absorbs both the "numeric pager" trap shape (`/news/date/2024/` and
11
+ * `/news/date/1.5e+32/` collapse to the same key) and the "query trap" shape
12
+ * (`?page=1` / `?page=2` / `?session=ab12cd` all collapse to the same key),
13
+ * without needing two separate `parentPath` definitions the way issue
14
+ * #208's original proposal did.
15
+ *
16
+ * Uses `../decompose-url.ts` (the pagination-detection one) — NOT
17
+ * `../../archive/populate-ref-tables/decompose-url.ts`, an unrelated same-named
18
+ * module with a different `DecomposedUrl` shape used for ref-table population.
19
+ *
20
+ * The masking rule here is the deliberate inverse of
21
+ * `../../archive/body-hash/mask-dynamic-ids.ts`: that module leaves
22
+ * pure-digit tokens untouched (they are more likely stable content than a
23
+ * dynamic id) and only masks mixed alphanumeric runs. A shape key needs the
24
+ * opposite: ANY digit inside a path segment marks it as "probably a
25
+ * pagination/date/id token", so the whole segment is collapsed. Do not share
26
+ * masking logic between the two — they classify the same kind of text for
27
+ * opposite purposes.
28
+ * @param url - A URL string (protocol-agnostic `//host/...` or full
29
+ * `https://host/...`), typically `ExURL.withoutHashAndAuth`.
30
+ * @returns The shape key, or `null` if `url` cannot be decomposed.
31
+ * @example
32
+ * ```ts
33
+ * computeShapeKey('//example.com/news/date/2024/');
34
+ * // => 'example.com/news/date/{n}/'
35
+ * computeShapeKey('//example.com/news/date/1.5e+32/');
36
+ * // => 'example.com/news/date/{n}/' — same shape
37
+ * computeShapeKey('//example.com/list?page=1');
38
+ * // => 'example.com/list?page={v}'
39
+ * ```
40
+ */
41
+ export function computeShapeKey(url) {
42
+ const decomposed = decomposeUrl(url);
43
+ if (!decomposed)
44
+ return null;
45
+ const { host, pathSegments, queryKeys } = decomposed;
46
+ const shapedSegments = pathSegments.map((segment) => DIGIT_CONTAINING_SEGMENT_PATTERN.test(segment) ? SEGMENT_PLACEHOLDER : segment);
47
+ let key = host;
48
+ if (shapedSegments.length > 0) {
49
+ key += `/${shapedSegments.join('/')}`;
50
+ }
51
+ if (queryKeys.length > 0) {
52
+ const pairs = queryKeys.map((k) => `${k}=${VALUE_PLACEHOLDER}`);
53
+ key += `?${pairs.join('&')}`;
54
+ }
55
+ return key;
56
+ }
@@ -0,0 +1,84 @@
1
+ import type { DedupeCapEvent, DedupeCapObservation, DedupeCapOptions } from './types.js';
2
+ /**
3
+ * Tracks, per URL shape, whether the crawl has run into a same-metadata
4
+ * cluster trap (a pager/query-parameter trap the site keeps serving 2xx
5
+ * for), and confirms it via a Misra-Gries majority-vote counter rather than
6
+ * a plain observation count.
7
+ *
8
+ * **Why Misra-Gries (one slot per shape) instead of a multi-layer memory
9
+ * design (issue #208's original proposal)**: a single `{ metaSig, count }`
10
+ * slot per shape can never overcount — `count` is a lower bound on the true
11
+ * number of matching observations, so false-positive cap firing is
12
+ * structurally impossible regardless of how many unrelated legit pages
13
+ * share a shape (e.g. `/product/{id}` with thousands of genuinely distinct
14
+ * pages). This makes most of that original proposal's eviction machinery
15
+ * unnecessary: age-based eviction and "parent-path bucket completion"
16
+ * eviction both exist there only to bound memory for a naive
17
+ * `Map<sig, count>`, which this design never needs since it holds at most
18
+ * one slot per *shape* (not per signature-per-shape). Only "cap-reached
19
+ * sticky migration" (`#sticky`) and "hard map cap with LRU eviction"
20
+ * (`mapCap`) remain relevant here.
21
+ *
22
+ * **Known limitation (accepted, not fixed)**: Misra-Gries (k=1) can only
23
+ * detect a *strict majority* signature. If a trap alternates between two
24
+ * near-equally-frequent `metaSig` values for the same shape (e.g. an
25
+ * even/odd-year template split), `count` oscillates near zero and the cap
26
+ * never fires. A total-observation-count backstop was considered and
27
+ * rejected: it would misfire on legitimate large sections (e.g. a
28
+ * `/product/{id}` catalogue with thousands of distinct, correctly-unique
29
+ * pages sharing one shape). Because arrival order under concurrent
30
+ * crawling is non-deterministic, whether this alternating-signature case
31
+ * fires is itself non-deterministic — test fixtures for this tracker use a
32
+ * single dominant `metaSig` per shape to keep results deterministic.
33
+ *
34
+ * **Rejected alternative — top-K "space-saving" per shape**: keeping the
35
+ * top K=4 `{metaSig, count}` candidates per shape (instead of one) was
36
+ * considered so a shape could distinguish more than one competing
37
+ * signature. Rejected because eviction semantics have no safe default: if
38
+ * an evicted candidate's count is inherited by its replacement (the
39
+ * textbook space-saving guarantee), churn through a large legitimate
40
+ * section (e.g. thousands of distinct `/product/{id}` pages sharing one
41
+ * shape) inflates an unrelated candidate's inherited count and can
42
+ * false-positive cap it; if not inherited, a genuine trap can be evicted
43
+ * before it accumulates enough count, producing a false negative. A single
44
+ * majority-vote slot per shape has neither failure mode.
45
+ *
46
+ * **Rejected alternative — streak counting** (increment on a match with the
47
+ * immediately preceding observation, reset to zero otherwise): fails the
48
+ * same way under concurrent crawling as the alternating-signature case
49
+ * above — a trap and an unrelated same-shape legit page interleaving resets
50
+ * the streak before it can reach the cap.
51
+ * @see {@link https://en.wikipedia.org/wiki/Boyer%E2%80%93Moore_majority_vote_algorithm} for the underlying algorithm (Misra-Gries generalises it to top-K; this uses K=1).
52
+ */
53
+ export default class DedupeCapTracker {
54
+ #private;
55
+ /** Number of distinct shapes currently held in the (non-sticky) state map. Exposed for the `mapCap` bound assertion in tests. */
56
+ get size(): number;
57
+ /** Number of shapes confirmed capped (sticky) so far. */
58
+ get stickyCount(): number;
59
+ /**
60
+ * @param options - `--dedupe-cap` / `--dedupe-map-cap` thresholds.
61
+ * @param preloadedSticky - Shape keys already confirmed capped in a prior
62
+ * session (from `dedupe_cap_events.shape_key`), seeded so `--resume` /
63
+ * `--append` / `--retry-failed` / `--inventory` do not re-admit a trap
64
+ * this crawl already paid the cost of discovering once.
65
+ */
66
+ constructor(options: DedupeCapOptions, preloadedSticky?: Iterable<string>);
67
+ /**
68
+ * Whether a shape has already been confirmed capped. Callers gate
69
+ * enqueue decisions on this before ever calling {@link observe}.
70
+ * @param shapeKey
71
+ */
72
+ isCapped(shapeKey: string): boolean;
73
+ /**
74
+ * Registers one page's observation and applies the Misra-Gries
75
+ * majority-vote update for its shape.
76
+ * @param observation - See {@link DedupeCapObservation}. Callers must not
77
+ * call this for a shape that is already capped (check {@link isCapped}
78
+ * first) — doing so is a no-op returning `null`, since the shape's slot
79
+ * was already dropped from `#state` when it capped.
80
+ * @returns A {@link DedupeCapEvent} the instant this observation causes
81
+ * the shape to newly cross its effective threshold, otherwise `null`.
82
+ */
83
+ observe(observation: DedupeCapObservation): DedupeCapEvent | null;
84
+ }
@@ -0,0 +1,185 @@
1
+ import { isShapeCapped } from './is-shape-capped.js';
2
+ /**
3
+ * Tracks, per URL shape, whether the crawl has run into a same-metadata
4
+ * cluster trap (a pager/query-parameter trap the site keeps serving 2xx
5
+ * for), and confirms it via a Misra-Gries majority-vote counter rather than
6
+ * a plain observation count.
7
+ *
8
+ * **Why Misra-Gries (one slot per shape) instead of a multi-layer memory
9
+ * design (issue #208's original proposal)**: a single `{ metaSig, count }`
10
+ * slot per shape can never overcount — `count` is a lower bound on the true
11
+ * number of matching observations, so false-positive cap firing is
12
+ * structurally impossible regardless of how many unrelated legit pages
13
+ * share a shape (e.g. `/product/{id}` with thousands of genuinely distinct
14
+ * pages). This makes most of that original proposal's eviction machinery
15
+ * unnecessary: age-based eviction and "parent-path bucket completion"
16
+ * eviction both exist there only to bound memory for a naive
17
+ * `Map<sig, count>`, which this design never needs since it holds at most
18
+ * one slot per *shape* (not per signature-per-shape). Only "cap-reached
19
+ * sticky migration" (`#sticky`) and "hard map cap with LRU eviction"
20
+ * (`mapCap`) remain relevant here.
21
+ *
22
+ * **Known limitation (accepted, not fixed)**: Misra-Gries (k=1) can only
23
+ * detect a *strict majority* signature. If a trap alternates between two
24
+ * near-equally-frequent `metaSig` values for the same shape (e.g. an
25
+ * even/odd-year template split), `count` oscillates near zero and the cap
26
+ * never fires. A total-observation-count backstop was considered and
27
+ * rejected: it would misfire on legitimate large sections (e.g. a
28
+ * `/product/{id}` catalogue with thousands of distinct, correctly-unique
29
+ * pages sharing one shape). Because arrival order under concurrent
30
+ * crawling is non-deterministic, whether this alternating-signature case
31
+ * fires is itself non-deterministic — test fixtures for this tracker use a
32
+ * single dominant `metaSig` per shape to keep results deterministic.
33
+ *
34
+ * **Rejected alternative — top-K "space-saving" per shape**: keeping the
35
+ * top K=4 `{metaSig, count}` candidates per shape (instead of one) was
36
+ * considered so a shape could distinguish more than one competing
37
+ * signature. Rejected because eviction semantics have no safe default: if
38
+ * an evicted candidate's count is inherited by its replacement (the
39
+ * textbook space-saving guarantee), churn through a large legitimate
40
+ * section (e.g. thousands of distinct `/product/{id}` pages sharing one
41
+ * shape) inflates an unrelated candidate's inherited count and can
42
+ * false-positive cap it; if not inherited, a genuine trap can be evicted
43
+ * before it accumulates enough count, producing a false negative. A single
44
+ * majority-vote slot per shape has neither failure mode.
45
+ *
46
+ * **Rejected alternative — streak counting** (increment on a match with the
47
+ * immediately preceding observation, reset to zero otherwise): fails the
48
+ * same way under concurrent crawling as the alternating-signature case
49
+ * above — a trap and an unrelated same-shape legit page interleaving resets
50
+ * the streak before it can reach the cap.
51
+ * @see {@link https://en.wikipedia.org/wiki/Boyer%E2%80%93Moore_majority_vote_algorithm} for the underlying algorithm (Misra-Gries generalises it to top-K; this uses K=1).
52
+ */
53
+ export default class DedupeCapTracker {
54
+ #options;
55
+ #state = new Map();
56
+ #sticky;
57
+ /** Number of distinct shapes currently held in the (non-sticky) state map. Exposed for the `mapCap` bound assertion in tests. */
58
+ get size() {
59
+ return this.#state.size;
60
+ }
61
+ /** Number of shapes confirmed capped (sticky) so far. */
62
+ get stickyCount() {
63
+ return this.#sticky.size;
64
+ }
65
+ /**
66
+ * @param options - `--dedupe-cap` / `--dedupe-map-cap` thresholds.
67
+ * @param preloadedSticky - Shape keys already confirmed capped in a prior
68
+ * session (from `dedupe_cap_events.shape_key`), seeded so `--resume` /
69
+ * `--append` / `--retry-failed` / `--inventory` do not re-admit a trap
70
+ * this crawl already paid the cost of discovering once.
71
+ */
72
+ constructor(options, preloadedSticky = []) {
73
+ this.#options = options;
74
+ this.#sticky = new Set(preloadedSticky);
75
+ }
76
+ /**
77
+ * Whether a shape has already been confirmed capped. Callers gate
78
+ * enqueue decisions on this before ever calling {@link observe}.
79
+ * @param shapeKey
80
+ */
81
+ isCapped(shapeKey) {
82
+ return isShapeCapped(this.#sticky, shapeKey);
83
+ }
84
+ /**
85
+ * Registers one page's observation and applies the Misra-Gries
86
+ * majority-vote update for its shape.
87
+ * @param observation - See {@link DedupeCapObservation}. Callers must not
88
+ * call this for a shape that is already capped (check {@link isCapped}
89
+ * first) — doing so is a no-op returning `null`, since the shape's slot
90
+ * was already dropped from `#state` when it capped.
91
+ * @returns A {@link DedupeCapEvent} the instant this observation causes
92
+ * the shape to newly cross its effective threshold, otherwise `null`.
93
+ */
94
+ observe(observation) {
95
+ const { shapeKey, metaSig, bodyHash, ogUrlMismatch, url } = observation;
96
+ if (this.#sticky.has(shapeKey))
97
+ return null;
98
+ const existing = this.#state.get(shapeKey);
99
+ let slot;
100
+ // The body-hash confidence signal only means something when compared
101
+ // against a hash a PRIOR observation already recorded for this shape —
102
+ // comparing a freshly-created (or just-reset) slot's `bodyHash`
103
+ // against itself would trivially "match" every single time (it is the
104
+ // same value), collapsing the threshold on the very first
105
+ // observation of any shape. So this stays `false` whenever the slot
106
+ // has no observation history to compare against yet.
107
+ let bodyHashMatches;
108
+ if (!existing) {
109
+ slot = { metaSig, count: 1, bodyHash };
110
+ bodyHashMatches = false;
111
+ }
112
+ else if (existing.metaSig === metaSig) {
113
+ existing.count++;
114
+ bodyHashMatches = existing.bodyHash.equals(bodyHash);
115
+ // Track the most recently observed body for this shape, not the
116
+ // one recorded when the slot was first created — otherwise a
117
+ // shape whose first page differs from an otherwise-identical run
118
+ // of later pages (e.g. a one-off warmup response) would compare
119
+ // every later page against that stale first hash forever and
120
+ // never see a match.
121
+ existing.bodyHash = bodyHash;
122
+ slot = existing;
123
+ }
124
+ else {
125
+ existing.count--;
126
+ if (existing.count <= 0) {
127
+ slot = { metaSig, count: 1, bodyHash };
128
+ }
129
+ else {
130
+ slot = existing;
131
+ }
132
+ bodyHashMatches = false;
133
+ }
134
+ // Re-insert to move this shape to the "most recently touched" end of
135
+ // the Map's iteration order, which `#enforceHardCap` relies on to
136
+ // evict the least-recently-touched shape first.
137
+ this.#state.delete(shapeKey);
138
+ this.#state.set(shapeKey, slot);
139
+ const effectiveThreshold = computeEffectiveThreshold(this.#options.cap, bodyHashMatches, ogUrlMismatch);
140
+ if (slot.count >= effectiveThreshold) {
141
+ this.#state.delete(shapeKey);
142
+ this.#sticky.add(shapeKey);
143
+ return {
144
+ shapeKey,
145
+ sampleUrl: url,
146
+ bodyHash,
147
+ effectiveThreshold,
148
+ observedCount: slot.count,
149
+ };
150
+ }
151
+ this.#enforceHardCap();
152
+ return null;
153
+ }
154
+ /**
155
+ * Evicts the least-recently-touched shape(s) until `#state` is back
156
+ * within `mapCap`. This is the pathological-case backstop — under normal
157
+ * operation the Misra-Gries design keeps `#state` bounded by the number
158
+ * of distinct shapes actually seen, which rarely approaches `mapCap`.
159
+ */
160
+ #enforceHardCap() {
161
+ while (this.#state.size > this.#options.mapCap) {
162
+ const oldestKey = this.#state.keys().next().value;
163
+ if (oldestKey === undefined)
164
+ break;
165
+ this.#state.delete(oldestKey);
166
+ }
167
+ }
168
+ }
169
+ /**
170
+ * Computes the effective same-cluster cap threshold: the base `--dedupe-cap`
171
+ * value, halved independently for each confidence signal present (a
172
+ * matching `body_hash` and an `og:url` that does not point at the page
173
+ * itself), rounded up so the threshold never reaches zero.
174
+ * @param baseCap
175
+ * @param bodyHashMatches
176
+ * @param ogUrlMismatch
177
+ */
178
+ function computeEffectiveThreshold(baseCap, bodyHashMatches, ogUrlMismatch) {
179
+ let threshold = baseCap;
180
+ if (bodyHashMatches)
181
+ threshold = Math.ceil(threshold / 2);
182
+ if (ogUrlMismatch)
183
+ threshold = Math.ceil(threshold / 2);
184
+ return Math.max(threshold, 1);
185
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Determines whether a predicted URL's rendered body is a byte-for-byte
3
+ * duplicate of the previous predicted page generated for the same URL shape.
4
+ *
5
+ * The comparison is deliberately against the previous *predicted* page, not
6
+ * the origin (real) page the pattern was detected from: a real listing
7
+ * page's body legitimately differs from an empty/placeholder predicted
8
+ * page's body regardless of whether the site is a trap, so comparing
9
+ * against the origin would never fire. Two consecutive predicted pages of
10
+ * the same shape rendering identical bodies is direct evidence the site
11
+ * ignores the extrapolated token entirely (e.g. an out-of-range
12
+ * `/news/date/{n}/` always serves the same "no results" template).
13
+ * @param bodyHash - The `computeBodyHash` result for the predicted page just scraped.
14
+ * @param lastBodyHash - The previous predicted page's body hash for the same
15
+ * shape, or `null` if this is the first predicted page seen for the shape.
16
+ * @returns `true` when both hashes exist and are byte-identical.
17
+ * @example
18
+ * ```ts
19
+ * isPredictedContentDuplicate(Buffer.from('a'), null); // false — no prior hash yet
20
+ * isPredictedContentDuplicate(Buffer.from('a'), Buffer.from('a')); // true
21
+ * isPredictedContentDuplicate(Buffer.from('a'), Buffer.from('b')); // false
22
+ * ```
23
+ */
24
+ export declare function isPredictedContentDuplicate(bodyHash: Buffer, lastBodyHash: Buffer | null): boolean;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Determines whether a predicted URL's rendered body is a byte-for-byte
3
+ * duplicate of the previous predicted page generated for the same URL shape.
4
+ *
5
+ * The comparison is deliberately against the previous *predicted* page, not
6
+ * the origin (real) page the pattern was detected from: a real listing
7
+ * page's body legitimately differs from an empty/placeholder predicted
8
+ * page's body regardless of whether the site is a trap, so comparing
9
+ * against the origin would never fire. Two consecutive predicted pages of
10
+ * the same shape rendering identical bodies is direct evidence the site
11
+ * ignores the extrapolated token entirely (e.g. an out-of-range
12
+ * `/news/date/{n}/` always serves the same "no results" template).
13
+ * @param bodyHash - The `computeBodyHash` result for the predicted page just scraped.
14
+ * @param lastBodyHash - The previous predicted page's body hash for the same
15
+ * shape, or `null` if this is the first predicted page seen for the shape.
16
+ * @returns `true` when both hashes exist and are byte-identical.
17
+ * @example
18
+ * ```ts
19
+ * isPredictedContentDuplicate(Buffer.from('a'), null); // false — no prior hash yet
20
+ * isPredictedContentDuplicate(Buffer.from('a'), Buffer.from('a')); // true
21
+ * isPredictedContentDuplicate(Buffer.from('a'), Buffer.from('b')); // false
22
+ * ```
23
+ */
24
+ export function isPredictedContentDuplicate(bodyHash, lastBodyHash) {
25
+ return lastBodyHash !== null && bodyHash.equals(lastBodyHash);
26
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Checks whether a shape has already been confirmed as a same-cluster trap
3
+ * and immigrated to the sticky set (see `DedupeCapTracker`). A capped shape
4
+ * needs only this O(1) Set lookup — its Misra-Gries slot has already been
5
+ * dropped from the tracker's main state map.
6
+ * @param sticky - The tracker's sticky-shape set.
7
+ * @param shapeKey - The shape key to check.
8
+ * @returns `true` if the shape is capped.
9
+ */
10
+ export declare function isShapeCapped(sticky: ReadonlySet<string>, shapeKey: string): boolean;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Checks whether a shape has already been confirmed as a same-cluster trap
3
+ * and immigrated to the sticky set (see `DedupeCapTracker`). A capped shape
4
+ * needs only this O(1) Set lookup — its Misra-Gries slot has already been
5
+ * dropped from the tracker's main state map.
6
+ * @param sticky - The tracker's sticky-shape set.
7
+ * @param shapeKey - The shape key to check.
8
+ * @returns `true` if the shape is capped.
9
+ */
10
+ export function isShapeCapped(sticky, shapeKey) {
11
+ return sticky.has(shapeKey);
12
+ }
@@ -0,0 +1,31 @@
1
+ import type { Meta } from '@d-zero/beholder';
2
+ /**
3
+ * Determines whether a page's (absolutised) `og:url` points somewhere other
4
+ * than the page itself — one of the two confidence signals that lower the
5
+ * effective same-cluster cap threshold (see `DedupeCapTracker`). A pager
6
+ * trap's `og:url` typically still points at the parent listing page rather
7
+ * than the (fake) paginated URL, which this signal is built to catch.
8
+ *
9
+ * Duplicates the tiny URL-absolutisation logic from
10
+ * `../../archive/meta/derive-flat-from-meta.ts` rather than importing it:
11
+ * that file exports only `deriveFlatFromMeta` (one export per file is a
12
+ * project convention), so its internal `absolutizeUrl` helper is not
13
+ * reachable from here. `og:url` arrives un-absolutised (beholder extracts it
14
+ * via `getAttribute`, preserving relative URLs as-written) — comparing it to
15
+ * the page's own absolute URL without resolving it first would treat every
16
+ * relative self-reference (e.g. `content="./"`) as a mismatch, inflating
17
+ * this signal on ordinary pages.
18
+ * @param meta - Beholder-derived metadata for the page.
19
+ * @param pageUrl - The page's own absolute URL.
20
+ * @returns `true` if `og:url` is present and resolves to a URL different
21
+ * from `pageUrl`; `false` if absent (no signal) or if it resolves to the
22
+ * same URL.
23
+ * @example
24
+ * ```ts
25
+ * resolveOgUrlMismatch({ title: '', og: { url: '/news' } } as Meta, 'https://example.com/news/date/2024/');
26
+ * // => true — og:url points at the parent listing, not this page
27
+ * resolveOgUrlMismatch({ title: '', og: { url: './' } } as Meta, 'https://example.com/');
28
+ * // => false — relative self-reference resolves to the same URL
29
+ * ```
30
+ */
31
+ export declare function resolveOgUrlMismatch(meta: Meta, pageUrl: string): boolean;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Determines whether a page's (absolutised) `og:url` points somewhere other
3
+ * than the page itself — one of the two confidence signals that lower the
4
+ * effective same-cluster cap threshold (see `DedupeCapTracker`). A pager
5
+ * trap's `og:url` typically still points at the parent listing page rather
6
+ * than the (fake) paginated URL, which this signal is built to catch.
7
+ *
8
+ * Duplicates the tiny URL-absolutisation logic from
9
+ * `../../archive/meta/derive-flat-from-meta.ts` rather than importing it:
10
+ * that file exports only `deriveFlatFromMeta` (one export per file is a
11
+ * project convention), so its internal `absolutizeUrl` helper is not
12
+ * reachable from here. `og:url` arrives un-absolutised (beholder extracts it
13
+ * via `getAttribute`, preserving relative URLs as-written) — comparing it to
14
+ * the page's own absolute URL without resolving it first would treat every
15
+ * relative self-reference (e.g. `content="./"`) as a mismatch, inflating
16
+ * this signal on ordinary pages.
17
+ * @param meta - Beholder-derived metadata for the page.
18
+ * @param pageUrl - The page's own absolute URL.
19
+ * @returns `true` if `og:url` is present and resolves to a URL different
20
+ * from `pageUrl`; `false` if absent (no signal) or if it resolves to the
21
+ * same URL.
22
+ * @example
23
+ * ```ts
24
+ * resolveOgUrlMismatch({ title: '', og: { url: '/news' } } as Meta, 'https://example.com/news/date/2024/');
25
+ * // => true — og:url points at the parent listing, not this page
26
+ * resolveOgUrlMismatch({ title: '', og: { url: './' } } as Meta, 'https://example.com/');
27
+ * // => false — relative self-reference resolves to the same URL
28
+ * ```
29
+ */
30
+ export function resolveOgUrlMismatch(meta, pageUrl) {
31
+ const raw = meta.og?.url;
32
+ if (!raw)
33
+ return false;
34
+ try {
35
+ return new URL(raw, pageUrl).href !== pageUrl;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }