@d-zero/page-cluster 0.2.0 → 0.3.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/LICENSE +21 -0
- package/README.md +95 -41
- package/dist/assign-contained-clusters.d.ts +42 -0
- package/dist/assign-contained-clusters.js +156 -0
- package/dist/auto-cut-threshold.d.ts +17 -0
- package/dist/auto-cut-threshold.js +36 -0
- package/dist/canonicalize-token-set.d.ts +17 -0
- package/dist/canonicalize-token-set.js +19 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.js +381 -0
- package/dist/collapse-anonymous-divs.d.ts +21 -0
- package/dist/collapse-anonymous-divs.js +42 -0
- package/dist/complete-linkage-dendrogram.d.ts +41 -0
- package/dist/complete-linkage-dendrogram.js +140 -0
- package/dist/derive-comparison-sets.d.ts +22 -0
- package/dist/derive-comparison-sets.js +33 -0
- package/dist/derive-path-cluster-keys.d.ts +53 -0
- package/dist/derive-path-cluster-keys.js +109 -0
- package/dist/extract-landmarks.d.ts +91 -45
- package/dist/extract-landmarks.js +122 -41
- package/dist/filter-first-party-stylesheet-hrefs.d.ts +58 -24
- package/dist/filter-first-party-stylesheet-hrefs.js +72 -33
- package/dist/find-shallowest-elements.d.ts +48 -11
- package/dist/find-shallowest-elements.js +41 -21
- package/dist/merge-cross-block-clusters.d.ts +61 -0
- package/dist/merge-cross-block-clusters.js +546 -0
- package/dist/pass0-blocking.d.ts +89 -0
- package/dist/pass0-blocking.js +87 -0
- package/dist/per-page-landmark-signatures.d.ts +48 -0
- package/dist/per-page-landmark-signatures.js +62 -0
- package/dist/reservoir-sample.d.ts +43 -0
- package/dist/reservoir-sample.js +98 -0
- package/dist/resolve-blocking-group-keys.d.ts +8 -2
- package/dist/resolve-blocking-group-keys.js +18 -4
- package/dist/resolve-landmark-variant-keys.d.ts +41 -20
- package/dist/resolve-landmark-variant-keys.js +69 -26
- package/dist/resolve-page-cluster-keys.d.ts +292 -191
- package/dist/resolve-page-cluster-keys.js +708 -157
- package/dist/resolve-structural-cluster-keys.d.ts +9 -0
- package/dist/resolve-structural-cluster-keys.js +14 -232
- package/dist/shape-token.d.ts +11 -0
- package/dist/shape-token.js +38 -0
- package/dist/stage-a-per-block.d.ts +133 -0
- package/dist/stage-a-per-block.js +178 -0
- package/dist/tokenize.d.ts +6 -0
- package/dist/tokenize.js +6 -0
- package/package.json +5 -58
- package/dist/html-region-utils.d.ts +0 -74
- package/dist/html-region-utils.js +0 -96
- package/dist/merge-landmark-affined-clusters.d.ts +0 -179
- package/dist/merge-landmark-affined-clusters.js +0 -544
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d-zero/page-cluster",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Tokenizes an HTML document's body into a structural signature for duplicate/near-duplicate page detection at crawl scale",
|
|
5
5
|
"author": "D-ZERO",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,58 +13,10 @@
|
|
|
13
13
|
"import": "./dist/tokenize.js",
|
|
14
14
|
"types": "./dist/tokenize.d.ts"
|
|
15
15
|
},
|
|
16
|
-
"./array-edit-distance": {
|
|
17
|
-
"import": "./dist/array-edit-distance.js",
|
|
18
|
-
"types": "./dist/array-edit-distance.d.ts"
|
|
19
|
-
},
|
|
20
|
-
"./cap-content-depth": {
|
|
21
|
-
"import": "./dist/cap-content-depth.js",
|
|
22
|
-
"types": "./dist/cap-content-depth.d.ts"
|
|
23
|
-
},
|
|
24
|
-
"./compute-document-frequency": {
|
|
25
|
-
"import": "./dist/compute-document-frequency.js",
|
|
26
|
-
"types": "./dist/compute-document-frequency.d.ts"
|
|
27
|
-
},
|
|
28
|
-
"./derive-path-group-key": {
|
|
29
|
-
"import": "./dist/derive-path-group-key.js",
|
|
30
|
-
"types": "./dist/derive-path-group-key.d.ts"
|
|
31
|
-
},
|
|
32
|
-
"./derive-stylesheet-group-key": {
|
|
33
|
-
"import": "./dist/derive-stylesheet-group-key.js",
|
|
34
|
-
"types": "./dist/derive-stylesheet-group-key.d.ts"
|
|
35
|
-
},
|
|
36
|
-
"./detect-content-depth-cap": {
|
|
37
|
-
"import": "./dist/detect-content-depth-cap.js",
|
|
38
|
-
"types": "./dist/detect-content-depth-cap.d.ts"
|
|
39
|
-
},
|
|
40
16
|
"./extract-landmarks": {
|
|
41
17
|
"import": "./dist/extract-landmarks.js",
|
|
42
18
|
"types": "./dist/extract-landmarks.d.ts"
|
|
43
19
|
},
|
|
44
|
-
"./filter-first-party-stylesheet-hrefs": {
|
|
45
|
-
"import": "./dist/filter-first-party-stylesheet-hrefs.js",
|
|
46
|
-
"types": "./dist/filter-first-party-stylesheet-hrefs.d.ts"
|
|
47
|
-
},
|
|
48
|
-
"./jaccard-similarity": {
|
|
49
|
-
"import": "./dist/jaccard-similarity.js",
|
|
50
|
-
"types": "./dist/jaccard-similarity.d.ts"
|
|
51
|
-
},
|
|
52
|
-
"./merge-landmark-affined-clusters": {
|
|
53
|
-
"import": "./dist/merge-landmark-affined-clusters.js",
|
|
54
|
-
"types": "./dist/merge-landmark-affined-clusters.d.ts"
|
|
55
|
-
},
|
|
56
|
-
"./reassign-orphan-block-keys": {
|
|
57
|
-
"import": "./dist/reassign-orphan-block-keys.js",
|
|
58
|
-
"types": "./dist/reassign-orphan-block-keys.d.ts"
|
|
59
|
-
},
|
|
60
|
-
"./remove-content-blocks": {
|
|
61
|
-
"import": "./dist/remove-content-blocks.js",
|
|
62
|
-
"types": "./dist/remove-content-blocks.d.ts"
|
|
63
|
-
},
|
|
64
|
-
"./resolve-blocking-group-keys": {
|
|
65
|
-
"import": "./dist/resolve-blocking-group-keys.js",
|
|
66
|
-
"types": "./dist/resolve-blocking-group-keys.d.ts"
|
|
67
|
-
},
|
|
68
20
|
"./resolve-landmark-variant-keys": {
|
|
69
21
|
"import": "./dist/resolve-landmark-variant-keys.js",
|
|
70
22
|
"types": "./dist/resolve-landmark-variant-keys.d.ts"
|
|
@@ -72,16 +24,9 @@
|
|
|
72
24
|
"./resolve-page-cluster-keys": {
|
|
73
25
|
"import": "./dist/resolve-page-cluster-keys.js",
|
|
74
26
|
"types": "./dist/resolve-page-cluster-keys.d.ts"
|
|
75
|
-
},
|
|
76
|
-
"./resolve-structural-cluster-keys": {
|
|
77
|
-
"import": "./dist/resolve-structural-cluster-keys.js",
|
|
78
|
-
"types": "./dist/resolve-structural-cluster-keys.d.ts"
|
|
79
|
-
},
|
|
80
|
-
"./split-tokens-by-frequency": {
|
|
81
|
-
"import": "./dist/split-tokens-by-frequency.js",
|
|
82
|
-
"types": "./dist/split-tokens-by-frequency.d.ts"
|
|
83
27
|
}
|
|
84
28
|
},
|
|
29
|
+
"bin": "./dist/cli.js",
|
|
85
30
|
"files": [
|
|
86
31
|
"dist"
|
|
87
32
|
],
|
|
@@ -91,6 +36,7 @@
|
|
|
91
36
|
"clean": "tsc --build --clean"
|
|
92
37
|
},
|
|
93
38
|
"dependencies": {
|
|
39
|
+
"@d-zero/dealer": "1.10.0",
|
|
94
40
|
"@d-zero/shared": "0.22.2",
|
|
95
41
|
"htmlparser2": "12.0.0"
|
|
96
42
|
},
|
|
@@ -98,5 +44,6 @@
|
|
|
98
44
|
"type": "git",
|
|
99
45
|
"url": "https://github.com/d-zero-dev/tools.git",
|
|
100
46
|
"directory": "packages/@d-zero/page-cluster"
|
|
101
|
-
}
|
|
47
|
+
},
|
|
48
|
+
"gitHead": "5429d79837cca262ae5b74b32922eae6f7ef2649"
|
|
102
49
|
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared by {@link ./extract-landmarks.js | extractLandmarks} and
|
|
3
|
-
* {@link ./remove-content-blocks.js | removeContentBlocks}: both walk `html`
|
|
4
|
-
* with htmlparser2 to find `[start, end)` spans of matched regions, then
|
|
5
|
-
* excise them from the original string. Not part of either file's own
|
|
6
|
-
* public API surface (unlike the smaller, ~7-line `requireIndex` copies
|
|
7
|
-
* duplicated elsewhere in this package), so it's factored out here instead
|
|
8
|
-
* of duplicated: this logic already had one real bug (see
|
|
9
|
-
* `escapeRegExp`/`isGenuineClose` below) found and fixed after
|
|
10
|
-
* `extractLandmarks` shipped, and a second independent copy would be a
|
|
11
|
-
* second place for that class of bug to reappear.
|
|
12
|
-
*/
|
|
13
|
-
/**
|
|
14
|
-
* Escapes regex metacharacters in `text` so it can be interpolated into a
|
|
15
|
-
* `RegExp` literally. Needed because a tag name reaching {@link isGenuineClose}
|
|
16
|
-
* is not guaranteed to be a plain HTML tag name: htmlparser2 accepts
|
|
17
|
-
* characters like `(`/`[` inside a tag name (`<div(foo role="banner">`
|
|
18
|
-
* parses with tag name `"div(foo"`), which would otherwise either throw
|
|
19
|
-
* (an unbalanced `(` is an invalid regex) or silently change what the regex
|
|
20
|
-
* matches.
|
|
21
|
-
* @param text
|
|
22
|
-
*/
|
|
23
|
-
export declare function escapeRegExp(text: string): string;
|
|
24
|
-
/**
|
|
25
|
-
* Whether `html` actually contains a literal closing tag for `tagName`
|
|
26
|
-
* ending at `endOffset`. htmlparser2 fires `onclosetag` not only for real
|
|
27
|
-
* closing tags but also when it force-closes a still-open ancestor to
|
|
28
|
-
* resolve a mismatch (e.g. `<header>H<main>...</main></body>` with no
|
|
29
|
-
* `</header>` ever written) — and in that forced case it reports the
|
|
30
|
-
* force-closed element's `endIndex` as wherever the *other*, unrelated
|
|
31
|
-
* closing tag that triggered the cascade happens to sit, not any position
|
|
32
|
-
* derived from the matched element itself (confirmed by direct htmlparser2
|
|
33
|
-
* event tracing: both the synthetic close and the real `body` close report
|
|
34
|
-
* the identical `endIndex`, because there is no real closing tag in the
|
|
35
|
-
* source for htmlparser2 to anchor a distinct position to). Trusting that
|
|
36
|
-
* offset would slice a candidate spanning all the way to wherever the
|
|
37
|
-
* unrelated tag ends, silently swallowing real content into the caller's
|
|
38
|
-
* remainder HTML. Checking that the text immediately preceding `endOffset`
|
|
39
|
-
* actually spells the expected closing tag catches exactly this: a genuine
|
|
40
|
-
* close always ends with it; a forced one ends with whatever unrelated tag
|
|
41
|
-
* forced it instead.
|
|
42
|
-
* @param html
|
|
43
|
-
* @param endOffset
|
|
44
|
-
* @param tagName
|
|
45
|
-
*/
|
|
46
|
-
export declare function isGenuineClose(html: string, endOffset: number, tagName: string): boolean;
|
|
47
|
-
/**
|
|
48
|
-
* Merges a set of (possibly overlapping or nested) `[start, end)` spans into
|
|
49
|
-
* the smallest equivalent set of disjoint spans, sorted by start offset.
|
|
50
|
-
* Matched spans commonly nest in real markup (e.g. a site nav living inside
|
|
51
|
-
* the header, `<header><nav>...</nav></header>`) — merging first means the
|
|
52
|
-
* later excision pass never has to reason about overlap.
|
|
53
|
-
* @param spans
|
|
54
|
-
*/
|
|
55
|
-
export declare function mergeSpans(spans: readonly {
|
|
56
|
-
start: number;
|
|
57
|
-
end: number;
|
|
58
|
-
}[]): {
|
|
59
|
-
start: number;
|
|
60
|
-
end: number;
|
|
61
|
-
}[];
|
|
62
|
-
/**
|
|
63
|
-
* Excises `spans` (merged via {@link mergeSpans}) from `html`, returning
|
|
64
|
-
* what's left. No placeholder is left in a span's place: a placeholder
|
|
65
|
-
* string would itself become a token once the remainder is tokenized,
|
|
66
|
-
* reintroducing exactly the kind of synthetic signal callers of this
|
|
67
|
-
* function exist to remove.
|
|
68
|
-
* @param html
|
|
69
|
-
* @param spans
|
|
70
|
-
*/
|
|
71
|
-
export declare function excise(html: string, spans: readonly {
|
|
72
|
-
start: number;
|
|
73
|
-
end: number;
|
|
74
|
-
}[]): string;
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared by {@link ./extract-landmarks.js | extractLandmarks} and
|
|
3
|
-
* {@link ./remove-content-blocks.js | removeContentBlocks}: both walk `html`
|
|
4
|
-
* with htmlparser2 to find `[start, end)` spans of matched regions, then
|
|
5
|
-
* excise them from the original string. Not part of either file's own
|
|
6
|
-
* public API surface (unlike the smaller, ~7-line `requireIndex` copies
|
|
7
|
-
* duplicated elsewhere in this package), so it's factored out here instead
|
|
8
|
-
* of duplicated: this logic already had one real bug (see
|
|
9
|
-
* `escapeRegExp`/`isGenuineClose` below) found and fixed after
|
|
10
|
-
* `extractLandmarks` shipped, and a second independent copy would be a
|
|
11
|
-
* second place for that class of bug to reappear.
|
|
12
|
-
*/
|
|
13
|
-
/**
|
|
14
|
-
* Escapes regex metacharacters in `text` so it can be interpolated into a
|
|
15
|
-
* `RegExp` literally. Needed because a tag name reaching {@link isGenuineClose}
|
|
16
|
-
* is not guaranteed to be a plain HTML tag name: htmlparser2 accepts
|
|
17
|
-
* characters like `(`/`[` inside a tag name (`<div(foo role="banner">`
|
|
18
|
-
* parses with tag name `"div(foo"`), which would otherwise either throw
|
|
19
|
-
* (an unbalanced `(` is an invalid regex) or silently change what the regex
|
|
20
|
-
* matches.
|
|
21
|
-
* @param text
|
|
22
|
-
*/
|
|
23
|
-
export function escapeRegExp(text) {
|
|
24
|
-
return text.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Whether `html` actually contains a literal closing tag for `tagName`
|
|
28
|
-
* ending at `endOffset`. htmlparser2 fires `onclosetag` not only for real
|
|
29
|
-
* closing tags but also when it force-closes a still-open ancestor to
|
|
30
|
-
* resolve a mismatch (e.g. `<header>H<main>...</main></body>` with no
|
|
31
|
-
* `</header>` ever written) — and in that forced case it reports the
|
|
32
|
-
* force-closed element's `endIndex` as wherever the *other*, unrelated
|
|
33
|
-
* closing tag that triggered the cascade happens to sit, not any position
|
|
34
|
-
* derived from the matched element itself (confirmed by direct htmlparser2
|
|
35
|
-
* event tracing: both the synthetic close and the real `body` close report
|
|
36
|
-
* the identical `endIndex`, because there is no real closing tag in the
|
|
37
|
-
* source for htmlparser2 to anchor a distinct position to). Trusting that
|
|
38
|
-
* offset would slice a candidate spanning all the way to wherever the
|
|
39
|
-
* unrelated tag ends, silently swallowing real content into the caller's
|
|
40
|
-
* remainder HTML. Checking that the text immediately preceding `endOffset`
|
|
41
|
-
* actually spells the expected closing tag catches exactly this: a genuine
|
|
42
|
-
* close always ends with it; a forced one ends with whatever unrelated tag
|
|
43
|
-
* forced it instead.
|
|
44
|
-
* @param html
|
|
45
|
-
* @param endOffset
|
|
46
|
-
* @param tagName
|
|
47
|
-
*/
|
|
48
|
-
export function isGenuineClose(html, endOffset, tagName) {
|
|
49
|
-
const windowStart = Math.max(0, endOffset - tagName.length - 3);
|
|
50
|
-
return new RegExp(`</\\s*${escapeRegExp(tagName)}\\s*>$`, 'i').test(html.slice(windowStart, endOffset));
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Merges a set of (possibly overlapping or nested) `[start, end)` spans into
|
|
54
|
-
* the smallest equivalent set of disjoint spans, sorted by start offset.
|
|
55
|
-
* Matched spans commonly nest in real markup (e.g. a site nav living inside
|
|
56
|
-
* the header, `<header><nav>...</nav></header>`) — merging first means the
|
|
57
|
-
* later excision pass never has to reason about overlap.
|
|
58
|
-
* @param spans
|
|
59
|
-
*/
|
|
60
|
-
export function mergeSpans(spans) {
|
|
61
|
-
const sorted = [...spans].toSorted((a, b) => a.start - b.start);
|
|
62
|
-
const merged = [];
|
|
63
|
-
for (const span of sorted) {
|
|
64
|
-
const last = merged.at(-1);
|
|
65
|
-
if (last && span.start <= last.end) {
|
|
66
|
-
last.end = Math.max(last.end, span.end);
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
merged.push({ ...span });
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return merged;
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Excises `spans` (merged via {@link mergeSpans}) from `html`, returning
|
|
76
|
-
* what's left. No placeholder is left in a span's place: a placeholder
|
|
77
|
-
* string would itself become a token once the remainder is tokenized,
|
|
78
|
-
* reintroducing exactly the kind of synthetic signal callers of this
|
|
79
|
-
* function exist to remove.
|
|
80
|
-
* @param html
|
|
81
|
-
* @param spans
|
|
82
|
-
*/
|
|
83
|
-
export function excise(html, spans) {
|
|
84
|
-
if (spans.length === 0) {
|
|
85
|
-
return html;
|
|
86
|
-
}
|
|
87
|
-
const merged = mergeSpans(spans);
|
|
88
|
-
let remainder = '';
|
|
89
|
-
let cursor = 0;
|
|
90
|
-
for (const span of merged) {
|
|
91
|
-
remainder += html.slice(cursor, span.start);
|
|
92
|
-
cursor = span.end;
|
|
93
|
-
}
|
|
94
|
-
remainder += html.slice(cursor);
|
|
95
|
-
return remainder;
|
|
96
|
-
}
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import type { ExtractLandmarksResult } from './extract-landmarks.js';
|
|
2
|
-
import type { TokenizeOptions } from './types.js';
|
|
3
|
-
/**
|
|
4
|
-
* @see mergeLandmarkAffinedClusters
|
|
5
|
-
*/
|
|
6
|
-
export type MergeLandmarkAffinedClustersOptions = TokenizeOptions & {
|
|
7
|
-
/**
|
|
8
|
-
* Reused as the landmark-variant identity threshold inside
|
|
9
|
-
* `computeLandmarkStatus`. Must be in `[0, 1]` (`RangeError` otherwise).
|
|
10
|
-
* Defaults to `0.8`, matching `resolveStructuralClusterKeys`'s own
|
|
11
|
-
* default — this file never calls that function, but the two thresholds
|
|
12
|
-
* represent the same concept ("how much token overlap counts as the same
|
|
13
|
-
* design").
|
|
14
|
-
*
|
|
15
|
-
* Deliberately not a separate, independent option: when
|
|
16
|
-
* `resolvePageClusterKeys` forwards its caller's single `options` object
|
|
17
|
-
* to both `resolveStructuralClusterKeys` (primary content clustering) and
|
|
18
|
-
* this file (landmark-variant identity), the same `similarityThreshold`
|
|
19
|
-
* value drives both, so loosening one to re-tune primary clustering (the
|
|
20
|
-
* documented `excludeLandmarks`/`similarityThreshold` interaction on
|
|
21
|
-
* `resolvePageClusterKeys`) also loosens landmark-variant matching. This
|
|
22
|
-
* mirrors `resolve-landmark-variant-keys.ts`'s own precedent
|
|
23
|
-
* (`resolveLandmarkVariantKeys` likewise forwards its caller's
|
|
24
|
-
* `options.similarityThreshold` straight into
|
|
25
|
-
* `resolveStructuralClusterKeys` with no landmark-specific override) and
|
|
26
|
-
* keeps the option surface to the three fields this file actually adds.
|
|
27
|
-
* Accepted as a known trade-off rather than split into its own knob until
|
|
28
|
-
* real-corpus tuning shows the two thresholds genuinely need to diverge —
|
|
29
|
-
* the same "starting heuristic, not yet corpus-validated" status this
|
|
30
|
-
* option's own default already carries.
|
|
31
|
-
*/
|
|
32
|
-
similarityThreshold?: number;
|
|
33
|
-
/**
|
|
34
|
-
* Upper bound, as a fraction of the whole corpus (`[0, 1]`), on how many
|
|
35
|
-
* pages may share a given (landmark type, variant) pair before that
|
|
36
|
-
* variant is considered too common to serve as evidence of a genuine
|
|
37
|
-
* template affinity. Strictly `<` this fraction ("rare", not
|
|
38
|
-
* "rare-or-equal"). `RangeError` outside `[0, 1]`. Defaults to `0.05` —
|
|
39
|
-
* an unvalidated starting heuristic, the same status as
|
|
40
|
-
* `similarityThreshold`'s own `0.8` default (see this file's JSDoc for
|
|
41
|
-
* why real-corpus validation is out of scope for this change).
|
|
42
|
-
*/
|
|
43
|
-
landmarkRarityThreshold?: number;
|
|
44
|
-
/**
|
|
45
|
-
* The secondary, looser complete-linkage content-similarity threshold
|
|
46
|
-
* applied only to pages whose landmark signature already qualifies (see
|
|
47
|
-
* `mergeLandmarkAffinedClusters`'s JSDoc). `RangeError` outside `[0, 1]`.
|
|
48
|
-
* Defaults to `0.6` — the value a withdrawn earlier prototype of this
|
|
49
|
-
* same mechanism proposed, and also the value
|
|
50
|
-
* `resolvePageClusterKeys`'s own `excludeLandmarks` JSDoc cites as having
|
|
51
|
-
* correctly re-merged a real 3-page block once landmarks were excluded
|
|
52
|
-
* and the raw-token `similarityThreshold` (`0.8`) became too strict.
|
|
53
|
-
*/
|
|
54
|
-
landmarkGateSimilarityThreshold?: number;
|
|
55
|
-
};
|
|
56
|
-
/**
|
|
57
|
-
* Validates `similarityThreshold`/`landmarkRarityThreshold`/
|
|
58
|
-
* `landmarkGateSimilarityThreshold` without running
|
|
59
|
-
* `mergeLandmarkAffinedClusters` itself — exported so
|
|
60
|
-
* `resolvePageClusterKeys` can fail fast on bad options even when `pages` is
|
|
61
|
-
* empty (its own per-block loop never reaches this function at all in that
|
|
62
|
-
* case). Mirrors `detect-content-depth-cap.ts`'s
|
|
63
|
-
* `validateDetectContentDepthCapOptions` exact rationale and shape.
|
|
64
|
-
* @param options
|
|
65
|
-
* @example
|
|
66
|
-
* ```ts
|
|
67
|
-
* // Fails fast on a bad option even though nothing here would otherwise
|
|
68
|
-
* // call mergeLandmarkAffinedClusters yet (e.g. cluster keys haven't been
|
|
69
|
-
* // computed).
|
|
70
|
-
* validateMergeLandmarkAffinedClustersOptions({ landmarkRarityThreshold: -1 }); // throws RangeError
|
|
71
|
-
* ```
|
|
72
|
-
*/
|
|
73
|
-
export declare function validateMergeLandmarkAffinedClustersOptions(options?: MergeLandmarkAffinedClustersOptions): void;
|
|
74
|
-
/**
|
|
75
|
-
* Re-keys the pages of two or more distinct
|
|
76
|
-
* {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys} clusters
|
|
77
|
-
* onto one shared key when every landmark type present on their pages is
|
|
78
|
-
* both *identical* and *rare* corpus-wide, and their actual content clears a
|
|
79
|
-
* secondary, looser similarity threshold.
|
|
80
|
-
*
|
|
81
|
-
* Reimplements a mechanism previously prototyped under this same name and
|
|
82
|
-
* withdrawn (no trace survives in commit history — this JSDoc is the only
|
|
83
|
-
* record). The withdrawn version merged clusters whenever their
|
|
84
|
-
* header/footer/nav/aside matched, full stop. Validated against two real
|
|
85
|
-
* crawl corpora (302 and 8,936 pages), that produced runaway over-merging:
|
|
86
|
-
* header/footer/nav were present on 99%+ of pages and typically reused
|
|
87
|
-
* site-wide unchanged (see `extractLandmarks`'s own JSDoc for that figure),
|
|
88
|
-
* so "landmarks match" was true for nearly every page pair and carried no
|
|
89
|
-
* discriminative power at all. This reimplementation only ever treats a
|
|
90
|
-
* landmark match as merge evidence when that specific landmark *variant* is
|
|
91
|
-
* itself uncommon corpus-wide (`landmarkRarityThreshold`) — the condition
|
|
92
|
-
* the withdrawn attempt lacked.
|
|
93
|
-
*
|
|
94
|
-
* The match requirement is deliberately the most conservative option
|
|
95
|
-
* considered: *every* landmark type actually present on a page must both
|
|
96
|
-
* match its counterpart's variant and be rare — a page with even one common
|
|
97
|
-
* ("everybody has this exact header") present type contributes no evidence
|
|
98
|
-
* at all, rather than partially qualifying. A looser rule (e.g. "at least
|
|
99
|
-
* one shared rare type is enough") was rejected because it reintroduces a
|
|
100
|
-
* version of the original failure mode: a page could ride a single
|
|
101
|
-
* incidentally-rare landmark into a merge despite otherwise-ordinary,
|
|
102
|
-
* ubiquitous chrome elsewhere on the same page.
|
|
103
|
-
*
|
|
104
|
-
* Frequency is counted corpus-wide, not per-block: a `resolveStructuralClusterKeys`
|
|
105
|
-
* cluster label (`cluster:N`) is only unique within the block it was computed
|
|
106
|
-
* in, but rarity here needs one consistent count across the whole input, the
|
|
107
|
-
* same reason `resolvePageClusterKeys` itself composes `[blockKey,
|
|
108
|
-
* localLabel]` via `JSON.stringify` rather than reusing bare labels across
|
|
109
|
-
* blocks.
|
|
110
|
-
*
|
|
111
|
-
* A page with none of the four landmark types present is excluded from
|
|
112
|
-
* consideration entirely (`existingCount === 0` below) — without this, every
|
|
113
|
-
* landmark-less page across the whole corpus would share one large,
|
|
114
|
-
* unbounded "no landmarks" group, defeating the self-limiting cost bound
|
|
115
|
-
* `landmarkRarityThreshold` is otherwise supposed to guarantee (see
|
|
116
|
-
* `computeLandmarkStatus`'s JSDoc for the cost analysis this depends on).
|
|
117
|
-
*
|
|
118
|
-
* Once pages are grouped by matching-and-rare landmark signature, only
|
|
119
|
-
* signature groups spanning two or more distinct existing cluster keys do
|
|
120
|
-
* any further work. Within such a group, the *content* token sets of the
|
|
121
|
-
* group's distinct cluster keys are complete-linkage-merged at
|
|
122
|
-
* `landmarkGateSimilarityThreshold` — looser than
|
|
123
|
-
* `resolveStructuralClusterKeys`'s own `similarityThreshold`, since the
|
|
124
|
-
* whole point of this mechanism is to bridge clusters whose *content*
|
|
125
|
-
* similarity alone fell just short of the primary threshold. Complete-linkage
|
|
126
|
-
* (not single-linkage) is used for the same reason
|
|
127
|
-
* `resolveStructuralClusterKeys` itself uses it: single-linkage's chaining
|
|
128
|
-
* would let one loosely-matching pair bridge two genuinely-unrelated
|
|
129
|
-
* clusters transitively.
|
|
130
|
-
*
|
|
131
|
-
* The resulting merge is applied at *page* granularity, not by blanket-
|
|
132
|
-
* reassigning every page of the involved cluster keys: only the specific
|
|
133
|
-
* pages that were actually pooled into the qualifying signature group (and,
|
|
134
|
-
* transitively, any other page unioned with them via a different signature
|
|
135
|
-
* group) move onto the shared key. A cluster's pages that never carried the
|
|
136
|
-
* rare landmark evidence keep their original key untouched, even if some
|
|
137
|
-
* other page sharing that same cluster key did qualify and merge elsewhere.
|
|
138
|
-
* This is deliberate, not an incidental restriction: applying a merge
|
|
139
|
-
* decision to *every* page of the involved cluster keys — evidenced by only
|
|
140
|
-
* a small subset of them — would extrapolate a coincidental pairing (e.g.
|
|
141
|
-
* one outlier page in each of two otherwise-unrelated clusters happening to
|
|
142
|
-
* share a rare seasonal-campaign header) into force-merging the clusters'
|
|
143
|
-
* entire, otherwise-dissimilar membership. That is the withdrawn prototype's
|
|
144
|
-
* over-merging failure mode reappearing through a different mechanism
|
|
145
|
-
* (whole-cluster application of a single-pair signal) rather than the
|
|
146
|
-
* landmark-commonality mechanism this file was reimplemented to fix — see
|
|
147
|
-
* this function's own regression test for a worked example.
|
|
148
|
-
*
|
|
149
|
-
* Merged pages are re-keyed to `landmark-merge:${JSON.stringify(sortedKeys)}`
|
|
150
|
-
* (`sortedKeys` being the *original* cluster keys the merged pages came
|
|
151
|
-
* from) — a fresh prefix that cannot collide with `css:`/`path:`/
|
|
152
|
-
* `orphan-merge:` or `resolvePageClusterKeys`'s own `[blockKey, "cluster:N"]`
|
|
153
|
-
* pairs (mirrors `reassign-orphan-block-keys.ts`'s `orphan-merge:` prefix).
|
|
154
|
-
* @param clusterKeys - one existing final key per page, same order/length as `landmarks`/`contentTokenSets`
|
|
155
|
-
* @param landmarks - `extractLandmarks(page.html)`'s full result per page (all four fields, not just `remainderHtml`)
|
|
156
|
-
* @param contentTokenSets - per-page content token sets to use for the secondary similarity gate. Should be independent of whichever landmark markup qualified the page as evidence (e.g. always landmark-excised), so this gate is a genuine second signal rather than re-counting the same landmark tokens already used to select the page — see `resolvePageClusterKeys`'s own call site for how it builds these
|
|
157
|
-
* @param options
|
|
158
|
-
* @example
|
|
159
|
-
* ```ts
|
|
160
|
-
* // tokenize() discards visible text (see its own JSDoc), so the two
|
|
161
|
-
* // header variants below must differ structurally (child element/class),
|
|
162
|
-
* // not merely in text, to compare as different landmark variants.
|
|
163
|
-
* mergeLandmarkAffinedClusters(
|
|
164
|
-
* ['["css:a", "cluster:0"]', '["css:b", "cluster:0"]', 'path:other'],
|
|
165
|
-
* [
|
|
166
|
-
* { header: '<header><i class="mark-a"></i></header>', remainderHtml: '' },
|
|
167
|
-
* { header: '<header><i class="mark-a"></i></header>', remainderHtml: '' },
|
|
168
|
-
* { header: '<header><b class="mark-b"></b></header>', remainderHtml: '' },
|
|
169
|
-
* ],
|
|
170
|
-
* [new Set(['a', 'b']), new Set(['a', 'c']), new Set(['z'])],
|
|
171
|
-
* { landmarkRarityThreshold: 0.7, landmarkGateSimilarityThreshold: 0.3 },
|
|
172
|
-
* );
|
|
173
|
-
* // pages 0 and 1 share an identical header used by only 2 of the 3 pages
|
|
174
|
-
* // (a 2/3 ≈ 0.667 corpus frequency, rare at threshold 0.7) and their
|
|
175
|
-
* // content clears 0.3, so they merge onto one landmark-merge: key; page 2
|
|
176
|
-
* // (a structurally different header) is left untouched
|
|
177
|
-
* ```
|
|
178
|
-
*/
|
|
179
|
-
export declare function mergeLandmarkAffinedClusters(clusterKeys: readonly string[], landmarks: readonly ExtractLandmarksResult[], contentTokenSets: readonly ReadonlySet<string>[], options?: MergeLandmarkAffinedClustersOptions): string[];
|