@d-zero/page-cluster 0.3.1 → 0.5.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/README.md +45 -8
- package/dist/build-cluster-reason.d.ts +119 -0
- package/dist/build-cluster-reason.js +63 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +64 -6
- package/dist/derive-blocking-reason.d.ts +32 -0
- package/dist/derive-blocking-reason.js +1 -0
- package/dist/extract-landmarks.d.ts +75 -14
- package/dist/extract-landmarks.js +64 -4
- package/dist/is-chrome-landmark-instance.d.ts +41 -0
- package/dist/is-chrome-landmark-instance.js +50 -0
- package/dist/merge-cross-block-clusters.d.ts +39 -3
- package/dist/merge-cross-block-clusters.js +28 -107
- package/dist/offset-to-line-column.d.ts +38 -0
- package/dist/offset-to-line-column.js +49 -0
- package/dist/pass0-blocking.d.ts +10 -0
- package/dist/pass0-blocking.js +26 -46
- package/dist/per-page-landmark-signatures.d.ts +9 -1
- package/dist/per-page-landmark-signatures.js +16 -4
- package/dist/reassign-orphan-block-keys.d.ts +9 -0
- package/dist/reassign-orphan-block-keys.js +5 -2
- package/dist/resolve-blocking-group-keys.d.ts +10 -0
- package/dist/resolve-blocking-group-keys.js +16 -86
- package/dist/resolve-page-cluster-keys.d.ts +37 -1
- package/dist/resolve-page-cluster-keys.js +123 -17
- package/dist/shell-quorum.d.ts +70 -0
- package/dist/shell-quorum.js +110 -0
- package/package.json +10 -2
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { excise } from './excise.js';
|
|
2
2
|
import { findMatchingElements, } from './find-shallowest-elements.js';
|
|
3
|
+
import { buildLineColumnIndex, offsetToLineColumn } from './offset-to-line-column.js';
|
|
3
4
|
const TAG_TO_TYPE = {
|
|
4
5
|
header: 'header',
|
|
5
6
|
footer: 'footer',
|
|
6
7
|
nav: 'nav',
|
|
7
8
|
aside: 'aside',
|
|
8
9
|
search: 'search',
|
|
10
|
+
main: 'main',
|
|
9
11
|
};
|
|
10
12
|
const ROLE_TO_TYPE = {
|
|
11
13
|
banner: 'header',
|
|
@@ -14,6 +16,7 @@ const ROLE_TO_TYPE = {
|
|
|
14
16
|
complementary: 'aside',
|
|
15
17
|
form: 'form',
|
|
16
18
|
search: 'search',
|
|
19
|
+
main: 'main',
|
|
17
20
|
};
|
|
18
21
|
/**
|
|
19
22
|
* Determines which landmark type(s) an element matches by tag name or
|
|
@@ -40,6 +43,16 @@ function matchLandmarkTypes(tagName, role) {
|
|
|
40
43
|
}
|
|
41
44
|
return types;
|
|
42
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Type guard splitting `findMatchingElements`' combined match list into the
|
|
48
|
+
* excisable six landmark types vs `main`. `main` is content, not chrome, and
|
|
49
|
+
* must never be mixed into the same `keepOutermost` sweep as the other six
|
|
50
|
+
* — see `extractLandmarks`'s "main handling" note for why.
|
|
51
|
+
* @param match
|
|
52
|
+
*/
|
|
53
|
+
function isMainMatch(match) {
|
|
54
|
+
return match.type === 'main';
|
|
55
|
+
}
|
|
43
56
|
/**
|
|
44
57
|
* Filters out any match whose whole-element span is strictly contained by
|
|
45
58
|
* another match's span, keeping only outermost instances. Runs across all
|
|
@@ -151,21 +164,50 @@ function keepOutermost(matches) {
|
|
|
151
164
|
* segment then disappears from the surviving paths, shortening them by one
|
|
152
165
|
* level. This is inherent to "delete the matched span, use whatever's
|
|
153
166
|
* left" and is not treated as a bug.
|
|
167
|
+
*
|
|
168
|
+
* ## Main handling
|
|
169
|
+
*
|
|
170
|
+
* `main` (the `<main>` tag or `role="main"`) is collected the same way as
|
|
171
|
+
* the other six types — one entry per genuinely-closed instance, in
|
|
172
|
+
* document order — but is kept out of every mechanism the other six feed:
|
|
173
|
+
*
|
|
174
|
+
* - It is **never excised**: its span is never added to `remainderHtml`'s
|
|
175
|
+
* excise list, because `main` is the page's actual content, not chrome.
|
|
176
|
+
* Removing it would gut `remainderHtml` down to whatever sits outside
|
|
177
|
+
* `<main>` (nothing, on most real pages).
|
|
178
|
+
* - Its `keepOutermost` nesting sweep runs **separately** from the other six
|
|
179
|
+
* types'. If it shared the sweep, a `<main>` that wraps most of the page
|
|
180
|
+
* (as it typically does) would make every `header`/`nav`/`aside` nested
|
|
181
|
+
* inside it look "contained by main" and get dropped — destroying the
|
|
182
|
+
* section-local chrome detection this module exists to enable (see "Why
|
|
183
|
+
* collect every instance" above). Only nested `<main>`s (an edge case —
|
|
184
|
+
* HTML discourages more than one) are deduplicated against each other.
|
|
185
|
+
* - It never contributes to chrome/shell-frequency analysis (`main` is
|
|
186
|
+
* absent from `computePerPageLandmarkInstances`'s `ALL_LANDMARK_TYPES`):
|
|
187
|
+
* its instances are reported for position purposes only, never treated as
|
|
188
|
+
* candidate chrome.
|
|
154
189
|
* @param html
|
|
155
190
|
* @example
|
|
156
191
|
* ```ts
|
|
157
192
|
* extractLandmarks('<body><header>H</header><main>M</main><footer>F</footer></body>');
|
|
158
193
|
* // {
|
|
159
|
-
* // header: ['<header>H</header>'
|
|
160
|
-
* //
|
|
194
|
+
* // header: [{ html: '<header>H</header>', startOffset: 6, endOffset: 24,
|
|
195
|
+
* // startLine: 1, startColumn: 7, endLine: 1, endColumn: 25 }],
|
|
196
|
+
* // footer: [{ html: '<footer>F</footer>', startOffset: 38, endOffset: 56,
|
|
197
|
+
* // startLine: 1, startColumn: 39, endLine: 1, endColumn: 57 }],
|
|
161
198
|
* // nav: [], aside: [], form: [], search: [],
|
|
199
|
+
* // main: [{ html: '<main>M</main>', startOffset: 24, endOffset: 38,
|
|
200
|
+
* // startLine: 1, startColumn: 25, endLine: 1, endColumn: 39 }],
|
|
162
201
|
* // remainderHtml: '<body><main>M</main></body>',
|
|
163
202
|
* // }
|
|
164
203
|
* ```
|
|
165
204
|
*/
|
|
166
205
|
export function extractLandmarks(html) {
|
|
167
206
|
const allMatches = findMatchingElements(html, matchLandmarkTypes);
|
|
168
|
-
const
|
|
207
|
+
const mainMatches = allMatches.filter(isMainMatch);
|
|
208
|
+
const excisableMatches = allMatches.filter((match) => !isMainMatch(match));
|
|
209
|
+
const outermost = keepOutermost(excisableMatches);
|
|
210
|
+
const outermostMain = keepOutermost(mainMatches);
|
|
169
211
|
const result = {
|
|
170
212
|
header: [],
|
|
171
213
|
footer: [],
|
|
@@ -173,13 +215,31 @@ export function extractLandmarks(html) {
|
|
|
173
215
|
aside: [],
|
|
174
216
|
form: [],
|
|
175
217
|
search: [],
|
|
218
|
+
main: [],
|
|
176
219
|
remainderHtml: html,
|
|
177
220
|
};
|
|
178
221
|
const spans = [];
|
|
222
|
+
const lineColumnIndex = buildLineColumnIndex(html);
|
|
223
|
+
const toInstance = (match) => {
|
|
224
|
+
const start = offsetToLineColumn(lineColumnIndex, match.startOffset);
|
|
225
|
+
const end = offsetToLineColumn(lineColumnIndex, match.endOffset);
|
|
226
|
+
return {
|
|
227
|
+
html: html.slice(match.startOffset, match.endOffset),
|
|
228
|
+
startOffset: match.startOffset,
|
|
229
|
+
endOffset: match.endOffset,
|
|
230
|
+
startLine: start.line,
|
|
231
|
+
startColumn: start.column,
|
|
232
|
+
endLine: end.line,
|
|
233
|
+
endColumn: end.column,
|
|
234
|
+
};
|
|
235
|
+
};
|
|
179
236
|
for (const match of outermost) {
|
|
180
|
-
result[match.type].push(
|
|
237
|
+
result[match.type].push(toInstance(match));
|
|
181
238
|
spans.push({ start: match.startOffset, end: match.endOffset });
|
|
182
239
|
}
|
|
240
|
+
for (const match of outermostMain) {
|
|
241
|
+
result.main.push(toInstance(match));
|
|
242
|
+
}
|
|
183
243
|
result.remainderHtml = excise(html, spans);
|
|
184
244
|
return result;
|
|
185
245
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default containment threshold for {@link ./is-chrome-landmark-instance.js | isChromeLandmarkInstance}.
|
|
3
|
+
* Chosen to match the quorum/shell fractions used elsewhere in this pipeline
|
|
4
|
+
* ({@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
|
|
5
|
+
* `QUORUM_FRACTION`, {@link ./shell-quorum.js | SHELL_QUORUM_FALLBACK_FRACTION}) —
|
|
6
|
+
* unlike those, this exact value has not been validated against real crawl
|
|
7
|
+
* data; it is a starting point carried over by convention, adjustable via
|
|
8
|
+
* this function's `threshold` parameter.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DEFAULT_CHROME_OVERLAP_THRESHOLD = 0.8;
|
|
11
|
+
/**
|
|
12
|
+
* Classifies a single landmark instance as chrome (shared site/section
|
|
13
|
+
* furniture) or content, given the instance's own tokens and the shell
|
|
14
|
+
* token set {@link ./shell-quorum.js | shellQuorum} discovered for its unit.
|
|
15
|
+
*
|
|
16
|
+
* ## Why containment (`|instance ∩ shell| / |instance|`) and not Jaccard
|
|
17
|
+
*
|
|
18
|
+
* `shellTokens` is the union of chrome tokens across an entire unit's
|
|
19
|
+
* landmark instances (header + nav + footer + …, corpus-wide), so it is
|
|
20
|
+
* usually far larger than any single instance's own token set. Jaccard's
|
|
21
|
+
* denominator is the *union* of both sets, which stays shell-sized even when
|
|
22
|
+
* the instance is 100% shell tokens — driving the score down regardless of
|
|
23
|
+
* how purely "shell" the instance is. Containment instead asks "of this
|
|
24
|
+
* instance's own tokens, how many are shell tokens", which is the question
|
|
25
|
+
* that actually matters for classifying one instance.
|
|
26
|
+
*
|
|
27
|
+
* An instance with zero tokens is never chrome (there is nothing to
|
|
28
|
+
* corroborate) — matches {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}'s
|
|
29
|
+
* own choice to drop zero-token instances before they ever reach a
|
|
30
|
+
* `PerPageLandmarkInstance`, kept here as a defensive default rather than an
|
|
31
|
+
* assumption about every caller.
|
|
32
|
+
* @param instanceTokens
|
|
33
|
+
* @param shellTokens
|
|
34
|
+
* @param threshold
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const shellTokens = shellQuorum(unitPerPageInstances);
|
|
38
|
+
* const isChrome = isChromeLandmarkInstance(instance.tokens, shellTokens);
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare function isChromeLandmarkInstance(instanceTokens: ReadonlySet<string>, shellTokens: ReadonlySet<string>, threshold?: number): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default containment threshold for {@link ./is-chrome-landmark-instance.js | isChromeLandmarkInstance}.
|
|
3
|
+
* Chosen to match the quorum/shell fractions used elsewhere in this pipeline
|
|
4
|
+
* ({@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
|
|
5
|
+
* `QUORUM_FRACTION`, {@link ./shell-quorum.js | SHELL_QUORUM_FALLBACK_FRACTION}) —
|
|
6
|
+
* unlike those, this exact value has not been validated against real crawl
|
|
7
|
+
* data; it is a starting point carried over by convention, adjustable via
|
|
8
|
+
* this function's `threshold` parameter.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_CHROME_OVERLAP_THRESHOLD = 0.8;
|
|
11
|
+
/**
|
|
12
|
+
* Classifies a single landmark instance as chrome (shared site/section
|
|
13
|
+
* furniture) or content, given the instance's own tokens and the shell
|
|
14
|
+
* token set {@link ./shell-quorum.js | shellQuorum} discovered for its unit.
|
|
15
|
+
*
|
|
16
|
+
* ## Why containment (`|instance ∩ shell| / |instance|`) and not Jaccard
|
|
17
|
+
*
|
|
18
|
+
* `shellTokens` is the union of chrome tokens across an entire unit's
|
|
19
|
+
* landmark instances (header + nav + footer + …, corpus-wide), so it is
|
|
20
|
+
* usually far larger than any single instance's own token set. Jaccard's
|
|
21
|
+
* denominator is the *union* of both sets, which stays shell-sized even when
|
|
22
|
+
* the instance is 100% shell tokens — driving the score down regardless of
|
|
23
|
+
* how purely "shell" the instance is. Containment instead asks "of this
|
|
24
|
+
* instance's own tokens, how many are shell tokens", which is the question
|
|
25
|
+
* that actually matters for classifying one instance.
|
|
26
|
+
*
|
|
27
|
+
* An instance with zero tokens is never chrome (there is nothing to
|
|
28
|
+
* corroborate) — matches {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}'s
|
|
29
|
+
* own choice to drop zero-token instances before they ever reach a
|
|
30
|
+
* `PerPageLandmarkInstance`, kept here as a defensive default rather than an
|
|
31
|
+
* assumption about every caller.
|
|
32
|
+
* @param instanceTokens
|
|
33
|
+
* @param shellTokens
|
|
34
|
+
* @param threshold
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const shellTokens = shellQuorum(unitPerPageInstances);
|
|
38
|
+
* const isChrome = isChromeLandmarkInstance(instance.tokens, shellTokens);
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function isChromeLandmarkInstance(instanceTokens, shellTokens, threshold = DEFAULT_CHROME_OVERLAP_THRESHOLD) {
|
|
42
|
+
if (instanceTokens.size === 0)
|
|
43
|
+
return false;
|
|
44
|
+
let hit = 0;
|
|
45
|
+
for (const token of instanceTokens) {
|
|
46
|
+
if (shellTokens.has(token))
|
|
47
|
+
hit++;
|
|
48
|
+
}
|
|
49
|
+
return hit / instanceTokens.size >= threshold;
|
|
50
|
+
}
|
|
@@ -18,11 +18,47 @@ export type CrossBlockUnit = {
|
|
|
18
18
|
readonly memberTokenSets: readonly ReadonlySet<string>[];
|
|
19
19
|
readonly memberLandmarkInstances: readonly (readonly PerPageLandmarkInstance[])[];
|
|
20
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* Frequency-based token core of a group of member pages: a token must be
|
|
23
|
+
* present in at least `QUORUM_FRACTION` of members to enter the core, with a
|
|
24
|
+
* full-union fallback when no token clears that bar (see
|
|
25
|
+
* {@link mergeCrossBlockClusters}'s JSDoc for why quorum beats strict
|
|
26
|
+
* intersection or full union). Exported so callers building a `ClusterReason`
|
|
27
|
+
* (`build-cluster-reason.ts`) can re-derive a final group's structural core
|
|
28
|
+
* from `finalGroupsByRoot` without duplicating this logic.
|
|
29
|
+
* @param memberDistinctiveTokens
|
|
30
|
+
*/
|
|
31
|
+
export declare function computeQuorumCore(memberDistinctiveTokens: readonly ReadonlySet<string>[]): ReadonlySet<string>;
|
|
32
|
+
/**
|
|
33
|
+
* One root key's pooled member state after Stage B converges: every
|
|
34
|
+
* `tokenSets`/`landmarkInstances` entry folded in from every unit merged into
|
|
35
|
+
* this root (down-sampled to `capMembers` when the caller opts in, same as
|
|
36
|
+
* during merging). This is the exact state `mergeCrossBlockClusters` already
|
|
37
|
+
* builds internally to run quorum-core/shell comparisons each round — it was
|
|
38
|
+
* discarded once `keyToRoot` was returned. Exposing it lets a `ClusterReason`
|
|
39
|
+
* (`build-cluster-reason.ts`) be built from data Stage B already computed,
|
|
40
|
+
* with no extra pass over the corpus.
|
|
41
|
+
*/
|
|
42
|
+
export type FinalGroupMembers = {
|
|
43
|
+
readonly tokenSets: readonly ReadonlySet<string>[];
|
|
44
|
+
readonly landmarkInstances: readonly (readonly PerPageLandmarkInstance[])[];
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* `mergeCrossBlockClusters`'s result: the root-key mapping every caller
|
|
48
|
+
* needs for `clusterKey` resolution, plus each surviving root's final pooled
|
|
49
|
+
* member state for callers that also want to explain *why* (`ClusterReason`).
|
|
50
|
+
*/
|
|
51
|
+
export type MergeCrossBlockClustersResult = {
|
|
52
|
+
/** Every input unit's `key` mapped to its final root key. Units not absorbed into any other unit map to themselves. */
|
|
53
|
+
readonly rootByKey: ReadonlyMap<string, string>;
|
|
54
|
+
/** Every surviving root key's final pooled member state. */
|
|
55
|
+
readonly finalGroupsByRoot: ReadonlyMap<string, FinalGroupMembers>;
|
|
56
|
+
};
|
|
21
57
|
/**
|
|
22
58
|
* Merges cross-block clusters (Stage B) via recursive quorum-core comparison.
|
|
23
59
|
*
|
|
24
|
-
* Returns
|
|
25
|
-
* not absorbed into any other unit map to themselves.
|
|
60
|
+
* Returns the root-key mapping (see {@link MergeCrossBlockClustersResult}).
|
|
61
|
+
* Units not absorbed into any other unit map to themselves.
|
|
26
62
|
*
|
|
27
63
|
* Three merge mechanisms run per round, in order:
|
|
28
64
|
* 1. **Fine stage** — complete-linkage at `CROSS_BLOCK_THRESHOLD` on quorum
|
|
@@ -58,4 +94,4 @@ export declare function mergeCrossBlockClusters(units: readonly CrossBlockUnit[]
|
|
|
58
94
|
* full-membership merge behavior unchanged.
|
|
59
95
|
*/
|
|
60
96
|
capMembers?: number;
|
|
61
|
-
}):
|
|
97
|
+
}): MergeCrossBlockClustersResult;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { assignContainedClusters } from './assign-contained-clusters.js';
|
|
2
|
-
import { autoCutThreshold } from './auto-cut-threshold.js';
|
|
3
2
|
import { collapseAnonymousDivs } from './collapse-anonymous-divs.js';
|
|
4
3
|
import { completeLinkageDendrogram, labelsAtThreshold, } from './complete-linkage-dendrogram.js';
|
|
5
4
|
import { computeDocumentFrequency } from './compute-document-frequency.js';
|
|
6
5
|
import { jaccardSimilarity } from './jaccard-similarity.js';
|
|
7
6
|
import { reservoirSample } from './reservoir-sample.js';
|
|
8
7
|
import { shapeToken } from './shape-token.js';
|
|
8
|
+
import { shellQuorum } from './shell-quorum.js';
|
|
9
9
|
import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
|
|
10
10
|
/**
|
|
11
11
|
* Fixed complete-linkage threshold for the cross-block fine stage.
|
|
@@ -26,11 +26,10 @@ const CROSS_BLOCK_THRESHOLD = 0.8;
|
|
|
26
26
|
* on real crawl data. Full union is shell-dominated: 298 pages collapsed into
|
|
27
27
|
* 4 clusters — also confirmed. 80% quorum avoids both failure modes.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* and only fires in the degenerate cases the JSDoc there describes.
|
|
29
|
+
* Not shared with {@link ./shell-quorum.js | shellQuorum}'s own fallback
|
|
30
|
+
* clamp (`SHELL_QUORUM_FALLBACK_FRACTION`) — the two happen to be the same
|
|
31
|
+
* value today because both were validated against the same real crawl
|
|
32
|
+
* corpora, but they are independently tunable.
|
|
34
33
|
*/
|
|
35
34
|
const QUORUM_FRACTION = 0.8;
|
|
36
35
|
/**
|
|
@@ -84,10 +83,16 @@ const GENERIC_SEGMENTS = new Set([
|
|
|
84
83
|
// Internal helpers
|
|
85
84
|
// ---------------------------------------------------------------------------
|
|
86
85
|
/**
|
|
87
|
-
*
|
|
86
|
+
* Frequency-based token core of a group of member pages: a token must be
|
|
87
|
+
* present in at least `QUORUM_FRACTION` of members to enter the core, with a
|
|
88
|
+
* full-union fallback when no token clears that bar (see
|
|
89
|
+
* {@link mergeCrossBlockClusters}'s JSDoc for why quorum beats strict
|
|
90
|
+
* intersection or full union). Exported so callers building a `ClusterReason`
|
|
91
|
+
* (`build-cluster-reason.ts`) can re-derive a final group's structural core
|
|
92
|
+
* from `finalGroupsByRoot` without duplicating this logic.
|
|
88
93
|
* @param memberDistinctiveTokens
|
|
89
94
|
*/
|
|
90
|
-
function
|
|
95
|
+
export function computeQuorumCore(memberDistinctiveTokens) {
|
|
91
96
|
const n = memberDistinctiveTokens.length;
|
|
92
97
|
if (n === 0)
|
|
93
98
|
return new Set();
|
|
@@ -159,105 +164,11 @@ function l2Contained(xSig, ySig) {
|
|
|
159
164
|
}
|
|
160
165
|
return true;
|
|
161
166
|
}
|
|
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
167
|
/**
|
|
257
168
|
* Merges cross-block clusters (Stage B) via recursive quorum-core comparison.
|
|
258
169
|
*
|
|
259
|
-
* Returns
|
|
260
|
-
* not absorbed into any other unit map to themselves.
|
|
170
|
+
* Returns the root-key mapping (see {@link MergeCrossBlockClustersResult}).
|
|
171
|
+
* Units not absorbed into any other unit map to themselves.
|
|
261
172
|
*
|
|
262
173
|
* Three merge mechanisms run per round, in order:
|
|
263
174
|
* 1. **Fine stage** — complete-linkage at `CROSS_BLOCK_THRESHOLD` on quorum
|
|
@@ -283,7 +194,13 @@ function shellQuorum(perPageInstances) {
|
|
|
283
194
|
*/
|
|
284
195
|
export function mergeCrossBlockClusters(units, options) {
|
|
285
196
|
if (units.length <= 1) {
|
|
286
|
-
return
|
|
197
|
+
return {
|
|
198
|
+
rootByKey: new Map(units.map((u) => [u.key, u.key])),
|
|
199
|
+
finalGroupsByRoot: new Map(units.map((u) => [
|
|
200
|
+
u.key,
|
|
201
|
+
{ tokenSets: u.memberTokenSets, landmarkInstances: u.memberLandmarkInstances },
|
|
202
|
+
])),
|
|
203
|
+
};
|
|
287
204
|
}
|
|
288
205
|
const threshold = options?.similarityThreshold ?? CROSS_BLOCK_THRESHOLD;
|
|
289
206
|
const capMembers = options?.capMembers;
|
|
@@ -356,7 +273,7 @@ export function mergeCrossBlockClusters(units, options) {
|
|
|
356
273
|
// Quorum core per group
|
|
357
274
|
const cores = new Map();
|
|
358
275
|
for (const key of groupKeys) {
|
|
359
|
-
cores.set(key,
|
|
276
|
+
cores.set(key, computeQuorumCore(groupDistinctive.get(key) ?? []));
|
|
360
277
|
}
|
|
361
278
|
// ---------------------------------------------------------------
|
|
362
279
|
// Fine stage: union-find over group indices
|
|
@@ -542,5 +459,9 @@ export function mergeCrossBlockClusters(units, options) {
|
|
|
542
459
|
break; // fully converged
|
|
543
460
|
applyMerges(l2Merges);
|
|
544
461
|
}
|
|
545
|
-
|
|
462
|
+
const finalGroupsByRoot = new Map([...groups.entries()].map(([root, g]) => [
|
|
463
|
+
root,
|
|
464
|
+
{ tokenSets: g.tokenSets, landmarkInstances: g.landmarkInstances },
|
|
465
|
+
]));
|
|
466
|
+
return { rootByKey: keyToRoot, finalGroupsByRoot };
|
|
546
467
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A 1-based line/column position within an HTML string.
|
|
3
|
+
*/
|
|
4
|
+
export type LineColumn = {
|
|
5
|
+
readonly line: number;
|
|
6
|
+
readonly column: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Precomputed lookup structure for {@link ./offset-to-line-column.js | offsetToLineColumn}:
|
|
10
|
+
* every newline's string-index offset, ascending.
|
|
11
|
+
*/
|
|
12
|
+
export type LineColumnIndex = {
|
|
13
|
+
readonly newlineOffsets: readonly number[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Scans `html` once and records every `\n` offset, so repeated
|
|
17
|
+
* {@link ./offset-to-line-column.js | offsetToLineColumn} calls against the
|
|
18
|
+
* same HTML string can binary-search instead of re-scanning from the start
|
|
19
|
+
* each time. Built once per page and reused across every landmark instance's
|
|
20
|
+
* start/end offset — a page with dozens of landmark instances would
|
|
21
|
+
* otherwise pay for a fresh O(n) scan per offset instead of one O(n) scan
|
|
22
|
+
* total.
|
|
23
|
+
* @param html
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildLineColumnIndex(html: string): LineColumnIndex;
|
|
26
|
+
/**
|
|
27
|
+
* Converts a string-index `offset` (the same unit as `htmlparser2`'s
|
|
28
|
+
* `startIndex`/`endIndex`, i.e. UTF-16 code units) into a 1-based
|
|
29
|
+
* `{line, column}` position, using an index built by
|
|
30
|
+
* {@link ./offset-to-line-column.js | buildLineColumnIndex}.
|
|
31
|
+
*
|
|
32
|
+
* `\r\n` line endings are handled without special-casing: the `\r` is
|
|
33
|
+
* counted as the last column of its own line, matching how most editors
|
|
34
|
+
* report position for CRLF files.
|
|
35
|
+
* @param index
|
|
36
|
+
* @param offset
|
|
37
|
+
*/
|
|
38
|
+
export declare function offsetToLineColumn(index: LineColumnIndex, offset: number): LineColumn;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scans `html` once and records every `\n` offset, so repeated
|
|
3
|
+
* {@link ./offset-to-line-column.js | offsetToLineColumn} calls against the
|
|
4
|
+
* same HTML string can binary-search instead of re-scanning from the start
|
|
5
|
+
* each time. Built once per page and reused across every landmark instance's
|
|
6
|
+
* start/end offset — a page with dozens of landmark instances would
|
|
7
|
+
* otherwise pay for a fresh O(n) scan per offset instead of one O(n) scan
|
|
8
|
+
* total.
|
|
9
|
+
* @param html
|
|
10
|
+
*/
|
|
11
|
+
export function buildLineColumnIndex(html) {
|
|
12
|
+
const newlineOffsets = [];
|
|
13
|
+
for (let i = 0; i < html.length; i++) {
|
|
14
|
+
if (html.codePointAt(i) === 10)
|
|
15
|
+
newlineOffsets.push(i);
|
|
16
|
+
}
|
|
17
|
+
return { newlineOffsets };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Converts a string-index `offset` (the same unit as `htmlparser2`'s
|
|
21
|
+
* `startIndex`/`endIndex`, i.e. UTF-16 code units) into a 1-based
|
|
22
|
+
* `{line, column}` position, using an index built by
|
|
23
|
+
* {@link ./offset-to-line-column.js | buildLineColumnIndex}.
|
|
24
|
+
*
|
|
25
|
+
* `\r\n` line endings are handled without special-casing: the `\r` is
|
|
26
|
+
* counted as the last column of its own line, matching how most editors
|
|
27
|
+
* report position for CRLF files.
|
|
28
|
+
* @param index
|
|
29
|
+
* @param offset
|
|
30
|
+
*/
|
|
31
|
+
export function offsetToLineColumn(index, offset) {
|
|
32
|
+
const { newlineOffsets } = index;
|
|
33
|
+
let low = 0;
|
|
34
|
+
let high = newlineOffsets.length;
|
|
35
|
+
while (low < high) {
|
|
36
|
+
const mid = (low + high) >>> 1;
|
|
37
|
+
if (newlineOffsets[mid] < offset) {
|
|
38
|
+
low = mid + 1;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
high = mid;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// `low` is the count of newlines strictly before `offset`, i.e. the
|
|
45
|
+
// number of completed lines — so `low` newlines completed means we're on
|
|
46
|
+
// line `low + 1`.
|
|
47
|
+
const lineStart = low === 0 ? 0 : newlineOffsets[low - 1] + 1;
|
|
48
|
+
return { line: low + 1, column: offset - lineStart + 1 };
|
|
49
|
+
}
|
package/dist/pass0-blocking.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BlockingReason } from './derive-blocking-reason.js';
|
|
1
2
|
import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
|
|
2
3
|
/**
|
|
3
4
|
* The corpus-wide, HTML-free inputs needed by
|
|
@@ -31,6 +32,12 @@ export type ResolveBlockKeysOptions = ResolveBlockingGroupKeysOptions & {
|
|
|
31
32
|
*/
|
|
32
33
|
readonly restrictStylesheetsToFirstParty?: boolean;
|
|
33
34
|
};
|
|
35
|
+
/** Return shape when `includeReasons: true` is passed to `resolveBlockKeys`. */
|
|
36
|
+
export type BlockKeysWithReasons = {
|
|
37
|
+
readonly blockKeys: string[];
|
|
38
|
+
/** One entry per distinct final block key produced, keyed by that key. */
|
|
39
|
+
readonly reasonsByBlockKey: ReadonlyMap<string, BlockingReason>;
|
|
40
|
+
};
|
|
34
41
|
/**
|
|
35
42
|
* Splits `resolvePageClusterKeys` into a size-flat first pass so the driver
|
|
36
43
|
* can decide per-block memory strategy before loading any page HTML. Runs the
|
|
@@ -71,6 +78,9 @@ export type ResolveBlockKeysOptions = ResolveBlockingGroupKeysOptions & {
|
|
|
71
78
|
* // ['css:<hash>', 'css:<hash>', 'path:about']
|
|
72
79
|
* ```
|
|
73
80
|
*/
|
|
81
|
+
export declare function resolveBlockKeys(pages: readonly Pass0PageSignals[], options: ResolveBlockKeysOptions & {
|
|
82
|
+
includeReasons: true;
|
|
83
|
+
}): BlockKeysWithReasons;
|
|
74
84
|
export declare function resolveBlockKeys(pages: readonly Pass0PageSignals[], options?: ResolveBlockKeysOptions): string[];
|
|
75
85
|
/**
|
|
76
86
|
* Groups pages by their block key while preserving each block's members in
|
package/dist/pass0-blocking.js
CHANGED
|
@@ -1,62 +1,42 @@
|
|
|
1
1
|
import { filterFirstPartyStylesheetHrefs } from './filter-first-party-stylesheet-hrefs.js';
|
|
2
|
-
import { reassignOrphanBlockKeys } from './reassign-orphan-block-keys.js';
|
|
2
|
+
import { REASSIGNED_KEY_PREFIX, reassignOrphanBlockKeys, } from './reassign-orphan-block-keys.js';
|
|
3
3
|
import { resolveBlockingGroupKeys } from './resolve-blocking-group-keys.js';
|
|
4
|
-
/**
|
|
5
|
-
* Splits `resolvePageClusterKeys` into a size-flat first pass so the driver
|
|
6
|
-
* can decide per-block memory strategy before loading any page HTML. Runs the
|
|
7
|
-
* three corpus-wide, HTML-free stages of blocking in the same order the
|
|
8
|
-
* in-memory driver already uses — first-party stylesheet filtering, blocking-
|
|
9
|
-
* key derivation, orphan reassignment — and returns one final block key per
|
|
10
|
-
* input page in input order.
|
|
11
|
-
*
|
|
12
|
-
* ## Why extract this from resolvePageClusterKeys?
|
|
13
|
-
*
|
|
14
|
-
* The in-memory driver holds every page's `html`, `remainderHtml`,
|
|
15
|
-
* `landmarks[]`, and pre-Stage-A prepared HTML at once. At 176k pages × ~57
|
|
16
|
-
* KB average, that alone breaks a 17 GB RAM machine well before Stage A
|
|
17
|
-
* starts (measured: OS SIGKILL at ~15,000 pages, before the resolve phase
|
|
18
|
-
* even began). All three blocking stages, in contrast, depend only on
|
|
19
|
-
* `paths` / `stylesheetHrefs` / `host` — a few hundred bytes per page. Running
|
|
20
|
-
* them first, HTML-free, lets the downstream per-block clustering hold HTML
|
|
21
|
-
* for only one block's pages at a time.
|
|
22
|
-
*
|
|
23
|
-
* ## Preserves in-memory driver semantics exactly
|
|
24
|
-
*
|
|
25
|
-
* The output of this function is byte-identical to the block-key portion of
|
|
26
|
-
* the current `resolvePageClusterKeys` for the same input, because it reuses
|
|
27
|
-
* the same three underlying functions in the same order with the same
|
|
28
|
-
* defaults. That guarantee is load-bearing: the size-threshold-gated Pass 1
|
|
29
|
-
* that follows this function runs the current in-memory implementation
|
|
30
|
-
* unchanged for small blocks, and any drift in block-key computation between
|
|
31
|
-
* Pass 0 and Pass 1 would silently misroute pages between the two paths.
|
|
32
|
-
* @param pages
|
|
33
|
-
* @param options
|
|
34
|
-
* @example
|
|
35
|
-
* ```ts
|
|
36
|
-
* const blockKeys = resolveBlockKeys([
|
|
37
|
-
* { paths: ['news', '1'], stylesheetHrefs: ['/a.css'], host: 'example.com' },
|
|
38
|
-
* { paths: ['news', '2'], stylesheetHrefs: ['/a.css'], host: 'example.com' },
|
|
39
|
-
* { paths: ['about'], stylesheetHrefs: [], host: 'example.com' },
|
|
40
|
-
* ]);
|
|
41
|
-
* // ['css:<hash>', 'css:<hash>', 'path:about']
|
|
42
|
-
* ```
|
|
43
|
-
*/
|
|
44
4
|
export function resolveBlockKeys(pages, options) {
|
|
45
5
|
const restrictStylesheetsToFirstParty = options?.restrictStylesheetsToFirstParty ?? true;
|
|
46
6
|
const blockingPages = restrictStylesheetsToFirstParty
|
|
47
7
|
? filterFirstPartyStylesheetHrefs(pages)
|
|
48
8
|
: pages;
|
|
49
|
-
const rawBlockKeys = resolveBlockingGroupKeys(blockingPages, options);
|
|
50
|
-
const reassignOrphans = options?.reassignOrphans ?? true;
|
|
51
|
-
if (!reassignOrphans)
|
|
52
|
-
return rawBlockKeys;
|
|
53
9
|
// Orphan reassignment always uses a numeric `pathDepth`. When the caller
|
|
54
10
|
// asked for `'auto'`, fall back to the historical default 1 here — a
|
|
55
11
|
// future PR that wires the auto-cut depth through can compute it once
|
|
56
12
|
// and pass it as a number to both `resolveBlockingGroupKeys` and this
|
|
57
13
|
// call to keep them consistent.
|
|
58
14
|
const numericPathDepth = typeof options?.pathDepth === 'number' ? options.pathDepth : undefined;
|
|
59
|
-
|
|
15
|
+
const reassignOrphans = options?.reassignOrphans ?? true;
|
|
16
|
+
if (!options?.includeReasons) {
|
|
17
|
+
const rawBlockKeys = resolveBlockingGroupKeys(blockingPages, options);
|
|
18
|
+
if (!reassignOrphans)
|
|
19
|
+
return rawBlockKeys;
|
|
20
|
+
return reassignOrphanBlockKeys(blockingPages, rawBlockKeys, numericPathDepth);
|
|
21
|
+
}
|
|
22
|
+
const { keys: rawBlockKeys, reasonsByKey } = resolveBlockingGroupKeys(blockingPages, {
|
|
23
|
+
...options,
|
|
24
|
+
includeReasons: true,
|
|
25
|
+
});
|
|
26
|
+
if (!reassignOrphans) {
|
|
27
|
+
return { blockKeys: rawBlockKeys, reasonsByBlockKey: reasonsByKey };
|
|
28
|
+
}
|
|
29
|
+
const finalBlockKeys = reassignOrphanBlockKeys(blockingPages, rawBlockKeys, numericPathDepth);
|
|
30
|
+
const reasonsByBlockKey = new Map(reasonsByKey);
|
|
31
|
+
for (const blockKey of finalBlockKeys) {
|
|
32
|
+
if (reasonsByBlockKey.has(blockKey))
|
|
33
|
+
continue;
|
|
34
|
+
reasonsByBlockKey.set(blockKey, {
|
|
35
|
+
kind: 'orphanMerge',
|
|
36
|
+
pathKey: blockKey.slice(REASSIGNED_KEY_PREFIX.length),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { blockKeys: finalBlockKeys, reasonsByBlockKey };
|
|
60
40
|
}
|
|
61
41
|
/**
|
|
62
42
|
* Groups pages by their block key while preserving each block's members in
|