@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.
- package/LICENSE +21 -0
- package/README.md +95 -41
- 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 +5 -58
- 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
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { assignContainedClusters } from './assign-contained-clusters.js';
|
|
2
|
+
import { autoCutThreshold } from './auto-cut-threshold.js';
|
|
3
|
+
import { collapseAnonymousDivs } from './collapse-anonymous-divs.js';
|
|
4
|
+
import { completeLinkageDendrogram, labelsAtThreshold, } from './complete-linkage-dendrogram.js';
|
|
5
|
+
import { computeDocumentFrequency } from './compute-document-frequency.js';
|
|
6
|
+
import { jaccardSimilarity } from './jaccard-similarity.js';
|
|
7
|
+
import { reservoirSample } from './reservoir-sample.js';
|
|
8
|
+
import { shapeToken } from './shape-token.js';
|
|
9
|
+
import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
|
|
10
|
+
/**
|
|
11
|
+
* Fixed complete-linkage threshold for the cross-block fine stage.
|
|
12
|
+
*
|
|
13
|
+
* Not auto-cut: cross-block units are few (typically 10–100 for a whole
|
|
14
|
+
* site), so the merge-height distribution is too sparse for max-gap detection
|
|
15
|
+
* to produce a reliable cut. Confirmed on real crawl data: without this fixed
|
|
16
|
+
* floor, auto-cut selected 0.045 on an 18-unit corpus, causing spurious
|
|
17
|
+
* micro-merges.
|
|
18
|
+
*/
|
|
19
|
+
const CROSS_BLOCK_THRESHOLD = 0.8;
|
|
20
|
+
/**
|
|
21
|
+
* Quorum fraction: a token must be present in at least this fraction of a
|
|
22
|
+
* unit's member pages to enter the unit's core.
|
|
23
|
+
*
|
|
24
|
+
* Strict intersection degenerates: a unit of 89 articles sharing only one
|
|
25
|
+
* common distinctive token produces jaccard 1.0 with everything — confirmed
|
|
26
|
+
* on real crawl data. Full union is shell-dominated: 298 pages collapsed into
|
|
27
|
+
* 4 clusters — also confirmed. 80% quorum avoids both failure modes.
|
|
28
|
+
*
|
|
29
|
+
* Also reused as the fallback clamp for {@link ./auto-cut-threshold.js |
|
|
30
|
+
* autoCutThreshold} when running on the per-landmark-instance frequency
|
|
31
|
+
* distribution in {@link ./merge-cross-block-clusters.js | shellQuorum}. The
|
|
32
|
+
* clamp only ever *loosens* the cut relative to this floor (never tightens),
|
|
33
|
+
* and only fires in the degenerate cases the JSDoc there describes.
|
|
34
|
+
*/
|
|
35
|
+
const QUORUM_FRACTION = 0.8;
|
|
36
|
+
/**
|
|
37
|
+
* Shape-based Jaccard threshold for "same skeleton, different class names".
|
|
38
|
+
* Class-name Jaccard for reports/projects/news list pages: 0.000; shape
|
|
39
|
+
* Jaccard: 1.000 on real crawl data.
|
|
40
|
+
*/
|
|
41
|
+
const SHAPE_JACCARD_THRESHOLD = 0.9;
|
|
42
|
+
/**
|
|
43
|
+
* Minimum member-page count for a unit to participate in shape-Jaccard
|
|
44
|
+
* comparison. Single-page units are excluded because their quorum core
|
|
45
|
+
* equals their raw token set with no frequency filtering — any two 1-page
|
|
46
|
+
* units with the same tag skeleton but completely different content will
|
|
47
|
+
* shape-merge spuriously. Multi-page units produce quorum cores that
|
|
48
|
+
* reflect a shared template rather than individual page noise, so shape
|
|
49
|
+
* comparison there is meaningful.
|
|
50
|
+
*/
|
|
51
|
+
const SHAPE_MIN_PAGES = 2;
|
|
52
|
+
/**
|
|
53
|
+
* L2-stage shell corroboration threshold. Prevents cross-microsite false
|
|
54
|
+
* merges: a microsite with a different shell (header/nav/footer) from the
|
|
55
|
+
* main site would otherwise merge via L2 alone. Confirmed on real crawl
|
|
56
|
+
* data: two false merges blocked, correct merges (same shell) unaffected.
|
|
57
|
+
*/
|
|
58
|
+
const SHELL_CORROBORATION_THRESHOLD = 0.8;
|
|
59
|
+
/**
|
|
60
|
+
* Maximum cross-block merge rounds. Real crawl data converged in ≤ 7 rounds
|
|
61
|
+
* on the two validation corpora (302 pages / 8,936 pages).
|
|
62
|
+
*/
|
|
63
|
+
const MAX_ROUNDS = 10;
|
|
64
|
+
/**
|
|
65
|
+
* Segments carrying no structural information at L2 resolution.
|
|
66
|
+
* Tokens whose only non-`main` content segments are all generic are excluded
|
|
67
|
+
* from L2 signatures as uninformative.
|
|
68
|
+
*/
|
|
69
|
+
const GENERIC_SEGMENTS = new Set([
|
|
70
|
+
'div',
|
|
71
|
+
'span',
|
|
72
|
+
'*',
|
|
73
|
+
'script',
|
|
74
|
+
'noscript',
|
|
75
|
+
'style',
|
|
76
|
+
'iframe',
|
|
77
|
+
'a',
|
|
78
|
+
'br',
|
|
79
|
+
'img',
|
|
80
|
+
'picture',
|
|
81
|
+
'source',
|
|
82
|
+
]);
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Internal helpers
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
*
|
|
88
|
+
* @param memberDistinctiveTokens
|
|
89
|
+
*/
|
|
90
|
+
function quorumCore(memberDistinctiveTokens) {
|
|
91
|
+
const n = memberDistinctiveTokens.length;
|
|
92
|
+
if (n === 0)
|
|
93
|
+
return new Set();
|
|
94
|
+
const minCount = Math.ceil(QUORUM_FRACTION * n);
|
|
95
|
+
const tokenCount = new Map();
|
|
96
|
+
for (const tokens of memberDistinctiveTokens) {
|
|
97
|
+
for (const token of tokens) {
|
|
98
|
+
tokenCount.set(token, (tokenCount.get(token) ?? 0) + 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const core = new Set();
|
|
102
|
+
for (const [token, count] of tokenCount) {
|
|
103
|
+
if (count >= minCount)
|
|
104
|
+
core.add(token);
|
|
105
|
+
}
|
|
106
|
+
if (core.size > 0)
|
|
107
|
+
return core;
|
|
108
|
+
// Fallback: union of all distinctive tokens (happens for very small units)
|
|
109
|
+
const union = new Set();
|
|
110
|
+
for (const tokens of memberDistinctiveTokens) {
|
|
111
|
+
for (const t of tokens)
|
|
112
|
+
union.add(t);
|
|
113
|
+
}
|
|
114
|
+
return union;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
*
|
|
118
|
+
* @param core
|
|
119
|
+
*/
|
|
120
|
+
function shapedCoreSet(core) {
|
|
121
|
+
const shaped = new Set();
|
|
122
|
+
for (const token of core)
|
|
123
|
+
shaped.add(shapeToken(token));
|
|
124
|
+
return shaped;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
*
|
|
128
|
+
* @param core
|
|
129
|
+
*/
|
|
130
|
+
function l2Signature(core) {
|
|
131
|
+
const counts = new Map();
|
|
132
|
+
for (const token of core) {
|
|
133
|
+
const shaped = shapeToken(token);
|
|
134
|
+
const segments = shaped.split('>');
|
|
135
|
+
const mainIdx = segments.findIndex((s) => s === 'main' || s.startsWith('main[') || s.startsWith('main.'));
|
|
136
|
+
if (mainIdx === -1)
|
|
137
|
+
continue;
|
|
138
|
+
// Take main + up to 2 levels after it
|
|
139
|
+
const truncated = segments.slice(mainIdx, mainIdx + 3);
|
|
140
|
+
const contentSegments = truncated.slice(1);
|
|
141
|
+
// Skip if all content segments are generic (or none exist)
|
|
142
|
+
if (contentSegments.every((s) => GENERIC_SEGMENTS.has(s))) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const key = truncated.join('>');
|
|
146
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
return counts.size > 0 ? counts : null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
*
|
|
152
|
+
* @param xSig
|
|
153
|
+
* @param ySig
|
|
154
|
+
*/
|
|
155
|
+
function l2Contained(xSig, ySig) {
|
|
156
|
+
for (const [key, xCount] of xSig) {
|
|
157
|
+
if (xCount > (ySig.get(key) ?? 0))
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Discovers a unit's shell tokens by auto-cutting the per-*token* page-
|
|
164
|
+
* frequency histogram of every landmark instance's tokens. This is the same
|
|
165
|
+
* max-gap primitive used for Stage A merge-height cutoffs, applied
|
|
166
|
+
* recursively at the landmark-token layer.
|
|
167
|
+
*
|
|
168
|
+
* ## Why per-token and not per-signature
|
|
169
|
+
*
|
|
170
|
+
* An earlier iteration ran the histogram at the level of full landmark-
|
|
171
|
+
* instance signatures (canonicalized token sets). That failed on a real,
|
|
172
|
+
* common pattern: a shared site chrome whose markup carries a per-page
|
|
173
|
+
* distinguishing element (a breadcrumb, a page-title element with a page-
|
|
174
|
+
* specific class, a "current" state). All pages have most of the same
|
|
175
|
+
* tokens, but every page's full signature is distinct because tokens embed
|
|
176
|
+
* class names. Per-signature counting saw 5 signatures at freq 0.2 each,
|
|
177
|
+
* autoCutThreshold on the flat distribution returned the clamp, and the
|
|
178
|
+
* shell collapsed to empty even though every page shared the core header
|
|
179
|
+
* skeleton. Per-token counting handles the same case correctly — the shared
|
|
180
|
+
* skeleton tokens each hit freq 1.0.
|
|
181
|
+
*
|
|
182
|
+
* ## The histogram
|
|
183
|
+
*
|
|
184
|
+
* For every member page, all its landmark instances are tokenized and
|
|
185
|
+
* unioned into a single per-page token set (order-agnostic, deduped: a
|
|
186
|
+
* token appearing in two of the page's landmarks still counts once for
|
|
187
|
+
* that page). The corpus histogram is then "how many pages contain each
|
|
188
|
+
* token". Tokens that appear on nearly every page are the unit's chrome;
|
|
189
|
+
* tokens that appear on only a handful are page-specific content that
|
|
190
|
+
* happens to be tagged as a landmark.
|
|
191
|
+
*
|
|
192
|
+
* ## Why auto-cut instead of a hard-coded quorum
|
|
193
|
+
*
|
|
194
|
+
* A fixed 80% quorum (this file's earlier implementation) baked one
|
|
195
|
+
* threshold in for every unit. Real corpora don't obey a universal cutoff:
|
|
196
|
+
* a section-local landmark token that appears on 60% of a unit's pages is
|
|
197
|
+
* the section's chrome under any reasonable reading, but 80% quorum
|
|
198
|
+
* discards it. Auto-cut looks at the *shape* of the frequency distribution
|
|
199
|
+
* and picks the widest gap between adjacent frequencies — if the
|
|
200
|
+
* distribution is `{1.00, 1.00, 0.65, 0.03, 0.02}`, the gap between 0.65
|
|
201
|
+
* and 0.03 (0.62) dwarfs everything else and the cut lands mid-gap around
|
|
202
|
+
* 0.34, correctly grouping the 0.65 tokens with the site-wide 1.00 ones as
|
|
203
|
+
* "chrome for this unit". If instead the distribution is flat, the clamp
|
|
204
|
+
* to {@link QUORUM_FRACTION} keeps the threshold from being tighter than
|
|
205
|
+
* the fallback default.
|
|
206
|
+
*
|
|
207
|
+
* ## Fallbacks
|
|
208
|
+
*
|
|
209
|
+
* A single distinct token (`heights.length < 2`) or a perfectly flat
|
|
210
|
+
* distribution (`maxGap === 0`) returns the {@link QUORUM_FRACTION} clamp
|
|
211
|
+
* verbatim — exactly the same 80%-quorum behavior as before. So degenerate
|
|
212
|
+
* cases degrade to the old contract; only richer distributions get the
|
|
213
|
+
* auto-cut benefit.
|
|
214
|
+
*
|
|
215
|
+
* A page with no landmarks contributes an empty set, deliberately, so the
|
|
216
|
+
* shell-corroboration jaccard between two landmark-less pages is 0 rather
|
|
217
|
+
* than 1 (which it would be if we handed back a `<body></body>`-derived
|
|
218
|
+
* `{body}` fallback set to both sides).
|
|
219
|
+
* @param perPageInstances
|
|
220
|
+
*/
|
|
221
|
+
function shellQuorum(perPageInstances) {
|
|
222
|
+
const pageCount = perPageInstances.length;
|
|
223
|
+
if (pageCount === 0)
|
|
224
|
+
return new Set();
|
|
225
|
+
// Union all instance token sets per page (dedupe within page: a token
|
|
226
|
+
// present on both header and footer of the same page still counts once
|
|
227
|
+
// for that page's contribution).
|
|
228
|
+
const tokenPageCount = new Map();
|
|
229
|
+
for (const instances of perPageInstances) {
|
|
230
|
+
const perPageUnion = new Set();
|
|
231
|
+
for (const inst of instances) {
|
|
232
|
+
for (const token of inst.tokens)
|
|
233
|
+
perPageUnion.add(token);
|
|
234
|
+
}
|
|
235
|
+
for (const token of perPageUnion) {
|
|
236
|
+
tokenPageCount.set(token, (tokenPageCount.get(token) ?? 0) + 1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (tokenPageCount.size === 0)
|
|
240
|
+
return new Set();
|
|
241
|
+
const frequencies = [];
|
|
242
|
+
for (const count of tokenPageCount.values()) {
|
|
243
|
+
frequencies.push(count / pageCount);
|
|
244
|
+
}
|
|
245
|
+
const cut = autoCutThreshold(frequencies, QUORUM_FRACTION);
|
|
246
|
+
const shell = new Set();
|
|
247
|
+
for (const [token, count] of tokenPageCount) {
|
|
248
|
+
if (count / pageCount >= cut)
|
|
249
|
+
shell.add(token);
|
|
250
|
+
}
|
|
251
|
+
return shell;
|
|
252
|
+
}
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Main function
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
/**
|
|
257
|
+
* Merges cross-block clusters (Stage B) via recursive quorum-core comparison.
|
|
258
|
+
*
|
|
259
|
+
* Returns a `Map` from each input unit's `key` to its final root key. Units
|
|
260
|
+
* not absorbed into any other unit map to themselves.
|
|
261
|
+
*
|
|
262
|
+
* Three merge mechanisms run per round, in order:
|
|
263
|
+
* 1. **Fine stage** — complete-linkage at `CROSS_BLOCK_THRESHOLD` on quorum
|
|
264
|
+
* cores, then containment assignment (0.9), then shape-Jaccard (0.9) for
|
|
265
|
+
* class-name-only differences.
|
|
266
|
+
* 2. **L2 stage** (only when fine found nothing) — multiset containment on
|
|
267
|
+
* `main`-anchored 2-level shape signatures, with shell corroboration
|
|
268
|
+
* (header+nav+footer quorum Jaccard ≥ 0.8) required.
|
|
269
|
+
*
|
|
270
|
+
* Rounds continue until neither stage finds anything, or `MAX_ROUNDS` is hit.
|
|
271
|
+
* Each round re-derives quorum cores from pooled members of merged units.
|
|
272
|
+
*
|
|
273
|
+
* Why quorum cores instead of strict intersection or full union:
|
|
274
|
+
* strict intersection degenerated on real crawl data (89 articles → 1 shared
|
|
275
|
+
* distinctive token → jaccard 1.0 false merges). Full union was shell-dominated
|
|
276
|
+
* (298-page avalanche into 4 clusters). Both failure modes are documented in
|
|
277
|
+
* `@d-zero/page-cluster` source JSDoc; quorum 80% + page-frequency shell
|
|
278
|
+
* removal was validated on two real crawl corpora.
|
|
279
|
+
* @param units Post-Stage-A clusters.
|
|
280
|
+
* @param options Forwarded `similarityThreshold` (defaults to 0.8).
|
|
281
|
+
* @param options.similarityThreshold
|
|
282
|
+
* @param options.capMembers
|
|
283
|
+
*/
|
|
284
|
+
export function mergeCrossBlockClusters(units, options) {
|
|
285
|
+
if (units.length <= 1) {
|
|
286
|
+
return new Map(units.map((u) => [u.key, u.key]));
|
|
287
|
+
}
|
|
288
|
+
const threshold = options?.similarityThreshold ?? CROSS_BLOCK_THRESHOLD;
|
|
289
|
+
const capMembers = options?.capMembers;
|
|
290
|
+
const groups = new Map();
|
|
291
|
+
for (const unit of units) {
|
|
292
|
+
groups.set(unit.key, {
|
|
293
|
+
tokenSets: [...unit.memberTokenSets],
|
|
294
|
+
landmarkInstances: [...unit.memberLandmarkInstances],
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// Maps every original key to its current root (updated on each merge)
|
|
298
|
+
const keyToRoot = new Map(units.map((u) => [u.key, u.key]));
|
|
299
|
+
/**
|
|
300
|
+
* Applies a list of [absorbed, root] merges to `groups` and `keyToRoot`.
|
|
301
|
+
* All absorbed groups' members are folded into their respective roots.
|
|
302
|
+
* @param merges
|
|
303
|
+
*/
|
|
304
|
+
function applyMerges(merges) {
|
|
305
|
+
for (const [absorbed, root] of merges) {
|
|
306
|
+
const absorbedG = groups.get(absorbed);
|
|
307
|
+
const rootG = groups.get(root);
|
|
308
|
+
if (!absorbedG || !rootG)
|
|
309
|
+
continue;
|
|
310
|
+
const mergedTokenSets = [...rootG.tokenSets, ...absorbedG.tokenSets];
|
|
311
|
+
const mergedLandmarkInstances = [
|
|
312
|
+
...rootG.landmarkInstances,
|
|
313
|
+
...absorbedG.landmarkInstances,
|
|
314
|
+
];
|
|
315
|
+
// Only down-sample when the caller explicitly opts in (streaming
|
|
316
|
+
// path). Same-index sampling keeps memberTokenSets[i] and
|
|
317
|
+
// landmarkInstances[i] parallel.
|
|
318
|
+
if (capMembers !== undefined && mergedTokenSets.length > capMembers) {
|
|
319
|
+
const indices = mergedTokenSets.map((_, i) => i);
|
|
320
|
+
const kept = reservoirSample(indices, capMembers, root);
|
|
321
|
+
rootG.tokenSets = kept.map((i) => mergedTokenSets[i]);
|
|
322
|
+
rootG.landmarkInstances = kept.map((i) => mergedLandmarkInstances[i]);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
rootG.tokenSets = mergedTokenSets;
|
|
326
|
+
rootG.landmarkInstances = mergedLandmarkInstances;
|
|
327
|
+
}
|
|
328
|
+
groups.delete(absorbed);
|
|
329
|
+
for (const [origKey, cur] of keyToRoot) {
|
|
330
|
+
if (cur === absorbed)
|
|
331
|
+
keyToRoot.set(origKey, root);
|
|
332
|
+
}
|
|
333
|
+
keyToRoot.set(absorbed, root);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
337
|
+
const groupKeys = [...groups.keys()];
|
|
338
|
+
const n = groupKeys.length;
|
|
339
|
+
if (n <= 1)
|
|
340
|
+
break;
|
|
341
|
+
// ---------------------------------------------------------------
|
|
342
|
+
// Compute corpus distinctive tokens (page-frequency shell removal)
|
|
343
|
+
// ---------------------------------------------------------------
|
|
344
|
+
const allPageTokenSets = groupKeys.flatMap((k) => groups.get(k).tokenSets);
|
|
345
|
+
const corpusFrequency = computeDocumentFrequency(allPageTokenSets);
|
|
346
|
+
const groupDistinctive = new Map();
|
|
347
|
+
for (const key of groupKeys) {
|
|
348
|
+
const g = groups.get(key);
|
|
349
|
+
const dist = [];
|
|
350
|
+
for (const tokens of g.tokenSets) {
|
|
351
|
+
const { contentTokens } = splitTokensByFrequency(tokens, corpusFrequency);
|
|
352
|
+
dist.push(contentTokens.size > 0 ? contentTokens : tokens);
|
|
353
|
+
}
|
|
354
|
+
groupDistinctive.set(key, dist);
|
|
355
|
+
}
|
|
356
|
+
// Quorum core per group
|
|
357
|
+
const cores = new Map();
|
|
358
|
+
for (const key of groupKeys) {
|
|
359
|
+
cores.set(key, quorumCore(groupDistinctive.get(key) ?? []));
|
|
360
|
+
}
|
|
361
|
+
// ---------------------------------------------------------------
|
|
362
|
+
// Fine stage: union-find over group indices
|
|
363
|
+
// ---------------------------------------------------------------
|
|
364
|
+
const parent = Array.from({ length: n }, (_, i) => i);
|
|
365
|
+
const ufFind = (x) => {
|
|
366
|
+
let r = x;
|
|
367
|
+
while (parent[r] !== r)
|
|
368
|
+
r = parent[r];
|
|
369
|
+
let c = x;
|
|
370
|
+
while (c !== r) {
|
|
371
|
+
const next = parent[c];
|
|
372
|
+
parent[c] = r;
|
|
373
|
+
c = next;
|
|
374
|
+
}
|
|
375
|
+
return r;
|
|
376
|
+
};
|
|
377
|
+
const ufUnion = (a, b) => {
|
|
378
|
+
const ra = ufFind(a);
|
|
379
|
+
const rb = ufFind(b);
|
|
380
|
+
if (ra !== rb)
|
|
381
|
+
parent[rb] = ra; // lower index wins
|
|
382
|
+
};
|
|
383
|
+
const coreSets = groupKeys.map((k) => cores.get(k) ?? new Set());
|
|
384
|
+
// Step 1: CL merges
|
|
385
|
+
const dendrogram = completeLinkageDendrogram(coreSets);
|
|
386
|
+
const clLabels = labelsAtThreshold(n, dendrogram, threshold);
|
|
387
|
+
for (let i = 0; i < n; i++) {
|
|
388
|
+
const r = clLabels[i];
|
|
389
|
+
if (r !== undefined && r !== i)
|
|
390
|
+
ufUnion(r, i);
|
|
391
|
+
}
|
|
392
|
+
// Step 2: Containment on the current union-find clusters
|
|
393
|
+
// Build union token set per UF cluster
|
|
394
|
+
const clusterUnion = new Map();
|
|
395
|
+
const clusterPageCount = new Map();
|
|
396
|
+
for (let i = 0; i < n; i++) {
|
|
397
|
+
const r = ufFind(i);
|
|
398
|
+
let u = clusterUnion.get(r);
|
|
399
|
+
if (!u) {
|
|
400
|
+
u = new Set();
|
|
401
|
+
clusterUnion.set(r, u);
|
|
402
|
+
}
|
|
403
|
+
for (const t of coreSets[i] ?? [])
|
|
404
|
+
u.add(collapseAnonymousDivs(t));
|
|
405
|
+
clusterPageCount.set(r, (clusterPageCount.get(r) ?? 0) +
|
|
406
|
+
(groups.get(groupKeys[i] ?? '')?.tokenSets.length ?? 0));
|
|
407
|
+
}
|
|
408
|
+
const contEntries = [...clusterUnion.entries()].map(([id, tokens]) => ({
|
|
409
|
+
id,
|
|
410
|
+
tokens: tokens,
|
|
411
|
+
pageCount: clusterPageCount.get(id) ?? 0,
|
|
412
|
+
}));
|
|
413
|
+
const contResult = assignContainedClusters(contEntries);
|
|
414
|
+
// Apply containment assignment: fromId (UF root index) → toId (UF root index)
|
|
415
|
+
for (const [fromId, toId] of contResult) {
|
|
416
|
+
if (fromId === toId)
|
|
417
|
+
continue;
|
|
418
|
+
// fromId/toId are group indices (the UF roots when contEntries was built)
|
|
419
|
+
ufUnion(toId, fromId);
|
|
420
|
+
}
|
|
421
|
+
// Step 3: Shape-Jaccard (multi-page units only — see SHAPE_MIN_PAGES)
|
|
422
|
+
const shapedCores = groupKeys.map((k) => shapedCoreSet(cores.get(k) ?? new Set()));
|
|
423
|
+
const groupPageCounts = groupKeys.map((k) => groups.get(k)?.tokenSets.length ?? 0);
|
|
424
|
+
for (let i = 0; i < n; i++) {
|
|
425
|
+
for (let j = i + 1; j < n; j++) {
|
|
426
|
+
if (ufFind(i) === ufFind(j))
|
|
427
|
+
continue;
|
|
428
|
+
if ((groupPageCounts[i] ?? 0) < SHAPE_MIN_PAGES ||
|
|
429
|
+
(groupPageCounts[j] ?? 0) < SHAPE_MIN_PAGES) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const si = shapedCores[i] ?? new Set();
|
|
433
|
+
const sj = shapedCores[j] ?? new Set();
|
|
434
|
+
if (jaccardSimilarity(si, sj) >= SHAPE_JACCARD_THRESHOLD) {
|
|
435
|
+
ufUnion(ufFind(i), ufFind(j));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Collect fine-stage merges: groups that share a UF root
|
|
440
|
+
const rootToFirstKey = new Map(); // UF root → first group key (alphabetically first)
|
|
441
|
+
const fineMerges = [];
|
|
442
|
+
for (let i = 0; i < n; i++) {
|
|
443
|
+
const r = ufFind(i);
|
|
444
|
+
const gk = groupKeys[i] ?? '';
|
|
445
|
+
const rootKey = rootToFirstKey.get(r);
|
|
446
|
+
if (rootKey === undefined) {
|
|
447
|
+
rootToFirstKey.set(r, gk);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
fineMerges.push([gk, rootKey]);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (fineMerges.length > 0) {
|
|
454
|
+
applyMerges(fineMerges);
|
|
455
|
+
continue; // next round
|
|
456
|
+
}
|
|
457
|
+
// ---------------------------------------------------------------
|
|
458
|
+
// L2 stage: multiset containment + shell corroboration
|
|
459
|
+
// ---------------------------------------------------------------
|
|
460
|
+
const l2Keys = [...groups.keys()];
|
|
461
|
+
const l2n = l2Keys.length;
|
|
462
|
+
if (l2n <= 1)
|
|
463
|
+
break;
|
|
464
|
+
// Lazily compute L2 sigs and shell quorums
|
|
465
|
+
const l2SigCache = new Map();
|
|
466
|
+
const shellCache = new Map();
|
|
467
|
+
const getL2Sig = (key) => {
|
|
468
|
+
if (!l2SigCache.has(key)) {
|
|
469
|
+
l2SigCache.set(key, l2Signature(cores.get(key) ?? new Set()));
|
|
470
|
+
}
|
|
471
|
+
return l2SigCache.get(key) ?? null;
|
|
472
|
+
};
|
|
473
|
+
const getShell = (key) => {
|
|
474
|
+
if (!shellCache.has(key)) {
|
|
475
|
+
shellCache.set(key, shellQuorum(groups.get(key)?.landmarkInstances ?? []));
|
|
476
|
+
}
|
|
477
|
+
return shellCache.get(key) ?? new Set();
|
|
478
|
+
};
|
|
479
|
+
// Collect valid L2 containment pairs and apply via union-find
|
|
480
|
+
// Direction: x is contained in y → x is absorbed by y
|
|
481
|
+
// Multiple pairs can apply in one round if they form consistent groups
|
|
482
|
+
const l2Parent = Array.from({ length: l2n }, (_, i) => i);
|
|
483
|
+
const l2Find = (x) => {
|
|
484
|
+
let r = x;
|
|
485
|
+
while (l2Parent[r] !== r)
|
|
486
|
+
r = l2Parent[r];
|
|
487
|
+
let c = x;
|
|
488
|
+
while (c !== r) {
|
|
489
|
+
const next = l2Parent[c];
|
|
490
|
+
l2Parent[c] = r;
|
|
491
|
+
c = next;
|
|
492
|
+
}
|
|
493
|
+
return r;
|
|
494
|
+
};
|
|
495
|
+
const l2Union = (a, b) => {
|
|
496
|
+
const ra = l2Find(a);
|
|
497
|
+
const rb = l2Find(b);
|
|
498
|
+
if (ra !== rb)
|
|
499
|
+
l2Parent[rb] = ra;
|
|
500
|
+
};
|
|
501
|
+
for (let xi = 0; xi < l2n; xi++) {
|
|
502
|
+
const xKey = l2Keys[xi] ?? '';
|
|
503
|
+
const xSig = getL2Sig(xKey);
|
|
504
|
+
if (!xSig)
|
|
505
|
+
continue;
|
|
506
|
+
for (let yi = 0; yi < l2n; yi++) {
|
|
507
|
+
if (xi === yi || l2Find(xi) === l2Find(yi))
|
|
508
|
+
continue;
|
|
509
|
+
const yKey = l2Keys[yi] ?? '';
|
|
510
|
+
const ySig = getL2Sig(yKey);
|
|
511
|
+
if (!ySig)
|
|
512
|
+
continue;
|
|
513
|
+
if (!l2Contained(xSig, ySig))
|
|
514
|
+
continue;
|
|
515
|
+
// Shell corroboration
|
|
516
|
+
const xShell = getShell(xKey);
|
|
517
|
+
const yShell = getShell(yKey);
|
|
518
|
+
if (xShell.size === 0 ||
|
|
519
|
+
yShell.size === 0 ||
|
|
520
|
+
jaccardSimilarity(xShell, yShell) < SHELL_CORROBORATION_THRESHOLD) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
// x absorbed by y: l2Parent[xi] = yi after l2Union
|
|
524
|
+
l2Union(yi, xi);
|
|
525
|
+
break; // xSig is stale once merged; let the next round re-evaluate
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const l2RootToFirst = new Map();
|
|
529
|
+
const l2Merges = [];
|
|
530
|
+
for (let i = 0; i < l2n; i++) {
|
|
531
|
+
const r = l2Find(i);
|
|
532
|
+
const gk = l2Keys[i] ?? '';
|
|
533
|
+
const rootKey = l2RootToFirst.get(r);
|
|
534
|
+
if (rootKey === undefined) {
|
|
535
|
+
l2RootToFirst.set(r, gk);
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
l2Merges.push([gk, rootKey]);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (l2Merges.length === 0)
|
|
542
|
+
break; // fully converged
|
|
543
|
+
applyMerges(l2Merges);
|
|
544
|
+
}
|
|
545
|
+
return keyToRoot;
|
|
546
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
|
|
2
|
+
/**
|
|
3
|
+
* The corpus-wide, HTML-free inputs needed by
|
|
4
|
+
* {@link ./pass0-blocking.js | resolveBlockKeys}: the blocking signals plus
|
|
5
|
+
* the page's own URL host. The full `PageClusterSignals` type carries `html`
|
|
6
|
+
* on top of these, but Pass 0 deliberately does not read HTML — the whole
|
|
7
|
+
* point of extracting the blocking step is that it can be run before any
|
|
8
|
+
* per-page HTML is held in memory.
|
|
9
|
+
*/
|
|
10
|
+
export type Pass0PageSignals = {
|
|
11
|
+
readonly paths: readonly string[];
|
|
12
|
+
readonly stylesheetHrefs: readonly string[];
|
|
13
|
+
readonly host?: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* @see resolveBlockKeys
|
|
17
|
+
*/
|
|
18
|
+
export type ResolveBlockKeysOptions = ResolveBlockingGroupKeysOptions & {
|
|
19
|
+
/**
|
|
20
|
+
* Apply {@link ./reassign-orphan-block-keys.js | reassignOrphanBlockKeys}
|
|
21
|
+
* to the blocking keys, so a page with no recorded stylesheets can rejoin
|
|
22
|
+
* a same-URL-section `css:` block instead of being stranded on its weaker
|
|
23
|
+
* `path:` fallback. Defaults to `true`.
|
|
24
|
+
*/
|
|
25
|
+
readonly reassignOrphans?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Apply {@link ./filter-first-party-stylesheet-hrefs.js |
|
|
28
|
+
* filterFirstPartyStylesheetHrefs} to `pages` before computing blocking
|
|
29
|
+
* keys, so a page's incidental third-party embeds do not contaminate the
|
|
30
|
+
* blocking signal. Defaults to `true`.
|
|
31
|
+
*/
|
|
32
|
+
readonly restrictStylesheetsToFirstParty?: boolean;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Splits `resolvePageClusterKeys` into a size-flat first pass so the driver
|
|
36
|
+
* can decide per-block memory strategy before loading any page HTML. Runs the
|
|
37
|
+
* three corpus-wide, HTML-free stages of blocking in the same order the
|
|
38
|
+
* in-memory driver already uses — first-party stylesheet filtering, blocking-
|
|
39
|
+
* key derivation, orphan reassignment — and returns one final block key per
|
|
40
|
+
* input page in input order.
|
|
41
|
+
*
|
|
42
|
+
* ## Why extract this from resolvePageClusterKeys?
|
|
43
|
+
*
|
|
44
|
+
* The in-memory driver holds every page's `html`, `remainderHtml`,
|
|
45
|
+
* `landmarks[]`, and pre-Stage-A prepared HTML at once. At 176k pages × ~57
|
|
46
|
+
* KB average, that alone breaks a 17 GB RAM machine well before Stage A
|
|
47
|
+
* starts (measured: OS SIGKILL at ~15,000 pages, before the resolve phase
|
|
48
|
+
* even began). All three blocking stages, in contrast, depend only on
|
|
49
|
+
* `paths` / `stylesheetHrefs` / `host` — a few hundred bytes per page. Running
|
|
50
|
+
* them first, HTML-free, lets the downstream per-block clustering hold HTML
|
|
51
|
+
* for only one block's pages at a time.
|
|
52
|
+
*
|
|
53
|
+
* ## Preserves in-memory driver semantics exactly
|
|
54
|
+
*
|
|
55
|
+
* The output of this function is byte-identical to the block-key portion of
|
|
56
|
+
* the current `resolvePageClusterKeys` for the same input, because it reuses
|
|
57
|
+
* the same three underlying functions in the same order with the same
|
|
58
|
+
* defaults. That guarantee is load-bearing: the size-threshold-gated Pass 1
|
|
59
|
+
* that follows this function runs the current in-memory implementation
|
|
60
|
+
* unchanged for small blocks, and any drift in block-key computation between
|
|
61
|
+
* Pass 0 and Pass 1 would silently misroute pages between the two paths.
|
|
62
|
+
* @param pages
|
|
63
|
+
* @param options
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* const blockKeys = resolveBlockKeys([
|
|
67
|
+
* { paths: ['news', '1'], stylesheetHrefs: ['/a.css'], host: 'example.com' },
|
|
68
|
+
* { paths: ['news', '2'], stylesheetHrefs: ['/a.css'], host: 'example.com' },
|
|
69
|
+
* { paths: ['about'], stylesheetHrefs: [], host: 'example.com' },
|
|
70
|
+
* ]);
|
|
71
|
+
* // ['css:<hash>', 'css:<hash>', 'path:about']
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveBlockKeys(pages: readonly Pass0PageSignals[], options?: ResolveBlockKeysOptions): string[];
|
|
75
|
+
/**
|
|
76
|
+
* Groups pages by their block key while preserving each block's members in
|
|
77
|
+
* input order. Returned as a `Map` so the caller can iterate blocks in
|
|
78
|
+
* insertion order (first-seen block first) — matching the order
|
|
79
|
+
* `resolvePageClusterKeys`'s own per-block loop already uses so cluster IDs
|
|
80
|
+
* assigned per block stay deterministic across in-memory and streaming
|
|
81
|
+
* paths.
|
|
82
|
+
* @param blockKeys
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const indices = groupIndicesByBlockKey(['a', 'b', 'a', 'c', 'a']);
|
|
86
|
+
* // Map { 'a' => [0, 2, 4], 'b' => [1], 'c' => [3] }
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export declare function groupIndicesByBlockKey(blockKeys: readonly string[]): Map<string, number[]>;
|