@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
|
@@ -1,124 +1,327 @@
|
|
|
1
|
+
import { autoCutThreshold } from './auto-cut-threshold.js';
|
|
1
2
|
import { capContentDepth } from './cap-content-depth.js';
|
|
2
3
|
import { detectContentDepthCap, validateDetectContentDepthCapOptions, } from './detect-content-depth-cap.js';
|
|
3
4
|
import { extractLandmarks } from './extract-landmarks.js';
|
|
4
5
|
import { filterFirstPartyStylesheetHrefs } from './filter-first-party-stylesheet-hrefs.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { jaccardSimilarity } from './jaccard-similarity.js';
|
|
7
|
+
import { mergeCrossBlockClusters } from './merge-cross-block-clusters.js';
|
|
8
|
+
import { groupIndicesByBlockKey, resolveBlockKeys } from './pass0-blocking.js';
|
|
9
|
+
import { computePerPageLandmarkInstances } from './per-page-landmark-signatures.js';
|
|
7
10
|
import { removeContentBlocks } from './remove-content-blocks.js';
|
|
8
|
-
import {
|
|
9
|
-
import { resolveStructuralClusterKeys } from './resolve-structural-cluster-keys.js';
|
|
11
|
+
import { stageAPerBlock } from './stage-a-per-block.js';
|
|
10
12
|
import { tokenize } from './tokenize.js';
|
|
11
13
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* (
|
|
15
|
-
*
|
|
16
|
-
* satisfy `noUncheckedIndexedAccess` without a non-null assertion. Not
|
|
17
|
-
* imported from `resolve-structural-cluster-keys.ts`'s own copy: that file's
|
|
18
|
-
* `export`s are its intended public API surface, and this ~7-line generic
|
|
19
|
-
* helper isn't worth carving an exception into that boundary for (same
|
|
20
|
-
* rationale as `readDpValue` in `array-edit-distance.ts` being its own
|
|
21
|
-
* independent copy rather than a shared import).
|
|
22
|
-
* @param values
|
|
23
|
-
* @param index
|
|
14
|
+
* FNV-1a 32-bit hash of a string, used to seed the per-block PRNG so
|
|
15
|
+
* reservoir sampling on the streaming path is deterministic for a given
|
|
16
|
+
* corpus (same input order → same sampled indices → same cluster keys).
|
|
17
|
+
* @param input
|
|
24
18
|
*/
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
function fnv1a32(input) {
|
|
20
|
+
let hash = 0x81_1c_9d_c5;
|
|
21
|
+
for (let i = 0; i < input.length; i++) {
|
|
22
|
+
hash ^= input.codePointAt(i) ?? 0;
|
|
23
|
+
hash = Math.imul(hash, 0x01_00_01_93);
|
|
29
24
|
}
|
|
30
|
-
return
|
|
25
|
+
return hash >>> 0;
|
|
31
26
|
}
|
|
32
27
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
28
|
+
* Mulberry32 — small, well-known 32-bit PRNG. Kept independent per block
|
|
29
|
+
* (each block seeds from its own block key via {@link ./resolve-page-cluster-keys.js | fnv1a32})
|
|
30
|
+
* so different blocks sample independently.
|
|
31
|
+
* @param seed
|
|
32
|
+
*/
|
|
33
|
+
function makeSeededPrng(seed) {
|
|
34
|
+
let state = (typeof seed === 'string' ? fnv1a32(seed) : seed) >>> 0;
|
|
35
|
+
return () => {
|
|
36
|
+
state = (state + 0x6d_2b_79_f5) >>> 0;
|
|
37
|
+
let t = state;
|
|
38
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
39
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
40
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Tokenizes a non-sample page using the block's learned parameters
|
|
45
|
+
* (`maxMainDepth` and local-signature reinjection set) and returns the
|
|
46
|
+
* key of the sample-derived cluster whose members it most closely matches
|
|
47
|
+
* by Jaccard similarity. Ties break in first-seen order (JS `Map`
|
|
48
|
+
* iteration). Falls back to a block-scoped singleton key when the block
|
|
49
|
+
* has no clusters at all (edge case: an empty sample, which shouldn't
|
|
50
|
+
* happen for a non-empty block but is defended against here).
|
|
51
|
+
* @param html
|
|
52
|
+
* @param assignment
|
|
53
|
+
* @param assignment.maxMainDepth
|
|
54
|
+
* @param assignment.localSignatures
|
|
55
|
+
* @param assignment.clustersByUnitKey
|
|
56
|
+
* @param excludeLandmarks
|
|
57
|
+
* @param contentBlockAttribute
|
|
58
|
+
* @param tokenizeOptions
|
|
59
|
+
* @param blockKey
|
|
60
|
+
*/
|
|
61
|
+
function assignPageToNearestCluster(html, assignment, excludeLandmarks, contentBlockAttribute, tokenizeOptions, blockKey) {
|
|
62
|
+
const landmarkResult = extractLandmarks(html);
|
|
63
|
+
const landmarksExcised = excludeLandmarks ? landmarkResult.remainderHtml : html;
|
|
64
|
+
let prepared = contentBlockAttribute === undefined
|
|
65
|
+
? landmarksExcised
|
|
66
|
+
: removeContentBlocks(landmarksExcised, { blockAttribute: contentBlockAttribute })
|
|
67
|
+
.remainderHtml;
|
|
68
|
+
if (assignment.maxMainDepth !== undefined) {
|
|
69
|
+
prepared = capContentDepth(prepared, {
|
|
70
|
+
landmark: 'main',
|
|
71
|
+
maxDepth: assignment.maxMainDepth,
|
|
72
|
+
}).remainderHtml;
|
|
73
|
+
}
|
|
74
|
+
const pageTokens = new Set(tokenize(prepared, tokenizeOptions).tokens);
|
|
75
|
+
// Reinject tokens for landmark instances whose signature matches the
|
|
76
|
+
// block's learned local-signature set (same rule the sample-side Stage
|
|
77
|
+
// A applied via computeLocalChromeArtifacts).
|
|
78
|
+
if (assignment.localSignatures.size > 0) {
|
|
79
|
+
const instances = computePerPageLandmarkInstances([landmarkResult], tokenizeOptions)[0];
|
|
80
|
+
for (const inst of instances) {
|
|
81
|
+
if (!assignment.localSignatures.has(inst.signature))
|
|
82
|
+
continue;
|
|
83
|
+
for (const t of inst.tokens)
|
|
84
|
+
pageTokens.add(t);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let bestKey;
|
|
88
|
+
let bestScore = -1;
|
|
89
|
+
for (const [unitKey, memberTokenSets] of assignment.clustersByUnitKey) {
|
|
90
|
+
let clusterBest = 0;
|
|
91
|
+
for (const memberTokens of memberTokenSets) {
|
|
92
|
+
const score = jaccardSimilarity(pageTokens, memberTokens);
|
|
93
|
+
if (score > clusterBest)
|
|
94
|
+
clusterBest = score;
|
|
95
|
+
}
|
|
96
|
+
if (clusterBest > bestScore) {
|
|
97
|
+
bestScore = clusterBest;
|
|
98
|
+
bestKey = unitKey;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return bestKey ?? JSON.stringify([blockKey, 'cluster:unassigned']);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
|
|
105
|
+
* into its block token set for Stage A clustering, restoring exactly the
|
|
106
|
+
* structural signal that landmark excision removed for those pages while
|
|
107
|
+
* keeping global chrome removed (the whole point of `excludeLandmarks`).
|
|
108
|
+
*
|
|
109
|
+
* ## Why token-level reinjection instead of one opaque pseudo-token
|
|
110
|
+
*
|
|
111
|
+
* An earlier iteration returned a single opaque token per local signature.
|
|
112
|
+
* That failed on real data: adding one distinctive token to a 100+-token
|
|
113
|
+
* page's set produces jaccard ~0.99 between "with-local-landmark" and
|
|
114
|
+
* "without-local-landmark" siblings, so Stage A's 0.8-clamped auto-cut
|
|
115
|
+
* silently merged them anyway. Reinjecting the landmark's actual tokens
|
|
116
|
+
* (typically 4–20 per landmark) restores the full structural weight of
|
|
117
|
+
* the distinction. A real mid-sized crawl corpus's section subtree with
|
|
118
|
+
* a shared section-local `<nav>` now splits correctly from siblings
|
|
119
|
+
* without one, since the reinjected local-nav tokens push jaccard below
|
|
120
|
+
* the cut.
|
|
121
|
+
*
|
|
122
|
+
* ## The corpus-level auto-cut
|
|
123
|
+
*
|
|
124
|
+
* Every page's landmark instances are canonicalized to a signature (via
|
|
125
|
+
* {@link ./canonicalize-token-set.js | canonicalizeTokenSet}); the corpus-
|
|
126
|
+
* wide histogram of "how many pages carry this signature" is fed to
|
|
127
|
+
* {@link ./auto-cut-threshold.js | autoCutThreshold} — the same primitive
|
|
128
|
+
* used at every other layer of this pipeline for merge-height cutoffs. The
|
|
129
|
+
* clamp caps the auto-cut at 0.8 so it never picks a threshold *above* the
|
|
130
|
+
* conservative default. A signature at or above the cut is global chrome —
|
|
131
|
+
* appears on effectively every page, so its tokens carry no discriminatory
|
|
132
|
+
* signal and are left excised. A signature below the cut is local chrome
|
|
133
|
+
* for the pages that carry it, and its tokens are reinjected into those
|
|
134
|
+
* pages' block token sets. Same technique as the per-unit shellQuorum in
|
|
135
|
+
* {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}, one
|
|
136
|
+
* layer up.
|
|
137
|
+
*
|
|
138
|
+
* ## The `count >= 2` gate
|
|
139
|
+
*
|
|
140
|
+
* A signature present on exactly one page is per-page variation, not
|
|
141
|
+
* shared local chrome — no "these pages have the same local chrome, those
|
|
142
|
+
* pages don't" grouping can be built from a singleton, and admitting
|
|
143
|
+
* singleton signatures would reinject each per-page-unique landmark into
|
|
144
|
+
* exactly one page's token set, causing spurious per-page cluster
|
|
145
|
+
* fragmentation across the corpus (confirmed against a 2-page fixture
|
|
146
|
+
* where two pages carry byte-different `<header>`s produced identical
|
|
147
|
+
* clusters as expected; without the gate, each would carry its own
|
|
148
|
+
* reinjected tokens and split).
|
|
149
|
+
* @param landmarks
|
|
150
|
+
* @param tokenizeOptions
|
|
151
|
+
*/
|
|
152
|
+
/**
|
|
153
|
+
* Companion to {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkTokens}
|
|
154
|
+
* that also returns the local-signature *set* the streaming path needs to
|
|
155
|
+
* reuse when tokenizing non-sample pages during Pass 1b. The in-memory path
|
|
156
|
+
* only cares about the per-page token sets (which pages carry which
|
|
157
|
+
* chrome-below-the-cut tokens); the streaming path additionally needs to
|
|
158
|
+
* apply the *same* "which signatures are local" verdict to pages that were
|
|
159
|
+
* not part of the sample the verdict was learned from.
|
|
40
160
|
* @param landmarks
|
|
161
|
+
* @param tokenizeOptions
|
|
41
162
|
*/
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
163
|
+
export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
164
|
+
const pageCount = landmarks.length;
|
|
165
|
+
if (pageCount === 0)
|
|
166
|
+
return { localSignatures: new Set(), localTokensByPage: [] };
|
|
167
|
+
const perPageInstances = computePerPageLandmarkInstances(landmarks, tokenizeOptions);
|
|
168
|
+
// Corpus-wide histogram: signature → { count, tokens }. tokens is the
|
|
169
|
+
// token set of any one occurrence of the signature (all occurrences are
|
|
170
|
+
// equal by construction).
|
|
171
|
+
const corpusHistogram = new Map();
|
|
172
|
+
for (const instances of perPageInstances) {
|
|
173
|
+
for (const inst of instances) {
|
|
174
|
+
const entry = corpusHistogram.get(inst.signature);
|
|
175
|
+
if (entry) {
|
|
176
|
+
entry.count++;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
corpusHistogram.set(inst.signature, { count: 1, tokens: inst.tokens });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (corpusHistogram.size === 0) {
|
|
184
|
+
return {
|
|
185
|
+
localSignatures: new Set(),
|
|
186
|
+
localTokensByPage: landmarks.map(() => new Set()),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const frequencies = [];
|
|
190
|
+
for (const entry of corpusHistogram.values()) {
|
|
191
|
+
frequencies.push(entry.count / pageCount);
|
|
192
|
+
}
|
|
193
|
+
const cut = autoCutThreshold(frequencies, 0.8);
|
|
194
|
+
// Signatures whose tokens we'll reinject: below cut, non-singleton.
|
|
195
|
+
const localSignatures = new Set();
|
|
196
|
+
for (const [sig, entry] of corpusHistogram) {
|
|
197
|
+
if (entry.count >= 2 && entry.count / pageCount < cut) {
|
|
198
|
+
localSignatures.add(sig);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (localSignatures.size === 0) {
|
|
202
|
+
return {
|
|
203
|
+
localSignatures: new Set(),
|
|
204
|
+
localTokensByPage: landmarks.map(() => new Set()),
|
|
205
|
+
};
|
|
45
206
|
}
|
|
46
|
-
|
|
207
|
+
const localTokensByPage = perPageInstances.map((instances) => {
|
|
208
|
+
const out = new Set();
|
|
209
|
+
for (const inst of instances) {
|
|
210
|
+
if (!localSignatures.has(inst.signature))
|
|
211
|
+
continue;
|
|
212
|
+
for (const token of inst.tokens)
|
|
213
|
+
out.add(token);
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
});
|
|
217
|
+
return { localSignatures, localTokensByPage };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
|
|
221
|
+
* into its block token set for Stage A clustering, restoring exactly the
|
|
222
|
+
* structural signal that landmark excision removed for those pages while
|
|
223
|
+
* keeping global chrome removed (the whole point of `excludeLandmarks`).
|
|
224
|
+
*
|
|
225
|
+
* See {@link ./resolve-page-cluster-keys.js | computeLocalChromeArtifacts}
|
|
226
|
+
* for the underlying algorithm — this function is a thin wrapper that
|
|
227
|
+
* discards the local-signature set, exposed for callers that only need the
|
|
228
|
+
* per-page tokens (the in-memory driver's use case).
|
|
229
|
+
* @param landmarks
|
|
230
|
+
* @param tokenizeOptions
|
|
231
|
+
*/
|
|
232
|
+
export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
|
|
233
|
+
return [...computeLocalChromeArtifacts(landmarks, tokenizeOptions).localTokensByPage];
|
|
47
234
|
}
|
|
48
235
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* `
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
236
|
+
* Corpus size at or below which the async factory-based
|
|
237
|
+
* `resolvePageClusterKeys` reads the entire input into an array and delegates
|
|
238
|
+
* to {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysInMemory}
|
|
239
|
+
* unchanged — preserving corpus-wide semantics (chrome discovery, Stage B
|
|
240
|
+
* across all pages) exactly.
|
|
241
|
+
*
|
|
242
|
+
* Above this threshold, the streaming path takes over: block dispatch during
|
|
243
|
+
* a second factory read, per-block chrome discovery (semantic drift from
|
|
244
|
+
* corpus-wide, unavoidable when the whole corpus does not fit in memory),
|
|
245
|
+
* and Stage B fed with the incrementally-accumulated cross-block units.
|
|
246
|
+
*
|
|
247
|
+
* Chosen from Phase 0 spike measurements: a ~9,000-page real crawl (biggest
|
|
248
|
+
* block ~3,900) completed in ~108s / 1.25 GB heap on the in-memory path.
|
|
249
|
+
* Doubling that headroom to 20,000 keeps every corpus previously validated
|
|
250
|
+
* (302, 1,416, 8,936, 89 pages) on the exact code path they were validated
|
|
251
|
+
* against, so their gate values (9 / 21 / 63 / 3 clusters respectively)
|
|
252
|
+
* remain byte-reproducible. A ~176,000-page real crawl OOM'd on the in-memory
|
|
253
|
+
* path well below this threshold worth of pages ever being materialized, so
|
|
254
|
+
* anything above 20,000 is routed to streaming.
|
|
255
|
+
*/
|
|
256
|
+
export const CORPUS_INLINE_THRESHOLD = 20_000;
|
|
257
|
+
/**
|
|
258
|
+
* Reservoir-sample size per block on the streaming path. Blocks larger than
|
|
259
|
+
* this have Stage A run on a random sample of `BLOCK_SAMPLE_SIZE` pages,
|
|
260
|
+
* with the remaining non-sample pages assigned via Jaccard similarity to
|
|
261
|
+
* the sample-derived clusters during Pass 1b. Blocks at or below this size
|
|
262
|
+
* still work — the sampling degenerates to "keep every input page unchanged"
|
|
263
|
+
* per the `reservoirSample` contract, so small blocks behave identically to
|
|
264
|
+
* the in-memory path.
|
|
265
|
+
*
|
|
266
|
+
* Chosen to bound accumulated Stage-B state: units × sample_size × per-
|
|
267
|
+
* member memory ≈ 200 units × 100 members × 25 KB ≈ 500 MB, well within an
|
|
268
|
+
* 8 GB Node heap even on macOS where jetsam (the kernel OOM killer) reacts
|
|
269
|
+
* to RSS pressure before V8's own heap limit trips.
|
|
270
|
+
*
|
|
271
|
+
* ## Semantic differences from the in-memory path
|
|
272
|
+
*
|
|
273
|
+
* - **Chrome discovery is sample-based per block.** Landmark signatures that
|
|
274
|
+
* are rare in the sample get treated as global chrome; only signatures
|
|
275
|
+
* that show up on ≥ 2 sample members and below the sample-derived
|
|
276
|
+
* auto-cut are reinjected. Full-block chrome discovery would see rare
|
|
277
|
+
* signatures too — the sample-based decision approximates it.
|
|
278
|
+
* - **Non-sample pages are assigned by max-Jaccard against sample member
|
|
279
|
+
* token sets.** A page whose closest sample member is genuinely dissimilar
|
|
280
|
+
* still gets slotted into the least-bad cluster; this is a pragmatic
|
|
281
|
+
* trade for a bounded assignment cost (no unbounded "outlier" cluster
|
|
282
|
+
* growth).
|
|
283
|
+
* - **Stage B sees the sample-based `CrossBlockUnit`s only.** Non-sample
|
|
284
|
+
* pages carry the final key that Stage B produces for their assigned
|
|
285
|
+
* sample cluster, without contributing to Stage B's own DF / quorum-core
|
|
286
|
+
* / shell-quorum computations.
|
|
287
|
+
*
|
|
288
|
+
* Preserves the in-memory path unchanged for corpora at or below
|
|
289
|
+
* {@link CORPUS_INLINE_THRESHOLD} — sampling is streaming-mode only.
|
|
290
|
+
*/
|
|
291
|
+
export const BLOCK_SAMPLE_SIZE = 100;
|
|
292
|
+
/**
|
|
293
|
+
* Preserves the previous synchronous, array-in / array-out API of
|
|
294
|
+
* `resolvePageClusterKeys` under a new name so the factory-based async
|
|
295
|
+
* export can take the primary name while callers that already had a
|
|
296
|
+
* materialized page array (spec tests, the in-repo dogfood harness,
|
|
297
|
+
* downstream code that hasn't switched to streaming yet) retain the
|
|
298
|
+
* exact same behavior.
|
|
299
|
+
*
|
|
300
|
+
* Semantics: identical to the pre-refactor `resolvePageClusterKeys`.
|
|
301
|
+
* Corpus-wide chrome discovery, Stage B across every page, no memory
|
|
302
|
+
* bound — meant to be called on inputs already known to fit in memory.
|
|
303
|
+
* The async factory-based export delegates here whenever
|
|
304
|
+
* `pages.length ≤ CORPUS_INLINE_THRESHOLD`, guaranteeing existing corpora
|
|
305
|
+
* hit exactly this code path.
|
|
89
306
|
* @param pages
|
|
90
307
|
* @param options
|
|
91
|
-
* @example
|
|
92
|
-
* ```ts
|
|
93
|
-
* resolvePageClusterKeys([
|
|
94
|
-
* { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
|
|
95
|
-
* { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
|
|
96
|
-
* { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
|
|
97
|
-
* ]);
|
|
98
|
-
* // pages 0 and 1 (same block, same structure) share a key; page 2 (different block) gets its own
|
|
99
|
-
* ```
|
|
100
308
|
*/
|
|
101
|
-
export function
|
|
309
|
+
export function resolvePageClusterKeysInMemory(pages, options) {
|
|
102
310
|
const excludeLandmarks = options?.excludeLandmarks ?? true;
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
// fields, so this computes it once up front whenever either is needed,
|
|
114
|
-
// rather than each option branch parsing independently.
|
|
115
|
-
const landmarks = excludeLandmarks || mergeRareLandmarkClusters
|
|
116
|
-
? pages.map((page) => extractLandmarks(page.html))
|
|
117
|
-
: undefined;
|
|
311
|
+
const similarityThreshold = options?.similarityThreshold ?? 0.8;
|
|
312
|
+
if (!(similarityThreshold >= 0 && similarityThreshold <= 1)) {
|
|
313
|
+
throw new RangeError(`resolvePageClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
|
|
314
|
+
}
|
|
315
|
+
// Always computed: landmark fields are needed by Stage B's shell
|
|
316
|
+
// corroboration regardless of `excludeLandmarks`, and `remainderHtml` is
|
|
317
|
+
// needed whenever `excludeLandmarks` is true.
|
|
318
|
+
const landmarks = pages.map((page) => extractLandmarks(page.html));
|
|
319
|
+
// Corpus-level chrome discovery
|
|
320
|
+
const localLandmarkTokensByPage = computeLocalLandmarkTokens(landmarks, options);
|
|
118
321
|
const contentBlockAttribute = options?.contentBlockAttribute;
|
|
119
322
|
const preparedHtml = pages.map((page, index) => {
|
|
120
323
|
const landmarksExcised = excludeLandmarks
|
|
121
|
-
?
|
|
324
|
+
? landmarks[index].remainderHtml
|
|
122
325
|
: page.html;
|
|
123
326
|
return contentBlockAttribute === undefined
|
|
124
327
|
? landmarksExcised
|
|
@@ -129,70 +332,418 @@ export function resolvePageClusterKeys(pages, options) {
|
|
|
129
332
|
const blockingPages = restrictStylesheetsToFirstParty
|
|
130
333
|
? filterFirstPartyStylesheetHrefs(pages)
|
|
131
334
|
: pages;
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
335
|
+
const blockKeys = resolveBlockKeys(blockingPages, options);
|
|
336
|
+
const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
|
|
337
|
+
// Validated here, eagerly, because it's otherwise only reached from
|
|
338
|
+
// inside the per-block loop below — which never runs at all for an empty
|
|
339
|
+
// `pages` (no blocks), silently skipping a bad option instead of failing
|
|
340
|
+
// fast the way a direct `detectContentDepthCap` call always does.
|
|
341
|
+
validateDetectContentDepthCapOptions(options);
|
|
342
|
+
const finalKeys = Array.from({ length: pages.length });
|
|
343
|
+
const crossBlockUnits = [];
|
|
344
|
+
for (const [blockKey, indices] of indicesByBlockKey) {
|
|
345
|
+
const result = stageAPerBlock({
|
|
346
|
+
blockKey,
|
|
347
|
+
memberIndices: indices,
|
|
348
|
+
preparedHtml: indices.map((i) => preparedHtml[i]),
|
|
349
|
+
landmarks: indices.map((i) => landmarks[i]),
|
|
350
|
+
localLandmarkTokensByPage: indices.map((i) => localLandmarkTokensByPage[i]),
|
|
351
|
+
}, options);
|
|
352
|
+
for (const [pageIndex, key] of result.pageKeys) {
|
|
353
|
+
finalKeys[pageIndex] = key;
|
|
142
354
|
}
|
|
143
|
-
|
|
144
|
-
|
|
355
|
+
crossBlockUnits.push(...result.crossBlockUnits);
|
|
356
|
+
}
|
|
357
|
+
// Stage B: cross-block merge — always runs regardless of options
|
|
358
|
+
const stageBResult = mergeCrossBlockClusters(crossBlockUnits, options);
|
|
359
|
+
for (let i = 0; i < finalKeys.length; i++) {
|
|
360
|
+
const currentKey = finalKeys[i];
|
|
361
|
+
const rootKey = stageBResult.get(currentKey);
|
|
362
|
+
if (rootKey !== undefined && rootKey !== currentKey) {
|
|
363
|
+
finalKeys[i] = rootKey;
|
|
145
364
|
}
|
|
146
365
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
366
|
+
return finalKeys;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Async twin of {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysInMemory}
|
|
370
|
+
* that emits `pass1-block-complete` (per block) and `stage-b-start`
|
|
371
|
+
* `ProgressEvent`s and yields control back to the event loop between
|
|
372
|
+
* blocks with `setImmediate`, so the async factory-based
|
|
373
|
+
* `resolvePageClusterKeys` can expose live progress on small corpora
|
|
374
|
+
* (`≤ CORPUS_INLINE_THRESHOLD`) without blocking the caller's UI thread.
|
|
375
|
+
*
|
|
376
|
+
* Semantic equivalence with `resolvePageClusterKeysInMemory` is preserved
|
|
377
|
+
* exactly: same corpus-wide chrome discovery, same per-block Stage A, same
|
|
378
|
+
* un-capped Stage B across the entire crossBlockUnits array. `finalKeys`
|
|
379
|
+
* returned here must be byte-for-byte identical to what the sync path
|
|
380
|
+
* would have produced for the same `pages` input — spec-enforced by
|
|
381
|
+
* `resolve-page-cluster-keys-streaming.spec.ts`.
|
|
382
|
+
*
|
|
383
|
+
* The sync `resolvePageClusterKeysInMemory` is deliberately left in place
|
|
384
|
+
* as its own implementation rather than being folded into a shared helper.
|
|
385
|
+
* The intentional duplication guarantees that library callers who pass no
|
|
386
|
+
* `onProgress` incur zero behavioral difference from the pre-refactor code
|
|
387
|
+
* (see the `onProgress === undefined` short-circuit in
|
|
388
|
+
* {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}).
|
|
389
|
+
* @param pages
|
|
390
|
+
* @param onProgress
|
|
391
|
+
* @param options
|
|
392
|
+
*/
|
|
393
|
+
async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
|
|
394
|
+
const excludeLandmarks = options?.excludeLandmarks ?? true;
|
|
395
|
+
const similarityThreshold = options?.similarityThreshold ?? 0.8;
|
|
396
|
+
if (!(similarityThreshold >= 0 && similarityThreshold <= 1)) {
|
|
397
|
+
throw new RangeError(`resolvePageClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
|
|
155
398
|
}
|
|
399
|
+
const landmarks = pages.map((page) => extractLandmarks(page.html));
|
|
400
|
+
const localLandmarkTokensByPage = computeLocalLandmarkTokens(landmarks, options);
|
|
401
|
+
const contentBlockAttribute = options?.contentBlockAttribute;
|
|
402
|
+
const preparedHtml = pages.map((page, index) => {
|
|
403
|
+
const landmarksExcised = excludeLandmarks
|
|
404
|
+
? landmarks[index].remainderHtml
|
|
405
|
+
: page.html;
|
|
406
|
+
return contentBlockAttribute === undefined
|
|
407
|
+
? landmarksExcised
|
|
408
|
+
: removeContentBlocks(landmarksExcised, { blockAttribute: contentBlockAttribute })
|
|
409
|
+
.remainderHtml;
|
|
410
|
+
});
|
|
411
|
+
const restrictStylesheetsToFirstParty = options?.restrictStylesheetsToFirstParty ?? true;
|
|
412
|
+
const blockingPages = restrictStylesheetsToFirstParty
|
|
413
|
+
? filterFirstPartyStylesheetHrefs(pages)
|
|
414
|
+
: pages;
|
|
415
|
+
const blockKeys = resolveBlockKeys(blockingPages, options);
|
|
416
|
+
const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
|
|
417
|
+
validateDetectContentDepthCapOptions(options);
|
|
156
418
|
const finalKeys = Array.from({ length: pages.length });
|
|
419
|
+
const crossBlockUnits = [];
|
|
420
|
+
const totalBlocks = indicesByBlockKey.size;
|
|
421
|
+
let blocksProcessed = 0;
|
|
157
422
|
for (const [blockKey, indices] of indicesByBlockKey) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
423
|
+
const result = stageAPerBlock({
|
|
424
|
+
blockKey,
|
|
425
|
+
memberIndices: indices,
|
|
426
|
+
preparedHtml: indices.map((i) => preparedHtml[i]),
|
|
427
|
+
landmarks: indices.map((i) => landmarks[i]),
|
|
428
|
+
localLandmarkTokensByPage: indices.map((i) => localLandmarkTokensByPage[i]),
|
|
429
|
+
}, options);
|
|
430
|
+
for (const [pageIndex, key] of result.pageKeys) {
|
|
431
|
+
finalKeys[pageIndex] = key;
|
|
432
|
+
}
|
|
433
|
+
crossBlockUnits.push(...result.crossBlockUnits);
|
|
434
|
+
blocksProcessed++;
|
|
435
|
+
onProgress({
|
|
436
|
+
phase: 'pass1-block-complete',
|
|
437
|
+
blockKey,
|
|
438
|
+
blocksProcessed,
|
|
439
|
+
totalBlocks,
|
|
173
440
|
});
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
441
|
+
// Yield to the event loop so Lanes' setTimeout frame can paint the
|
|
442
|
+
// updated header before the next block starts.
|
|
443
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
444
|
+
}
|
|
445
|
+
onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
|
|
446
|
+
const stageBResult = mergeCrossBlockClusters(crossBlockUnits, options);
|
|
447
|
+
for (let i = 0; i < finalKeys.length; i++) {
|
|
448
|
+
const currentKey = finalKeys[i];
|
|
449
|
+
const rootKey = stageBResult.get(currentKey);
|
|
450
|
+
if (rootKey !== undefined && rootKey !== currentKey) {
|
|
451
|
+
finalKeys[i] = rootKey;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return finalKeys;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Streaming, memory-bounded version of `resolvePageClusterKeysInMemory`.
|
|
458
|
+
*
|
|
459
|
+
* ## Behavior gate
|
|
460
|
+
*
|
|
461
|
+
* - `pageCount ≤ CORPUS_INLINE_THRESHOLD` — reads the whole factory into an
|
|
462
|
+
* array, delegates to `resolvePageClusterKeysInMemory`. Same corpus-wide
|
|
463
|
+
* chrome discovery, same Stage B across every page. All previously
|
|
464
|
+
* validated corpora (302 / 1,416 / 8,936 / 89 pages) hit this path.
|
|
465
|
+
* - `pageCount > CORPUS_INLINE_THRESHOLD` — streaming path: reads the
|
|
466
|
+
* factory twice (once for blocking signals, once for HTML processing),
|
|
467
|
+
* dispatches HTML per block, runs Stage A per block, accumulates
|
|
468
|
+
* cross-block units, then runs Stage B across all accumulated units. Peak
|
|
469
|
+
* memory ≈ largest single block, not the whole corpus.
|
|
470
|
+
*
|
|
471
|
+
* ## Semantic differences in streaming mode
|
|
472
|
+
*
|
|
473
|
+
* - **Chrome discovery is per-block, not corpus-wide.** In the in-memory
|
|
474
|
+
* path, {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkTokens}
|
|
475
|
+
* runs on all pages at once. In streaming mode the entire corpus cannot
|
|
476
|
+
* be held at once, so chrome discovery runs per block. A landmark
|
|
477
|
+
* signature that is rare corpus-wide but common within one block will
|
|
478
|
+
* be treated as global chrome in streaming mode, whereas the in-memory
|
|
479
|
+
* mode would treat it as local. This trade-off is why the threshold
|
|
480
|
+
* above is set generously — every real corpus historically validated
|
|
481
|
+
* here stays on the in-memory path.
|
|
482
|
+
* @param pages
|
|
483
|
+
* @param options
|
|
484
|
+
* @example
|
|
485
|
+
* ```ts
|
|
486
|
+
* // JSONL file source — factory can be re-invoked to re-open the file.
|
|
487
|
+
* import { createReadStream } from 'node:fs';
|
|
488
|
+
* import readline from 'node:readline';
|
|
489
|
+
*
|
|
490
|
+
* const keys = await resolvePageClusterKeys(() => {
|
|
491
|
+
* const lines = readline.createInterface({ input: createReadStream('pages.jsonl') });
|
|
492
|
+
* return (async function* () {
|
|
493
|
+
* for await (const line of lines) yield JSON.parse(line);
|
|
494
|
+
* })();
|
|
495
|
+
* });
|
|
496
|
+
* ```
|
|
497
|
+
*/
|
|
498
|
+
export async function resolvePageClusterKeys(pages, options) {
|
|
499
|
+
const onProgress = options?.onProgress;
|
|
500
|
+
// Pass 0: HTML-free — collect blocking signals (paths, stylesheetHrefs,
|
|
501
|
+
// host) into an array. This is the only per-page state we keep across
|
|
502
|
+
// the whole corpus in streaming mode.
|
|
503
|
+
const blockingSignals = [];
|
|
504
|
+
for await (const page of pages()) {
|
|
505
|
+
if (onProgress && blockingSignals.length > 0 && blockingSignals.length % 1000 === 0) {
|
|
506
|
+
onProgress({ phase: 'pass0-signals', pagesSeen: blockingSignals.length });
|
|
507
|
+
}
|
|
508
|
+
blockingSignals.push({
|
|
509
|
+
paths: page.paths,
|
|
510
|
+
stylesheetHrefs: page.stylesheetHrefs,
|
|
511
|
+
host: page.host,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
if (blockingSignals.length === 0)
|
|
515
|
+
return [];
|
|
516
|
+
// Small corpus: read the whole factory again with HTML this time, then
|
|
517
|
+
// delegate to the in-memory path. Preserves every corpus-wide semantic
|
|
518
|
+
// (chrome, Stage B) for corpora within the threshold.
|
|
519
|
+
if (blockingSignals.length <= CORPUS_INLINE_THRESHOLD) {
|
|
520
|
+
const fullPages = [];
|
|
521
|
+
for await (const page of pages()) {
|
|
522
|
+
fullPages.push({
|
|
523
|
+
paths: page.paths,
|
|
524
|
+
stylesheetHrefs: page.stylesheetHrefs,
|
|
525
|
+
html: page.html,
|
|
526
|
+
host: page.host,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
// Without an onProgress callback the caller does not need visibility
|
|
530
|
+
// into per-block progress, so delegate to the untouched sync path —
|
|
531
|
+
// keeping behavior byte-for-byte identical (and yield-overhead-free)
|
|
532
|
+
// to how library-only consumers experienced this before the CLI
|
|
533
|
+
// progress work landed.
|
|
534
|
+
if (onProgress === undefined) {
|
|
535
|
+
return resolvePageClusterKeysInMemory(fullPages, options);
|
|
536
|
+
}
|
|
537
|
+
return resolveSmallCorpusWithProgress(fullPages, onProgress, options);
|
|
538
|
+
}
|
|
539
|
+
// Large corpus: streaming path.
|
|
540
|
+
const excludeLandmarks = options?.excludeLandmarks ?? true;
|
|
541
|
+
const similarityThreshold = options?.similarityThreshold ?? 0.8;
|
|
542
|
+
if (!(similarityThreshold >= 0 && similarityThreshold <= 1)) {
|
|
543
|
+
throw new RangeError(`resolvePageClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
|
|
544
|
+
}
|
|
545
|
+
validateDetectContentDepthCapOptions(options);
|
|
546
|
+
const restrictStylesheetsToFirstParty = options?.restrictStylesheetsToFirstParty ?? true;
|
|
547
|
+
const blockingPagesForKeys = restrictStylesheetsToFirstParty
|
|
548
|
+
? filterFirstPartyStylesheetHrefs(blockingSignals)
|
|
549
|
+
: blockingSignals;
|
|
550
|
+
const blockKeys = resolveBlockKeys(blockingPagesForKeys, options);
|
|
551
|
+
const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
|
|
552
|
+
const finalKeys = Array.from({ length: blockingSignals.length });
|
|
553
|
+
const crossBlockUnits = [];
|
|
554
|
+
const contentBlockAttribute = options?.contentBlockAttribute;
|
|
555
|
+
/** Non-sample page indices that need Pass 1b Jaccard-based assignment. */
|
|
556
|
+
const pendingAssignmentBlockKeyByIndex = new Map();
|
|
557
|
+
/** Block-level artifacts saved after Stage A runs on the sample. */
|
|
558
|
+
const blockAssignments = new Map();
|
|
559
|
+
const buckets = new Map();
|
|
560
|
+
for (const [blockKey, indices] of indicesByBlockKey) {
|
|
561
|
+
buckets.set(blockKey, {
|
|
562
|
+
blockKey,
|
|
563
|
+
targetSize: indices.length,
|
|
564
|
+
reservoirIndices: [],
|
|
565
|
+
reservoirPreparedHtml: [],
|
|
566
|
+
reservoirLandmarks: [],
|
|
567
|
+
seenCount: 0,
|
|
568
|
+
prng: makeSeededPrng(blockKey),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Runs Stage A on the block's reservoir sample, records sample-member
|
|
573
|
+
* cluster keys into `finalKeys`, saves per-cluster member token sets so
|
|
574
|
+
* Pass 1b can Jaccard-assign non-sample pages, and appends the produced
|
|
575
|
+
* `CrossBlockUnit`s to the Stage-B input.
|
|
576
|
+
* @param bucket
|
|
577
|
+
*/
|
|
578
|
+
function flushBlock(bucket) {
|
|
579
|
+
const { localSignatures, localTokensByPage } = computeLocalChromeArtifacts(bucket.reservoirLandmarks, options);
|
|
580
|
+
const result = stageAPerBlock({
|
|
581
|
+
blockKey: bucket.blockKey,
|
|
582
|
+
memberIndices: bucket.reservoirIndices,
|
|
583
|
+
preparedHtml: bucket.reservoirPreparedHtml,
|
|
584
|
+
landmarks: bucket.reservoirLandmarks,
|
|
585
|
+
localLandmarkTokensByPage: localTokensByPage,
|
|
586
|
+
},
|
|
587
|
+
// No capMembers — the reservoir already bounds `sampleSize`.
|
|
588
|
+
options);
|
|
589
|
+
for (const [idx, key] of result.pageKeys) {
|
|
590
|
+
finalKeys[idx] = key;
|
|
591
|
+
}
|
|
592
|
+
crossBlockUnits.push(...result.crossBlockUnits);
|
|
593
|
+
if (bucket.seenCount > bucket.reservoirIndices.length) {
|
|
594
|
+
// Save assignment artifacts for Pass 1b.
|
|
595
|
+
const maxMainDepth = bucket.reservoirPreparedHtml.length > 1
|
|
596
|
+
? detectContentDepthCap(bucket.reservoirPreparedHtml, options)
|
|
597
|
+
: undefined;
|
|
598
|
+
const clustersByUnitKey = new Map();
|
|
599
|
+
for (const unit of result.crossBlockUnits) {
|
|
600
|
+
clustersByUnitKey.set(unit.key, [...unit.memberTokenSets]);
|
|
601
|
+
}
|
|
602
|
+
blockAssignments.set(bucket.blockKey, {
|
|
603
|
+
maxMainDepth,
|
|
604
|
+
localSignatures,
|
|
605
|
+
clustersByUnitKey,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
// Encourage V8 to reclaim the reservoir's transient allocations.
|
|
609
|
+
const maybeGc = globalThis.gc;
|
|
610
|
+
if (maybeGc !== undefined)
|
|
611
|
+
maybeGc();
|
|
612
|
+
}
|
|
613
|
+
// Pass 1: stream HTML pages, reservoir-sample each block, run Stage A
|
|
614
|
+
// on the sample the moment the block is fully seen.
|
|
615
|
+
let pageIndex = 0;
|
|
616
|
+
for await (const page of pages()) {
|
|
617
|
+
const blockKey = blockKeys[pageIndex];
|
|
618
|
+
const bucket = buckets.get(blockKey);
|
|
619
|
+
if (bucket === undefined) {
|
|
620
|
+
throw new Error(`resolvePageClusterKeys: block "${blockKey}" is missing from the bucket registry`);
|
|
621
|
+
}
|
|
622
|
+
// Reservoir sampling (Algorithm R): keep the first BLOCK_SAMPLE_SIZE
|
|
623
|
+
// pages, then for each subsequent one replace a random reservoir slot
|
|
624
|
+
// with decreasing probability.
|
|
625
|
+
if (bucket.reservoirIndices.length < BLOCK_SAMPLE_SIZE) {
|
|
626
|
+
const landmarkResult = extractLandmarks(page.html);
|
|
627
|
+
const landmarksExcised = excludeLandmarks
|
|
628
|
+
? landmarkResult.remainderHtml
|
|
629
|
+
: page.html;
|
|
630
|
+
const prepared = contentBlockAttribute === undefined
|
|
631
|
+
? landmarksExcised
|
|
632
|
+
: removeContentBlocks(landmarksExcised, {
|
|
633
|
+
blockAttribute: contentBlockAttribute,
|
|
634
|
+
}).remainderHtml;
|
|
635
|
+
const strippedLandmark = { ...landmarkResult, remainderHtml: '' };
|
|
636
|
+
bucket.reservoirIndices.push(pageIndex);
|
|
637
|
+
bucket.reservoirPreparedHtml.push(prepared);
|
|
638
|
+
bucket.reservoirLandmarks.push(strippedLandmark);
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
const j = Math.floor(bucket.prng() * (bucket.seenCount + 1));
|
|
642
|
+
if (j < BLOCK_SAMPLE_SIZE) {
|
|
643
|
+
const landmarkResult = extractLandmarks(page.html);
|
|
644
|
+
const landmarksExcised = excludeLandmarks
|
|
645
|
+
? landmarkResult.remainderHtml
|
|
646
|
+
: page.html;
|
|
647
|
+
const prepared = contentBlockAttribute === undefined
|
|
648
|
+
? landmarksExcised
|
|
649
|
+
: removeContentBlocks(landmarksExcised, {
|
|
650
|
+
blockAttribute: contentBlockAttribute,
|
|
651
|
+
}).remainderHtml;
|
|
652
|
+
const strippedLandmark = { ...landmarkResult, remainderHtml: '' };
|
|
653
|
+
const evicted = bucket.reservoirIndices[j];
|
|
654
|
+
pendingAssignmentBlockKeyByIndex.set(evicted, bucket.blockKey);
|
|
655
|
+
bucket.reservoirIndices[j] = pageIndex;
|
|
656
|
+
bucket.reservoirPreparedHtml[j] = prepared;
|
|
657
|
+
bucket.reservoirLandmarks[j] = strippedLandmark;
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
pendingAssignmentBlockKeyByIndex.set(pageIndex, bucket.blockKey);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
bucket.seenCount++;
|
|
664
|
+
if (bucket.seenCount === bucket.targetSize) {
|
|
665
|
+
flushBlock(bucket);
|
|
666
|
+
buckets.delete(bucket.blockKey);
|
|
667
|
+
if (onProgress) {
|
|
668
|
+
onProgress({
|
|
669
|
+
phase: 'pass1-block-complete',
|
|
670
|
+
blockKey: bucket.blockKey,
|
|
671
|
+
blocksProcessed: indicesByBlockKey.size - buckets.size,
|
|
672
|
+
totalBlocks: indicesByBlockKey.size,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
pageIndex++;
|
|
677
|
+
}
|
|
678
|
+
if (pageIndex !== blockingSignals.length) {
|
|
679
|
+
throw new Error(`resolvePageClusterKeys: streaming input yielded ${pageIndex} pages but Pass 0 saw ${blockingSignals.length} — factory must produce the same pages in the same order on repeated invocations`);
|
|
680
|
+
}
|
|
681
|
+
if (buckets.size > 0) {
|
|
682
|
+
throw new Error(`resolvePageClusterKeys: ${buckets.size} block(s) never reached their target size — this should not happen if the factory produced identical pages across the two passes`);
|
|
683
|
+
}
|
|
684
|
+
// Pass 1b: for every non-sample page (evicted from a block's reservoir
|
|
685
|
+
// or never selected), re-stream its HTML and Jaccard-assign it to the
|
|
686
|
+
// nearest sample-derived cluster in its block. Nothing is added to
|
|
687
|
+
// crossBlockUnits here — the assignment writes directly into finalKeys.
|
|
688
|
+
if (pendingAssignmentBlockKeyByIndex.size > 0) {
|
|
689
|
+
const totalToAssign = pendingAssignmentBlockKeyByIndex.size;
|
|
690
|
+
let assignedCount = 0;
|
|
691
|
+
let assignPageIndex = 0;
|
|
692
|
+
for await (const page of pages()) {
|
|
693
|
+
const targetBlockKey = pendingAssignmentBlockKeyByIndex.get(assignPageIndex);
|
|
694
|
+
if (targetBlockKey !== undefined) {
|
|
695
|
+
const assignment = blockAssignments.get(targetBlockKey);
|
|
696
|
+
if (assignment !== undefined) {
|
|
697
|
+
finalKeys[assignPageIndex] = assignPageToNearestCluster(page.html, assignment, excludeLandmarks, contentBlockAttribute, options, targetBlockKey);
|
|
698
|
+
}
|
|
699
|
+
assignedCount++;
|
|
700
|
+
if (onProgress && assignedCount % 1000 === 0) {
|
|
701
|
+
onProgress({
|
|
702
|
+
phase: 'pass1b-assign',
|
|
703
|
+
pagesAssigned: assignedCount,
|
|
704
|
+
pagesToAssign: totalToAssign,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
assignPageIndex++;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// Stage B: cross-block merge over the accumulated (sample-based) units.
|
|
712
|
+
// Each unit already has at most BLOCK_SAMPLE_SIZE members from the
|
|
713
|
+
// reservoir sampling above, so no additional cap is needed here — the
|
|
714
|
+
// per-merge cost stays bounded across rounds.
|
|
715
|
+
if (onProgress) {
|
|
716
|
+
onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
|
|
717
|
+
}
|
|
718
|
+
const stageBResult = mergeCrossBlockClusters(crossBlockUnits, {
|
|
719
|
+
...options,
|
|
720
|
+
capMembers: BLOCK_SAMPLE_SIZE,
|
|
721
|
+
});
|
|
722
|
+
for (let i = 0; i < finalKeys.length; i++) {
|
|
723
|
+
const currentKey = finalKeys[i];
|
|
724
|
+
const rootKey = stageBResult.get(currentKey);
|
|
725
|
+
if (rootKey !== undefined && rootKey !== currentKey) {
|
|
726
|
+
finalKeys[i] = rootKey;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return finalKeys;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Convenience wrapper that runs {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}
|
|
733
|
+
* on a materialized array. Preserves the pre-refactor sync API for callers
|
|
734
|
+
* that already have all pages in memory, while flowing through the same
|
|
735
|
+
* async driver so behavior stays consistent across the two entry points.
|
|
736
|
+
* @param pages
|
|
737
|
+
* @param options
|
|
738
|
+
* @example
|
|
739
|
+
* ```ts
|
|
740
|
+
* const keys = await resolvePageClusterKeysFromArray([
|
|
741
|
+
* { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
|
|
742
|
+
* { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
|
|
743
|
+
* { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
|
|
744
|
+
* ]);
|
|
745
|
+
* ```
|
|
746
|
+
*/
|
|
747
|
+
export function resolvePageClusterKeysFromArray(pages, options) {
|
|
748
|
+
return resolvePageClusterKeys(() => pages, options);
|
|
198
749
|
}
|