@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -41
  3. package/dist/assign-contained-clusters.d.ts +42 -0
  4. package/dist/assign-contained-clusters.js +156 -0
  5. package/dist/auto-cut-threshold.d.ts +17 -0
  6. package/dist/auto-cut-threshold.js +36 -0
  7. package/dist/canonicalize-token-set.d.ts +17 -0
  8. package/dist/canonicalize-token-set.js +19 -0
  9. package/dist/cli.d.ts +39 -0
  10. package/dist/cli.js +381 -0
  11. package/dist/collapse-anonymous-divs.d.ts +21 -0
  12. package/dist/collapse-anonymous-divs.js +42 -0
  13. package/dist/complete-linkage-dendrogram.d.ts +41 -0
  14. package/dist/complete-linkage-dendrogram.js +140 -0
  15. package/dist/derive-comparison-sets.d.ts +22 -0
  16. package/dist/derive-comparison-sets.js +33 -0
  17. package/dist/derive-path-cluster-keys.d.ts +53 -0
  18. package/dist/derive-path-cluster-keys.js +109 -0
  19. package/dist/extract-landmarks.d.ts +91 -45
  20. package/dist/extract-landmarks.js +122 -41
  21. package/dist/filter-first-party-stylesheet-hrefs.d.ts +58 -24
  22. package/dist/filter-first-party-stylesheet-hrefs.js +72 -33
  23. package/dist/find-shallowest-elements.d.ts +48 -11
  24. package/dist/find-shallowest-elements.js +41 -21
  25. package/dist/merge-cross-block-clusters.d.ts +61 -0
  26. package/dist/merge-cross-block-clusters.js +546 -0
  27. package/dist/pass0-blocking.d.ts +89 -0
  28. package/dist/pass0-blocking.js +87 -0
  29. package/dist/per-page-landmark-signatures.d.ts +48 -0
  30. package/dist/per-page-landmark-signatures.js +62 -0
  31. package/dist/reservoir-sample.d.ts +43 -0
  32. package/dist/reservoir-sample.js +98 -0
  33. package/dist/resolve-blocking-group-keys.d.ts +8 -2
  34. package/dist/resolve-blocking-group-keys.js +18 -4
  35. package/dist/resolve-landmark-variant-keys.d.ts +41 -20
  36. package/dist/resolve-landmark-variant-keys.js +69 -26
  37. package/dist/resolve-page-cluster-keys.d.ts +292 -191
  38. package/dist/resolve-page-cluster-keys.js +708 -157
  39. package/dist/resolve-structural-cluster-keys.d.ts +9 -0
  40. package/dist/resolve-structural-cluster-keys.js +14 -232
  41. package/dist/shape-token.d.ts +11 -0
  42. package/dist/shape-token.js +38 -0
  43. package/dist/stage-a-per-block.d.ts +133 -0
  44. package/dist/stage-a-per-block.js +178 -0
  45. package/dist/tokenize.d.ts +6 -0
  46. package/dist/tokenize.js +6 -0
  47. package/package.json +5 -58
  48. package/dist/html-region-utils.d.ts +0 -74
  49. package/dist/html-region-utils.js +0 -96
  50. package/dist/merge-landmark-affined-clusters.d.ts +0 -179
  51. 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
- /** Forwarded to `derivePathGroupKey` as-is. */
16
- pathDepth?: number;
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 pathDepth = options?.pathDepth;
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([], pathDepth);
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
- return `path:${derivePathGroupKey(page.paths, pathDepth)}`;
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
- * Each call re-runs {@link ./extract-landmarks.js | extractLandmarks} over
19
- * the entire `htmlList`, keeping only the one field matching `landmarkType`
20
- * and discarding the other three it also computed. Calling this once per
21
- * `landmarkType` (as the paragraph above suggests, for a caller that wants
22
- * more than one) therefore re-parses every page once per type requested. A
23
- * caller for whom that cost is material should call `extractLandmarks`
24
- * itself once per page, read all four fields off the single result, and feed
25
- * each field's token sets to
26
- * {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
27
- * directly (with the same empty-set sentinel for a missing field) instead of
28
- * calling this function multiple times.
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
- * A page with no match for `landmarkType` (per
31
- * {@link ./extract-landmarks.js | extractLandmarks}) compares as an empty
32
- * token set. `jaccardSimilarity`'s documented treatment of two empty sets as
33
- * similarity `1` (see its JSDoc) means every landmark-less page lands in the
34
- * same "has no such landmark" group with no extra branching needed here, and
35
- * unambiguously in a different group from every page that does have one
36
- * (`jaccardSimilarity(∅, nonEmpty)` is always `0`). A landmark that exists
37
- * but is empty (e.g. `<header></header>`) never collides with this sentinel:
38
- * `tokenize` still emits at least the element's own segment for it.
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
- * Each call re-runs {@link ./extract-landmarks.js | extractLandmarks} over
15
- * the entire `htmlList`, keeping only the one field matching `landmarkType`
16
- * and discarding the other three it also computed. Calling this once per
17
- * `landmarkType` (as the paragraph above suggests, for a caller that wants
18
- * more than one) therefore re-parses every page once per type requested. A
19
- * caller for whom that cost is material should call `extractLandmarks`
20
- * itself once per page, read all four fields off the single result, and feed
21
- * each field's token sets to
22
- * {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
23
- * directly (with the same empty-set sentinel for a missing field) instead of
24
- * calling this function multiple times.
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 match for `landmarkType` (per
27
- * {@link ./extract-landmarks.js | extractLandmarks}) compares as an empty
28
- * token set. `jaccardSimilarity`'s documented treatment of two empty sets as
29
- * similarity `1` (see its JSDoc) means every landmark-less page lands in the
30
- * same "has no such landmark" group with no extra branching needed here, and
31
- * unambiguously in a different group from every page that does have one
32
- * (`jaccardSimilarity(∅, nonEmpty)` is always `0`). A landmark that exists
33
- * but is empty (e.g. `<header></header>`) never collides with this sentinel:
34
- * `tokenize` still emits at least the element's own segment for it.
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 tokenSets = htmlList.map((html) => {
64
- const region = extractLandmarks(html)[landmarkType];
65
- if (region === undefined) {
66
- return new Set();
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(tokenize(`<body>${region}</body>`, options).tokens);
111
+ return best?.tokens ?? new Set();
69
112
  });
70
113
  return resolveStructuralClusterKeys(tokenSets, options);
71
114
  }