@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,287 @@
|
|
|
1
|
+
import { computeDocumentFrequency } from './compute-document-frequency.js';
|
|
2
|
+
import { jaccardSimilarity } from './jaccard-similarity.js';
|
|
3
|
+
import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
|
|
4
|
+
const DEFAULT_SIMILARITY_THRESHOLD = 0.8;
|
|
5
|
+
/**
|
|
6
|
+
* `jaccardSimilarity()` returns `intersectionSize / unionSize`, a
|
|
7
|
+
* floating-point division that can land a hair below the caller's intended
|
|
8
|
+
* threshold even when the two are mathematically equal (e.g. a threshold
|
|
9
|
+
* assembled from arithmetic like `0.1 + 0.2` is `0.30000000000000004`, not
|
|
10
|
+
* `0.3`), which would otherwise make a pair at the documented inclusive
|
|
11
|
+
* boundary fail the `>=` check it should pass. Subtracting this epsilon
|
|
12
|
+
* before comparing absorbs that rounding noise (same technique and value as
|
|
13
|
+
* `BOUNDARY_EPSILON` in `split-tokens-by-frequency.ts`).
|
|
14
|
+
*/
|
|
15
|
+
const BOUNDARY_EPSILON = 1e-9;
|
|
16
|
+
/**
|
|
17
|
+
* Below this many pages, `computeDocumentFrequency`/`splitTokensByFrequency`
|
|
18
|
+
* (the default 90% cutoff) degenerate rather than usefully separate chrome
|
|
19
|
+
* from content — see `deriveComparisonSets` for the failure mode. Derived
|
|
20
|
+
* from `splitTokensByFrequency`'s own cutoff: a token missing from exactly
|
|
21
|
+
* one page out of `n` still counts as chrome only if
|
|
22
|
+
* `(n - 1) / n >= 0.9`, i.e. `n >= 10`. Below that, this function falls back
|
|
23
|
+
* to comparing `tokenSets` directly, unfiltered.
|
|
24
|
+
*/
|
|
25
|
+
const MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT = 10;
|
|
26
|
+
/**
|
|
27
|
+
* Narrows each page's token set to its page-specific content before
|
|
28
|
+
* clustering, so pages that only share site-wide chrome (header/nav/footer)
|
|
29
|
+
* don't read as more similar than they structurally are, and so genuine
|
|
30
|
+
* layout matches aren't swamped by chrome noise at loose thresholds — see
|
|
31
|
+
* `splitTokensByFrequency`'s JSDoc for the two failure modes this fixes.
|
|
32
|
+
* Confirmed on real crawl data (a corporate site using a freeform CMS block
|
|
33
|
+
* editor for its content area): without this, two pages built from the same
|
|
34
|
+
* article template but a different mix of content blocks could score *lower*
|
|
35
|
+
* on raw Jaccard than two pages built from genuinely different templates
|
|
36
|
+
* that happen to share more chrome relative to their (smaller) content area.
|
|
37
|
+
*
|
|
38
|
+
* Skipped entirely below `MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT` pages: at the
|
|
39
|
+
* default 90% cutoff, `splitTokensByFrequency` requires a token to appear on
|
|
40
|
+
* literally every page to count as chrome once `n < 10` (see that constant's
|
|
41
|
+
* JSDoc for the derivation). At `n = 2` this is a total degenerate case, not
|
|
42
|
+
* just an imprecise one — `content(A) = A \ B` and `content(B) = B \ A` are
|
|
43
|
+
* disjoint *by construction* for any two sets, so
|
|
44
|
+
* `jaccardSimilarity(content(A), content(B))` is always `0` unless `A` and
|
|
45
|
+
* `B` are identical, regardless of how similar they actually are (confirmed:
|
|
46
|
+
* two pages sharing 999 of 1000 tokens, differing in exactly one each, go
|
|
47
|
+
* from a raw similarity of `0.998` to a content-only similarity of `0`).
|
|
48
|
+
* Falling back to unfiltered `tokenSets` below the floor accepts chrome
|
|
49
|
+
* dilution for small blocks rather than this much sharper failure.
|
|
50
|
+
*
|
|
51
|
+
* A page whose *entire* token set narrows away (every one of its tokens
|
|
52
|
+
* clears the chrome cutoff) falls back to its own raw tokens rather than the
|
|
53
|
+
* empty result: `jaccardSimilarity` treats two empty sets as similarity `1`
|
|
54
|
+
* (by design, for two genuinely-empty `<body>`s — see its JSDoc), but two
|
|
55
|
+
* different* all-chrome pages narrowing to empty for unrelated reasons
|
|
56
|
+
* (e.g. one page is only a header+footer, another is only a nav) would
|
|
57
|
+
* otherwise be forced into the same cluster by that shortcut regardless of
|
|
58
|
+
* whether their actual structure matches. Falling back only when narrowing
|
|
59
|
+
* collapsed a page to nothing — not for every page — keeps the normal case
|
|
60
|
+
* (a page with at least one page-specific token) unaffected.
|
|
61
|
+
* @param tokenSets
|
|
62
|
+
*/
|
|
63
|
+
function deriveComparisonSets(tokenSets) {
|
|
64
|
+
if (tokenSets.length < MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT) {
|
|
65
|
+
return tokenSets;
|
|
66
|
+
}
|
|
67
|
+
const corpusFrequency = computeDocumentFrequency(tokenSets);
|
|
68
|
+
return tokenSets.map((tokens) => {
|
|
69
|
+
const { contentTokens } = splitTokensByFrequency(tokens, corpusFrequency);
|
|
70
|
+
return contentTokens.size === 0 && tokens.size > 0 ? tokens : contentTokens;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reads `values[index]`, throwing instead of returning `undefined`. Every
|
|
75
|
+
* call site here indexes within bounds it just established itself (loop
|
|
76
|
+
* ranges, or an index freshly returned by the same array's own scan), so the
|
|
77
|
+
* thrown branch is unreachable in practice; it exists to satisfy
|
|
78
|
+
* `noUncheckedIndexedAccess` without a non-null assertion (same rationale as
|
|
79
|
+
* `readDpValue` in `array-edit-distance.ts`, generalized to any array-like).
|
|
80
|
+
* Deliberately not exported and shared with `resolve-page-cluster-keys.ts`'s
|
|
81
|
+
* own copy: this file's `export`s are its intended public API surface (the
|
|
82
|
+
* main function and its options type), and every other internal helper here
|
|
83
|
+
* (`find`, `clusterByCompleteLinkage`, `deriveComparisonSets`) is likewise
|
|
84
|
+
* kept unexported — sharing just this one helper across files would carve an
|
|
85
|
+
* exception into that boundary for a ~7-line generic utility.
|
|
86
|
+
* @param values
|
|
87
|
+
* @param index
|
|
88
|
+
*/
|
|
89
|
+
function requireIndex(values, index) {
|
|
90
|
+
const value = values[index];
|
|
91
|
+
if (value === undefined) {
|
|
92
|
+
throw new Error('resolveStructuralClusterKeys: index out of bounds');
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Finds the representative (root) of `index`'s set, compressing every
|
|
98
|
+
* traversed link so future lookups on the same path are near-constant time.
|
|
99
|
+
* @param parent
|
|
100
|
+
* @param index
|
|
101
|
+
*/
|
|
102
|
+
function find(parent, index) {
|
|
103
|
+
let root = index;
|
|
104
|
+
while (requireIndex(parent, root) !== root) {
|
|
105
|
+
root = requireIndex(parent, root);
|
|
106
|
+
}
|
|
107
|
+
let current = index;
|
|
108
|
+
while (current !== root) {
|
|
109
|
+
const next = requireIndex(parent, current);
|
|
110
|
+
parent[current] = root;
|
|
111
|
+
current = next;
|
|
112
|
+
}
|
|
113
|
+
return root;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Complete-linkage hierarchical clustering of `tokenSets` (whatever sets the
|
|
117
|
+
* caller wants compared — `resolveStructuralClusterKeys` passes
|
|
118
|
+
* `deriveComparisonSets`'s output, not necessarily the raw per-page token
|
|
119
|
+
* sets), cut at `threshold`, computed via the NN-chain algorithm (Murtagh,
|
|
120
|
+
* F., 1983, "A
|
|
121
|
+
* Survey of Recent Advances in Hierarchical Clustering Algorithms," The
|
|
122
|
+
* Computer Journal 26(4)). NN-chain produces the exact same dendrogram as
|
|
123
|
+
* naively re-scanning every live cluster pair for the best merge at each
|
|
124
|
+
* step, but in O(n²) time instead of O(n³): each cluster follows a chain of
|
|
125
|
+
* mutually-improving nearest neighbors until it lands on a pair that are
|
|
126
|
+
* each other's nearest neighbor (a "reciprocal nearest neighbor", RNN); that
|
|
127
|
+
* pair's merge is provably a valid next step in the correct dendrogram. This
|
|
128
|
+
* is a genuine algorithmic speedup, not an approximation — see
|
|
129
|
+
* `resolveStructuralClusterKeys`'s JSDoc for why an approximation was
|
|
130
|
+
* rejected.
|
|
131
|
+
*
|
|
132
|
+
* Complete-linkage was chosen over single-linkage (connected components of
|
|
133
|
+
* the threshold graph) because single-linkage's "chaining" lets one
|
|
134
|
+
* unrepresentative page transitively merge two otherwise-unrelated
|
|
135
|
+
* templates — the opposite of what template detection needs. Complete-
|
|
136
|
+
* linkage requires *every* pair across two clusters to clear the threshold
|
|
137
|
+
* before merging them, which rules that out. Cluster-to-cluster similarity
|
|
138
|
+
* is maintained via the Lance-Williams update for complete-linkage:
|
|
139
|
+
* `similarity(merged, Z) = min(similarity(X, Z), similarity(Y, Z))`.
|
|
140
|
+
*
|
|
141
|
+
* The algorithm always runs every one of the `size - 1` possible merges to
|
|
142
|
+
* completion (down to a single root), never stopping early at `threshold`.
|
|
143
|
+
* This looks wasteful but isn't optional: Lance-Williams monotonicity
|
|
144
|
+
* (Lance, G. N. & Williams, W. T., 1967, "A General Theory of Classificatory
|
|
145
|
+
* Sorting Strategies," The Computer Journal 9(4)) guarantees no height
|
|
146
|
+
* inversions inside the dendrogram itself (a merge's similarity is always ≥
|
|
147
|
+
* the similarity of every merge nested inside it), but says nothing about
|
|
148
|
+
* the chronological order in which independent, not-yet-connected
|
|
149
|
+
* chains happen to resolve their own RNN pairs — one chain can easily
|
|
150
|
+
* stumble onto a low-similarity RNN pair before a different, still-unvisited
|
|
151
|
+
* chain uncovers a high-similarity one elsewhere. Stopping the whole
|
|
152
|
+
* algorithm at the first below-threshold merge would therefore discard
|
|
153
|
+
* later, still-valid above-threshold merges (confirmed by this file's
|
|
154
|
+
* differential test against a naive reference — an earlier version of this
|
|
155
|
+
* function that broke early on the first below-threshold RNN pair failed it
|
|
156
|
+
* for exactly this reason). Instead, every merge is always folded into the
|
|
157
|
+
* `active`/`similarity` bookkeeping so the algorithm can keep discovering
|
|
158
|
+
* the rest of the true dendrogram, but only merges scoring `>= threshold`
|
|
159
|
+
* are recorded in `parent` (the union-find used for final membership).
|
|
160
|
+
* Monotonicity guarantees this is safe: any merge scoring `>= threshold` was
|
|
161
|
+
* necessarily built out of children merges that scored at least as high, so
|
|
162
|
+
* restricting the union-find to threshold-clearing merges — regardless of
|
|
163
|
+
* the chronological order they were discovered in — reconstructs exactly
|
|
164
|
+
* the correct threshold cut.
|
|
165
|
+
* @param tokenSets
|
|
166
|
+
* @param threshold
|
|
167
|
+
*/
|
|
168
|
+
function clusterByCompleteLinkage(tokenSets, threshold) {
|
|
169
|
+
const size = tokenSets.length;
|
|
170
|
+
const parent = Int32Array.from({ length: size }, (_, index) => index);
|
|
171
|
+
const similarity = new Float64Array(size * size);
|
|
172
|
+
for (let i = 0; i < size; i++) {
|
|
173
|
+
for (let j = i + 1; j < size; j++) {
|
|
174
|
+
const score = jaccardSimilarity(requireIndex(tokenSets, i), requireIndex(tokenSets, j));
|
|
175
|
+
similarity[i * size + j] = score;
|
|
176
|
+
similarity[j * size + i] = score;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const active = new Uint8Array(size).fill(1);
|
|
180
|
+
const chain = [];
|
|
181
|
+
const findFreshStart = () => {
|
|
182
|
+
for (let index = 0; index < size; index++) {
|
|
183
|
+
if (requireIndex(active, index) === 1) {
|
|
184
|
+
return index;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw new Error('resolveStructuralClusterKeys: no active cluster left to resume from');
|
|
188
|
+
};
|
|
189
|
+
let activeCount = size;
|
|
190
|
+
while (activeCount > 1) {
|
|
191
|
+
if (chain.length === 0) {
|
|
192
|
+
chain.push(findFreshStart());
|
|
193
|
+
}
|
|
194
|
+
const top = requireIndex(chain, chain.length - 1);
|
|
195
|
+
let best = -1;
|
|
196
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
197
|
+
for (let candidate = 0; candidate < size; candidate++) {
|
|
198
|
+
if (candidate !== top && requireIndex(active, candidate) === 1) {
|
|
199
|
+
const score = requireIndex(similarity, top * size + candidate);
|
|
200
|
+
if (score > bestScore) {
|
|
201
|
+
bestScore = score;
|
|
202
|
+
best = candidate;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const secondFromTop = chain.length >= 2 ? chain.at(-2) : undefined;
|
|
207
|
+
if (best === secondFromTop) {
|
|
208
|
+
chain.pop();
|
|
209
|
+
chain.pop();
|
|
210
|
+
const survivor = Math.min(top, best);
|
|
211
|
+
const dead = Math.max(top, best);
|
|
212
|
+
for (let candidate = 0; candidate < size; candidate++) {
|
|
213
|
+
if (candidate !== top &&
|
|
214
|
+
candidate !== best &&
|
|
215
|
+
requireIndex(active, candidate) === 1) {
|
|
216
|
+
const merged = Math.min(requireIndex(similarity, top * size + candidate), requireIndex(similarity, best * size + candidate));
|
|
217
|
+
similarity[survivor * size + candidate] = merged;
|
|
218
|
+
similarity[candidate * size + survivor] = merged;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
active[dead] = 0;
|
|
222
|
+
if (bestScore >= threshold - BOUNDARY_EPSILON) {
|
|
223
|
+
parent[find(parent, dead)] = find(parent, survivor);
|
|
224
|
+
}
|
|
225
|
+
activeCount--;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
chain.push(best);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return Array.from({ length: size }, (_, index) => find(parent, index));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Resolves, within a single already-blocked group of pages (e.g. one key
|
|
235
|
+
* from {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}),
|
|
236
|
+
* which pages share a structural template. Returns one cluster key per
|
|
237
|
+
* page, in the same order as `tokenSets`. Does not call
|
|
238
|
+
* {@link ./tokenize.js | tokenize} itself (callers pass pages already
|
|
239
|
+
* tokenized and turned into `Set`s, mirroring
|
|
240
|
+
* {@link ./compute-document-frequency.js | computeDocumentFrequency}'s
|
|
241
|
+
* contract) and does not orchestrate multiple blocks — a heterogeneous
|
|
242
|
+
* corpus should be split into blocks by the caller before reaching this
|
|
243
|
+
* function.
|
|
244
|
+
*
|
|
245
|
+
* MinHash/LSH-based approximation and medoid-based refinement of these
|
|
246
|
+
* clusters are intentionally out of scope: NN-chain already computes the
|
|
247
|
+
* exact complete-linkage clustering in O(n²), so there is no accuracy being
|
|
248
|
+
* traded away by not approximating, and no evidence yet that O(n²) is a
|
|
249
|
+
* real bottleneck at the block sizes this function actually sees.
|
|
250
|
+
*
|
|
251
|
+
* Before comparing, each page's token set is narrowed to its page-specific
|
|
252
|
+
* content via `splitTokensByFrequency` (see `deriveComparisonSets`) once
|
|
253
|
+
* `tokenSets.length` is large enough for that to be statistically meaningful
|
|
254
|
+
* — below that floor, chrome dilution is accepted as the lesser failure and
|
|
255
|
+
* comparison falls back to the raw sets.
|
|
256
|
+
* @param tokenSets
|
|
257
|
+
* @param options
|
|
258
|
+
* @example
|
|
259
|
+
* ```ts
|
|
260
|
+
* resolveStructuralClusterKeys([
|
|
261
|
+
* new Set(['body>header', 'body>main>.card', 'body>footer']),
|
|
262
|
+
* new Set(['body>header', 'body>main>.card', 'body>footer']),
|
|
263
|
+
* new Set(['body>nav', 'body>main>form']),
|
|
264
|
+
* ]);
|
|
265
|
+
* // ['cluster:0', 'cluster:0', 'cluster:1']
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
export function resolveStructuralClusterKeys(tokenSets, options) {
|
|
269
|
+
const similarityThreshold = options?.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD;
|
|
270
|
+
if (!(similarityThreshold >= 0 && similarityThreshold <= 1)) {
|
|
271
|
+
throw new RangeError(`resolveStructuralClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
|
|
272
|
+
}
|
|
273
|
+
if (tokenSets.length === 0) {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
const comparisonSets = deriveComparisonSets(tokenSets);
|
|
277
|
+
const roots = clusterByCompleteLinkage(comparisonSets, similarityThreshold);
|
|
278
|
+
const rootToLabel = new Map();
|
|
279
|
+
return roots.map((root) => {
|
|
280
|
+
let label = rootToLabel.get(root);
|
|
281
|
+
if (label === undefined) {
|
|
282
|
+
label = `cluster:${rootToLabel.size}`;
|
|
283
|
+
rootToLabel.set(root, label);
|
|
284
|
+
}
|
|
285
|
+
return label;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ResolvedOptions, TokenizeResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Drives `htmlparser2` over `html` and returns the ordered leaf paths found
|
|
4
|
+
* under the first `<body>`. Anything outside that first `<body>` is ignored
|
|
5
|
+
* (`<head>`, a second top-level `<body>`) — see `tokenize.ts` for why — and so
|
|
6
|
+
* is any stray `<body>` tag nested *inside* the first one's content, a known
|
|
7
|
+
* artifact of broken SSR/templating: browsers create no node for it, so its
|
|
8
|
+
* open/close tags are swallowed while its content still attaches to whatever
|
|
9
|
+
* real element actually contains it.
|
|
10
|
+
*
|
|
11
|
+
* WHY a stack of frames rather than emitting tokens the moment a tag opens:
|
|
12
|
+
* whether a class-less/role-less/type-less `div`/`span` folds away depends
|
|
13
|
+
* on its final child-element count, which is only settled once *it* closes
|
|
14
|
+
* — by which point any leaf inside it has already been visited. Buffering
|
|
15
|
+
* each open ancestor's not-yet-finalized descendant paths
|
|
16
|
+
* (`Frame.pendingPaths`) lets that decision be made retroactively without
|
|
17
|
+
* ever materializing the whole document as a tree: memory stays
|
|
18
|
+
* proportional to nesting depth and pending output, not document size.
|
|
19
|
+
*
|
|
20
|
+
* `htmlparser2`'s `script`/`style` handling never fires `onopentag`/
|
|
21
|
+
* `onclosetag` for content inside those tags (they're parsed as raw text),
|
|
22
|
+
* so only `svg`/`noscript` need active suppression of their nested
|
|
23
|
+
* open/close events; `depth` guards against those two self-nesting
|
|
24
|
+
* (`<svg><svg>...`).
|
|
25
|
+
*
|
|
26
|
+
* The `<body>` tag's own `class` is deliberately excluded when building its
|
|
27
|
+
* frame's `segment` (always plain `"body"`, never `"body.xxx"`) — see
|
|
28
|
+
* `TokenizeResult`'s JSDoc for why — and captured separately into
|
|
29
|
+
* `bodyClassList` instead of being discarded outright.
|
|
30
|
+
* @param html
|
|
31
|
+
* @param options
|
|
32
|
+
*/
|
|
33
|
+
export declare function runTokenizer(html: string, options: ResolvedOptions): TokenizeResult;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Parser } from 'htmlparser2';
|
|
2
|
+
import { createFrame } from './create-frame.js';
|
|
3
|
+
import { formatBracket } from './format-bracket.js';
|
|
4
|
+
import { hashContent } from './hash-content.js';
|
|
5
|
+
import { isOpaqueTagName } from './opaque-tags.js';
|
|
6
|
+
import { parseClassList } from './parse-class-list.js';
|
|
7
|
+
import { resolveClosedFrame } from './resolve-closed-frame.js';
|
|
8
|
+
/**
|
|
9
|
+
* The innermost open frame. Every call site only reaches this after checking
|
|
10
|
+
* `stack.length > 0`, so the thrown branch is unreachable in practice; it
|
|
11
|
+
* exists to satisfy `noUncheckedIndexedAccess` without a non-null assertion.
|
|
12
|
+
* @param stack
|
|
13
|
+
*/
|
|
14
|
+
function topOf(stack) {
|
|
15
|
+
const frame = stack.at(-1);
|
|
16
|
+
if (!frame) {
|
|
17
|
+
throw new Error('runTokenizer: expected an open frame on the stack');
|
|
18
|
+
}
|
|
19
|
+
return frame;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Drives `htmlparser2` over `html` and returns the ordered leaf paths found
|
|
23
|
+
* under the first `<body>`. Anything outside that first `<body>` is ignored
|
|
24
|
+
* (`<head>`, a second top-level `<body>`) — see `tokenize.ts` for why — and so
|
|
25
|
+
* is any stray `<body>` tag nested *inside* the first one's content, a known
|
|
26
|
+
* artifact of broken SSR/templating: browsers create no node for it, so its
|
|
27
|
+
* open/close tags are swallowed while its content still attaches to whatever
|
|
28
|
+
* real element actually contains it.
|
|
29
|
+
*
|
|
30
|
+
* WHY a stack of frames rather than emitting tokens the moment a tag opens:
|
|
31
|
+
* whether a class-less/role-less/type-less `div`/`span` folds away depends
|
|
32
|
+
* on its final child-element count, which is only settled once *it* closes
|
|
33
|
+
* — by which point any leaf inside it has already been visited. Buffering
|
|
34
|
+
* each open ancestor's not-yet-finalized descendant paths
|
|
35
|
+
* (`Frame.pendingPaths`) lets that decision be made retroactively without
|
|
36
|
+
* ever materializing the whole document as a tree: memory stays
|
|
37
|
+
* proportional to nesting depth and pending output, not document size.
|
|
38
|
+
*
|
|
39
|
+
* `htmlparser2`'s `script`/`style` handling never fires `onopentag`/
|
|
40
|
+
* `onclosetag` for content inside those tags (they're parsed as raw text),
|
|
41
|
+
* so only `svg`/`noscript` need active suppression of their nested
|
|
42
|
+
* open/close events; `depth` guards against those two self-nesting
|
|
43
|
+
* (`<svg><svg>...`).
|
|
44
|
+
*
|
|
45
|
+
* The `<body>` tag's own `class` is deliberately excluded when building its
|
|
46
|
+
* frame's `segment` (always plain `"body"`, never `"body.xxx"`) — see
|
|
47
|
+
* `TokenizeResult`'s JSDoc for why — and captured separately into
|
|
48
|
+
* `bodyClassList` instead of being discarded outright.
|
|
49
|
+
* @param html
|
|
50
|
+
* @param options
|
|
51
|
+
*/
|
|
52
|
+
export function runTokenizer(html, options) {
|
|
53
|
+
const stack = [];
|
|
54
|
+
let opaque = null;
|
|
55
|
+
let bodyDone = false;
|
|
56
|
+
let result = [];
|
|
57
|
+
let bodyClassList = [];
|
|
58
|
+
// Counts <body> open tags ignored because a body was already open (a
|
|
59
|
+
// stray/duplicated body from broken SSR/templating). Browsers create no
|
|
60
|
+
// node for these, so neither the open nor its matching close tag should
|
|
61
|
+
// touch the frame stack; this counter lets onclosetag recognize and
|
|
62
|
+
// swallow that matching close instead of popping an unrelated frame.
|
|
63
|
+
let ignoredBodyOpens = 0;
|
|
64
|
+
const parser = new Parser({
|
|
65
|
+
onopentag(name, attribs) {
|
|
66
|
+
if (opaque) {
|
|
67
|
+
if (name === opaque.tagName) {
|
|
68
|
+
opaque.depth++;
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (stack.length === 0) {
|
|
73
|
+
if (name === 'body' && !bodyDone) {
|
|
74
|
+
bodyClassList = parseClassList(attribs.class, options.filterNoiseClasses);
|
|
75
|
+
stack.push(createFrame(name, { ...attribs, class: '' }, options));
|
|
76
|
+
}
|
|
77
|
+
// Ignore everything else outside <body> (head, a second top-level <html>/<body>, ...).
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (name === 'body') {
|
|
81
|
+
ignoredBodyOpens++;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
topOf(stack).childElementCount++;
|
|
85
|
+
if (isOpaqueTagName(name)) {
|
|
86
|
+
opaque = {
|
|
87
|
+
tagName: name,
|
|
88
|
+
depth: 1,
|
|
89
|
+
contentStart: parser.endIndex + 1,
|
|
90
|
+
role: attribs.role || undefined,
|
|
91
|
+
type: attribs.type || undefined,
|
|
92
|
+
};
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
stack.push(createFrame(name, attribs, options));
|
|
96
|
+
},
|
|
97
|
+
onclosetag(name) {
|
|
98
|
+
if (opaque) {
|
|
99
|
+
if (name === opaque.tagName) {
|
|
100
|
+
opaque.depth--;
|
|
101
|
+
if (opaque.depth === 0) {
|
|
102
|
+
const raw = html.slice(opaque.contentStart, parser.startIndex);
|
|
103
|
+
const bracket = formatBracket({
|
|
104
|
+
role: opaque.role,
|
|
105
|
+
type: opaque.type,
|
|
106
|
+
sha: hashContent(raw),
|
|
107
|
+
});
|
|
108
|
+
topOf(stack).pendingPaths.push(`${name}${bracket}`);
|
|
109
|
+
opaque = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (name === 'body' && ignoredBodyOpens > 0) {
|
|
115
|
+
ignoredBodyOpens--;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (stack.length === 0) {
|
|
119
|
+
// Closing tag outside <body> (or a second top-level </body>): nothing to do.
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const frame = stack.pop();
|
|
123
|
+
if (!frame) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const contributed = resolveClosedFrame(frame);
|
|
127
|
+
if (stack.length === 0) {
|
|
128
|
+
result = contributed;
|
|
129
|
+
bodyDone = true;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// Not `push(...contributed)`: spreading tens of thousands of
|
|
133
|
+
// arguments into a single call (a realistic count for a flat,
|
|
134
|
+
// high-fan-out template like a sitemap/listing page — exactly
|
|
135
|
+
// the shape this package targets) throws `RangeError: Maximum
|
|
136
|
+
// call stack size exceeded`. A plain loop has no such limit.
|
|
137
|
+
const target = topOf(stack).pendingPaths;
|
|
138
|
+
for (const path of contributed) {
|
|
139
|
+
target.push(path);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
oncomment(data) {
|
|
144
|
+
if (!options.includeComments || opaque || stack.length === 0) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
topOf(stack).pendingPaths.push(`comment[sha=${hashContent(data)}]`);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
parser.parseComplete(html);
|
|
151
|
+
return { tokens: result, bodyClassList };
|
|
152
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DocumentFrequency } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Splits one page's tokens into "template" (site chrome: header/nav/footer,
|
|
4
|
+
* or any other structure repeated across most of the corpus) and "content"
|
|
5
|
+
* (page-specific structure), using each token's document frequency from
|
|
6
|
+
* `computeDocumentFrequency`. Comparing these two groups separately with
|
|
7
|
+
* `jaccardSimilarity()` — rather than the page's full token set at once —
|
|
8
|
+
* is what fixes two failures a single flat Jaccard has: common chrome
|
|
9
|
+
* diluting genuine content differences at loose similarity thresholds, and
|
|
10
|
+
* page-specific content differences (e.g. a freeform CMS block editor page,
|
|
11
|
+
* where the exact block mix varies per page) swamping a real *layout*
|
|
12
|
+
* match. See `computeDocumentFrequency`'s JSDoc for why `corpusFrequency`
|
|
13
|
+
* must come from a homogeneous page collection for this split to work.
|
|
14
|
+
*
|
|
15
|
+
* `corpusFrequency` bundles `documentFrequency` with the `pageCount` it was
|
|
16
|
+
* computed from (rather than taking `pageCount` as a separate argument) so
|
|
17
|
+
* the two can never be passed out of sync — e.g. a caller re-slicing or
|
|
18
|
+
* filtering the page list after computing frequencies but before using
|
|
19
|
+
* them, which would otherwise silently produce a wrong cutoff with no error
|
|
20
|
+
* raised anywhere.
|
|
21
|
+
*
|
|
22
|
+
* A token absent from `documentFrequency` is treated as frequency 0 (i.e.
|
|
23
|
+
* content): it never appeared in the corpus the frequency map was built
|
|
24
|
+
* from, so it cannot be corpus-wide chrome. If `pageCount` is 0 (empty
|
|
25
|
+
* corpus), every token is classified as content for the same reason: with
|
|
26
|
+
* no pages to have observed repetition across, nothing can be confirmed as
|
|
27
|
+
* chrome.
|
|
28
|
+
*
|
|
29
|
+
* `threshold` must be a fraction in `(0, 1]`, not a percentage — passing
|
|
30
|
+
* `90` instead of `0.9` would make the cutoff exceed every possible
|
|
31
|
+
* frequency and misclassify even universal chrome as content, so this is
|
|
32
|
+
* validated eagerly rather than left to fail silently downstream.
|
|
33
|
+
* @param tokens
|
|
34
|
+
* @param corpusFrequency
|
|
35
|
+
* @param threshold
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const corpusFrequency = computeDocumentFrequency(allPagesTokenSets);
|
|
39
|
+
* splitTokensByFrequency(pageTokens, corpusFrequency);
|
|
40
|
+
* // { templateTokens: Set(...), contentTokens: Set(...) }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare function splitTokensByFrequency(tokens: ReadonlySet<string>, corpusFrequency: DocumentFrequency, threshold?: number): {
|
|
44
|
+
templateTokens: Set<string>;
|
|
45
|
+
contentTokens: Set<string>;
|
|
46
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default document-frequency cutoff: a token present in at least 90% of the
|
|
3
|
+
* pages passed to `computeDocumentFrequency` is treated as site chrome.
|
|
4
|
+
* Real-data validation against a small single-layout corporate site (a few
|
|
5
|
+
* hundred pages) found the corpus's tokens cleanly bimodal — the same set
|
|
6
|
+
* of chrome tokens was identified whether the cutoff was set anywhere from
|
|
7
|
+
* 50% to 95% — so the exact value is not sensitive within that range for a
|
|
8
|
+
* homogeneous corpus. 90% was picked as a value comfortably inside that
|
|
9
|
+
* stable range rather than at either edge.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_TEMPLATE_FREQUENCY_THRESHOLD = 0.9;
|
|
12
|
+
/**
|
|
13
|
+
* `threshold * pageCount` is a floating-point product and can overshoot the
|
|
14
|
+
* intended integer boundary (verified: `0.55 * 100 === 55.00000000000001`
|
|
15
|
+
* in JS), which would otherwise make a token at the exact documented
|
|
16
|
+
* inclusive boundary (`frequency === threshold * pageCount`) fail the
|
|
17
|
+
* `>=` check it should pass. Subtracting this epsilon before comparing
|
|
18
|
+
* absorbs that rounding noise without being large enough to affect any
|
|
19
|
+
* genuinely-below-threshold token (frequencies are integers, so the true
|
|
20
|
+
* gap between "at the boundary" and "one below it" is always >= 1).
|
|
21
|
+
*/
|
|
22
|
+
const BOUNDARY_EPSILON = 1e-9;
|
|
23
|
+
/**
|
|
24
|
+
* Splits one page's tokens into "template" (site chrome: header/nav/footer,
|
|
25
|
+
* or any other structure repeated across most of the corpus) and "content"
|
|
26
|
+
* (page-specific structure), using each token's document frequency from
|
|
27
|
+
* `computeDocumentFrequency`. Comparing these two groups separately with
|
|
28
|
+
* `jaccardSimilarity()` — rather than the page's full token set at once —
|
|
29
|
+
* is what fixes two failures a single flat Jaccard has: common chrome
|
|
30
|
+
* diluting genuine content differences at loose similarity thresholds, and
|
|
31
|
+
* page-specific content differences (e.g. a freeform CMS block editor page,
|
|
32
|
+
* where the exact block mix varies per page) swamping a real *layout*
|
|
33
|
+
* match. See `computeDocumentFrequency`'s JSDoc for why `corpusFrequency`
|
|
34
|
+
* must come from a homogeneous page collection for this split to work.
|
|
35
|
+
*
|
|
36
|
+
* `corpusFrequency` bundles `documentFrequency` with the `pageCount` it was
|
|
37
|
+
* computed from (rather than taking `pageCount` as a separate argument) so
|
|
38
|
+
* the two can never be passed out of sync — e.g. a caller re-slicing or
|
|
39
|
+
* filtering the page list after computing frequencies but before using
|
|
40
|
+
* them, which would otherwise silently produce a wrong cutoff with no error
|
|
41
|
+
* raised anywhere.
|
|
42
|
+
*
|
|
43
|
+
* A token absent from `documentFrequency` is treated as frequency 0 (i.e.
|
|
44
|
+
* content): it never appeared in the corpus the frequency map was built
|
|
45
|
+
* from, so it cannot be corpus-wide chrome. If `pageCount` is 0 (empty
|
|
46
|
+
* corpus), every token is classified as content for the same reason: with
|
|
47
|
+
* no pages to have observed repetition across, nothing can be confirmed as
|
|
48
|
+
* chrome.
|
|
49
|
+
*
|
|
50
|
+
* `threshold` must be a fraction in `(0, 1]`, not a percentage — passing
|
|
51
|
+
* `90` instead of `0.9` would make the cutoff exceed every possible
|
|
52
|
+
* frequency and misclassify even universal chrome as content, so this is
|
|
53
|
+
* validated eagerly rather than left to fail silently downstream.
|
|
54
|
+
* @param tokens
|
|
55
|
+
* @param corpusFrequency
|
|
56
|
+
* @param threshold
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* const corpusFrequency = computeDocumentFrequency(allPagesTokenSets);
|
|
60
|
+
* splitTokensByFrequency(pageTokens, corpusFrequency);
|
|
61
|
+
* // { templateTokens: Set(...), contentTokens: Set(...) }
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function splitTokensByFrequency(tokens, corpusFrequency, threshold = DEFAULT_TEMPLATE_FREQUENCY_THRESHOLD) {
|
|
65
|
+
if (!(threshold > 0 && threshold <= 1)) {
|
|
66
|
+
throw new RangeError(`splitTokensByFrequency: threshold must be a fraction in (0, 1], got ${threshold}`);
|
|
67
|
+
}
|
|
68
|
+
const { documentFrequency, pageCount } = corpusFrequency;
|
|
69
|
+
const templateTokens = new Set();
|
|
70
|
+
const contentTokens = new Set();
|
|
71
|
+
if (pageCount === 0) {
|
|
72
|
+
for (const token of tokens) {
|
|
73
|
+
contentTokens.add(token);
|
|
74
|
+
}
|
|
75
|
+
return { templateTokens, contentTokens };
|
|
76
|
+
}
|
|
77
|
+
const cutoff = threshold * pageCount - BOUNDARY_EPSILON;
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
const frequency = documentFrequency.get(token) ?? 0;
|
|
80
|
+
if (frequency >= cutoff) {
|
|
81
|
+
templateTokens.add(token);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
contentTokens.add(token);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { templateTokens, contentTokens };
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { TokenizeOptions, TokenizeResult } from './types.js';
|
|
2
|
+
export type { TokenizeOptions, TokenizeResult } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Tokenizes the structural skeleton of an HTML document's `<body>` for
|
|
5
|
+
* duplicate/near-duplicate page detection at crawl scale. This is the first
|
|
6
|
+
* building block of `@d-zero/page-cluster`; clustering on top of these
|
|
7
|
+
* tokens is layered on by
|
|
8
|
+
* {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
|
|
9
|
+
* (exact O(n²) complete-linkage via NN-chain) and orchestrated across blocks
|
|
10
|
+
* by {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys} —
|
|
11
|
+
* MinHash/LSH-based approximation was considered and rejected: see
|
|
12
|
+
* `resolveStructuralClusterKeys`'s JSDoc for why.
|
|
13
|
+
*
|
|
14
|
+
* Only `<body>` is tokenized. `<head>` (title/meta/link/OGP/...) is ignored
|
|
15
|
+
* entirely: `@d-zero/beholder` already extracts it comprehensively, but from
|
|
16
|
+
* a live `Document` (Puppeteer/jsdom) rather than a raw HTML string, so that
|
|
17
|
+
* logic can't be reused here without building a DOM — exactly what this
|
|
18
|
+
* function avoids for speed. Callers who need head metadata should call
|
|
19
|
+
* `@d-zero/beholder` separately.
|
|
20
|
+
*
|
|
21
|
+
* Visible text is discarded entirely: this function measures *structural*
|
|
22
|
+
* similarity, and including per-page text would make otherwise-identical
|
|
23
|
+
* templates look unique.
|
|
24
|
+
*
|
|
25
|
+
* The returned array intentionally does not deduplicate or
|
|
26
|
+
* run-length-compress repeated paths — even though the eventual consumer
|
|
27
|
+
* (a MinHash/LSH classifier) will reduce this array to a `Set` for
|
|
28
|
+
* comparison, and a `Set` alone already collapses any number of repeated
|
|
29
|
+
* entries. Compressing here first (e.g. `"li>a*3"`) would embed the
|
|
30
|
+
* arrangement of neighboring siblings into the token string itself: a
|
|
31
|
+
* `current`/`active`-style state class on exactly one sibling (its position
|
|
32
|
+
* varies per page, e.g. which nav item is "current") shifts which runs are
|
|
33
|
+
* adjacent, so the same template could serialize as `"li>a*2"` on one page
|
|
34
|
+
* and as two separate `"li>a"` entries (split by the state-bearing sibling)
|
|
35
|
+
* on another — literally different strings for what should compare equal
|
|
36
|
+
* once turned into a `Set`. Leaving the array uncompressed sidesteps that
|
|
37
|
+
* entirely:
|
|
38
|
+
* `Set(["li>a", "li.current>a", "li>a"])` and
|
|
39
|
+
* `Set(["li.current>a", "li>a", "li>a"])` are the same two-element set no
|
|
40
|
+
* matter where the state class lands. If a future consumer needs a shorter
|
|
41
|
+
* array for e.g. array-edit-distance comparisons on pathologically large
|
|
42
|
+
* pages, that consumer should apply its own compression tuned to its own
|
|
43
|
+
* needs, since compression is coupled to how the caller will read counts
|
|
44
|
+
* back out again — folding that guess into this package's contract can't be
|
|
45
|
+
* un-shipped later.
|
|
46
|
+
*
|
|
47
|
+
* `<body>`'s own `class` is excluded from every leaf path and returned
|
|
48
|
+
* separately as `bodyClassList` — see {@link ./types.js | TokenizeResult}'s
|
|
49
|
+
* JSDoc for why.
|
|
50
|
+
* @param html
|
|
51
|
+
* @param options
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* tokenize('<body><div class="card"><ul><li>A</li><li>B</li></ul></div></body>');
|
|
55
|
+
* // { tokens: ["body>.card>ul>li", "body>.card>ul>li"], bodyClassList: [] }
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function tokenize(html: string, options?: TokenizeOptions): TokenizeResult;
|