@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,4 +1,4 @@
|
|
|
1
|
-
import type { ExtractLandmarksResult, LandmarkType } from './extract-landmarks.js';
|
|
1
|
+
import type { ExtractLandmarksResult, LandmarkPosition, LandmarkType } from './extract-landmarks.js';
|
|
2
2
|
import type { TokenizeOptions } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Every landmark type extractLandmarks may populate, iterated in a fixed
|
|
@@ -13,11 +13,19 @@ export declare const ALL_LANDMARK_TYPES: readonly LandmarkType[];
|
|
|
13
13
|
* {@link ./canonicalize-token-set.js | canonicalizeTokenSet}). Signatures
|
|
14
14
|
* are reused across consumers so two callers see the same "same instance"
|
|
15
15
|
* verdict without independently re-canonicalizing.
|
|
16
|
+
*
|
|
17
|
+
* `position` is the instance's location in the page it came from, computed
|
|
18
|
+
* once by {@link ./extract-landmarks.js | extractLandmarks} and carried here
|
|
19
|
+
* unchanged — a back-reference for callers that need to report where a
|
|
20
|
+
* chrome-classified instance actually sits, not just that it exists. It
|
|
21
|
+
* plays no part in `tokens`/`signature` computation or in the corpus-
|
|
22
|
+
* frequency logic that consumes this type.
|
|
16
23
|
*/
|
|
17
24
|
export type PerPageLandmarkInstance = {
|
|
18
25
|
readonly type: LandmarkType;
|
|
19
26
|
readonly tokens: ReadonlySet<string>;
|
|
20
27
|
readonly signature: string;
|
|
28
|
+
readonly position: LandmarkPosition;
|
|
21
29
|
};
|
|
22
30
|
/**
|
|
23
31
|
* Tokenizes every landmark instance across every page once, keyed by page
|
|
@@ -44,17 +44,29 @@ export function computePerPageLandmarkInstances(landmarks, tokenizeOptions) {
|
|
|
44
44
|
const seenSignatures = new Set();
|
|
45
45
|
const out = [];
|
|
46
46
|
for (const type of ALL_LANDMARK_TYPES) {
|
|
47
|
-
for (const
|
|
48
|
-
if (!
|
|
47
|
+
for (const instance of entry[type]) {
|
|
48
|
+
if (!instance.html)
|
|
49
49
|
continue;
|
|
50
|
-
const tokens = new Set(tokenize(`<body>${
|
|
50
|
+
const tokens = new Set(tokenize(`<body>${instance.html}</body>`, tokenizeOptions).tokens);
|
|
51
51
|
if (tokens.size === 0)
|
|
52
52
|
continue;
|
|
53
53
|
const signature = canonicalizeTokenSet(tokens);
|
|
54
54
|
if (seenSignatures.has(signature))
|
|
55
55
|
continue;
|
|
56
56
|
seenSignatures.add(signature);
|
|
57
|
-
out.push({
|
|
57
|
+
out.push({
|
|
58
|
+
type,
|
|
59
|
+
tokens,
|
|
60
|
+
signature,
|
|
61
|
+
position: {
|
|
62
|
+
startOffset: instance.startOffset,
|
|
63
|
+
endOffset: instance.endOffset,
|
|
64
|
+
startLine: instance.startLine,
|
|
65
|
+
startColumn: instance.startColumn,
|
|
66
|
+
endLine: instance.endLine,
|
|
67
|
+
endColumn: instance.endColumn,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
58
70
|
}
|
|
59
71
|
}
|
|
60
72
|
return out;
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { PageBlockingSignals } from './resolve-blocking-group-keys.js';
|
|
2
|
+
/**
|
|
3
|
+
* Prefix distinguishing a reassigned key from the `css:`/`path:` keys
|
|
4
|
+
* {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} itself
|
|
5
|
+
* produces, so the two families can never collide. Exported so
|
|
6
|
+
* {@link ./pass0-blocking.js | resolveBlockKeys} can recover the confined
|
|
7
|
+
* path key back out of a reassigned block key when building `BlockingReason`s,
|
|
8
|
+
* without duplicating this literal.
|
|
9
|
+
*/
|
|
10
|
+
export declare const REASSIGNED_KEY_PREFIX = "orphan-merge:";
|
|
2
11
|
/**
|
|
3
12
|
* Rewrites the `path:`-fallback key of an "orphan" page — one with no
|
|
4
13
|
* stylesheet references recorded at all — to match a same-URL-section `css:`
|
|
@@ -2,9 +2,12 @@ import { derivePathGroupKey } from './derive-path-group-key.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Prefix distinguishing a reassigned key from the `css:`/`path:` keys
|
|
4
4
|
* {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} itself
|
|
5
|
-
* produces, so the two families can never collide.
|
|
5
|
+
* produces, so the two families can never collide. Exported so
|
|
6
|
+
* {@link ./pass0-blocking.js | resolveBlockKeys} can recover the confined
|
|
7
|
+
* path key back out of a reassigned block key when building `BlockingReason`s,
|
|
8
|
+
* without duplicating this literal.
|
|
6
9
|
*/
|
|
7
|
-
const REASSIGNED_KEY_PREFIX = 'orphan-merge:';
|
|
10
|
+
export const REASSIGNED_KEY_PREFIX = 'orphan-merge:';
|
|
8
11
|
/**
|
|
9
12
|
* Reads `values[index]`, throwing instead of returning `undefined`. Every
|
|
10
13
|
* call site here indexes `pages`/`pathKeys`/`blockKeys` with a position
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BlockingReason } from './derive-blocking-reason.js';
|
|
1
2
|
/**
|
|
2
3
|
* The two blocking signals {@link ./derive-path-group-key.js | derivePathGroupKey}
|
|
3
4
|
* and {@link ./derive-stylesheet-group-key.js | deriveStylesheetGroupKey} need,
|
|
@@ -36,6 +37,12 @@ export type ResolveBlockingGroupKeysOptions = {
|
|
|
36
37
|
/** Forwarded to `splitTokensByFrequency` as-is. */
|
|
37
38
|
hrefCommonThreshold?: number;
|
|
38
39
|
};
|
|
40
|
+
/** Return shape when `includeReasons: true` is passed to `resolveBlockingGroupKeys`. */
|
|
41
|
+
export type BlockingGroupKeysWithReasons = {
|
|
42
|
+
readonly keys: string[];
|
|
43
|
+
/** One entry per distinct blocking key produced, keyed by that key. */
|
|
44
|
+
readonly reasonsByKey: ReadonlyMap<string, BlockingReason>;
|
|
45
|
+
};
|
|
39
46
|
/**
|
|
40
47
|
* Resolves, per page, which of the two independent blocking signals — the
|
|
41
48
|
* exact stylesheet set or the URL path — to actually use as that page's
|
|
@@ -119,4 +126,7 @@ export type ResolveBlockingGroupKeysOptions = {
|
|
|
119
126
|
* // common.css is loaded by all 3 pages and is filtered out as non-discriminative chrome.
|
|
120
127
|
* ```
|
|
121
128
|
*/
|
|
129
|
+
export declare function resolveBlockingGroupKeys(pages: readonly PageBlockingSignals[], options: ResolveBlockingGroupKeysOptions & {
|
|
130
|
+
includeReasons: true;
|
|
131
|
+
}): BlockingGroupKeysWithReasons;
|
|
122
132
|
export declare function resolveBlockingGroupKeys(pages: readonly PageBlockingSignals[], options?: ResolveBlockingGroupKeysOptions): string[];
|
|
@@ -4,89 +4,6 @@ import { derivePathGroupKey } from './derive-path-group-key.js';
|
|
|
4
4
|
import { deriveStylesheetGroupKey } from './derive-stylesheet-group-key.js';
|
|
5
5
|
import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
|
|
6
6
|
const DEFAULT_MIN_CSS_GROUP_SIZE = 2;
|
|
7
|
-
/**
|
|
8
|
-
* Resolves, per page, which of the two independent blocking signals — the
|
|
9
|
-
* exact stylesheet set or the URL path — to actually use as that page's
|
|
10
|
-
* grouping key. Returns one key per page, in the same order as `pages`.
|
|
11
|
-
*
|
|
12
|
-
* Literature on entity-resolution blocking (Michelson & Knoblock's DNF
|
|
13
|
-
* scheme, canopy clustering, ensemble blocking) combines independent
|
|
14
|
-
* blocking predicates with OR to generate *candidate pairs* for a later
|
|
15
|
-
* similarity/classification pass. This function instead commits each page to
|
|
16
|
-
* exactly one final key: `resolve-page-cluster-keys.js`'s
|
|
17
|
-
* `resolvePageClusterKeys` *does* run a later refinement step
|
|
18
|
-
* (`resolveStructuralClusterKeys`) on top of whichever key a page lands on,
|
|
19
|
-
* but only within that one key's candidate pool — it has no way to pull in
|
|
20
|
-
* a page that this function routed to a different key. So this function's
|
|
21
|
-
* per-page choice is still effectively final for blocking purposes: a page
|
|
22
|
-
* assigned to the wrong key here never re-enters the correct key's pool
|
|
23
|
-
* downstream. A true OR-merge (letting a page carry both the stylesheet and
|
|
24
|
-
* path candidates, deferring to the refinement step to reconcile overlapping
|
|
25
|
-
* results across them) would close that gap, but is deliberately deferred —
|
|
26
|
-
* it needs the same literature-plus-real-data validation cycle this
|
|
27
|
-
* package's linkage-criterion and NN-chain choices already went through, not
|
|
28
|
-
* a change bundled in alongside unrelated fixes. Until then, a
|
|
29
|
-
* priority-with-fallback decision — try the strong signal, fall back to the
|
|
30
|
-
* weak one — is the applicable pattern here, not OR-merge: a union of
|
|
31
|
-
* equivalence relations can only ever coarsen a partition, never split it,
|
|
32
|
-
* but the whole point of preferring the stylesheet signal is that it *splits*
|
|
33
|
-
* pages a URL-path-only grouping would otherwise lump together (confirmed
|
|
34
|
-
* against real crawl data: a single page embedded under an otherwise-uniform
|
|
35
|
-
* URL section, but loading a completely different stylesheet set, is exactly
|
|
36
|
-
* the case a path-only key misses and a stylesheet key catches).
|
|
37
|
-
*
|
|
38
|
-
* Before comparing stylesheet sets, this reuses
|
|
39
|
-
* {@link ./compute-document-frequency.js | computeDocumentFrequency} and
|
|
40
|
-
* {@link ./split-tokens-by-frequency.js | splitTokensByFrequency} — originally
|
|
41
|
-
* built to separate a page's site-wide chrome from its page-specific HTML
|
|
42
|
-
* structure — to strip stylesheet hrefs that recur across most of `pages`
|
|
43
|
-
* (e.g. a shared reset/font stylesheet) before hashing. Without this, two
|
|
44
|
-
* pages from otherwise-unrelated sections that happen to load only that one
|
|
45
|
-
* shared stylesheet would satisfy `minCssGroupSize` and be wrongly treated as
|
|
46
|
-
* the same template family: the problem there isn't too few pages sharing
|
|
47
|
-
* the key (raising `minCssGroupSize` doesn't fix it), it's that the key
|
|
48
|
-
* itself carries no discriminative information. A page whose stylesheet set
|
|
49
|
-
* is empty, or becomes empty after this filtering, always falls back to the
|
|
50
|
-
* path key — loading no distinctive stylesheet is an absence of evidence,
|
|
51
|
-
* not evidence of a shared template, so it must never itself become a
|
|
52
|
-
* matching signal.
|
|
53
|
-
*
|
|
54
|
-
* Document frequency is computed only over pages that load at least one
|
|
55
|
-
* stylesheet: including stylesheet-less pages in the denominator would dilute
|
|
56
|
-
* every href's frequency ratio (e.g. a stylesheet loaded by 100% of the pages
|
|
57
|
-
* that load *any* stylesheet would read as a low, "distinctive" frequency if
|
|
58
|
-
* most pages in the batch load none), letting a genuinely non-discriminative,
|
|
59
|
-
* site-wide stylesheet slip through the common-href filter.
|
|
60
|
-
*
|
|
61
|
-
* Like `computeDocumentFrequency` itself, this expects `pages` to be a
|
|
62
|
-
* roughly homogeneous batch (one site, or one section of a large
|
|
63
|
-
* multi-template site) — see that function's JSDoc for why a federation of
|
|
64
|
-
* independently-templated sub-sections defeats frequency-based filtering.
|
|
65
|
-
* Splitting a heterogeneous crawl into sections before calling this function
|
|
66
|
-
* is the caller's responsibility.
|
|
67
|
-
*
|
|
68
|
-
* This filtering needs enough stylesheet-bearing pages to tell "loaded by
|
|
69
|
-
* every page that has any stylesheet" apart from "coincidentally the only
|
|
70
|
-
* stylesheet two pages happen to load": with only two stylesheet-bearing
|
|
71
|
-
* pages in the whole batch and nothing else to contrast against, any
|
|
72
|
-
* stylesheet they share reads as 100% common and gets filtered out,
|
|
73
|
-
* producing a path-key fallback even when the two pages are a genuine
|
|
74
|
-
* template match. A third, differently-styled page (as in the example below)
|
|
75
|
-
* is what gives the shared stylesheet a frequency below the common-href
|
|
76
|
-
* cutoff.
|
|
77
|
-
* @param pages
|
|
78
|
-
* @param options
|
|
79
|
-
* @example
|
|
80
|
-
* ```ts
|
|
81
|
-
* resolveBlockingGroupKeys([
|
|
82
|
-
* { paths: ['dept-a', 'news', '1'], stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/common.css'] },
|
|
83
|
-
* { paths: ['dept-a', 'news', '2'], stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/common.css'] },
|
|
84
|
-
* { paths: ['dept-b', 'about'], stylesheetHrefs: ['https://example.com/common.css'] },
|
|
85
|
-
* ]);
|
|
86
|
-
* // ['css:<hash of a.css>', 'css:<hash of a.css>', 'path:dept-b']
|
|
87
|
-
* // common.css is loaded by all 3 pages and is filtered out as non-discriminative chrome.
|
|
88
|
-
* ```
|
|
89
|
-
*/
|
|
90
7
|
export function resolveBlockingGroupKeys(pages, options) {
|
|
91
8
|
const pathDepthOption = options?.pathDepth;
|
|
92
9
|
const minCssGroupSize = options?.minCssGroupSize ?? DEFAULT_MIN_CSS_GROUP_SIZE;
|
|
@@ -121,14 +38,27 @@ export function resolveBlockingGroupKeys(pages, options) {
|
|
|
121
38
|
cssKeyCounts.set(cssKey, (cssKeyCounts.get(cssKey) ?? 0) + 1);
|
|
122
39
|
}
|
|
123
40
|
}
|
|
124
|
-
|
|
41
|
+
const reasonsByKey = new Map();
|
|
42
|
+
const keys = pages.map((page, index) => {
|
|
125
43
|
const cssKey = cssKeys[index];
|
|
126
44
|
if (cssKey !== undefined && (cssKeyCounts.get(cssKey) ?? 0) >= minCssGroupSize) {
|
|
127
|
-
|
|
45
|
+
const key = `css:${cssKey}`;
|
|
46
|
+
if (!reasonsByKey.has(key)) {
|
|
47
|
+
reasonsByKey.set(key, {
|
|
48
|
+
kind: 'css',
|
|
49
|
+
distinctiveStylesheetHrefs: [...(distinctiveHrefs[index] ?? [])].toSorted(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return key;
|
|
128
53
|
}
|
|
129
54
|
const pathKey = perPagePathKeys === null
|
|
130
55
|
? derivePathGroupKey(page.paths, pathDepthOption)
|
|
131
56
|
: (perPagePathKeys[index] ?? '');
|
|
132
|
-
|
|
57
|
+
const key = `path:${pathKey}`;
|
|
58
|
+
if (!reasonsByKey.has(key)) {
|
|
59
|
+
reasonsByKey.set(key, { kind: 'path', pathKey });
|
|
60
|
+
}
|
|
61
|
+
return key;
|
|
133
62
|
});
|
|
63
|
+
return options?.includeReasons ? { keys, reasonsByKey } : keys;
|
|
134
64
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { ClusterReason } from './build-cluster-reason.js';
|
|
1
2
|
import type { ExtractLandmarksResult } from './extract-landmarks.js';
|
|
3
|
+
import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js';
|
|
2
4
|
import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
|
|
3
5
|
import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
|
|
4
6
|
import type { TokenizeOptions } from './types.js';
|
|
@@ -65,6 +67,7 @@ import type { TokenizeOptions } from './types.js';
|
|
|
65
67
|
export declare function computeLocalChromeArtifacts(landmarks: readonly ExtractLandmarksResult[], tokenizeOptions: TokenizeOptions | undefined): {
|
|
66
68
|
readonly localSignatures: ReadonlySet<string>;
|
|
67
69
|
readonly localTokensByPage: readonly ReadonlySet<string>[];
|
|
70
|
+
readonly perPageInstances: readonly (readonly PerPageLandmarkInstance[])[];
|
|
68
71
|
};
|
|
69
72
|
/**
|
|
70
73
|
* Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
|
|
@@ -162,9 +165,42 @@ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGro
|
|
|
162
165
|
* sync helper to running a per-block async loop that emits
|
|
163
166
|
* `pass1-block-complete` and `stage-b-start`. Omitting `onProgress`
|
|
164
167
|
* keeps the small-corpus branch on the pre-refactor sync path with
|
|
165
|
-
* zero yield overhead.
|
|
168
|
+
* zero yield overhead. Ignored when `onClusterReason` is set — that
|
|
169
|
+
* option always routes the small-corpus branch through the sync
|
|
170
|
+
* helper (see `onClusterReason`'s own JSDoc), so no progress events
|
|
171
|
+
* fire in that combination.
|
|
166
172
|
*/
|
|
167
173
|
onProgress?: (event: ProgressEvent) => void;
|
|
174
|
+
/**
|
|
175
|
+
* Optional observability hook invoked once per **final cluster** (not
|
|
176
|
+
* per page) with that cluster's {@link ClusterReason} — the blocking
|
|
177
|
+
* signal that grouped it, its DOM-structural token core, its
|
|
178
|
+
* per-landmark-type commonality, and the sibling cluster keys it was
|
|
179
|
+
* split from within the same Pass-0 block. Unlike `onProgress`, this
|
|
180
|
+
* fires on every path — small-corpus and streaming alike — because a
|
|
181
|
+
* `ClusterReason` is sized by cluster count, not page count, so it
|
|
182
|
+
* carries no streaming-path memory risk the way a per-page report
|
|
183
|
+
* would.
|
|
184
|
+
*
|
|
185
|
+
* Building the reasons re-uses Stage A/B's own intermediate state (the
|
|
186
|
+
* quorum core, the per-unit landmark instances, the blocking
|
|
187
|
+
* evidence) — it does not re-tokenize pages or re-run corpus-wide
|
|
188
|
+
* discovery. Omitting `onClusterReason` skips that bookkeeping
|
|
189
|
+
* entirely, so existing callers pay nothing for this option.
|
|
190
|
+
*
|
|
191
|
+
* On the async factory-based `resolvePageClusterKeys`, setting this
|
|
192
|
+
* forces the small-corpus branch through the sync
|
|
193
|
+
* `resolvePageClusterKeysInMemory` helper regardless of `onProgress`
|
|
194
|
+
* — see `onProgress`'s own JSDoc.
|
|
195
|
+
* @example
|
|
196
|
+
* ```ts
|
|
197
|
+
* const reasons = new Map<string, ClusterReason>();
|
|
198
|
+
* const keys = await resolvePageClusterKeys(pages, {
|
|
199
|
+
* onClusterReason: (key, reason) => reasons.set(key, reason),
|
|
200
|
+
* });
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
onClusterReason?: (clusterKey: string, reason: ClusterReason) => void;
|
|
168
204
|
};
|
|
169
205
|
/**
|
|
170
206
|
* Corpus size at or below which the async factory-based
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { autoCutThreshold } from './auto-cut-threshold.js';
|
|
2
|
+
import { buildClusterReason } from './build-cluster-reason.js';
|
|
2
3
|
import { capContentDepth } from './cap-content-depth.js';
|
|
3
4
|
import { detectContentDepthCap, validateDetectContentDepthCapOptions, } from './detect-content-depth-cap.js';
|
|
4
5
|
import { extractLandmarks } from './extract-landmarks.js';
|
|
@@ -162,8 +163,9 @@ function assignPageToNearestCluster(html, assignment, excludeLandmarks, contentB
|
|
|
162
163
|
*/
|
|
163
164
|
export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
164
165
|
const pageCount = landmarks.length;
|
|
165
|
-
if (pageCount === 0)
|
|
166
|
-
return { localSignatures: new Set(), localTokensByPage: [] };
|
|
166
|
+
if (pageCount === 0) {
|
|
167
|
+
return { localSignatures: new Set(), localTokensByPage: [], perPageInstances: [] };
|
|
168
|
+
}
|
|
167
169
|
const perPageInstances = computePerPageLandmarkInstances(landmarks, tokenizeOptions);
|
|
168
170
|
// Corpus-wide histogram: signature → { count, tokens }. tokens is the
|
|
169
171
|
// token set of any one occurrence of the signature (all occurrences are
|
|
@@ -184,6 +186,7 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
|
184
186
|
return {
|
|
185
187
|
localSignatures: new Set(),
|
|
186
188
|
localTokensByPage: landmarks.map(() => new Set()),
|
|
189
|
+
perPageInstances,
|
|
187
190
|
};
|
|
188
191
|
}
|
|
189
192
|
const frequencies = [];
|
|
@@ -202,6 +205,7 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
|
202
205
|
return {
|
|
203
206
|
localSignatures: new Set(),
|
|
204
207
|
localTokensByPage: landmarks.map(() => new Set()),
|
|
208
|
+
perPageInstances,
|
|
205
209
|
};
|
|
206
210
|
}
|
|
207
211
|
const localTokensByPage = perPageInstances.map((instances) => {
|
|
@@ -214,7 +218,7 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
|
214
218
|
}
|
|
215
219
|
return out;
|
|
216
220
|
});
|
|
217
|
-
return { localSignatures, localTokensByPage };
|
|
221
|
+
return { localSignatures, localTokensByPage, perPageInstances };
|
|
218
222
|
}
|
|
219
223
|
/**
|
|
220
224
|
* Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
|
|
@@ -232,6 +236,63 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
|
232
236
|
export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
|
|
233
237
|
return [...computeLocalChromeArtifacts(landmarks, tokenizeOptions).localTokensByPage];
|
|
234
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Builds and emits one {@link ClusterReason} per final cluster via
|
|
241
|
+
* `onClusterReason`, from data Stage A/B already computed for clustering
|
|
242
|
+
* itself: `crossBlockUnits` (Stage A's pre-merge units, each carrying its
|
|
243
|
+
* originating block key inside `JSON.parse(unit.key)[0]`), Stage B's
|
|
244
|
+
* `rootByKey`/`finalGroupsByRoot`, the per-block-key `BlockingReason`s Pass 0
|
|
245
|
+
* derived, and the per-block sibling-unit-key lists the driver accumulated
|
|
246
|
+
* alongside its Stage A loop. No re-tokenization and no extra corpus pass —
|
|
247
|
+
* this only re-groups references the driver already held.
|
|
248
|
+
* @param crossBlockUnits
|
|
249
|
+
* @param rootByKey
|
|
250
|
+
* @param finalGroupsByRoot
|
|
251
|
+
* @param reasonsByBlockKey
|
|
252
|
+
* @param siblingUnitKeysByBlock
|
|
253
|
+
* @param onClusterReason
|
|
254
|
+
*/
|
|
255
|
+
function emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason) {
|
|
256
|
+
const unitKeysByRoot = new Map();
|
|
257
|
+
for (const unit of crossBlockUnits) {
|
|
258
|
+
const root = rootByKey.get(unit.key) ?? unit.key;
|
|
259
|
+
const list = unitKeysByRoot.get(root);
|
|
260
|
+
if (list) {
|
|
261
|
+
list.push(unit.key);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
unitKeysByRoot.set(root, [unit.key]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const [rootKey, unitKeys] of unitKeysByRoot) {
|
|
268
|
+
const finalGroup = finalGroupsByRoot.get(rootKey);
|
|
269
|
+
if (!finalGroup)
|
|
270
|
+
continue;
|
|
271
|
+
const seenBlockKeys = new Set();
|
|
272
|
+
const blocking = [];
|
|
273
|
+
const siblingRoots = new Set();
|
|
274
|
+
for (const unitKey of unitKeys) {
|
|
275
|
+
const blockKey = JSON.parse(unitKey)[0];
|
|
276
|
+
if (!seenBlockKeys.has(blockKey)) {
|
|
277
|
+
seenBlockKeys.add(blockKey);
|
|
278
|
+
const reason = reasonsByBlockKey.get(blockKey);
|
|
279
|
+
if (reason)
|
|
280
|
+
blocking.push({ blockKey, reason });
|
|
281
|
+
}
|
|
282
|
+
for (const siblingUnitKey of siblingUnitKeysByBlock.get(blockKey) ?? []) {
|
|
283
|
+
const siblingRoot = rootByKey.get(siblingUnitKey) ?? siblingUnitKey;
|
|
284
|
+
if (siblingRoot !== rootKey)
|
|
285
|
+
siblingRoots.add(siblingRoot);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
onClusterReason(rootKey, buildClusterReason({
|
|
289
|
+
tokenSets: finalGroup.tokenSets,
|
|
290
|
+
landmarkInstances: finalGroup.landmarkInstances,
|
|
291
|
+
blocking,
|
|
292
|
+
siblingClusterKeys: [...siblingRoots].toSorted(),
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
235
296
|
/**
|
|
236
297
|
* Corpus size at or below which the async factory-based
|
|
237
298
|
* `resolvePageClusterKeys` reads the entire input into an array and delegates
|
|
@@ -313,11 +374,9 @@ export function resolvePageClusterKeysInMemory(pages, options) {
|
|
|
313
374
|
throw new RangeError(`resolvePageClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
|
|
314
375
|
}
|
|
315
376
|
// Always computed: landmark fields are needed by Stage B's shell
|
|
316
|
-
// corroboration regardless of `excludeLandmarks
|
|
317
|
-
// needed whenever `excludeLandmarks` is true.
|
|
377
|
+
// corroboration regardless of `excludeLandmarks`.
|
|
318
378
|
const landmarks = pages.map((page) => extractLandmarks(page.html));
|
|
319
|
-
|
|
320
|
-
const localLandmarkTokensByPage = computeLocalLandmarkTokens(landmarks, options);
|
|
379
|
+
const { localTokensByPage: localLandmarkTokensByPage } = computeLocalChromeArtifacts(landmarks, options);
|
|
321
380
|
const contentBlockAttribute = options?.contentBlockAttribute;
|
|
322
381
|
const preparedHtml = pages.map((page, index) => {
|
|
323
382
|
const landmarksExcised = excludeLandmarks
|
|
@@ -332,7 +391,20 @@ export function resolvePageClusterKeysInMemory(pages, options) {
|
|
|
332
391
|
const blockingPages = restrictStylesheetsToFirstParty
|
|
333
392
|
? filterFirstPartyStylesheetHrefs(pages)
|
|
334
393
|
: pages;
|
|
335
|
-
|
|
394
|
+
// Reasons (blocking evidence) are only worth deriving when a caller
|
|
395
|
+
// actually asked for `onClusterReason` — see that option's own JSDoc for
|
|
396
|
+
// why this is the only place ClusterReason bookkeeping is opt-in.
|
|
397
|
+
const onClusterReason = options?.onClusterReason;
|
|
398
|
+
let blockKeys;
|
|
399
|
+
let reasonsByBlockKey;
|
|
400
|
+
if (onClusterReason) {
|
|
401
|
+
const result = resolveBlockKeys(blockingPages, { ...options, includeReasons: true });
|
|
402
|
+
blockKeys = result.blockKeys;
|
|
403
|
+
reasonsByBlockKey = result.reasonsByBlockKey;
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
blockKeys = resolveBlockKeys(blockingPages, options);
|
|
407
|
+
}
|
|
336
408
|
const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
|
|
337
409
|
// Validated here, eagerly, because it's otherwise only reached from
|
|
338
410
|
// inside the per-block loop below — which never runs at all for an empty
|
|
@@ -341,6 +413,9 @@ export function resolvePageClusterKeysInMemory(pages, options) {
|
|
|
341
413
|
validateDetectContentDepthCapOptions(options);
|
|
342
414
|
const finalKeys = Array.from({ length: pages.length });
|
|
343
415
|
const crossBlockUnits = [];
|
|
416
|
+
const siblingUnitKeysByBlock = onClusterReason
|
|
417
|
+
? new Map()
|
|
418
|
+
: undefined;
|
|
344
419
|
for (const [blockKey, indices] of indicesByBlockKey) {
|
|
345
420
|
const result = stageAPerBlock({
|
|
346
421
|
blockKey,
|
|
@@ -353,16 +428,20 @@ export function resolvePageClusterKeysInMemory(pages, options) {
|
|
|
353
428
|
finalKeys[pageIndex] = key;
|
|
354
429
|
}
|
|
355
430
|
crossBlockUnits.push(...result.crossBlockUnits);
|
|
431
|
+
siblingUnitKeysByBlock?.set(blockKey, result.crossBlockUnits.map((u) => u.key));
|
|
356
432
|
}
|
|
357
433
|
// Stage B: cross-block merge — always runs regardless of options
|
|
358
|
-
const
|
|
434
|
+
const { rootByKey, finalGroupsByRoot } = mergeCrossBlockClusters(crossBlockUnits, options);
|
|
359
435
|
for (let i = 0; i < finalKeys.length; i++) {
|
|
360
436
|
const currentKey = finalKeys[i];
|
|
361
|
-
const rootKey =
|
|
437
|
+
const rootKey = rootByKey.get(currentKey);
|
|
362
438
|
if (rootKey !== undefined && rootKey !== currentKey) {
|
|
363
439
|
finalKeys[i] = rootKey;
|
|
364
440
|
}
|
|
365
441
|
}
|
|
442
|
+
if (onClusterReason && reasonsByBlockKey && siblingUnitKeysByBlock) {
|
|
443
|
+
emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
|
|
444
|
+
}
|
|
366
445
|
return finalKeys;
|
|
367
446
|
}
|
|
368
447
|
/**
|
|
@@ -443,10 +522,10 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
|
|
|
443
522
|
await new Promise((resolve) => setImmediate(resolve));
|
|
444
523
|
}
|
|
445
524
|
onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
|
|
446
|
-
const
|
|
525
|
+
const { rootByKey } = mergeCrossBlockClusters(crossBlockUnits, options);
|
|
447
526
|
for (let i = 0; i < finalKeys.length; i++) {
|
|
448
527
|
const currentKey = finalKeys[i];
|
|
449
|
-
const rootKey =
|
|
528
|
+
const rootKey = rootByKey.get(currentKey);
|
|
450
529
|
if (rootKey !== undefined && rootKey !== currentKey) {
|
|
451
530
|
finalKeys[i] = rootKey;
|
|
452
531
|
}
|
|
@@ -530,8 +609,12 @@ export async function resolvePageClusterKeys(pages, options) {
|
|
|
530
609
|
// into per-block progress, so delegate to the untouched sync path —
|
|
531
610
|
// keeping behavior byte-for-byte identical (and yield-overhead-free)
|
|
532
611
|
// to how library-only consumers experienced this before the CLI
|
|
533
|
-
// progress work landed.
|
|
534
|
-
|
|
612
|
+
// progress work landed. `onClusterReason` always routes here too (see
|
|
613
|
+
// its own JSDoc): `resolveSmallCorpusWithProgress` has no
|
|
614
|
+
// cluster-reason support, and duplicating that logic into the
|
|
615
|
+
// progress-emitting path for a reporting feature that has nothing to
|
|
616
|
+
// do with progress observability isn't worth the added surface.
|
|
617
|
+
if (onProgress === undefined || options?.onClusterReason) {
|
|
535
618
|
return resolvePageClusterKeysInMemory(fullPages, options);
|
|
536
619
|
}
|
|
537
620
|
return resolveSmallCorpusWithProgress(fullPages, onProgress, options);
|
|
@@ -547,11 +630,30 @@ export async function resolvePageClusterKeys(pages, options) {
|
|
|
547
630
|
const blockingPagesForKeys = restrictStylesheetsToFirstParty
|
|
548
631
|
? filterFirstPartyStylesheetHrefs(blockingSignals)
|
|
549
632
|
: blockingSignals;
|
|
550
|
-
|
|
633
|
+
// Reasons (blocking evidence) cost nothing beyond bookkeeping — a Map
|
|
634
|
+
// keyed by distinct block key, not by page — but are only derived when a
|
|
635
|
+
// caller actually asked for `onClusterReason`.
|
|
636
|
+
const onClusterReason = options?.onClusterReason;
|
|
637
|
+
let blockKeys;
|
|
638
|
+
let reasonsByBlockKey;
|
|
639
|
+
if (onClusterReason) {
|
|
640
|
+
const result = resolveBlockKeys(blockingPagesForKeys, {
|
|
641
|
+
...options,
|
|
642
|
+
includeReasons: true,
|
|
643
|
+
});
|
|
644
|
+
blockKeys = result.blockKeys;
|
|
645
|
+
reasonsByBlockKey = result.reasonsByBlockKey;
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
blockKeys = resolveBlockKeys(blockingPagesForKeys, options);
|
|
649
|
+
}
|
|
551
650
|
const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
|
|
552
651
|
const finalKeys = Array.from({ length: blockingSignals.length });
|
|
553
652
|
const crossBlockUnits = [];
|
|
554
653
|
const contentBlockAttribute = options?.contentBlockAttribute;
|
|
654
|
+
const siblingUnitKeysByBlock = onClusterReason
|
|
655
|
+
? new Map()
|
|
656
|
+
: undefined;
|
|
555
657
|
/** Non-sample page indices that need Pass 1b Jaccard-based assignment. */
|
|
556
658
|
const pendingAssignmentBlockKeyByIndex = new Map();
|
|
557
659
|
/** Block-level artifacts saved after Stage A runs on the sample. */
|
|
@@ -590,6 +692,7 @@ export async function resolvePageClusterKeys(pages, options) {
|
|
|
590
692
|
finalKeys[idx] = key;
|
|
591
693
|
}
|
|
592
694
|
crossBlockUnits.push(...result.crossBlockUnits);
|
|
695
|
+
siblingUnitKeysByBlock?.set(bucket.blockKey, result.crossBlockUnits.map((u) => u.key));
|
|
593
696
|
if (bucket.seenCount > bucket.reservoirIndices.length) {
|
|
594
697
|
// Save assignment artifacts for Pass 1b.
|
|
595
698
|
const maxMainDepth = bucket.reservoirPreparedHtml.length > 1
|
|
@@ -715,17 +818,20 @@ export async function resolvePageClusterKeys(pages, options) {
|
|
|
715
818
|
if (onProgress) {
|
|
716
819
|
onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
|
|
717
820
|
}
|
|
718
|
-
const
|
|
821
|
+
const { rootByKey, finalGroupsByRoot } = mergeCrossBlockClusters(crossBlockUnits, {
|
|
719
822
|
...options,
|
|
720
823
|
capMembers: BLOCK_SAMPLE_SIZE,
|
|
721
824
|
});
|
|
722
825
|
for (let i = 0; i < finalKeys.length; i++) {
|
|
723
826
|
const currentKey = finalKeys[i];
|
|
724
|
-
const rootKey =
|
|
827
|
+
const rootKey = rootByKey.get(currentKey);
|
|
725
828
|
if (rootKey !== undefined && rootKey !== currentKey) {
|
|
726
829
|
finalKeys[i] = rootKey;
|
|
727
830
|
}
|
|
728
831
|
}
|
|
832
|
+
if (onClusterReason && reasonsByBlockKey && siblingUnitKeysByBlock) {
|
|
833
|
+
emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
|
|
834
|
+
}
|
|
729
835
|
return finalKeys;
|
|
730
836
|
}
|
|
731
837
|
/**
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js';
|
|
2
|
+
/**
|
|
3
|
+
* Discovers a unit's shell tokens by auto-cutting the per-*token* page-
|
|
4
|
+
* frequency histogram of every landmark instance's tokens. This is the same
|
|
5
|
+
* max-gap primitive used for Stage A merge-height cutoffs, applied
|
|
6
|
+
* recursively at the landmark-token layer.
|
|
7
|
+
*
|
|
8
|
+
* ## Why per-token and not per-signature
|
|
9
|
+
*
|
|
10
|
+
* An earlier iteration ran the histogram at the level of full landmark-
|
|
11
|
+
* instance signatures (canonicalized token sets). That failed on a real,
|
|
12
|
+
* common pattern: a shared site chrome whose markup carries a per-page
|
|
13
|
+
* distinguishing element (a breadcrumb, a page-title element with a page-
|
|
14
|
+
* specific class, a "current" state). All pages have most of the same
|
|
15
|
+
* tokens, but every page's full signature is distinct because tokens embed
|
|
16
|
+
* class names. Per-signature counting saw 5 signatures at freq 0.2 each,
|
|
17
|
+
* autoCutThreshold on the flat distribution returned the clamp, and the
|
|
18
|
+
* shell collapsed to empty even though every page shared the core header
|
|
19
|
+
* skeleton. Per-token counting handles the same case correctly — the shared
|
|
20
|
+
* skeleton tokens each hit freq 1.0.
|
|
21
|
+
*
|
|
22
|
+
* ## The histogram
|
|
23
|
+
*
|
|
24
|
+
* For every member page, all its landmark instances are tokenized and
|
|
25
|
+
* unioned into a single per-page token set (order-agnostic, deduped: a
|
|
26
|
+
* token appearing in two of the page's landmarks still counts once for
|
|
27
|
+
* that page). The corpus histogram is then "how many pages contain each
|
|
28
|
+
* token". Tokens that appear on nearly every page are the unit's chrome;
|
|
29
|
+
* tokens that appear on only a handful are page-specific content that
|
|
30
|
+
* happens to be tagged as a landmark.
|
|
31
|
+
*
|
|
32
|
+
* ## Why auto-cut instead of a hard-coded quorum
|
|
33
|
+
*
|
|
34
|
+
* A fixed 80% quorum (this file's earlier implementation) baked one
|
|
35
|
+
* threshold in for every unit. Real corpora don't obey a universal cutoff:
|
|
36
|
+
* a section-local landmark token that appears on 60% of a unit's pages is
|
|
37
|
+
* the section's chrome under any reasonable reading, but 80% quorum
|
|
38
|
+
* discards it. Auto-cut looks at the *shape* of the frequency distribution
|
|
39
|
+
* and picks the widest gap between adjacent frequencies — if the
|
|
40
|
+
* distribution is `{1.00, 1.00, 0.65, 0.03, 0.02}`, the gap between 0.65
|
|
41
|
+
* and 0.03 (0.62) dwarfs everything else and the cut lands mid-gap around
|
|
42
|
+
* 0.34, correctly grouping the 0.65 tokens with the site-wide 1.00 ones as
|
|
43
|
+
* "chrome for this unit". If instead the distribution is flat, the clamp
|
|
44
|
+
* to {@link SHELL_QUORUM_FALLBACK_FRACTION} keeps the threshold from being
|
|
45
|
+
* tighter than the fallback default.
|
|
46
|
+
*
|
|
47
|
+
* ## Fallbacks
|
|
48
|
+
*
|
|
49
|
+
* A single distinct token (`heights.length < 2`) or a perfectly flat
|
|
50
|
+
* distribution (`maxGap === 0`) returns the
|
|
51
|
+
* {@link SHELL_QUORUM_FALLBACK_FRACTION} clamp verbatim — exactly the same
|
|
52
|
+
* 80%-quorum behavior as before. So degenerate cases degrade to the old
|
|
53
|
+
* contract; only richer distributions get the auto-cut benefit.
|
|
54
|
+
*
|
|
55
|
+
* A page with no landmarks contributes an empty set, deliberately, so the
|
|
56
|
+
* shell-corroboration jaccard between two landmark-less pages is 0 rather
|
|
57
|
+
* than 1 (which it would be if we handed back a `<body></body>`-derived
|
|
58
|
+
* `{body}` fallback set to both sides).
|
|
59
|
+
*
|
|
60
|
+
* ## Reuse
|
|
61
|
+
*
|
|
62
|
+
* Originally private to {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
|
|
63
|
+
* Stage B L2 corroboration; exported from its own module so
|
|
64
|
+
* {@link ./build-cluster-reason.js | buildClusterReason} can run it once per
|
|
65
|
+
* final cluster, per landmark type, to classify individual landmark
|
|
66
|
+
* instances as chrome (see {@link ./is-chrome-landmark-instance.js |
|
|
67
|
+
* isChromeLandmarkInstance}).
|
|
68
|
+
* @param perPageInstances
|
|
69
|
+
*/
|
|
70
|
+
export declare function shellQuorum(perPageInstances: readonly (readonly PerPageLandmarkInstance[])[]): ReadonlySet<string>;
|