@d-zero/page-cluster 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -41
- package/dist/assign-contained-clusters.d.ts +42 -0
- package/dist/assign-contained-clusters.js +156 -0
- package/dist/auto-cut-threshold.d.ts +17 -0
- package/dist/auto-cut-threshold.js +36 -0
- package/dist/canonicalize-token-set.d.ts +17 -0
- package/dist/canonicalize-token-set.js +19 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.js +381 -0
- package/dist/collapse-anonymous-divs.d.ts +21 -0
- package/dist/collapse-anonymous-divs.js +42 -0
- package/dist/complete-linkage-dendrogram.d.ts +41 -0
- package/dist/complete-linkage-dendrogram.js +140 -0
- package/dist/derive-comparison-sets.d.ts +22 -0
- package/dist/derive-comparison-sets.js +33 -0
- package/dist/derive-path-cluster-keys.d.ts +53 -0
- package/dist/derive-path-cluster-keys.js +109 -0
- package/dist/extract-landmarks.d.ts +91 -45
- package/dist/extract-landmarks.js +122 -41
- package/dist/filter-first-party-stylesheet-hrefs.d.ts +58 -24
- package/dist/filter-first-party-stylesheet-hrefs.js +72 -33
- package/dist/find-shallowest-elements.d.ts +48 -11
- package/dist/find-shallowest-elements.js +41 -21
- package/dist/merge-cross-block-clusters.d.ts +61 -0
- package/dist/merge-cross-block-clusters.js +546 -0
- package/dist/pass0-blocking.d.ts +89 -0
- package/dist/pass0-blocking.js +87 -0
- package/dist/per-page-landmark-signatures.d.ts +48 -0
- package/dist/per-page-landmark-signatures.js +62 -0
- package/dist/reservoir-sample.d.ts +43 -0
- package/dist/reservoir-sample.js +98 -0
- package/dist/resolve-blocking-group-keys.d.ts +8 -2
- package/dist/resolve-blocking-group-keys.js +18 -4
- package/dist/resolve-landmark-variant-keys.d.ts +41 -20
- package/dist/resolve-landmark-variant-keys.js +69 -26
- package/dist/resolve-page-cluster-keys.d.ts +292 -191
- package/dist/resolve-page-cluster-keys.js +708 -157
- package/dist/resolve-structural-cluster-keys.d.ts +9 -0
- package/dist/resolve-structural-cluster-keys.js +14 -232
- package/dist/shape-token.d.ts +11 -0
- package/dist/shape-token.js +38 -0
- package/dist/stage-a-per-block.d.ts +133 -0
- package/dist/stage-a-per-block.js +178 -0
- package/dist/tokenize.d.ts +6 -0
- package/dist/tokenize.js +6 -0
- package/package.json +5 -58
- package/dist/html-region-utils.d.ts +0 -74
- package/dist/html-region-utils.js +0 -96
- package/dist/merge-landmark-affined-clusters.d.ts +0 -179
- package/dist/merge-landmark-affined-clusters.js +0 -544
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { filterFirstPartyStylesheetHrefs } from './filter-first-party-stylesheet-hrefs.js';
|
|
2
|
+
import { reassignOrphanBlockKeys } from './reassign-orphan-block-keys.js';
|
|
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
|
+
export function resolveBlockKeys(pages, options) {
|
|
45
|
+
const restrictStylesheetsToFirstParty = options?.restrictStylesheetsToFirstParty ?? true;
|
|
46
|
+
const blockingPages = restrictStylesheetsToFirstParty
|
|
47
|
+
? filterFirstPartyStylesheetHrefs(pages)
|
|
48
|
+
: pages;
|
|
49
|
+
const rawBlockKeys = resolveBlockingGroupKeys(blockingPages, options);
|
|
50
|
+
const reassignOrphans = options?.reassignOrphans ?? true;
|
|
51
|
+
if (!reassignOrphans)
|
|
52
|
+
return rawBlockKeys;
|
|
53
|
+
// Orphan reassignment always uses a numeric `pathDepth`. When the caller
|
|
54
|
+
// asked for `'auto'`, fall back to the historical default 1 here — a
|
|
55
|
+
// future PR that wires the auto-cut depth through can compute it once
|
|
56
|
+
// and pass it as a number to both `resolveBlockingGroupKeys` and this
|
|
57
|
+
// call to keep them consistent.
|
|
58
|
+
const numericPathDepth = typeof options?.pathDepth === 'number' ? options.pathDepth : undefined;
|
|
59
|
+
return reassignOrphanBlockKeys(blockingPages, rawBlockKeys, numericPathDepth);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Groups pages by their block key while preserving each block's members in
|
|
63
|
+
* input order. Returned as a `Map` so the caller can iterate blocks in
|
|
64
|
+
* insertion order (first-seen block first) — matching the order
|
|
65
|
+
* `resolvePageClusterKeys`'s own per-block loop already uses so cluster IDs
|
|
66
|
+
* assigned per block stay deterministic across in-memory and streaming
|
|
67
|
+
* paths.
|
|
68
|
+
* @param blockKeys
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* const indices = groupIndicesByBlockKey(['a', 'b', 'a', 'c', 'a']);
|
|
72
|
+
* // Map { 'a' => [0, 2, 4], 'b' => [1], 'c' => [3] }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function groupIndicesByBlockKey(blockKeys) {
|
|
76
|
+
const groups = new Map();
|
|
77
|
+
for (const [index, blockKey] of blockKeys.entries()) {
|
|
78
|
+
const list = groups.get(blockKey);
|
|
79
|
+
if (list) {
|
|
80
|
+
list.push(index);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
groups.set(blockKey, [index]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return groups;
|
|
87
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtractLandmarksResult, LandmarkType } from './extract-landmarks.js';
|
|
2
|
+
import type { TokenizeOptions } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every landmark type extractLandmarks may populate, iterated in a fixed
|
|
5
|
+
* order so downstream signature vectors are byte-stable and every consumer
|
|
6
|
+
* agrees on the same enumeration.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ALL_LANDMARK_TYPES: readonly LandmarkType[];
|
|
9
|
+
/**
|
|
10
|
+
* One landmark instance's tokenized identity: the token set produced by
|
|
11
|
+
* tokenizing the instance's raw HTML wrapped in a `<body>` shell, plus that
|
|
12
|
+
* token set's canonical signature string (via
|
|
13
|
+
* {@link ./canonicalize-token-set.js | canonicalizeTokenSet}). Signatures
|
|
14
|
+
* are reused across consumers so two callers see the same "same instance"
|
|
15
|
+
* verdict without independently re-canonicalizing.
|
|
16
|
+
*/
|
|
17
|
+
export type PerPageLandmarkInstance = {
|
|
18
|
+
readonly type: LandmarkType;
|
|
19
|
+
readonly tokens: ReadonlySet<string>;
|
|
20
|
+
readonly signature: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Tokenizes every landmark instance across every page once, keyed by page
|
|
24
|
+
* index. Shared by:
|
|
25
|
+
* - {@link ./merge-cross-block-clusters.js | shellQuorum} — for cross-block
|
|
26
|
+
* shell corroboration
|
|
27
|
+
* - {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkPseudoTokens}
|
|
28
|
+
* — for injecting local-chrome pseudo-tokens into Stage A block token
|
|
29
|
+
* sets
|
|
30
|
+
* - {@link ./resolve-landmark-variant-keys.js | resolveLandmarkVariantKeys}
|
|
31
|
+
* — for picking each page's canonical instance for variant clustering
|
|
32
|
+
*
|
|
33
|
+
* Every consumer previously reimplemented this loop, which was both a
|
|
34
|
+
* duplication risk (a tokenization fix landing in one but not the others)
|
|
35
|
+
* and a real performance cost — every landmark region on the page was
|
|
36
|
+
* tokenized 2 or 3 times per pipeline invocation. This single pass replaces
|
|
37
|
+
* all of that.
|
|
38
|
+
*
|
|
39
|
+
* Within each page, instances that tokenize to the same signature are
|
|
40
|
+
* deduped (kept once): a CMS glitch that duplicates the site footer, or a
|
|
41
|
+
* `<header role="navigation">` matching both `header` and `nav` at
|
|
42
|
+
* identical spans, must not count twice against the corpus histogram.
|
|
43
|
+
* Across pages there is no dedupe — each page contributes its own instance
|
|
44
|
+
* list.
|
|
45
|
+
* @param landmarks
|
|
46
|
+
* @param tokenizeOptions
|
|
47
|
+
*/
|
|
48
|
+
export declare function computePerPageLandmarkInstances(landmarks: readonly ExtractLandmarksResult[], tokenizeOptions?: TokenizeOptions): readonly (readonly PerPageLandmarkInstance[])[];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { canonicalizeTokenSet } from './canonicalize-token-set.js';
|
|
2
|
+
import { tokenize } from './tokenize.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every landmark type extractLandmarks may populate, iterated in a fixed
|
|
5
|
+
* order so downstream signature vectors are byte-stable and every consumer
|
|
6
|
+
* agrees on the same enumeration.
|
|
7
|
+
*/
|
|
8
|
+
export const ALL_LANDMARK_TYPES = [
|
|
9
|
+
'header',
|
|
10
|
+
'footer',
|
|
11
|
+
'nav',
|
|
12
|
+
'aside',
|
|
13
|
+
'form',
|
|
14
|
+
'search',
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Tokenizes every landmark instance across every page once, keyed by page
|
|
18
|
+
* index. Shared by:
|
|
19
|
+
* - {@link ./merge-cross-block-clusters.js | shellQuorum} — for cross-block
|
|
20
|
+
* shell corroboration
|
|
21
|
+
* - {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkPseudoTokens}
|
|
22
|
+
* — for injecting local-chrome pseudo-tokens into Stage A block token
|
|
23
|
+
* sets
|
|
24
|
+
* - {@link ./resolve-landmark-variant-keys.js | resolveLandmarkVariantKeys}
|
|
25
|
+
* — for picking each page's canonical instance for variant clustering
|
|
26
|
+
*
|
|
27
|
+
* Every consumer previously reimplemented this loop, which was both a
|
|
28
|
+
* duplication risk (a tokenization fix landing in one but not the others)
|
|
29
|
+
* and a real performance cost — every landmark region on the page was
|
|
30
|
+
* tokenized 2 or 3 times per pipeline invocation. This single pass replaces
|
|
31
|
+
* all of that.
|
|
32
|
+
*
|
|
33
|
+
* Within each page, instances that tokenize to the same signature are
|
|
34
|
+
* deduped (kept once): a CMS glitch that duplicates the site footer, or a
|
|
35
|
+
* `<header role="navigation">` matching both `header` and `nav` at
|
|
36
|
+
* identical spans, must not count twice against the corpus histogram.
|
|
37
|
+
* Across pages there is no dedupe — each page contributes its own instance
|
|
38
|
+
* list.
|
|
39
|
+
* @param landmarks
|
|
40
|
+
* @param tokenizeOptions
|
|
41
|
+
*/
|
|
42
|
+
export function computePerPageLandmarkInstances(landmarks, tokenizeOptions) {
|
|
43
|
+
return landmarks.map((entry) => {
|
|
44
|
+
const seenSignatures = new Set();
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const type of ALL_LANDMARK_TYPES) {
|
|
47
|
+
for (const instanceHtml of entry[type]) {
|
|
48
|
+
if (!instanceHtml)
|
|
49
|
+
continue;
|
|
50
|
+
const tokens = new Set(tokenize(`<body>${instanceHtml}</body>`, tokenizeOptions).tokens);
|
|
51
|
+
if (tokens.size === 0)
|
|
52
|
+
continue;
|
|
53
|
+
const signature = canonicalizeTokenSet(tokens);
|
|
54
|
+
if (seenSignatures.has(signature))
|
|
55
|
+
continue;
|
|
56
|
+
seenSignatures.add(signature);
|
|
57
|
+
out.push({ type, tokens, signature });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs Algorithm R (Vitter, 1985) — the standard one-pass reservoir sampling
|
|
3
|
+
* algorithm — to pick `sampleSize` items from `items`, deterministically for
|
|
4
|
+
* a given `seed`. Returns them in input order.
|
|
5
|
+
*
|
|
6
|
+
* ## Why deterministic
|
|
7
|
+
*
|
|
8
|
+
* `resolvePageClusterKeys`'s cluster keys must be reproducible: given the
|
|
9
|
+
* same input pages in the same order, subsequent runs must produce the same
|
|
10
|
+
* cluster keys, or downstream code that stores/compares them by value
|
|
11
|
+
* silently drifts across runs. `Math.random()` violates that outright.
|
|
12
|
+
* Reservoir sampling on top of a seedable PRNG (see {@link ./reservoir-sample.js | mulberry32})
|
|
13
|
+
* preserves determinism while retaining reservoir sampling's O(1)-space,
|
|
14
|
+
* one-pass memory profile — the whole point of using it in the first place
|
|
15
|
+
* (a block too large for full in-memory processing must not require
|
|
16
|
+
* per-page auxiliary state for sampling either).
|
|
17
|
+
*
|
|
18
|
+
* ## Seed handling
|
|
19
|
+
*
|
|
20
|
+
* A `number` seed is used as-is; a `string` seed is hashed via
|
|
21
|
+
* {@link ./reservoir-sample.js | fnv1a32} first so the caller can pass a
|
|
22
|
+
* stable identifier (e.g. a block key like `"orphan-merge:news"`) without
|
|
23
|
+
* having to compute a numeric hash itself. Different blocks get different
|
|
24
|
+
* samples by passing each block's key as the seed.
|
|
25
|
+
*
|
|
26
|
+
* ## Edge cases
|
|
27
|
+
*
|
|
28
|
+
* - `sampleSize <= 0` — returns `[]`.
|
|
29
|
+
* - `sampleSize >= items.length` — returns all of `items` in input order,
|
|
30
|
+
* without invoking the PRNG. This matters for regression: a block small
|
|
31
|
+
* enough to fit its whole membership in the sample must not have items
|
|
32
|
+
* reordered by any sampling logic, so downstream cluster labels stay in
|
|
33
|
+
* the same first-seen order as the in-memory path.
|
|
34
|
+
* @param items
|
|
35
|
+
* @param sampleSize
|
|
36
|
+
* @param seed
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const sample = reservoirSample([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 3, 'block-a');
|
|
40
|
+
* // deterministic 3-item subset, always the same for seed 'block-a'
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare function reservoirSample<T>(items: readonly T[], sampleSize: number, seed?: number | string): T[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mulberry32 — a tiny, well-known 32-bit PRNG. Chosen because it fits in a
|
|
3
|
+
* closure, seeds from a single 32-bit integer (so the caller can derive it
|
|
4
|
+
* deterministically from something stable like a block key), and produces a
|
|
5
|
+
* uniform enough sequence for reservoir sampling. Not cryptographic; that is
|
|
6
|
+
* not what this file needs.
|
|
7
|
+
* @param seed
|
|
8
|
+
*/
|
|
9
|
+
function mulberry32(seed) {
|
|
10
|
+
let state = seed >>> 0;
|
|
11
|
+
return () => {
|
|
12
|
+
state = (state + 0x6d_2b_79_f5) >>> 0;
|
|
13
|
+
let t = state;
|
|
14
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
15
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
16
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* FNV-1a 32-bit hash of a string, used as the default seed source for
|
|
21
|
+
* {@link ./reservoir-sample.js | reservoirSample} when the caller passes a
|
|
22
|
+
* `string` seed. Small, dependency-free, and deterministic across runs and
|
|
23
|
+
* machines — the whole point of using it as a seed is that a block key like
|
|
24
|
+
* `"orphan-merge:news"` always samples the same subset of members.
|
|
25
|
+
* @param input
|
|
26
|
+
*/
|
|
27
|
+
function fnv1a32(input) {
|
|
28
|
+
let hash = 0x81_1c_9d_c5;
|
|
29
|
+
for (let i = 0; i < input.length; i++) {
|
|
30
|
+
hash ^= input.codePointAt(i) ?? 0;
|
|
31
|
+
hash = Math.imul(hash, 0x01_00_01_93);
|
|
32
|
+
}
|
|
33
|
+
return hash >>> 0;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Runs Algorithm R (Vitter, 1985) — the standard one-pass reservoir sampling
|
|
37
|
+
* algorithm — to pick `sampleSize` items from `items`, deterministically for
|
|
38
|
+
* a given `seed`. Returns them in input order.
|
|
39
|
+
*
|
|
40
|
+
* ## Why deterministic
|
|
41
|
+
*
|
|
42
|
+
* `resolvePageClusterKeys`'s cluster keys must be reproducible: given the
|
|
43
|
+
* same input pages in the same order, subsequent runs must produce the same
|
|
44
|
+
* cluster keys, or downstream code that stores/compares them by value
|
|
45
|
+
* silently drifts across runs. `Math.random()` violates that outright.
|
|
46
|
+
* Reservoir sampling on top of a seedable PRNG (see {@link ./reservoir-sample.js | mulberry32})
|
|
47
|
+
* preserves determinism while retaining reservoir sampling's O(1)-space,
|
|
48
|
+
* one-pass memory profile — the whole point of using it in the first place
|
|
49
|
+
* (a block too large for full in-memory processing must not require
|
|
50
|
+
* per-page auxiliary state for sampling either).
|
|
51
|
+
*
|
|
52
|
+
* ## Seed handling
|
|
53
|
+
*
|
|
54
|
+
* A `number` seed is used as-is; a `string` seed is hashed via
|
|
55
|
+
* {@link ./reservoir-sample.js | fnv1a32} first so the caller can pass a
|
|
56
|
+
* stable identifier (e.g. a block key like `"orphan-merge:news"`) without
|
|
57
|
+
* having to compute a numeric hash itself. Different blocks get different
|
|
58
|
+
* samples by passing each block's key as the seed.
|
|
59
|
+
*
|
|
60
|
+
* ## Edge cases
|
|
61
|
+
*
|
|
62
|
+
* - `sampleSize <= 0` — returns `[]`.
|
|
63
|
+
* - `sampleSize >= items.length` — returns all of `items` in input order,
|
|
64
|
+
* without invoking the PRNG. This matters for regression: a block small
|
|
65
|
+
* enough to fit its whole membership in the sample must not have items
|
|
66
|
+
* reordered by any sampling logic, so downstream cluster labels stay in
|
|
67
|
+
* the same first-seen order as the in-memory path.
|
|
68
|
+
* @param items
|
|
69
|
+
* @param sampleSize
|
|
70
|
+
* @param seed
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* const sample = reservoirSample([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 3, 'block-a');
|
|
74
|
+
* // deterministic 3-item subset, always the same for seed 'block-a'
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function reservoirSample(items, sampleSize, seed = 0) {
|
|
78
|
+
if (!(Number.isInteger(sampleSize) && sampleSize >= 0)) {
|
|
79
|
+
throw new RangeError(`reservoirSample: sampleSize must be a non-negative integer, got ${sampleSize}`);
|
|
80
|
+
}
|
|
81
|
+
if (sampleSize === 0 || items.length === 0)
|
|
82
|
+
return [];
|
|
83
|
+
if (sampleSize >= items.length)
|
|
84
|
+
return [...items];
|
|
85
|
+
const numericSeed = typeof seed === 'string' ? fnv1a32(seed) : seed >>> 0;
|
|
86
|
+
const rand = mulberry32(numericSeed);
|
|
87
|
+
// Reservoir holds the picked positions (indices into `items`); we return
|
|
88
|
+
// items sorted by those positions to preserve input order in the result.
|
|
89
|
+
const reservoirIndices = Array.from({ length: sampleSize }, (_, i) => i);
|
|
90
|
+
for (let i = sampleSize; i < items.length; i++) {
|
|
91
|
+
const j = Math.floor(rand() * (i + 1));
|
|
92
|
+
if (j < sampleSize) {
|
|
93
|
+
reservoirIndices[j] = i;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
reservoirIndices.sort((a, b) => a - b);
|
|
97
|
+
return reservoirIndices.map((position) => items[position]);
|
|
98
|
+
}
|
|
@@ -12,8 +12,14 @@ export type PageBlockingSignals = {
|
|
|
12
12
|
* @see resolveBlockingGroupKeys
|
|
13
13
|
*/
|
|
14
14
|
export type ResolveBlockingGroupKeysOptions = {
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Forwarded to `derivePathGroupKey` when a number, which is the historical
|
|
17
|
+
* default. Set to `'auto'` to instead run
|
|
18
|
+
* {@link ./derive-path-cluster-keys.js | derivePathClusterKeys} on the
|
|
19
|
+
* corpus and let it pick the depth data-driven — see that function's
|
|
20
|
+
* JSDoc for the algorithm and its opt-in staging rationale.
|
|
21
|
+
*/
|
|
22
|
+
pathDepth?: number | 'auto';
|
|
17
23
|
/**
|
|
18
24
|
* Minimum number of pages that must share a stylesheet-derived key before
|
|
19
25
|
* it's trusted as real evidence, rather than a coincidence. Must be at
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { computeDocumentFrequency } from './compute-document-frequency.js';
|
|
2
|
+
import { derivePathClusterKeys } from './derive-path-cluster-keys.js';
|
|
2
3
|
import { derivePathGroupKey } from './derive-path-group-key.js';
|
|
3
4
|
import { deriveStylesheetGroupKey } from './derive-stylesheet-group-key.js';
|
|
4
5
|
import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
|
|
@@ -87,7 +88,7 @@ const DEFAULT_MIN_CSS_GROUP_SIZE = 2;
|
|
|
87
88
|
* ```
|
|
88
89
|
*/
|
|
89
90
|
export function resolveBlockingGroupKeys(pages, options) {
|
|
90
|
-
const
|
|
91
|
+
const pathDepthOption = options?.pathDepth;
|
|
91
92
|
const minCssGroupSize = options?.minCssGroupSize ?? DEFAULT_MIN_CSS_GROUP_SIZE;
|
|
92
93
|
const hrefCommonThreshold = options?.hrefCommonThreshold;
|
|
93
94
|
if (!(Number.isInteger(minCssGroupSize) && minCssGroupSize >= 2)) {
|
|
@@ -95,9 +96,19 @@ export function resolveBlockingGroupKeys(pages, options) {
|
|
|
95
96
|
}
|
|
96
97
|
// Eagerly delegate pathDepth/hrefCommonThreshold validation to the
|
|
97
98
|
// functions that own it, instead of only discovering an invalid option
|
|
98
|
-
// once some page's data happens to reach that branch below.
|
|
99
|
-
derivePathGroupKey
|
|
99
|
+
// once some page's data happens to reach that branch below. `'auto'`
|
|
100
|
+
// skips validation here because it doesn't reach derivePathGroupKey's
|
|
101
|
+
// number-only signature until per-page fallback below.
|
|
102
|
+
if (pathDepthOption !== 'auto') {
|
|
103
|
+
derivePathGroupKey([], pathDepthOption);
|
|
104
|
+
}
|
|
100
105
|
splitTokensByFrequency(new Set(), { documentFrequency: new Map(), pageCount: 0 }, hrefCommonThreshold);
|
|
106
|
+
// Resolve `pathDepth: 'auto'` to a data-driven per-page key list once,
|
|
107
|
+
// before the per-page loop below, so the auto-cut sweep is amortized
|
|
108
|
+
// over the whole call rather than repeated per page.
|
|
109
|
+
const perPagePathKeys = pathDepthOption === 'auto'
|
|
110
|
+
? derivePathClusterKeys(pages.map((page) => page.paths)).keys
|
|
111
|
+
: null;
|
|
101
112
|
const hrefSets = pages.map((page) => new Set(page.stylesheetHrefs));
|
|
102
113
|
// Pages with no stylesheets at all must not count toward the denominator:
|
|
103
114
|
// see the JSDoc note above on document-frequency dilution.
|
|
@@ -115,6 +126,9 @@ export function resolveBlockingGroupKeys(pages, options) {
|
|
|
115
126
|
if (cssKey !== undefined && (cssKeyCounts.get(cssKey) ?? 0) >= minCssGroupSize) {
|
|
116
127
|
return `css:${cssKey}`;
|
|
117
128
|
}
|
|
118
|
-
|
|
129
|
+
const pathKey = perPagePathKeys === null
|
|
130
|
+
? derivePathGroupKey(page.paths, pathDepthOption)
|
|
131
|
+
: (perPagePathKeys[index] ?? '');
|
|
132
|
+
return `path:${pathKey}`;
|
|
119
133
|
});
|
|
120
134
|
}
|
|
@@ -15,27 +15,42 @@ export type ResolveLandmarkVariantKeysOptions = TokenizeOptions & ResolveStructu
|
|
|
15
15
|
* `resolvePageClusterKeys` and combine the results themselves; this function
|
|
16
16
|
* does not know about, or merge with, the other one's output.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
18
|
+
* ## Per-page canonical instance selection
|
|
19
|
+
*
|
|
20
|
+
* A page can now have any number of landmark instances of the requested
|
|
21
|
+
* type (see {@link ./extract-landmarks.js | extractLandmarks} for why the
|
|
22
|
+
* old shallowest-wins rule was removed). For "which variant does this page
|
|
23
|
+
* have", the canonical instance is the one whose token-set signature is
|
|
24
|
+
* most common across the corpus, ties broken by document order. This picks
|
|
25
|
+
* the site-wide chrome instance automatically — the shared site header
|
|
26
|
+
* dominates the corpus histogram — while article-specific `<header>`s that
|
|
27
|
+
* vary per page carry frequency 1 and are never selected. Choosing this way
|
|
28
|
+
* is the data-driven analogue of the old shallowest-wins rule, without
|
|
29
|
+
* fragmenting variant keys into singletons (a real regression risk of a
|
|
30
|
+
* naive "union every instance's tokens" approach: 10 pages that each carry
|
|
31
|
+
* both a site header and a per-article header would every one produce a
|
|
32
|
+
* distinct union token set, collapsing every page into its own variant key).
|
|
33
|
+
*
|
|
34
|
+
* A page with no matching landmark compares as an empty token set.
|
|
35
|
+
* `jaccardSimilarity`'s documented treatment of two empty sets as
|
|
36
|
+
* similarity `1` (see its JSDoc) means every landmark-less page lands in
|
|
37
|
+
* the same "has no such landmark" group with no extra branching needed
|
|
38
|
+
* here, and unambiguously in a different group from every page that does
|
|
39
|
+
* have one (`jaccardSimilarity(∅, nonEmpty)` is always `0`). A landmark
|
|
40
|
+
* that exists but tokenizes to an empty set never collides with this
|
|
41
|
+
* sentinel because `tokenize` skips it (see `computePerPageLandmarkInstances`).
|
|
29
42
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
43
|
+
* ## Cost
|
|
44
|
+
*
|
|
45
|
+
* Each call re-runs `extractLandmarks` over the entire `htmlList`, keeping
|
|
46
|
+
* only the one field matching `landmarkType`. Calling this once per
|
|
47
|
+
* `landmarkType` (as callers wanting more than one design signal do)
|
|
48
|
+
* re-parses every page once per type requested. A caller for whom that cost
|
|
49
|
+
* is material should call `extractLandmarks` itself once per page, read all
|
|
50
|
+
* fields off the single result, and feed each field's canonical-instance
|
|
51
|
+
* tokens to
|
|
52
|
+
* {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
|
|
53
|
+
* directly instead.
|
|
39
54
|
*
|
|
40
55
|
* Does not block by URL path or stylesheet first (unlike
|
|
41
56
|
* `resolvePageClusterKeys`): the same header design is normally reused
|
|
@@ -50,6 +65,7 @@ export type ResolveLandmarkVariantKeysOptions = TokenizeOptions & ResolveStructu
|
|
|
50
65
|
* @param options
|
|
51
66
|
* @example
|
|
52
67
|
* ```ts
|
|
68
|
+
* // Header variants
|
|
53
69
|
* resolveLandmarkVariantKeys(
|
|
54
70
|
* [
|
|
55
71
|
* '<body><header><nav>A</nav></header></body>',
|
|
@@ -61,6 +77,11 @@ export type ResolveLandmarkVariantKeysOptions = TokenizeOptions & ResolveStructu
|
|
|
61
77
|
* // pages 0 and 1 (structurally identical header) share a key; page 2 (a
|
|
62
78
|
* // different header structure) gets its own — text content alone (e.g.
|
|
63
79
|
* // the "A" vs "B" text) would not, since tokenize() discards visible text.
|
|
80
|
+
*
|
|
81
|
+
* // Same idea works for the other landmark types:
|
|
82
|
+
* resolveLandmarkVariantKeys(htmlList, 'nav');
|
|
83
|
+
* resolveLandmarkVariantKeys(htmlList, 'footer');
|
|
84
|
+
* resolveLandmarkVariantKeys(htmlList, 'aside');
|
|
64
85
|
* ```
|
|
65
86
|
*/
|
|
66
87
|
export declare function resolveLandmarkVariantKeys(htmlList: readonly string[], landmarkType: LandmarkType, options?: ResolveLandmarkVariantKeysOptions): string[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { extractLandmarks } from './extract-landmarks.js';
|
|
2
|
+
import { computePerPageLandmarkInstances } from './per-page-landmark-signatures.js';
|
|
2
3
|
import { resolveStructuralClusterKeys } from './resolve-structural-cluster-keys.js';
|
|
3
|
-
import { tokenize } from './tokenize.js';
|
|
4
4
|
/**
|
|
5
5
|
* Classifies which *variant* of a single landmark type (e.g. "which header
|
|
6
6
|
* design") each page has, independently of
|
|
@@ -11,27 +11,42 @@ import { tokenize } from './tokenize.js';
|
|
|
11
11
|
* `resolvePageClusterKeys` and combine the results themselves; this function
|
|
12
12
|
* does not know about, or merge with, the other one's output.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
14
|
+
* ## Per-page canonical instance selection
|
|
15
|
+
*
|
|
16
|
+
* A page can now have any number of landmark instances of the requested
|
|
17
|
+
* type (see {@link ./extract-landmarks.js | extractLandmarks} for why the
|
|
18
|
+
* old shallowest-wins rule was removed). For "which variant does this page
|
|
19
|
+
* have", the canonical instance is the one whose token-set signature is
|
|
20
|
+
* most common across the corpus, ties broken by document order. This picks
|
|
21
|
+
* the site-wide chrome instance automatically — the shared site header
|
|
22
|
+
* dominates the corpus histogram — while article-specific `<header>`s that
|
|
23
|
+
* vary per page carry frequency 1 and are never selected. Choosing this way
|
|
24
|
+
* is the data-driven analogue of the old shallowest-wins rule, without
|
|
25
|
+
* fragmenting variant keys into singletons (a real regression risk of a
|
|
26
|
+
* naive "union every instance's tokens" approach: 10 pages that each carry
|
|
27
|
+
* both a site header and a per-article header would every one produce a
|
|
28
|
+
* distinct union token set, collapsing every page into its own variant key).
|
|
25
29
|
*
|
|
26
|
-
* A page with no
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
* A page with no matching landmark compares as an empty token set.
|
|
31
|
+
* `jaccardSimilarity`'s documented treatment of two empty sets as
|
|
32
|
+
* similarity `1` (see its JSDoc) means every landmark-less page lands in
|
|
33
|
+
* the same "has no such landmark" group with no extra branching needed
|
|
34
|
+
* here, and unambiguously in a different group from every page that does
|
|
35
|
+
* have one (`jaccardSimilarity(∅, nonEmpty)` is always `0`). A landmark
|
|
36
|
+
* that exists but tokenizes to an empty set never collides with this
|
|
37
|
+
* sentinel because `tokenize` skips it (see `computePerPageLandmarkInstances`).
|
|
38
|
+
*
|
|
39
|
+
* ## Cost
|
|
40
|
+
*
|
|
41
|
+
* Each call re-runs `extractLandmarks` over the entire `htmlList`, keeping
|
|
42
|
+
* only the one field matching `landmarkType`. Calling this once per
|
|
43
|
+
* `landmarkType` (as callers wanting more than one design signal do)
|
|
44
|
+
* re-parses every page once per type requested. A caller for whom that cost
|
|
45
|
+
* is material should call `extractLandmarks` itself once per page, read all
|
|
46
|
+
* fields off the single result, and feed each field's canonical-instance
|
|
47
|
+
* tokens to
|
|
48
|
+
* {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
|
|
49
|
+
* directly instead.
|
|
35
50
|
*
|
|
36
51
|
* Does not block by URL path or stylesheet first (unlike
|
|
37
52
|
* `resolvePageClusterKeys`): the same header design is normally reused
|
|
@@ -46,6 +61,7 @@ import { tokenize } from './tokenize.js';
|
|
|
46
61
|
* @param options
|
|
47
62
|
* @example
|
|
48
63
|
* ```ts
|
|
64
|
+
* // Header variants
|
|
49
65
|
* resolveLandmarkVariantKeys(
|
|
50
66
|
* [
|
|
51
67
|
* '<body><header><nav>A</nav></header></body>',
|
|
@@ -57,15 +73,42 @@ import { tokenize } from './tokenize.js';
|
|
|
57
73
|
* // pages 0 and 1 (structurally identical header) share a key; page 2 (a
|
|
58
74
|
* // different header structure) gets its own — text content alone (e.g.
|
|
59
75
|
* // the "A" vs "B" text) would not, since tokenize() discards visible text.
|
|
76
|
+
*
|
|
77
|
+
* // Same idea works for the other landmark types:
|
|
78
|
+
* resolveLandmarkVariantKeys(htmlList, 'nav');
|
|
79
|
+
* resolveLandmarkVariantKeys(htmlList, 'footer');
|
|
80
|
+
* resolveLandmarkVariantKeys(htmlList, 'aside');
|
|
60
81
|
* ```
|
|
61
82
|
*/
|
|
62
83
|
export function resolveLandmarkVariantKeys(htmlList, landmarkType, options) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
const landmarks = htmlList.map((html) => extractLandmarks(html));
|
|
85
|
+
const perPageInstances = computePerPageLandmarkInstances(landmarks, options);
|
|
86
|
+
// Corpus-wide instance-signature histogram (restricted to the requested
|
|
87
|
+
// landmark type). Used to pick each page's canonical instance —
|
|
88
|
+
// see this function's JSDoc for why "most common" beats "union of all".
|
|
89
|
+
const corpusInstanceCount = new Map();
|
|
90
|
+
for (const instances of perPageInstances) {
|
|
91
|
+
const seenTypedSignatures = new Set();
|
|
92
|
+
for (const inst of instances) {
|
|
93
|
+
if (inst.type !== landmarkType)
|
|
94
|
+
continue;
|
|
95
|
+
if (seenTypedSignatures.has(inst.signature))
|
|
96
|
+
continue;
|
|
97
|
+
seenTypedSignatures.add(inst.signature);
|
|
98
|
+
corpusInstanceCount.set(inst.signature, (corpusInstanceCount.get(inst.signature) ?? 0) + 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const tokenSets = perPageInstances.map((instances) => {
|
|
102
|
+
let best;
|
|
103
|
+
for (const inst of instances) {
|
|
104
|
+
if (inst.type !== landmarkType)
|
|
105
|
+
continue;
|
|
106
|
+
const count = corpusInstanceCount.get(inst.signature) ?? 0;
|
|
107
|
+
if (best === undefined || count > best.count) {
|
|
108
|
+
best = { tokens: inst.tokens, count };
|
|
109
|
+
}
|
|
67
110
|
}
|
|
68
|
-
return new Set(
|
|
111
|
+
return best?.tokens ?? new Set();
|
|
69
112
|
});
|
|
70
113
|
return resolveStructuralClusterKeys(tokenSets, options);
|
|
71
114
|
}
|