@d-zero/page-cluster 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -41
  3. package/dist/assign-contained-clusters.d.ts +42 -0
  4. package/dist/assign-contained-clusters.js +156 -0
  5. package/dist/auto-cut-threshold.d.ts +17 -0
  6. package/dist/auto-cut-threshold.js +36 -0
  7. package/dist/canonicalize-token-set.d.ts +17 -0
  8. package/dist/canonicalize-token-set.js +19 -0
  9. package/dist/cli.d.ts +39 -0
  10. package/dist/cli.js +381 -0
  11. package/dist/collapse-anonymous-divs.d.ts +21 -0
  12. package/dist/collapse-anonymous-divs.js +42 -0
  13. package/dist/complete-linkage-dendrogram.d.ts +41 -0
  14. package/dist/complete-linkage-dendrogram.js +140 -0
  15. package/dist/derive-comparison-sets.d.ts +22 -0
  16. package/dist/derive-comparison-sets.js +33 -0
  17. package/dist/derive-path-cluster-keys.d.ts +53 -0
  18. package/dist/derive-path-cluster-keys.js +109 -0
  19. package/dist/extract-landmarks.d.ts +91 -45
  20. package/dist/extract-landmarks.js +122 -41
  21. package/dist/filter-first-party-stylesheet-hrefs.d.ts +58 -24
  22. package/dist/filter-first-party-stylesheet-hrefs.js +72 -33
  23. package/dist/find-shallowest-elements.d.ts +48 -11
  24. package/dist/find-shallowest-elements.js +41 -21
  25. package/dist/merge-cross-block-clusters.d.ts +61 -0
  26. package/dist/merge-cross-block-clusters.js +546 -0
  27. package/dist/pass0-blocking.d.ts +89 -0
  28. package/dist/pass0-blocking.js +87 -0
  29. package/dist/per-page-landmark-signatures.d.ts +48 -0
  30. package/dist/per-page-landmark-signatures.js +62 -0
  31. package/dist/reservoir-sample.d.ts +43 -0
  32. package/dist/reservoir-sample.js +98 -0
  33. package/dist/resolve-blocking-group-keys.d.ts +8 -2
  34. package/dist/resolve-blocking-group-keys.js +18 -4
  35. package/dist/resolve-landmark-variant-keys.d.ts +41 -20
  36. package/dist/resolve-landmark-variant-keys.js +69 -26
  37. package/dist/resolve-page-cluster-keys.d.ts +292 -191
  38. package/dist/resolve-page-cluster-keys.js +708 -157
  39. package/dist/resolve-structural-cluster-keys.d.ts +9 -0
  40. package/dist/resolve-structural-cluster-keys.js +14 -232
  41. package/dist/shape-token.d.ts +11 -0
  42. package/dist/shape-token.js +38 -0
  43. package/dist/stage-a-per-block.d.ts +133 -0
  44. package/dist/stage-a-per-block.js +178 -0
  45. package/dist/tokenize.d.ts +6 -0
  46. package/dist/tokenize.js +6 -0
  47. package/package.json +5 -58
  48. package/dist/html-region-utils.d.ts +0 -74
  49. package/dist/html-region-utils.js +0 -96
  50. package/dist/merge-landmark-affined-clusters.d.ts +0 -179
  51. 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 { computeDocumentFrequency } from './compute-document-frequency.js';
2
- import { jaccardSimilarity } from './jaccard-similarity.js';
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 roots = clusterByCompleteLinkage(comparisonSets, similarityThreshold);
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
+ }
@@ -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) {