@nitpicker/crawler 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/archive/archive.d.ts +136 -10
- package/lib/archive/archive.js +147 -9
- package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
- package/lib/archive/body-hash/compute-body-hash.js +31 -0
- package/lib/archive/body-hash/extract-body.d.ts +18 -0
- package/lib/archive/body-hash/extract-body.js +29 -0
- package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
- package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
- package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
- package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
- package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
- package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
- package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
- package/lib/archive/cache/clear-archive-cache-root.js +32 -0
- package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
- package/lib/archive/cache/list-archive-cache-entries.js +125 -0
- package/lib/archive/cache/path-exists.d.ts +9 -0
- package/lib/archive/cache/path-exists.js +18 -0
- package/lib/archive/cache/types.d.ts +33 -0
- package/lib/archive/cache/types.js +1 -0
- package/lib/archive/create-adjunct-tables.d.ts +14 -0
- package/lib/archive/create-adjunct-tables.js +169 -0
- package/lib/archive/create-entity-tables.js +21 -1
- package/lib/archive/database.d.ts +67 -2
- package/lib/archive/database.js +92 -3
- package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
- package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
- package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
- package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
- package/lib/archive/db-ops/_shared/types.d.ts +2 -0
- package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
- package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
- package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
- package/lib/archive/db-ops/analysis/types.d.ts +61 -0
- package/lib/archive/db-ops/analysis/types.js +1 -0
- package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
- package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
- package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
- package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
- package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
- package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
- package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
- package/lib/archive/db-ops/console-logs/types.js +1 -0
- package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
- package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
- package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
- package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
- package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
- package/lib/archive/db-ops/lifecycle/init.js +21 -2
- package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
- package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
- package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
- package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
- package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
- package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
- package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
- package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
- package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
- package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
- package/lib/archive/db-ops/pages/write/update-page.js +12 -0
- package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
- package/lib/archive/filesystem/output-binary.d.ts +12 -0
- package/lib/archive/filesystem/output-binary.js +17 -0
- package/lib/archive/get-failed-page-messages.d.ts +20 -8
- package/lib/archive/get-failed-page-messages.js +29 -15
- package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
- package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
- package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
- package/lib/archive/migrate-page-meta-body-hash.js +52 -0
- package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
- package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
- package/lib/archive/page.d.ts +1 -1
- package/lib/archive/types.d.ts +60 -2
- package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
- package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
- package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
- package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
- package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
- package/lib/archive/url-alias/format-alias-key.js +14 -0
- package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
- package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
- package/lib/archive/url-alias/types.d.ts +7 -0
- package/lib/archive/url-alias/types.js +1 -0
- package/lib/classify-error-kind.d.ts +1 -0
- package/lib/classify-error-kind.js +14 -0
- package/lib/crawler/assert-chrome-installed.d.ts +24 -0
- package/lib/crawler/assert-chrome-installed.js +43 -0
- package/lib/crawler/choose-probe-host.d.ts +24 -0
- package/lib/crawler/choose-probe-host.js +38 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
- package/lib/crawler/crawler.d.ts +12 -0
- package/lib/crawler/crawler.js +496 -29
- package/lib/crawler/decode-auth-credential.d.ts +29 -0
- package/lib/crawler/decode-auth-credential.js +39 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
- package/lib/crawler/dedupe/compute-shape-key.js +56 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
- package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
- package/lib/crawler/dedupe/is-shape-capped.js +12 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
- package/lib/crawler/dedupe/types.d.ts +42 -0
- package/lib/crawler/dedupe/types.js +1 -0
- package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
- package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
- package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
- package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
- package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
- package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
- package/lib/crawler/fetch-destination.js +14 -2
- package/lib/crawler/generate-predicted-urls.d.ts +12 -0
- package/lib/crawler/generate-predicted-urls.js +33 -2
- package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
- package/lib/crawler/network-gate.d.ts +49 -0
- package/lib/crawler/network-gate.js +78 -0
- package/lib/crawler/network-outage-detector.d.ts +51 -0
- package/lib/crawler/network-outage-detector.js +81 -0
- package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
- package/lib/crawler/network-outage-summary-counter.js +18 -0
- package/lib/crawler/probe-network.d.ts +26 -0
- package/lib/crawler/probe-network.js +21 -0
- package/lib/crawler/types.d.ts +185 -1
- package/lib/crawler-orchestrator.d.ts +74 -18
- package/lib/crawler-orchestrator.js +267 -27
- package/lib/crawler.d.ts +22 -0
- package/lib/crawler.js +18 -0
- package/lib/is-within-outage-window.d.ts +49 -0
- package/lib/is-within-outage-window.js +33 -0
- package/lib/network-related-error-kinds.d.ts +23 -0
- package/lib/network-related-error-kinds.js +28 -0
- package/lib/permanent-error-kinds.d.ts +9 -4
- package/lib/permanent-error-kinds.js +10 -4
- package/lib/types.d.ts +11 -2
- package/lib/utils/array/each-splitted.d.ts +1 -1
- package/lib/utils/compute-file-sha256.d.ts +17 -17
- package/lib/utils/compute-file-sha256.js +18 -47
- package/package.json +3 -3
|
@@ -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;
|
|
@@ -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
|
+
}
|