@d-zero/page-cluster 0.2.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/README.md +68 -0
- package/dist/array-edit-distance.d.ts +20 -0
- package/dist/array-edit-distance.js +52 -0
- package/dist/build-segment.d.ts +18 -0
- package/dist/build-segment.js +27 -0
- package/dist/cap-content-depth.d.ts +69 -0
- package/dist/cap-content-depth.js +161 -0
- package/dist/compute-document-frequency.d.ts +33 -0
- package/dist/compute-document-frequency.js +40 -0
- package/dist/create-frame.d.ts +16 -0
- package/dist/create-frame.js +29 -0
- package/dist/derive-path-group-key.d.ts +44 -0
- package/dist/derive-path-group-key.js +51 -0
- package/dist/derive-stylesheet-group-key.d.ts +36 -0
- package/dist/derive-stylesheet-group-key.js +41 -0
- package/dist/detect-content-depth-cap.d.ts +114 -0
- package/dist/detect-content-depth-cap.js +137 -0
- package/dist/escape-reg-exp.d.ts +11 -0
- package/dist/escape-reg-exp.js +13 -0
- package/dist/excise.d.ts +13 -0
- package/dist/excise.js +24 -0
- package/dist/extract-landmarks.d.ts +82 -0
- package/dist/extract-landmarks.js +104 -0
- package/dist/filter-first-party-stylesheet-hrefs.d.ts +73 -0
- package/dist/filter-first-party-stylesheet-hrefs.js +118 -0
- package/dist/find-shallowest-elements.d.ts +39 -0
- package/dist/find-shallowest-elements.js +121 -0
- package/dist/foldable-tags.d.ts +8 -0
- package/dist/foldable-tags.js +8 -0
- package/dist/format-bracket.d.ts +11 -0
- package/dist/format-bracket.js +17 -0
- package/dist/hash-content.d.ts +22 -0
- package/dist/hash-content.js +26 -0
- package/dist/html-region-utils.d.ts +74 -0
- package/dist/html-region-utils.js +96 -0
- package/dist/is-fold-candidate.d.ts +13 -0
- package/dist/is-fold-candidate.js +16 -0
- package/dist/is-genuine-close.d.ts +23 -0
- package/dist/is-genuine-close.js +27 -0
- package/dist/is-noise-class.d.ts +6 -0
- package/dist/is-noise-class.js +8 -0
- package/dist/jaccard-similarity.d.ts +23 -0
- package/dist/jaccard-similarity.js +36 -0
- package/dist/merge-landmark-affined-clusters.d.ts +179 -0
- package/dist/merge-landmark-affined-clusters.js +544 -0
- package/dist/merge-spans.d.ts +15 -0
- package/dist/merge-spans.js +22 -0
- package/dist/noise-class-patterns.d.ts +21 -0
- package/dist/noise-class-patterns.js +74 -0
- package/dist/normalize-for-hash.d.ts +10 -0
- package/dist/normalize-for-hash.js +12 -0
- package/dist/opaque-tags.d.ts +17 -0
- package/dist/opaque-tags.js +18 -0
- package/dist/parse-class-list.d.ts +10 -0
- package/dist/parse-class-list.js +23 -0
- package/dist/reassign-orphan-block-keys.d.ts +81 -0
- package/dist/reassign-orphan-block-keys.js +159 -0
- package/dist/remove-content-blocks.d.ts +67 -0
- package/dist/remove-content-blocks.js +150 -0
- package/dist/resolve-blocking-group-keys.d.ts +116 -0
- package/dist/resolve-blocking-group-keys.js +120 -0
- package/dist/resolve-closed-frame.d.ts +26 -0
- package/dist/resolve-closed-frame.js +33 -0
- package/dist/resolve-landmark-variant-keys.d.ts +66 -0
- package/dist/resolve-landmark-variant-keys.js +71 -0
- package/dist/resolve-options.d.ts +6 -0
- package/dist/resolve-options.js +10 -0
- package/dist/resolve-page-cluster-keys.d.ts +222 -0
- package/dist/resolve-page-cluster-keys.js +198 -0
- package/dist/resolve-structural-cluster-keys.d.ts +50 -0
- package/dist/resolve-structural-cluster-keys.js +287 -0
- package/dist/run-tokenizer.d.ts +33 -0
- package/dist/run-tokenizer.js +152 -0
- package/dist/split-tokens-by-frequency.d.ts +46 -0
- package/dist/split-tokens-by-frequency.js +88 -0
- package/dist/tokenize.d.ts +58 -0
- package/dist/tokenize.js +60 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.js +1 -0
- package/package.json +102 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { jaccardSimilarity } from './jaccard-similarity.js';
|
|
2
|
+
import { tokenize } from './tokenize.js';
|
|
3
|
+
/**
|
|
4
|
+
* The four landmark types checked, in the fixed order every signature string
|
|
5
|
+
* built below iterates them in — order must be stable so two pages with the
|
|
6
|
+
* same set of matching-and-rare types always produce byte-identical
|
|
7
|
+
* signature strings.
|
|
8
|
+
*/
|
|
9
|
+
const LANDMARK_TYPES = ['header', 'footer', 'nav', 'aside'];
|
|
10
|
+
const DEFAULT_SIMILARITY_THRESHOLD = 0.8;
|
|
11
|
+
const DEFAULT_LANDMARK_RARITY_THRESHOLD = 0.05;
|
|
12
|
+
const DEFAULT_LANDMARK_GATE_SIMILARITY_THRESHOLD = 0.6;
|
|
13
|
+
/**
|
|
14
|
+
* Same technique and value as `BOUNDARY_EPSILON` in
|
|
15
|
+
* `resolve-structural-cluster-keys.ts` and `split-tokens-by-frequency.ts`,
|
|
16
|
+
* kept as an independent per-file copy by this package's convention (see
|
|
17
|
+
* `resolve-structural-cluster-keys.ts`'s own `BOUNDARY_EPSILON` JSDoc).
|
|
18
|
+
*/
|
|
19
|
+
const BOUNDARY_EPSILON = 1e-9;
|
|
20
|
+
/**
|
|
21
|
+
* Prefix distinguishing a landmark-gated merge's key from every other key
|
|
22
|
+
* family this package produces (`css:`/`path:`/`orphan-merge:`/the
|
|
23
|
+
* `[blockKey, "cluster:N"]` JSON pairs `resolvePageClusterKeys` itself
|
|
24
|
+
* emits), so the families can never collide. Mirrors
|
|
25
|
+
* `reassign-orphan-block-keys.ts`'s `REASSIGNED_KEY_PREFIX`.
|
|
26
|
+
*/
|
|
27
|
+
const MERGED_KEY_PREFIX = 'landmark-merge:';
|
|
28
|
+
/**
|
|
29
|
+
* Reads `values[index]`, throwing instead of returning `undefined`. Every
|
|
30
|
+
* call site here indexes with a position this function generated itself, so
|
|
31
|
+
* the thrown branch is unreachable in practice; it exists to satisfy
|
|
32
|
+
* `noUncheckedIndexedAccess` without a non-null assertion. Independent copy
|
|
33
|
+
* by this package's established convention — see
|
|
34
|
+
* `resolve-page-cluster-keys.ts`'s own `requireIndex` JSDoc.
|
|
35
|
+
* @param values
|
|
36
|
+
* @param index
|
|
37
|
+
*/
|
|
38
|
+
function requireIndex(values, index) {
|
|
39
|
+
const value = values[index];
|
|
40
|
+
if (value === undefined) {
|
|
41
|
+
throw new Error('mergeLandmarkAffinedClusters: index out of bounds');
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reads `map.get(key)`, throwing instead of returning `undefined`. Every
|
|
47
|
+
* call site here looks up a key this function (or its caller, in the same
|
|
48
|
+
* pass) just inserted, so the thrown branch is unreachable in practice — the
|
|
49
|
+
* `Map` analogue of `requireIndex` above.
|
|
50
|
+
* @param map
|
|
51
|
+
* @param key
|
|
52
|
+
*/
|
|
53
|
+
function requireMapValue(map, key) {
|
|
54
|
+
const value = map.get(key);
|
|
55
|
+
if (value === undefined) {
|
|
56
|
+
throw new Error('mergeLandmarkAffinedClusters: expected map entry missing');
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Validates that `value` (one of this file's three `[0, 1]`-range options,
|
|
62
|
+
* `name` being its option name for the thrown message) is in range,
|
|
63
|
+
* throwing `RangeError` otherwise. Shared by
|
|
64
|
+
* `validateMergeLandmarkAffinedClustersOptions`'s three checks so their
|
|
65
|
+
* range and message format can never drift apart from each other.
|
|
66
|
+
* @param value
|
|
67
|
+
* @param name
|
|
68
|
+
*/
|
|
69
|
+
function requireThreshold(value, name) {
|
|
70
|
+
if (!(value >= 0 && value <= 1)) {
|
|
71
|
+
throw new RangeError(`mergeLandmarkAffinedClusters: ${name} must be between 0 and 1, got ${value}`);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Validates `similarityThreshold`/`landmarkRarityThreshold`/
|
|
77
|
+
* `landmarkGateSimilarityThreshold` without running
|
|
78
|
+
* `mergeLandmarkAffinedClusters` itself — exported so
|
|
79
|
+
* `resolvePageClusterKeys` can fail fast on bad options even when `pages` is
|
|
80
|
+
* empty (its own per-block loop never reaches this function at all in that
|
|
81
|
+
* case). Mirrors `detect-content-depth-cap.ts`'s
|
|
82
|
+
* `validateDetectContentDepthCapOptions` exact rationale and shape.
|
|
83
|
+
* @param options
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* // Fails fast on a bad option even though nothing here would otherwise
|
|
87
|
+
* // call mergeLandmarkAffinedClusters yet (e.g. cluster keys haven't been
|
|
88
|
+
* // computed).
|
|
89
|
+
* validateMergeLandmarkAffinedClustersOptions({ landmarkRarityThreshold: -1 }); // throws RangeError
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export function validateMergeLandmarkAffinedClustersOptions(options) {
|
|
93
|
+
requireThreshold(options?.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD, 'similarityThreshold');
|
|
94
|
+
requireThreshold(options?.landmarkRarityThreshold ?? DEFAULT_LANDMARK_RARITY_THRESHOLD, 'landmarkRarityThreshold');
|
|
95
|
+
requireThreshold(options?.landmarkGateSimilarityThreshold ??
|
|
96
|
+
DEFAULT_LANDMARK_GATE_SIMILARITY_THRESHOLD, 'landmarkGateSimilarityThreshold');
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Canonicalizes a token set into a string that is identical for two sets iff
|
|
100
|
+
* their members are identical — sorted so the (undefined) `Set` iteration
|
|
101
|
+
* order can never make two structurally-equal sets hash differently. Used
|
|
102
|
+
* only to bucket byte-for-byte-identical landmark token sets before
|
|
103
|
+
* clustering (see `computeLandmarkStatus`'s JSDoc); not a general-purpose
|
|
104
|
+
* set-hashing utility.
|
|
105
|
+
*
|
|
106
|
+
* `JSON.stringify` of the sorted array, not a plain joined string: a token
|
|
107
|
+
* itself can contain a space (`format-bracket.ts` splices an element's raw
|
|
108
|
+
* `role`/`type` attribute value in verbatim, e.g. `role="a b"` produces the
|
|
109
|
+
* literal token `header[role=a b]`), so a delimiter-joined string would let
|
|
110
|
+
* two genuinely different sets — e.g. `{"a b", "c"}` and `{"a", "b", "c"}` —
|
|
111
|
+
* collide on the identical joined string `"a b c"`. `JSON.stringify` escapes
|
|
112
|
+
* each array element as its own quoted string, so no element's content can
|
|
113
|
+
* ever be mistaken for the array's own structural delimiters. Matches this
|
|
114
|
+
* file's own `landmark-merge:` key construction (`JSON.stringify(sortedKeys)`),
|
|
115
|
+
* which relies on the same collision-safety for the same reason.
|
|
116
|
+
* @param tokens
|
|
117
|
+
*/
|
|
118
|
+
function canonicalizeTokenSet(tokens) {
|
|
119
|
+
return JSON.stringify([...tokens].toSorted());
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Finds the representative (root) of `index`'s set, compressing every
|
|
123
|
+
* traversed link. Independent copy of
|
|
124
|
+
* `resolve-structural-cluster-keys.ts`'s own `find`, by the same convention
|
|
125
|
+
* as `requireIndex` above.
|
|
126
|
+
* @param parent
|
|
127
|
+
* @param index
|
|
128
|
+
*/
|
|
129
|
+
function find(parent, index) {
|
|
130
|
+
let root = index;
|
|
131
|
+
while (requireIndex(parent, root) !== root) {
|
|
132
|
+
root = requireIndex(parent, root);
|
|
133
|
+
}
|
|
134
|
+
let current = index;
|
|
135
|
+
while (current !== root) {
|
|
136
|
+
const next = requireIndex(parent, current);
|
|
137
|
+
parent[current] = root;
|
|
138
|
+
current = next;
|
|
139
|
+
}
|
|
140
|
+
return root;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Complete-linkage merge of a small number of nodes (`nodeCount`) given a
|
|
144
|
+
* precomputed, symmetric `nodeCount`x`nodeCount` similarity matrix. Returns
|
|
145
|
+
* the resulting partition as groups of node indices.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately not a shared import of `resolve-structural-cluster-keys.ts`'s
|
|
148
|
+
* NN-chain implementation, which is hard-wired to compute Jaccard similarity
|
|
149
|
+
* from token sets internally rather than accepting a precomputed matrix.
|
|
150
|
+
* Both call sites in this file (`computeLandmarkStatus`'s deduplicated
|
|
151
|
+
* landmark-variant buckets, and `mergeLandmarkAffinedClusters`'s
|
|
152
|
+
* rare-signature groups' distinct cluster keys) feed this function a
|
|
153
|
+
* `nodeCount` that is self-limited to a small size by construction — see
|
|
154
|
+
* `computeLandmarkStatus`'s own JSDoc for the cost analysis — so a
|
|
155
|
+
* brute-force repeated-best-pair merge (same reference shape as
|
|
156
|
+
* `resolve-structural-cluster-keys.spec.ts`'s differential-test helper) is
|
|
157
|
+
* used directly rather than re-implementing NN-chain a second time for a
|
|
158
|
+
* negligible input size.
|
|
159
|
+
* @param nodeCount
|
|
160
|
+
* @param similarity
|
|
161
|
+
* @param threshold
|
|
162
|
+
*/
|
|
163
|
+
function mergeSmallClustersByCompleteLinkage(nodeCount, similarity, threshold) {
|
|
164
|
+
let groups = Array.from({ length: nodeCount }, (_, index) => [index]);
|
|
165
|
+
for (;;) {
|
|
166
|
+
let bestPair;
|
|
167
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
168
|
+
for (let i = 0; i < groups.length; i++) {
|
|
169
|
+
for (let j = i + 1; j < groups.length; j++) {
|
|
170
|
+
let minSimilarity = Number.POSITIVE_INFINITY;
|
|
171
|
+
for (const a of requireIndex(groups, i)) {
|
|
172
|
+
for (const b of requireIndex(groups, j)) {
|
|
173
|
+
minSimilarity = Math.min(minSimilarity, requireIndex(similarity, a * nodeCount + b));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (minSimilarity > bestScore) {
|
|
177
|
+
bestScore = minSimilarity;
|
|
178
|
+
bestPair = [i, j];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!bestPair || bestScore < threshold - BOUNDARY_EPSILON) {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
const [i, j] = bestPair;
|
|
186
|
+
groups[i] = [...requireIndex(groups, i), ...requireIndex(groups, j)];
|
|
187
|
+
groups = groups.filter((_, index) => index !== j);
|
|
188
|
+
}
|
|
189
|
+
return groups;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* For one landmark type, determines each page's corpus-wide variant identity
|
|
193
|
+
* and whether that variant is rare (see `mergeLandmarkAffinedClusters`'s
|
|
194
|
+
* JSDoc for what "rare" gates). A page missing this landmark type reports
|
|
195
|
+
* `{ exists: false }` unconditionally — it never counts toward any variant's
|
|
196
|
+
* frequency and is never itself "rare" or "common".
|
|
197
|
+
*
|
|
198
|
+
* Does not call `resolveStructuralClusterKeys` (unlike
|
|
199
|
+
* `resolve-landmark-variant-keys.ts`'s own landmark-variant classification).
|
|
200
|
+
* That function unconditionally narrows its input via
|
|
201
|
+
* `deriveComparisonSets` once given 10+ items — stripping out tokens shared
|
|
202
|
+
* by 90%+ of the compared sets as "chrome" and comparing only what's left.
|
|
203
|
+
* That is the right behavior for comparing *whole pages* (shared chrome
|
|
204
|
+
* should not inflate similarity between otherwise-different content), but
|
|
205
|
+
* it is backwards for comparing *landmark fragments to each other*: the
|
|
206
|
+
* stable, shared bulk of a header's markup is exactly the signal that two
|
|
207
|
+
* pages have "the same header design", and stripping it out would leave
|
|
208
|
+
* only incidental per-page differences (e.g. a "current page" nav-highlight
|
|
209
|
+
* class) to compare on. This function therefore uses raw `jaccardSimilarity`
|
|
210
|
+
* directly instead.
|
|
211
|
+
*
|
|
212
|
+
* It also skips the O(n²) all-pairs comparison `resolveStructuralClusterKeys`
|
|
213
|
+
* would otherwise run across the *entire, unblocked* corpus (a real cost:
|
|
214
|
+
* estimated ~19s and ~640MB for a single such call over an 8,936-page corpus,
|
|
215
|
+
* extrapolated from `detectContentDepthCap`'s own measured ~4s/call over a
|
|
216
|
+
* 4,085-page block — four landmark types would multiply that to ~76s). Real
|
|
217
|
+
* sites near-universally reuse byte-identical (post-tokenization) landmark
|
|
218
|
+
* markup across pages of the same template, so pages are first bucketed by
|
|
219
|
+
* exact token-set equality (`canonicalizeTokenSet`, O(n)) before any
|
|
220
|
+
* similarity is computed at all; only the resulting *distinct* buckets
|
|
221
|
+
* (expected in the tens at most, even for a large real corpus) are compared
|
|
222
|
+
* pairwise and complete-linkage-merged. This is not an approximation:
|
|
223
|
+
* deduplicating identical items before a Jaccard-based complete-linkage
|
|
224
|
+
* clustering step, then broadcasting each surviving cluster's label back to
|
|
225
|
+
* every item in the buckets it absorbed, produces the same partition a full
|
|
226
|
+
* item-by-item comparison would (`jaccardSimilarity` of two identical sets is
|
|
227
|
+
* always `1`, so duplicates always land in the same cluster; a duplicate's
|
|
228
|
+
* similarity to every other item is by definition identical to its
|
|
229
|
+
* representative's). If a corpus instead has near-zero landmark reuse (every
|
|
230
|
+
* page's markup for this type is unique), bucket count approaches page
|
|
231
|
+
* count and this degrades toward the O(n²) cost it otherwise avoids — but
|
|
232
|
+
* that scenario also means there is no shared, rare landmark for this
|
|
233
|
+
* mechanism to find evidence in regardless, so the degenerate cost case and
|
|
234
|
+
* the case where this feature has nothing to contribute coincide.
|
|
235
|
+
* @param type
|
|
236
|
+
* @param landmarks
|
|
237
|
+
* @param similarityThreshold
|
|
238
|
+
* @param landmarkRarityThreshold
|
|
239
|
+
* @param options
|
|
240
|
+
*/
|
|
241
|
+
function computeLandmarkStatus(type, landmarks, similarityThreshold, landmarkRarityThreshold, options) {
|
|
242
|
+
const pageCount = landmarks.length;
|
|
243
|
+
const tokenSets = landmarks.map((entry) => {
|
|
244
|
+
const region = entry[type];
|
|
245
|
+
return region === undefined
|
|
246
|
+
? undefined
|
|
247
|
+
: new Set(tokenize(`<body>${region}</body>`, options).tokens);
|
|
248
|
+
});
|
|
249
|
+
const bucketIndexByKey = new Map();
|
|
250
|
+
const bucketRepresentatives = [];
|
|
251
|
+
const bucketIndexOfPage = tokenSets.map((tokens) => {
|
|
252
|
+
if (tokens === undefined) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const key = canonicalizeTokenSet(tokens);
|
|
256
|
+
const existing = bucketIndexByKey.get(key);
|
|
257
|
+
if (existing !== undefined) {
|
|
258
|
+
return existing;
|
|
259
|
+
}
|
|
260
|
+
const index = bucketRepresentatives.length;
|
|
261
|
+
bucketRepresentatives.push(tokens);
|
|
262
|
+
bucketIndexByKey.set(key, index);
|
|
263
|
+
return index;
|
|
264
|
+
});
|
|
265
|
+
const bucketCount = bucketRepresentatives.length;
|
|
266
|
+
const similarity = new Float64Array(bucketCount * bucketCount);
|
|
267
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
268
|
+
for (let j = i + 1; j < bucketCount; j++) {
|
|
269
|
+
const score = jaccardSimilarity(requireIndex(bucketRepresentatives, i), requireIndex(bucketRepresentatives, j));
|
|
270
|
+
similarity[i * bucketCount + j] = score;
|
|
271
|
+
similarity[j * bucketCount + i] = score;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const groups = mergeSmallClustersByCompleteLinkage(bucketCount, similarity, similarityThreshold);
|
|
275
|
+
const groupLabelByBucketIndex = new Map();
|
|
276
|
+
for (const [groupIndex, bucketIndices] of groups.entries()) {
|
|
277
|
+
for (const bucketIndex of bucketIndices) {
|
|
278
|
+
groupLabelByBucketIndex.set(bucketIndex, `variant:${groupIndex}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const variantLabelOfPage = bucketIndexOfPage.map((bucketIndex) => bucketIndex === undefined
|
|
282
|
+
? undefined
|
|
283
|
+
: requireMapValue(groupLabelByBucketIndex, bucketIndex));
|
|
284
|
+
const countByLabel = new Map();
|
|
285
|
+
for (const label of variantLabelOfPage) {
|
|
286
|
+
if (label !== undefined) {
|
|
287
|
+
countByLabel.set(label, (countByLabel.get(label) ?? 0) + 1);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return variantLabelOfPage.map((label) => {
|
|
291
|
+
if (label === undefined) {
|
|
292
|
+
return { exists: false };
|
|
293
|
+
}
|
|
294
|
+
const ratio = requireMapValue(countByLabel, label) / pageCount;
|
|
295
|
+
return { exists: true, rare: ratio < landmarkRarityThreshold, variantLabel: label };
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Re-keys the pages of two or more distinct
|
|
300
|
+
* {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys} clusters
|
|
301
|
+
* onto one shared key when every landmark type present on their pages is
|
|
302
|
+
* both *identical* and *rare* corpus-wide, and their actual content clears a
|
|
303
|
+
* secondary, looser similarity threshold.
|
|
304
|
+
*
|
|
305
|
+
* Reimplements a mechanism previously prototyped under this same name and
|
|
306
|
+
* withdrawn (no trace survives in commit history — this JSDoc is the only
|
|
307
|
+
* record). The withdrawn version merged clusters whenever their
|
|
308
|
+
* header/footer/nav/aside matched, full stop. Validated against two real
|
|
309
|
+
* crawl corpora (302 and 8,936 pages), that produced runaway over-merging:
|
|
310
|
+
* header/footer/nav were present on 99%+ of pages and typically reused
|
|
311
|
+
* site-wide unchanged (see `extractLandmarks`'s own JSDoc for that figure),
|
|
312
|
+
* so "landmarks match" was true for nearly every page pair and carried no
|
|
313
|
+
* discriminative power at all. This reimplementation only ever treats a
|
|
314
|
+
* landmark match as merge evidence when that specific landmark *variant* is
|
|
315
|
+
* itself uncommon corpus-wide (`landmarkRarityThreshold`) — the condition
|
|
316
|
+
* the withdrawn attempt lacked.
|
|
317
|
+
*
|
|
318
|
+
* The match requirement is deliberately the most conservative option
|
|
319
|
+
* considered: *every* landmark type actually present on a page must both
|
|
320
|
+
* match its counterpart's variant and be rare — a page with even one common
|
|
321
|
+
* ("everybody has this exact header") present type contributes no evidence
|
|
322
|
+
* at all, rather than partially qualifying. A looser rule (e.g. "at least
|
|
323
|
+
* one shared rare type is enough") was rejected because it reintroduces a
|
|
324
|
+
* version of the original failure mode: a page could ride a single
|
|
325
|
+
* incidentally-rare landmark into a merge despite otherwise-ordinary,
|
|
326
|
+
* ubiquitous chrome elsewhere on the same page.
|
|
327
|
+
*
|
|
328
|
+
* Frequency is counted corpus-wide, not per-block: a `resolveStructuralClusterKeys`
|
|
329
|
+
* cluster label (`cluster:N`) is only unique within the block it was computed
|
|
330
|
+
* in, but rarity here needs one consistent count across the whole input, the
|
|
331
|
+
* same reason `resolvePageClusterKeys` itself composes `[blockKey,
|
|
332
|
+
* localLabel]` via `JSON.stringify` rather than reusing bare labels across
|
|
333
|
+
* blocks.
|
|
334
|
+
*
|
|
335
|
+
* A page with none of the four landmark types present is excluded from
|
|
336
|
+
* consideration entirely (`existingCount === 0` below) — without this, every
|
|
337
|
+
* landmark-less page across the whole corpus would share one large,
|
|
338
|
+
* unbounded "no landmarks" group, defeating the self-limiting cost bound
|
|
339
|
+
* `landmarkRarityThreshold` is otherwise supposed to guarantee (see
|
|
340
|
+
* `computeLandmarkStatus`'s JSDoc for the cost analysis this depends on).
|
|
341
|
+
*
|
|
342
|
+
* Once pages are grouped by matching-and-rare landmark signature, only
|
|
343
|
+
* signature groups spanning two or more distinct existing cluster keys do
|
|
344
|
+
* any further work. Within such a group, the *content* token sets of the
|
|
345
|
+
* group's distinct cluster keys are complete-linkage-merged at
|
|
346
|
+
* `landmarkGateSimilarityThreshold` — looser than
|
|
347
|
+
* `resolveStructuralClusterKeys`'s own `similarityThreshold`, since the
|
|
348
|
+
* whole point of this mechanism is to bridge clusters whose *content*
|
|
349
|
+
* similarity alone fell just short of the primary threshold. Complete-linkage
|
|
350
|
+
* (not single-linkage) is used for the same reason
|
|
351
|
+
* `resolveStructuralClusterKeys` itself uses it: single-linkage's chaining
|
|
352
|
+
* would let one loosely-matching pair bridge two genuinely-unrelated
|
|
353
|
+
* clusters transitively.
|
|
354
|
+
*
|
|
355
|
+
* The resulting merge is applied at *page* granularity, not by blanket-
|
|
356
|
+
* reassigning every page of the involved cluster keys: only the specific
|
|
357
|
+
* pages that were actually pooled into the qualifying signature group (and,
|
|
358
|
+
* transitively, any other page unioned with them via a different signature
|
|
359
|
+
* group) move onto the shared key. A cluster's pages that never carried the
|
|
360
|
+
* rare landmark evidence keep their original key untouched, even if some
|
|
361
|
+
* other page sharing that same cluster key did qualify and merge elsewhere.
|
|
362
|
+
* This is deliberate, not an incidental restriction: applying a merge
|
|
363
|
+
* decision to *every* page of the involved cluster keys — evidenced by only
|
|
364
|
+
* a small subset of them — would extrapolate a coincidental pairing (e.g.
|
|
365
|
+
* one outlier page in each of two otherwise-unrelated clusters happening to
|
|
366
|
+
* share a rare seasonal-campaign header) into force-merging the clusters'
|
|
367
|
+
* entire, otherwise-dissimilar membership. That is the withdrawn prototype's
|
|
368
|
+
* over-merging failure mode reappearing through a different mechanism
|
|
369
|
+
* (whole-cluster application of a single-pair signal) rather than the
|
|
370
|
+
* landmark-commonality mechanism this file was reimplemented to fix — see
|
|
371
|
+
* this function's own regression test for a worked example.
|
|
372
|
+
*
|
|
373
|
+
* Merged pages are re-keyed to `landmark-merge:${JSON.stringify(sortedKeys)}`
|
|
374
|
+
* (`sortedKeys` being the *original* cluster keys the merged pages came
|
|
375
|
+
* from) — a fresh prefix that cannot collide with `css:`/`path:`/
|
|
376
|
+
* `orphan-merge:` or `resolvePageClusterKeys`'s own `[blockKey, "cluster:N"]`
|
|
377
|
+
* pairs (mirrors `reassign-orphan-block-keys.ts`'s `orphan-merge:` prefix).
|
|
378
|
+
* @param clusterKeys - one existing final key per page, same order/length as `landmarks`/`contentTokenSets`
|
|
379
|
+
* @param landmarks - `extractLandmarks(page.html)`'s full result per page (all four fields, not just `remainderHtml`)
|
|
380
|
+
* @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
|
|
381
|
+
* @param options
|
|
382
|
+
* @example
|
|
383
|
+
* ```ts
|
|
384
|
+
* // tokenize() discards visible text (see its own JSDoc), so the two
|
|
385
|
+
* // header variants below must differ structurally (child element/class),
|
|
386
|
+
* // not merely in text, to compare as different landmark variants.
|
|
387
|
+
* mergeLandmarkAffinedClusters(
|
|
388
|
+
* ['["css:a", "cluster:0"]', '["css:b", "cluster:0"]', 'path:other'],
|
|
389
|
+
* [
|
|
390
|
+
* { header: '<header><i class="mark-a"></i></header>', remainderHtml: '' },
|
|
391
|
+
* { header: '<header><i class="mark-a"></i></header>', remainderHtml: '' },
|
|
392
|
+
* { header: '<header><b class="mark-b"></b></header>', remainderHtml: '' },
|
|
393
|
+
* ],
|
|
394
|
+
* [new Set(['a', 'b']), new Set(['a', 'c']), new Set(['z'])],
|
|
395
|
+
* { landmarkRarityThreshold: 0.7, landmarkGateSimilarityThreshold: 0.3 },
|
|
396
|
+
* );
|
|
397
|
+
* // pages 0 and 1 share an identical header used by only 2 of the 3 pages
|
|
398
|
+
* // (a 2/3 ≈ 0.667 corpus frequency, rare at threshold 0.7) and their
|
|
399
|
+
* // content clears 0.3, so they merge onto one landmark-merge: key; page 2
|
|
400
|
+
* // (a structurally different header) is left untouched
|
|
401
|
+
* ```
|
|
402
|
+
*/
|
|
403
|
+
export function mergeLandmarkAffinedClusters(clusterKeys, landmarks, contentTokenSets, options) {
|
|
404
|
+
// Self-validates, unlike relying solely on a caller's separate
|
|
405
|
+
// validateMergeLandmarkAffinedClustersOptions call: mirrors
|
|
406
|
+
// detectContentDepthCap's own first line, and matters here because this
|
|
407
|
+
// function is directly importable via this package's
|
|
408
|
+
// `./merge-landmark-affined-clusters` subpath export, not only reachable
|
|
409
|
+
// through resolvePageClusterKeys's own eager pre-validation. Without
|
|
410
|
+
// this, an out-of-range or NaN threshold would silently defeat
|
|
411
|
+
// mergeSmallClustersByCompleteLinkage's stop condition
|
|
412
|
+
// (`bestScore < threshold - BOUNDARY_EPSILON` never becomes true against
|
|
413
|
+
// a negative or NaN threshold) and force-merge every candidate group
|
|
414
|
+
// into one, with no error raised.
|
|
415
|
+
validateMergeLandmarkAffinedClustersOptions(options);
|
|
416
|
+
if (clusterKeys.length === 0) {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
const similarityThreshold = options?.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD;
|
|
420
|
+
const landmarkRarityThreshold = options?.landmarkRarityThreshold ?? DEFAULT_LANDMARK_RARITY_THRESHOLD;
|
|
421
|
+
const landmarkGateSimilarityThreshold = options?.landmarkGateSimilarityThreshold ??
|
|
422
|
+
DEFAULT_LANDMARK_GATE_SIMILARITY_THRESHOLD;
|
|
423
|
+
const statusByType = new Map(LANDMARK_TYPES.map((type) => [
|
|
424
|
+
type,
|
|
425
|
+
computeLandmarkStatus(type, landmarks, similarityThreshold, landmarkRarityThreshold, options),
|
|
426
|
+
]));
|
|
427
|
+
const signatures = clusterKeys.map((_, pageIndex) => {
|
|
428
|
+
let existingCount = 0;
|
|
429
|
+
let allRare = true;
|
|
430
|
+
const parts = [];
|
|
431
|
+
for (const type of LANDMARK_TYPES) {
|
|
432
|
+
const status = requireIndex(requireMapValue(statusByType, type), pageIndex);
|
|
433
|
+
if (!status.exists) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
existingCount++;
|
|
437
|
+
if (!status.rare) {
|
|
438
|
+
allRare = false;
|
|
439
|
+
}
|
|
440
|
+
parts.push(`${type}:${status.variantLabel}`);
|
|
441
|
+
}
|
|
442
|
+
return existingCount > 0 && allRare ? parts.join('|') : undefined;
|
|
443
|
+
});
|
|
444
|
+
const pageIndicesBySignature = new Map();
|
|
445
|
+
for (const [pageIndex, signature] of signatures.entries()) {
|
|
446
|
+
if (signature === undefined) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const indices = pageIndicesBySignature.get(signature);
|
|
450
|
+
if (indices) {
|
|
451
|
+
indices.push(pageIndex);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
pageIndicesBySignature.set(signature, [pageIndex]);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Page-level union-find, not cluster-key-level: a merge decision drawn
|
|
458
|
+
// from a signature group's (necessarily partial — only the pages that
|
|
459
|
+
// happened to land in that group) evidence must bind only the specific
|
|
460
|
+
// pages that supplied it. Unioning by cluster key instead would apply a
|
|
461
|
+
// single qualifying pair's evidence to every page sharing either
|
|
462
|
+
// cluster key, including pages with no evidence at all — see this
|
|
463
|
+
// function's own JSDoc "page granularity" section for why that
|
|
464
|
+
// reintroduces the withdrawn prototype's over-merging failure.
|
|
465
|
+
const parent = Int32Array.from({ length: clusterKeys.length }, (_, index) => index);
|
|
466
|
+
for (const pageIndices of pageIndicesBySignature.values()) {
|
|
467
|
+
const memberIndicesByClusterKey = new Map();
|
|
468
|
+
for (const pageIndex of pageIndices) {
|
|
469
|
+
const clusterKey = requireIndex(clusterKeys, pageIndex);
|
|
470
|
+
const members = memberIndicesByClusterKey.get(clusterKey);
|
|
471
|
+
if (members) {
|
|
472
|
+
members.push(pageIndex);
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
memberIndicesByClusterKey.set(clusterKey, [pageIndex]);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const groupClusterKeys = [...memberIndicesByClusterKey.keys()];
|
|
479
|
+
if (groupClusterKeys.length <= 1) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const groupMembers = groupClusterKeys.map((key) => requireMapValue(memberIndicesByClusterKey, key));
|
|
483
|
+
const k = groupClusterKeys.length;
|
|
484
|
+
const similarity = new Float64Array(k * k);
|
|
485
|
+
for (let i = 0; i < k; i++) {
|
|
486
|
+
for (let j = i + 1; j < k; j++) {
|
|
487
|
+
let minSimilarity = Number.POSITIVE_INFINITY;
|
|
488
|
+
for (const p of requireIndex(groupMembers, i)) {
|
|
489
|
+
for (const q of requireIndex(groupMembers, j)) {
|
|
490
|
+
const score = jaccardSimilarity(requireIndex(contentTokenSets, p), requireIndex(contentTokenSets, q));
|
|
491
|
+
minSimilarity = Math.min(minSimilarity, score);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
similarity[i * k + j] = minSimilarity;
|
|
495
|
+
similarity[j * k + i] = minSimilarity;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const mergedGroups = mergeSmallClustersByCompleteLinkage(k, similarity, landmarkGateSimilarityThreshold);
|
|
499
|
+
for (const group of mergedGroups) {
|
|
500
|
+
if (group.length <= 1) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
// Union only the specific evidence pages behind the cluster keys
|
|
504
|
+
// in this merged group — not every page that happens to share
|
|
505
|
+
// those keys elsewhere in the corpus (see this function's JSDoc).
|
|
506
|
+
const evidencePages = group.flatMap((clusterIndex) => requireIndex(groupMembers, clusterIndex));
|
|
507
|
+
const firstPage = requireIndex(evidencePages, 0);
|
|
508
|
+
for (let i = 1; i < evidencePages.length; i++) {
|
|
509
|
+
const rootA = find(parent, firstPage);
|
|
510
|
+
const rootB = find(parent, requireIndex(evidencePages, i));
|
|
511
|
+
if (rootA !== rootB) {
|
|
512
|
+
parent[rootB] = rootA;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const pageIndicesByRoot = new Map();
|
|
518
|
+
for (let pageIndex = 0; pageIndex < clusterKeys.length; pageIndex++) {
|
|
519
|
+
const root = find(parent, pageIndex);
|
|
520
|
+
const indices = pageIndicesByRoot.get(root);
|
|
521
|
+
if (indices) {
|
|
522
|
+
indices.push(pageIndex);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
pageIndicesByRoot.set(root, [pageIndex]);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const remap = new Map();
|
|
529
|
+
for (const indices of pageIndicesByRoot.values()) {
|
|
530
|
+
const originalKeys = new Set(indices.map((index) => requireIndex(clusterKeys, index)));
|
|
531
|
+
// A component every one of whose pages already shares one original
|
|
532
|
+
// cluster key never actually crossed a cluster boundary (the common
|
|
533
|
+
// case: most pages never entered any signature group at all, so
|
|
534
|
+
// their root is just themselves) — nothing to re-key.
|
|
535
|
+
if (originalKeys.size <= 1) {
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const mergedKey = `${MERGED_KEY_PREFIX}${JSON.stringify([...originalKeys].toSorted())}`;
|
|
539
|
+
for (const index of indices) {
|
|
540
|
+
remap.set(index, mergedKey);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return clusterKeys.map((key, index) => remap.get(index) ?? key);
|
|
544
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merges a set of (possibly overlapping or nested) `[start, end)` spans into
|
|
3
|
+
* the smallest equivalent set of disjoint spans, sorted by start offset.
|
|
4
|
+
* Matched spans commonly nest in real markup (e.g. a site nav living inside
|
|
5
|
+
* the header, `<header><nav>...</nav></header>`) — merging first means the
|
|
6
|
+
* later excision pass never has to reason about overlap.
|
|
7
|
+
* @param spans
|
|
8
|
+
*/
|
|
9
|
+
export declare function mergeSpans(spans: readonly {
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
}[]): {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
}[];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merges a set of (possibly overlapping or nested) `[start, end)` spans into
|
|
3
|
+
* the smallest equivalent set of disjoint spans, sorted by start offset.
|
|
4
|
+
* Matched spans commonly nest in real markup (e.g. a site nav living inside
|
|
5
|
+
* the header, `<header><nav>...</nav></header>`) — merging first means the
|
|
6
|
+
* later excision pass never has to reason about overlap.
|
|
7
|
+
* @param spans
|
|
8
|
+
*/
|
|
9
|
+
export function mergeSpans(spans) {
|
|
10
|
+
const sorted = [...spans].toSorted((a, b) => a.start - b.start);
|
|
11
|
+
const merged = [];
|
|
12
|
+
for (const span of sorted) {
|
|
13
|
+
const last = merged.at(-1);
|
|
14
|
+
if (last && span.start <= last.end) {
|
|
15
|
+
last.end = Math.max(last.end, span.end);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
merged.push({ ...span });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return merged;
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristics for auto-generated class names that change on every build even
|
|
3
|
+
* when the underlying template is unchanged (CSS Modules, styled-components,
|
|
4
|
+
* emotion, bundler content-hash suffixes). Left in place, these would make
|
|
5
|
+
* identical templates look structurally different across builds/deploys,
|
|
6
|
+
* defeating near-duplicate detection. The generic alphanumeric-hash pattern requires
|
|
7
|
+
* both a letter and a digit so real words (e.g. BEM modifiers like
|
|
8
|
+
* `card--active`) are not caught by accident; it is still the least precise
|
|
9
|
+
* entry here, which is why `filterNoiseClasses` can be turned off.
|
|
10
|
+
*
|
|
11
|
+
* The `sc-`/`css-`/generic-hex patterns below all follow the same
|
|
12
|
+
* "require a digit or uppercase letter" idiom to rule out real English words
|
|
13
|
+
* that happen to fit the hash's character-set shape (`sc-header`,
|
|
14
|
+
* `css-editor`, `section-facade`, ...). This trades a small, accepted
|
|
15
|
+
* false-negative rate for hash generators that occasionally produce an
|
|
16
|
+
* all-lowercase, all-letter run (same trade-off already made for the
|
|
17
|
+
* double-underscore pattern below) against eliminating false positives on
|
|
18
|
+
* ordinary class names, which is the more common and more disruptive
|
|
19
|
+
* failure for this package's purpose.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEFAULT_NOISE_CLASS_PATTERNS: readonly RegExp[];
|