@d-zero/page-cluster 0.2.0 → 0.3.1
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 +131 -39
- 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 +6 -59
- 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
|
@@ -35,6 +35,15 @@ export type ResolveStructuralClusterKeysOptions = {
|
|
|
35
35
|
* `tokenSets.length` is large enough for that to be statistically meaningful
|
|
36
36
|
* — below that floor, chrome dilution is accepted as the lesser failure and
|
|
37
37
|
* comparison falls back to the raw sets.
|
|
38
|
+
*
|
|
39
|
+
* The clustering algorithm is NN-chain complete-linkage (Murtagh, F., 1983,
|
|
40
|
+
* "A Survey of Recent Advances in Hierarchical Clustering Algorithms," The
|
|
41
|
+
* Computer Journal 26(4)), implemented in `completeLinkageDendrogram`. The
|
|
42
|
+
* dendrogram is cut at `similarityThreshold` by `labelsAtThreshold`, using
|
|
43
|
+
* Lance-Williams monotonicity (Lance, G. N. & Williams, W. T., 1967, "A
|
|
44
|
+
* General Theory of Classificatory Sorting Strategies," The Computer Journal
|
|
45
|
+
* 9(4)) to guarantee that threshold cuts are safe regardless of the order
|
|
46
|
+
* in which NN-chain discovered the merges.
|
|
38
47
|
* @param tokenSets
|
|
39
48
|
* @param options
|
|
40
49
|
* @example
|
|
@@ -1,235 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
|
|
1
|
+
import { completeLinkageDendrogram, labelsAtThreshold, } from './complete-linkage-dendrogram.js';
|
|
2
|
+
import { deriveComparisonSets } from './derive-comparison-sets.js';
|
|
4
3
|
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
4
|
/**
|
|
234
5
|
* Resolves, within a single already-blocked group of pages (e.g. one key
|
|
235
6
|
* from {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}),
|
|
@@ -253,6 +24,15 @@ function clusterByCompleteLinkage(tokenSets, threshold) {
|
|
|
253
24
|
* `tokenSets.length` is large enough for that to be statistically meaningful
|
|
254
25
|
* — below that floor, chrome dilution is accepted as the lesser failure and
|
|
255
26
|
* comparison falls back to the raw sets.
|
|
27
|
+
*
|
|
28
|
+
* The clustering algorithm is NN-chain complete-linkage (Murtagh, F., 1983,
|
|
29
|
+
* "A Survey of Recent Advances in Hierarchical Clustering Algorithms," The
|
|
30
|
+
* Computer Journal 26(4)), implemented in `completeLinkageDendrogram`. The
|
|
31
|
+
* dendrogram is cut at `similarityThreshold` by `labelsAtThreshold`, using
|
|
32
|
+
* Lance-Williams monotonicity (Lance, G. N. & Williams, W. T., 1967, "A
|
|
33
|
+
* General Theory of Classificatory Sorting Strategies," The Computer Journal
|
|
34
|
+
* 9(4)) to guarantee that threshold cuts are safe regardless of the order
|
|
35
|
+
* in which NN-chain discovered the merges.
|
|
256
36
|
* @param tokenSets
|
|
257
37
|
* @param options
|
|
258
38
|
* @example
|
|
@@ -274,7 +54,9 @@ export function resolveStructuralClusterKeys(tokenSets, options) {
|
|
|
274
54
|
return [];
|
|
275
55
|
}
|
|
276
56
|
const comparisonSets = deriveComparisonSets(tokenSets);
|
|
277
|
-
const
|
|
57
|
+
const size = comparisonSets.length;
|
|
58
|
+
const merges = completeLinkageDendrogram(comparisonSets);
|
|
59
|
+
const roots = labelsAtThreshold(size, merges, similarityThreshold);
|
|
278
60
|
const rootToLabel = new Map();
|
|
279
61
|
return roots.map((root) => {
|
|
280
62
|
let label = rootToLabel.get(root);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripping classes while keeping the bracket reveals the tag-level (or
|
|
3
|
+
* wrapper-level) shape: `section.c-page-sub__reports` and
|
|
4
|
+
* `section.c-page-sub__projects` both shape to `section`, exposing that they
|
|
5
|
+
* are the same structural element with different BEM class names. Used in
|
|
6
|
+
* cross-block clustering to merge blocks whose class names differ but whose
|
|
7
|
+
* skeletons match (confirmed on real crawl data: reports/projects/news list
|
|
8
|
+
* pages had class-name Jaccard 0.000 and shape Jaccard 1.000).
|
|
9
|
+
* @param token
|
|
10
|
+
*/
|
|
11
|
+
export declare function shapeToken(token: string): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param segment
|
|
3
|
+
*/
|
|
4
|
+
function shapeSegment(segment) {
|
|
5
|
+
const bracketIndex = segment.indexOf('[');
|
|
6
|
+
const bracket = bracketIndex === -1 ? '' : segment.slice(bracketIndex);
|
|
7
|
+
const base = bracketIndex === -1 ? segment : segment.slice(0, bracketIndex);
|
|
8
|
+
let shapedBase;
|
|
9
|
+
if (base.startsWith('.')) {
|
|
10
|
+
// Foldable tag (div/span) with classes: .class1.class2 → *
|
|
11
|
+
shapedBase = '*';
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
const dotIndex = base.indexOf('.');
|
|
15
|
+
if (dotIndex > 0) {
|
|
16
|
+
// Non-foldable tag with classes: tag.class1 → tag
|
|
17
|
+
shapedBase = base.slice(0, dotIndex);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
// No classes (tag name only, or empty for bracket-only segments)
|
|
21
|
+
shapedBase = base;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return `${shapedBase}${bracket}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Stripping classes while keeping the bracket reveals the tag-level (or
|
|
28
|
+
* wrapper-level) shape: `section.c-page-sub__reports` and
|
|
29
|
+
* `section.c-page-sub__projects` both shape to `section`, exposing that they
|
|
30
|
+
* are the same structural element with different BEM class names. Used in
|
|
31
|
+
* cross-block clustering to merge blocks whose class names differ but whose
|
|
32
|
+
* skeletons match (confirmed on real crawl data: reports/projects/news list
|
|
33
|
+
* pages had class-name Jaccard 0.000 and shape Jaccard 1.000).
|
|
34
|
+
* @param token
|
|
35
|
+
*/
|
|
36
|
+
export function shapeToken(token) {
|
|
37
|
+
return token.split('>').map(shapeSegment).join('>');
|
|
38
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ExtractLandmarksResult } from './extract-landmarks.js';
|
|
2
|
+
import type { CrossBlockUnit } from './merge-cross-block-clusters.js';
|
|
3
|
+
import type { TokenizeOptions } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Maximum number of member pages retained per `CrossBlockUnit` for Stage B.
|
|
6
|
+
* Units larger than this are down-sampled deterministically (URL-independent
|
|
7
|
+
* — seeded from the unit key) via {@link ./reservoir-sample.js | reservoirSample}.
|
|
8
|
+
*
|
|
9
|
+
* ## Why cap
|
|
10
|
+
*
|
|
11
|
+
* `CrossBlockUnit.memberTokenSets` is `readonly ReadonlySet<string>[]`.
|
|
12
|
+
* V8's Set carries substantial per-entry overhead (hash slot + string
|
|
13
|
+
* reference + backing array padding), so a 200-token set weighs ~20 KB in
|
|
14
|
+
* practice — an order of magnitude more than the raw byte sum of its
|
|
15
|
+
* strings. Without a cap, the streaming path's batch-by-batch accumulation
|
|
16
|
+
* of `CrossBlockUnit`s across a 176k-page corpus reached OOM at ~100k pages
|
|
17
|
+
* on an 8 GB heap, entirely from Set overhead of retained members.
|
|
18
|
+
*
|
|
19
|
+
* ## Why the value
|
|
20
|
+
*
|
|
21
|
+
* Stage B's per-round computations (`computeDocumentFrequency`,
|
|
22
|
+
* `quorumCore`, `shellQuorum`) are frequency-based, so a representative
|
|
23
|
+
* sample gives statistically similar cores and shells to the full
|
|
24
|
+
* membership. 100 members is enough for the 80 % quorum threshold to
|
|
25
|
+
* discriminate signal from noise (needs ≥ 80 of 100 = 80 % vs. the
|
|
26
|
+
* corpus-wide 80 %), and keeps a per-unit memory footprint of ~2.5 MB
|
|
27
|
+
* (100 × ~25 KB per member incl. tokens + landmark instances). For a
|
|
28
|
+
* corpus with ~500 units that's ~1.25 GB — well within an 8 GB heap.
|
|
29
|
+
*
|
|
30
|
+
* Applied at unit *creation* here in {@link ./stage-a-per-block.js | stageAPerBlock},
|
|
31
|
+
* and re-applied after each merge in
|
|
32
|
+
* {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
|
|
33
|
+
* `applyMerges`, so a merged group can never balloon past this cap either.
|
|
34
|
+
*
|
|
35
|
+
* ## In-memory-path preservation
|
|
36
|
+
*
|
|
37
|
+
* A block with `≤ MAX_MEMBERS_PER_UNIT` members hits the deterministic
|
|
38
|
+
* "return the full input unchanged" branch of {@link ./reservoir-sample.js | reservoirSample},
|
|
39
|
+
* so every previously validated corpus (302 / 1,416 / 8,936 / 89 pages)
|
|
40
|
+
* keeps every member of every unit — the cap only kicks in for cluster
|
|
41
|
+
* sizes that could not run under the pre-refactor implementation anyway.
|
|
42
|
+
*/
|
|
43
|
+
export declare const MAX_MEMBERS_PER_UNIT = 100;
|
|
44
|
+
/**
|
|
45
|
+
* @see stageAPerBlock
|
|
46
|
+
*/
|
|
47
|
+
export type StageAPerBlockOptions = TokenizeOptions & {
|
|
48
|
+
readonly similarityThreshold?: number;
|
|
49
|
+
readonly minKneeRatio?: number;
|
|
50
|
+
readonly maxCandidateDepth?: number;
|
|
51
|
+
/**
|
|
52
|
+
* When set, any Stage-A cluster with more than this many members has its
|
|
53
|
+
* `memberTokenSets` / `memberLandmarkInstances` down-sampled via
|
|
54
|
+
* {@link ./reservoir-sample.js | reservoirSample} (seed = unit key,
|
|
55
|
+
* deterministic). Callers on the in-memory path omit this so validated
|
|
56
|
+
* corpora keep full membership; the streaming path sets it to
|
|
57
|
+
* {@link MAX_MEMBERS_PER_UNIT} to bound accumulated Stage B state.
|
|
58
|
+
*/
|
|
59
|
+
readonly capMembers?: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* @see stageAPerBlock
|
|
63
|
+
*/
|
|
64
|
+
export type StageAPerBlockInput = {
|
|
65
|
+
/** The block's block key (as produced by pass 0). */
|
|
66
|
+
readonly blockKey: string;
|
|
67
|
+
/**
|
|
68
|
+
* Original input indices of this block's members, in input order. Returned
|
|
69
|
+
* unchanged in the mapping so the caller can write pages' cluster keys
|
|
70
|
+
* back into a global keys array. Same length as `preparedHtml` /
|
|
71
|
+
* `landmarks` / `localLandmarkTokensByPage`.
|
|
72
|
+
*/
|
|
73
|
+
readonly memberIndices: readonly number[];
|
|
74
|
+
/**
|
|
75
|
+
* The block's per-page landmark-excised (and optionally
|
|
76
|
+
* `removeContentBlocks`-stripped) HTML — what the in-memory driver calls
|
|
77
|
+
* `preparedHtml`. Provided by the caller to keep this helper stateless
|
|
78
|
+
* about `excludeLandmarks` / `contentBlockAttribute` decisions.
|
|
79
|
+
*/
|
|
80
|
+
readonly preparedHtml: readonly string[];
|
|
81
|
+
/** The block's per-page {@link ./extract-landmarks.js | ExtractLandmarksResult}. */
|
|
82
|
+
readonly landmarks: readonly ExtractLandmarksResult[];
|
|
83
|
+
/**
|
|
84
|
+
* The block's per-page local-landmark reinjection token sets. Same-shape
|
|
85
|
+
* output of {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkTokens},
|
|
86
|
+
* pre-computed by the caller so this helper stays agnostic to whether
|
|
87
|
+
* chrome discovery ran corpus-wide (small-corpus in-memory path) or
|
|
88
|
+
* per-block (large-corpus streaming path).
|
|
89
|
+
*/
|
|
90
|
+
readonly localLandmarkTokensByPage: readonly ReadonlySet<string>[];
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* @see stageAPerBlock
|
|
94
|
+
*/
|
|
95
|
+
export type StageAPerBlockResult = {
|
|
96
|
+
/**
|
|
97
|
+
* Map from `memberIndices[i]` (original input index) to that page's
|
|
98
|
+
* post-Stage-A cluster key. Keys are of the form
|
|
99
|
+
* `JSON.stringify([blockKey, "cluster:N"])` (unchanged from the in-memory
|
|
100
|
+
* driver's per-block loop) — Stage B later rewrites them into merged
|
|
101
|
+
* groups when it finds cross-block matches.
|
|
102
|
+
*/
|
|
103
|
+
readonly pageKeys: ReadonlyMap<number, string>;
|
|
104
|
+
/**
|
|
105
|
+
* One {@link ./merge-cross-block-clusters.js | CrossBlockUnit} per
|
|
106
|
+
* post-Stage-A cluster, in first-seen order.
|
|
107
|
+
*/
|
|
108
|
+
readonly crossBlockUnits: readonly CrossBlockUnit[];
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Runs Stage A (dendrogram + auto-cut + containment assignment) on one
|
|
112
|
+
* block's pages, and returns both the per-page cluster keys and the
|
|
113
|
+
* {@link ./merge-cross-block-clusters.js | CrossBlockUnit} rows that Stage B
|
|
114
|
+
* needs afterward.
|
|
115
|
+
*
|
|
116
|
+
* ## Why extract this from `resolvePageClusterKeys`?
|
|
117
|
+
*
|
|
118
|
+
* The in-memory driver holds every page's HTML/landmarks/preparedHtml in
|
|
119
|
+
* arrays before the per-block loop begins, and any dataset large enough to
|
|
120
|
+
* break memory does so before Stage A even starts. For streaming mode this
|
|
121
|
+
* inner per-block loop needs to run for one block at a time: read that
|
|
122
|
+
* block's HTML, run Stage A, emit its crossBlockUnit rows, free the block's
|
|
123
|
+
* memory, move to the next. Splitting the Stage A body into a standalone
|
|
124
|
+
* function is what makes that per-block iteration possible without
|
|
125
|
+
* duplicating the Stage A logic between the two drivers.
|
|
126
|
+
*
|
|
127
|
+
* Preserves the in-memory driver's per-block behavior exactly for the same
|
|
128
|
+
* inputs — same `preparedHtml`, same `landmarks`, same
|
|
129
|
+
* `localLandmarkTokensByPage`, same `options` → same output.
|
|
130
|
+
* @param input
|
|
131
|
+
* @param options
|
|
132
|
+
*/
|
|
133
|
+
export declare function stageAPerBlock(input: StageAPerBlockInput, options?: StageAPerBlockOptions): StageAPerBlockResult;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { assignContainedClusters } from './assign-contained-clusters.js';
|
|
2
|
+
import { autoCutThreshold } from './auto-cut-threshold.js';
|
|
3
|
+
import { capContentDepth } from './cap-content-depth.js';
|
|
4
|
+
import { collapseAnonymousDivs } from './collapse-anonymous-divs.js';
|
|
5
|
+
import { completeLinkageDendrogram, labelsAtThreshold, } from './complete-linkage-dendrogram.js';
|
|
6
|
+
import { deriveComparisonSets, MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT, } from './derive-comparison-sets.js';
|
|
7
|
+
import { detectContentDepthCap } from './detect-content-depth-cap.js';
|
|
8
|
+
import { computePerPageLandmarkInstances } from './per-page-landmark-signatures.js';
|
|
9
|
+
import { reservoirSample } from './reservoir-sample.js';
|
|
10
|
+
import { tokenize } from './tokenize.js';
|
|
11
|
+
/**
|
|
12
|
+
* Maximum number of member pages retained per `CrossBlockUnit` for Stage B.
|
|
13
|
+
* Units larger than this are down-sampled deterministically (URL-independent
|
|
14
|
+
* — seeded from the unit key) via {@link ./reservoir-sample.js | reservoirSample}.
|
|
15
|
+
*
|
|
16
|
+
* ## Why cap
|
|
17
|
+
*
|
|
18
|
+
* `CrossBlockUnit.memberTokenSets` is `readonly ReadonlySet<string>[]`.
|
|
19
|
+
* V8's Set carries substantial per-entry overhead (hash slot + string
|
|
20
|
+
* reference + backing array padding), so a 200-token set weighs ~20 KB in
|
|
21
|
+
* practice — an order of magnitude more than the raw byte sum of its
|
|
22
|
+
* strings. Without a cap, the streaming path's batch-by-batch accumulation
|
|
23
|
+
* of `CrossBlockUnit`s across a 176k-page corpus reached OOM at ~100k pages
|
|
24
|
+
* on an 8 GB heap, entirely from Set overhead of retained members.
|
|
25
|
+
*
|
|
26
|
+
* ## Why the value
|
|
27
|
+
*
|
|
28
|
+
* Stage B's per-round computations (`computeDocumentFrequency`,
|
|
29
|
+
* `quorumCore`, `shellQuorum`) are frequency-based, so a representative
|
|
30
|
+
* sample gives statistically similar cores and shells to the full
|
|
31
|
+
* membership. 100 members is enough for the 80 % quorum threshold to
|
|
32
|
+
* discriminate signal from noise (needs ≥ 80 of 100 = 80 % vs. the
|
|
33
|
+
* corpus-wide 80 %), and keeps a per-unit memory footprint of ~2.5 MB
|
|
34
|
+
* (100 × ~25 KB per member incl. tokens + landmark instances). For a
|
|
35
|
+
* corpus with ~500 units that's ~1.25 GB — well within an 8 GB heap.
|
|
36
|
+
*
|
|
37
|
+
* Applied at unit *creation* here in {@link ./stage-a-per-block.js | stageAPerBlock},
|
|
38
|
+
* and re-applied after each merge in
|
|
39
|
+
* {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
|
|
40
|
+
* `applyMerges`, so a merged group can never balloon past this cap either.
|
|
41
|
+
*
|
|
42
|
+
* ## In-memory-path preservation
|
|
43
|
+
*
|
|
44
|
+
* A block with `≤ MAX_MEMBERS_PER_UNIT` members hits the deterministic
|
|
45
|
+
* "return the full input unchanged" branch of {@link ./reservoir-sample.js | reservoirSample},
|
|
46
|
+
* so every previously validated corpus (302 / 1,416 / 8,936 / 89 pages)
|
|
47
|
+
* keeps every member of every unit — the cap only kicks in for cluster
|
|
48
|
+
* sizes that could not run under the pre-refactor implementation anyway.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_MEMBERS_PER_UNIT = 100;
|
|
51
|
+
/**
|
|
52
|
+
* Runs Stage A (dendrogram + auto-cut + containment assignment) on one
|
|
53
|
+
* block's pages, and returns both the per-page cluster keys and the
|
|
54
|
+
* {@link ./merge-cross-block-clusters.js | CrossBlockUnit} rows that Stage B
|
|
55
|
+
* needs afterward.
|
|
56
|
+
*
|
|
57
|
+
* ## Why extract this from `resolvePageClusterKeys`?
|
|
58
|
+
*
|
|
59
|
+
* The in-memory driver holds every page's HTML/landmarks/preparedHtml in
|
|
60
|
+
* arrays before the per-block loop begins, and any dataset large enough to
|
|
61
|
+
* break memory does so before Stage A even starts. For streaming mode this
|
|
62
|
+
* inner per-block loop needs to run for one block at a time: read that
|
|
63
|
+
* block's HTML, run Stage A, emit its crossBlockUnit rows, free the block's
|
|
64
|
+
* memory, move to the next. Splitting the Stage A body into a standalone
|
|
65
|
+
* function is what makes that per-block iteration possible without
|
|
66
|
+
* duplicating the Stage A logic between the two drivers.
|
|
67
|
+
*
|
|
68
|
+
* Preserves the in-memory driver's per-block behavior exactly for the same
|
|
69
|
+
* inputs — same `preparedHtml`, same `landmarks`, same
|
|
70
|
+
* `localLandmarkTokensByPage`, same `options` → same output.
|
|
71
|
+
* @param input
|
|
72
|
+
* @param options
|
|
73
|
+
*/
|
|
74
|
+
export function stageAPerBlock(input, options) {
|
|
75
|
+
const { blockKey, memberIndices, preparedHtml, landmarks, localLandmarkTokensByPage } = input;
|
|
76
|
+
const similarityThreshold = options?.similarityThreshold ?? 0.8;
|
|
77
|
+
// A block of 1 can never produce more than one cluster regardless of how
|
|
78
|
+
// it's tokenized — nothing to compare it against — so detecting a knee
|
|
79
|
+
// and capping for it would only spend a full multi-depth sweep to arrive
|
|
80
|
+
// back at the same single-cluster result. Skipped rather than swept.
|
|
81
|
+
const maxMainDepth = preparedHtml.length > 1 ? detectContentDepthCap(preparedHtml, options) : undefined;
|
|
82
|
+
const blockTokenSets = preparedHtml.map((html, position) => {
|
|
83
|
+
const capped = maxMainDepth === undefined
|
|
84
|
+
? html
|
|
85
|
+
: capContentDepth(html, { landmark: 'main', maxDepth: maxMainDepth })
|
|
86
|
+
.remainderHtml;
|
|
87
|
+
const tokens = new Set(tokenize(capped, options).tokens);
|
|
88
|
+
// Reinject each page's local-landmark tokens (see
|
|
89
|
+
// resolve-page-cluster-keys.ts's computeLocalLandmarkTokens JSDoc).
|
|
90
|
+
const localTokens = localLandmarkTokensByPage[position];
|
|
91
|
+
if (localTokens !== undefined) {
|
|
92
|
+
for (const token of localTokens)
|
|
93
|
+
tokens.add(token);
|
|
94
|
+
}
|
|
95
|
+
return tokens;
|
|
96
|
+
});
|
|
97
|
+
// Stage A: dendrogram + auto-cut + optional containment assignment
|
|
98
|
+
const blockSize = blockTokenSets.length;
|
|
99
|
+
const comparisonSets = deriveComparisonSets(blockTokenSets);
|
|
100
|
+
const merges = completeLinkageDendrogram(comparisonSets);
|
|
101
|
+
const cut = autoCutThreshold(merges.map((m) => m.height), similarityThreshold);
|
|
102
|
+
let roots = labelsAtThreshold(blockSize, merges, cut);
|
|
103
|
+
// Containment assignment only for blocks large enough to have had
|
|
104
|
+
// frequency-based comparison sets (same MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT
|
|
105
|
+
// threshold as deriveComparisonSets).
|
|
106
|
+
if (blockSize >= MIN_PAGE_COUNT_FOR_FREQUENCY_SPLIT) {
|
|
107
|
+
const clusterTokens = new Map();
|
|
108
|
+
const clusterPageCount = new Map();
|
|
109
|
+
for (let i = 0; i < blockSize; i++) {
|
|
110
|
+
const r = roots[i];
|
|
111
|
+
let tokens = clusterTokens.get(r);
|
|
112
|
+
if (!tokens) {
|
|
113
|
+
tokens = new Set();
|
|
114
|
+
clusterTokens.set(r, tokens);
|
|
115
|
+
}
|
|
116
|
+
for (const t of comparisonSets[i])
|
|
117
|
+
tokens.add(collapseAnonymousDivs(t));
|
|
118
|
+
clusterPageCount.set(r, (clusterPageCount.get(r) ?? 0) + 1);
|
|
119
|
+
}
|
|
120
|
+
const entries = [...clusterTokens.entries()].map(([id, tokens]) => ({
|
|
121
|
+
id,
|
|
122
|
+
tokens: tokens,
|
|
123
|
+
pageCount: clusterPageCount.get(id) ?? 0,
|
|
124
|
+
}));
|
|
125
|
+
const contResult = assignContainedClusters(entries);
|
|
126
|
+
roots = roots.map((r) => contResult.get(r) ?? r);
|
|
127
|
+
}
|
|
128
|
+
// Assign string cluster labels in first-seen order
|
|
129
|
+
const rootToLabel = new Map();
|
|
130
|
+
const localLabels = roots.map((root) => {
|
|
131
|
+
let label = rootToLabel.get(root);
|
|
132
|
+
if (label === undefined) {
|
|
133
|
+
label = `cluster:${rootToLabel.size}`;
|
|
134
|
+
rootToLabel.set(root, label);
|
|
135
|
+
}
|
|
136
|
+
return label;
|
|
137
|
+
});
|
|
138
|
+
const pageKeys = new Map();
|
|
139
|
+
const unitKeyToPositions = new Map();
|
|
140
|
+
for (const [position, memberIndex] of memberIndices.entries()) {
|
|
141
|
+
const unitKey = JSON.stringify([blockKey, localLabels[position]]);
|
|
142
|
+
pageKeys.set(memberIndex, unitKey);
|
|
143
|
+
let positions = unitKeyToPositions.get(unitKey);
|
|
144
|
+
if (!positions) {
|
|
145
|
+
positions = [];
|
|
146
|
+
unitKeyToPositions.set(unitKey, positions);
|
|
147
|
+
}
|
|
148
|
+
positions.push(position);
|
|
149
|
+
}
|
|
150
|
+
// Pre-tokenize each page's landmark instances once now, and hand Stage B
|
|
151
|
+
// those instance lists directly instead of the ~10×-larger raw
|
|
152
|
+
// `ExtractLandmarksResult` objects. Stage B's only consumer of landmark
|
|
153
|
+
// data (`shellQuorum`) previously re-ran `computePerPageLandmarkInstances`
|
|
154
|
+
// on every call — the new signature accepts pre-tokenized instances, so
|
|
155
|
+
// we compute them once per page here and skip the repeat work as a
|
|
156
|
+
// side benefit. The memory reduction is the primary reason: 176k pages ×
|
|
157
|
+
// ~1 KB PerPageLandmarkInstance is ~200 MB, versus ~2–3 GB when we kept
|
|
158
|
+
// full ExtractLandmarksResult objects.
|
|
159
|
+
const memberLandmarkInstancesByPage = computePerPageLandmarkInstances(landmarks, options);
|
|
160
|
+
const crossBlockUnits = [];
|
|
161
|
+
for (const [unitKey, positions] of unitKeyToPositions) {
|
|
162
|
+
// Down-sample any unit that exceeds `capMembers` (opt-in — the
|
|
163
|
+
// streaming path passes MAX_MEMBERS_PER_UNIT; the in-memory path
|
|
164
|
+
// omits the option to preserve full-membership Stage B semantics
|
|
165
|
+
// unchanged for validated corpora). Reservoir seed is the unit key
|
|
166
|
+
// so the sampling is deterministic across runs for the same input.
|
|
167
|
+
const cap = options?.capMembers;
|
|
168
|
+
const sampledPositions = cap !== undefined && positions.length > cap
|
|
169
|
+
? reservoirSample(positions, cap, unitKey)
|
|
170
|
+
: positions;
|
|
171
|
+
crossBlockUnits.push({
|
|
172
|
+
key: unitKey,
|
|
173
|
+
memberTokenSets: sampledPositions.map((pos) => blockTokenSets[pos]),
|
|
174
|
+
memberLandmarkInstances: sampledPositions.map((pos) => memberLandmarkInstancesByPage[pos]),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return { pageKeys, crossBlockUnits };
|
|
178
|
+
}
|
package/dist/tokenize.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export type { TokenizeOptions, TokenizeResult } from './types.js';
|
|
|
53
53
|
* ```ts
|
|
54
54
|
* tokenize('<body><div class="card"><ul><li>A</li><li>B</li></ul></div></body>');
|
|
55
55
|
* // { tokens: ["body>.card>ul>li", "body>.card>ul>li"], bodyClassList: [] }
|
|
56
|
+
*
|
|
57
|
+
* // Disable the built-in hash-noise class filter to keep every class name.
|
|
58
|
+
* tokenize('<body><div class="c-abc123"><p>x</p></div></body>', {
|
|
59
|
+
* filterNoiseClasses: false,
|
|
60
|
+
* });
|
|
61
|
+
* // { tokens: ["body>.c-abc123>p"], bodyClassList: [] }
|
|
56
62
|
* ```
|
|
57
63
|
*/
|
|
58
64
|
export declare function tokenize(html: string, options?: TokenizeOptions): TokenizeResult;
|
package/dist/tokenize.js
CHANGED
|
@@ -53,6 +53,12 @@ import { runTokenizer } from './run-tokenizer.js';
|
|
|
53
53
|
* ```ts
|
|
54
54
|
* tokenize('<body><div class="card"><ul><li>A</li><li>B</li></ul></div></body>');
|
|
55
55
|
* // { tokens: ["body>.card>ul>li", "body>.card>ul>li"], bodyClassList: [] }
|
|
56
|
+
*
|
|
57
|
+
* // Disable the built-in hash-noise class filter to keep every class name.
|
|
58
|
+
* tokenize('<body><div class="c-abc123"><p>x</p></div></body>', {
|
|
59
|
+
* filterNoiseClasses: false,
|
|
60
|
+
* });
|
|
61
|
+
* // { tokens: ["body>.c-abc123>p"], bodyClassList: [] }
|
|
56
62
|
* ```
|
|
57
63
|
*/
|
|
58
64
|
export function tokenize(html, options) {
|