@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.
Files changed (80) hide show
  1. package/README.md +68 -0
  2. package/dist/array-edit-distance.d.ts +20 -0
  3. package/dist/array-edit-distance.js +52 -0
  4. package/dist/build-segment.d.ts +18 -0
  5. package/dist/build-segment.js +27 -0
  6. package/dist/cap-content-depth.d.ts +69 -0
  7. package/dist/cap-content-depth.js +161 -0
  8. package/dist/compute-document-frequency.d.ts +33 -0
  9. package/dist/compute-document-frequency.js +40 -0
  10. package/dist/create-frame.d.ts +16 -0
  11. package/dist/create-frame.js +29 -0
  12. package/dist/derive-path-group-key.d.ts +44 -0
  13. package/dist/derive-path-group-key.js +51 -0
  14. package/dist/derive-stylesheet-group-key.d.ts +36 -0
  15. package/dist/derive-stylesheet-group-key.js +41 -0
  16. package/dist/detect-content-depth-cap.d.ts +114 -0
  17. package/dist/detect-content-depth-cap.js +137 -0
  18. package/dist/escape-reg-exp.d.ts +11 -0
  19. package/dist/escape-reg-exp.js +13 -0
  20. package/dist/excise.d.ts +13 -0
  21. package/dist/excise.js +24 -0
  22. package/dist/extract-landmarks.d.ts +82 -0
  23. package/dist/extract-landmarks.js +104 -0
  24. package/dist/filter-first-party-stylesheet-hrefs.d.ts +73 -0
  25. package/dist/filter-first-party-stylesheet-hrefs.js +118 -0
  26. package/dist/find-shallowest-elements.d.ts +39 -0
  27. package/dist/find-shallowest-elements.js +121 -0
  28. package/dist/foldable-tags.d.ts +8 -0
  29. package/dist/foldable-tags.js +8 -0
  30. package/dist/format-bracket.d.ts +11 -0
  31. package/dist/format-bracket.js +17 -0
  32. package/dist/hash-content.d.ts +22 -0
  33. package/dist/hash-content.js +26 -0
  34. package/dist/html-region-utils.d.ts +74 -0
  35. package/dist/html-region-utils.js +96 -0
  36. package/dist/is-fold-candidate.d.ts +13 -0
  37. package/dist/is-fold-candidate.js +16 -0
  38. package/dist/is-genuine-close.d.ts +23 -0
  39. package/dist/is-genuine-close.js +27 -0
  40. package/dist/is-noise-class.d.ts +6 -0
  41. package/dist/is-noise-class.js +8 -0
  42. package/dist/jaccard-similarity.d.ts +23 -0
  43. package/dist/jaccard-similarity.js +36 -0
  44. package/dist/merge-landmark-affined-clusters.d.ts +179 -0
  45. package/dist/merge-landmark-affined-clusters.js +544 -0
  46. package/dist/merge-spans.d.ts +15 -0
  47. package/dist/merge-spans.js +22 -0
  48. package/dist/noise-class-patterns.d.ts +21 -0
  49. package/dist/noise-class-patterns.js +74 -0
  50. package/dist/normalize-for-hash.d.ts +10 -0
  51. package/dist/normalize-for-hash.js +12 -0
  52. package/dist/opaque-tags.d.ts +17 -0
  53. package/dist/opaque-tags.js +18 -0
  54. package/dist/parse-class-list.d.ts +10 -0
  55. package/dist/parse-class-list.js +23 -0
  56. package/dist/reassign-orphan-block-keys.d.ts +81 -0
  57. package/dist/reassign-orphan-block-keys.js +159 -0
  58. package/dist/remove-content-blocks.d.ts +67 -0
  59. package/dist/remove-content-blocks.js +150 -0
  60. package/dist/resolve-blocking-group-keys.d.ts +116 -0
  61. package/dist/resolve-blocking-group-keys.js +120 -0
  62. package/dist/resolve-closed-frame.d.ts +26 -0
  63. package/dist/resolve-closed-frame.js +33 -0
  64. package/dist/resolve-landmark-variant-keys.d.ts +66 -0
  65. package/dist/resolve-landmark-variant-keys.js +71 -0
  66. package/dist/resolve-options.d.ts +6 -0
  67. package/dist/resolve-options.js +10 -0
  68. package/dist/resolve-page-cluster-keys.d.ts +222 -0
  69. package/dist/resolve-page-cluster-keys.js +198 -0
  70. package/dist/resolve-structural-cluster-keys.d.ts +50 -0
  71. package/dist/resolve-structural-cluster-keys.js +287 -0
  72. package/dist/run-tokenizer.d.ts +33 -0
  73. package/dist/run-tokenizer.js +152 -0
  74. package/dist/split-tokens-by-frequency.d.ts +46 -0
  75. package/dist/split-tokens-by-frequency.js +88 -0
  76. package/dist/tokenize.d.ts +58 -0
  77. package/dist/tokenize.js +60 -0
  78. package/dist/types.d.ts +85 -0
  79. package/dist/types.js +1 -0
  80. 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;