@d-zero/page-cluster 0.2.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 (80) hide show
  1. package/README.md +68 -0
  2. package/dist/array-edit-distance.d.ts +20 -0
  3. package/dist/array-edit-distance.js +52 -0
  4. package/dist/build-segment.d.ts +18 -0
  5. package/dist/build-segment.js +27 -0
  6. package/dist/cap-content-depth.d.ts +69 -0
  7. package/dist/cap-content-depth.js +161 -0
  8. package/dist/compute-document-frequency.d.ts +33 -0
  9. package/dist/compute-document-frequency.js +40 -0
  10. package/dist/create-frame.d.ts +16 -0
  11. package/dist/create-frame.js +29 -0
  12. package/dist/derive-path-group-key.d.ts +44 -0
  13. package/dist/derive-path-group-key.js +51 -0
  14. package/dist/derive-stylesheet-group-key.d.ts +36 -0
  15. package/dist/derive-stylesheet-group-key.js +41 -0
  16. package/dist/detect-content-depth-cap.d.ts +114 -0
  17. package/dist/detect-content-depth-cap.js +137 -0
  18. package/dist/escape-reg-exp.d.ts +11 -0
  19. package/dist/escape-reg-exp.js +13 -0
  20. package/dist/excise.d.ts +13 -0
  21. package/dist/excise.js +24 -0
  22. package/dist/extract-landmarks.d.ts +82 -0
  23. package/dist/extract-landmarks.js +104 -0
  24. package/dist/filter-first-party-stylesheet-hrefs.d.ts +73 -0
  25. package/dist/filter-first-party-stylesheet-hrefs.js +118 -0
  26. package/dist/find-shallowest-elements.d.ts +39 -0
  27. package/dist/find-shallowest-elements.js +121 -0
  28. package/dist/foldable-tags.d.ts +8 -0
  29. package/dist/foldable-tags.js +8 -0
  30. package/dist/format-bracket.d.ts +11 -0
  31. package/dist/format-bracket.js +17 -0
  32. package/dist/hash-content.d.ts +22 -0
  33. package/dist/hash-content.js +26 -0
  34. package/dist/html-region-utils.d.ts +74 -0
  35. package/dist/html-region-utils.js +96 -0
  36. package/dist/is-fold-candidate.d.ts +13 -0
  37. package/dist/is-fold-candidate.js +16 -0
  38. package/dist/is-genuine-close.d.ts +23 -0
  39. package/dist/is-genuine-close.js +27 -0
  40. package/dist/is-noise-class.d.ts +6 -0
  41. package/dist/is-noise-class.js +8 -0
  42. package/dist/jaccard-similarity.d.ts +23 -0
  43. package/dist/jaccard-similarity.js +36 -0
  44. package/dist/merge-landmark-affined-clusters.d.ts +179 -0
  45. package/dist/merge-landmark-affined-clusters.js +544 -0
  46. package/dist/merge-spans.d.ts +15 -0
  47. package/dist/merge-spans.js +22 -0
  48. package/dist/noise-class-patterns.d.ts +21 -0
  49. package/dist/noise-class-patterns.js +74 -0
  50. package/dist/normalize-for-hash.d.ts +10 -0
  51. package/dist/normalize-for-hash.js +12 -0
  52. package/dist/opaque-tags.d.ts +17 -0
  53. package/dist/opaque-tags.js +18 -0
  54. package/dist/parse-class-list.d.ts +10 -0
  55. package/dist/parse-class-list.js +23 -0
  56. package/dist/reassign-orphan-block-keys.d.ts +81 -0
  57. package/dist/reassign-orphan-block-keys.js +159 -0
  58. package/dist/remove-content-blocks.d.ts +67 -0
  59. package/dist/remove-content-blocks.js +150 -0
  60. package/dist/resolve-blocking-group-keys.d.ts +116 -0
  61. package/dist/resolve-blocking-group-keys.js +120 -0
  62. package/dist/resolve-closed-frame.d.ts +26 -0
  63. package/dist/resolve-closed-frame.js +33 -0
  64. package/dist/resolve-landmark-variant-keys.d.ts +66 -0
  65. package/dist/resolve-landmark-variant-keys.js +71 -0
  66. package/dist/resolve-options.d.ts +6 -0
  67. package/dist/resolve-options.js +10 -0
  68. package/dist/resolve-page-cluster-keys.d.ts +222 -0
  69. package/dist/resolve-page-cluster-keys.js +198 -0
  70. package/dist/resolve-structural-cluster-keys.d.ts +50 -0
  71. package/dist/resolve-structural-cluster-keys.js +287 -0
  72. package/dist/run-tokenizer.d.ts +33 -0
  73. package/dist/run-tokenizer.js +152 -0
  74. package/dist/split-tokens-by-frequency.d.ts +46 -0
  75. package/dist/split-tokens-by-frequency.js +88 -0
  76. package/dist/tokenize.d.ts +58 -0
  77. package/dist/tokenize.js +60 -0
  78. package/dist/types.d.ts +85 -0
  79. package/dist/types.js +1 -0
  80. package/package.json +102 -0
@@ -0,0 +1,222 @@
1
+ import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
2
+ import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
3
+ import type { TokenizeOptions } from './types.js';
4
+ /**
5
+ * Per-page input to {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}:
6
+ * the blocking signals {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}
7
+ * needs, plus the page's raw HTML. Raw HTML (rather than a pre-tokenized
8
+ * `Set`, as earlier versions of this type required) because
9
+ * `resolvePageClusterKeys` now needs to decide *how* to tokenize each page
10
+ * (see `excludeLandmarks` below) — a decision a caller handed a bare
11
+ * `Set<string>` could no longer make correctly on its own.
12
+ */
13
+ export type PageClusterSignals = {
14
+ paths: readonly string[];
15
+ stylesheetHrefs: readonly string[];
16
+ html: string;
17
+ };
18
+ /**
19
+ * @see resolvePageClusterKeys
20
+ */
21
+ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGroupKeysOptions & ResolveStructuralClusterKeysOptions & {
22
+ /**
23
+ * Tokenize each page's `<header>`/`<footer>`/`<nav>`/`<aside>`-excised
24
+ * remainder ({@link ./extract-landmarks.js | extractLandmarks}'s
25
+ * `remainderHtml`) instead of its raw HTML, so shared site chrome never
26
+ * reaches the structural-similarity comparison. Defaults to `true`.
27
+ * Set to `false` to fall back to tokenizing the untouched page (the
28
+ * pre-landmark-extraction behavior) — this is a large behavioral
29
+ * change not yet validated across many sites beyond the two real
30
+ * corpora checked so far, so the escape hatch is kept available.
31
+ *
32
+ * Leaving this `true` means every page's HTML is parsed twice (once by
33
+ * `extractLandmarks`, once by `tokenize` on its `remainderHtml`)
34
+ * instead of once. Measured on a real crawl corpus (8,936 pages),
35
+ * this is still net faster overall than the single-parse `false`
36
+ * path (17,557ms vs 25,997ms end-to-end): `remainderHtml` is
37
+ * substantially shorter than the original page once landmarks are
38
+ * excised, and the resulting smaller `tokenize` pass costs less than
39
+ * the extra `extractLandmarks` pass adds.
40
+ */
41
+ excludeLandmarks?: boolean;
42
+ /**
43
+ * Apply {@link ./reassign-orphan-block-keys.js | reassignOrphanBlockKeys}
44
+ * to the blocking keys before clustering, so a page with no recorded
45
+ * stylesheets ("orphan" — often a crawl-completeness gap, not evidence
46
+ * the page is actually template-less) can rejoin a same-URL-section
47
+ * `css:` block instead of being stranded on its weaker `path:` fallback.
48
+ * Defaults to `true`. Set to `false` to fall back to the raw
49
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}
50
+ * output — kept available both because this is not yet broadly-
51
+ * validated beyond the two real crawls checked so far, and because it
52
+ * has a known trade-off documented on
53
+ * {@link ./reassign-orphan-block-keys.js | reassignOrphanBlockKeys}
54
+ * itself: pooling pages for comparison can change unrelated pages'
55
+ * cluster outcomes too, not just the orphan's.
56
+ */
57
+ reassignOrphans?: boolean;
58
+ /**
59
+ * Apply {@link ./remove-content-blocks.js | removeContentBlocks} to each
60
+ * page's landmark-excised remainder before tokenizing, so a freeform
61
+ * block-editor content area's page-to-page variation (which specific
62
+ * mix of blocks an author used) never reaches the structural-similarity
63
+ * comparison. No default — unlike `excludeLandmarks`/`reassignOrphans`,
64
+ * this needs the caller's own block-editor attribute name (see
65
+ * `removeContentBlocks`'s `blockAttribute` option), which this package
66
+ * cannot guess. Omit to skip this step entirely.
67
+ */
68
+ contentBlockAttribute?: string;
69
+ /**
70
+ * Apply {@link ./filter-first-party-stylesheet-hrefs.js |
71
+ * filterFirstPartyStylesheetHrefs} to `pages` before computing blocking
72
+ * keys, so a page's incidental third-party embeds (e.g. a video
73
+ * player's own stylesheet, extra web-font requests pulled in by a
74
+ * widget) never get mistaken for a template-identifying signal by
75
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}.
76
+ * Defaults to `true`. Set to `false` to block on every page's full,
77
+ * unfiltered `stylesheetHrefs` — kept available for the same reason
78
+ * `excludeLandmarks`'s escape hatch is: a real but not yet broadly-
79
+ * validated behavioral change (confirmed so far on one real crawl).
80
+ *
81
+ * Inherits `filterFirstPartyStylesheetHrefs`'s "roughly homogeneous
82
+ * batch" precondition (see that function's own JSDoc): `pages` should
83
+ * be one site or one section, the same expectation
84
+ * `resolveBlockingGroupKeys` already places on its own input.
85
+ */
86
+ restrictStylesheetsToFirstParty?: boolean;
87
+ /**
88
+ * Apply {@link ./detect-content-depth-cap.js | detectContentDepthCap}
89
+ * separately *within each block* (after `excludeLandmarks`/
90
+ * `contentBlockAttribute`, after blocking, before that block's own
91
+ * tokenizing) to find how many levels of nesting inside
92
+ * `<main>`/`role="main"` to keep, then
93
+ * {@link ./cap-content-depth.js | capContentDepth} each of that
94
+ * block's pages at that depth — a `contentBlockAttribute`-style fix
95
+ * for freeform-content noise that needs no site-specific attribute
96
+ * name, since `<main>` is an HTML5/ARIA standard. Defaults to
97
+ * `false`: unlike `contentBlockAttribute` (which does nothing unless
98
+ * a matching attribute is actually present), this discards real
99
+ * content whenever a page has a `<main>`/`role="main"` at all, so
100
+ * it's opt-in until validated on more than the two real corpora
101
+ * checked so far.
102
+ *
103
+ * Per-block rather than once across the whole corpus: different
104
+ * blocks (different templates/sections) can have genuinely different
105
+ * "skeleton depths." Confirmed on real crawl data: an 814-page block
106
+ * whose own knee sits at depth 2 stayed at 189 clusters (barely moved
107
+ * from 224 uncapped) when capped at depth 3 — the knee derived from
108
+ * the *whole* 8,936-page corpus, dominated by two much larger blocks
109
+ * whose own knee is 3. Re-deriving the knee for that block alone
110
+ * brings it down to 46. A block too small for its knee-detection
111
+ * sweep to find a reliable jump just falls through to
112
+ * `detectContentDepthCap`'s own no-knee fallback (the largest
113
+ * candidate depth, effectively "don't cap") — the same safe default
114
+ * it already has for any input, now reached per-block instead of
115
+ * corpus-wide. Skipped entirely (no cap) for a block of exactly 1
116
+ * page — nothing to compare it against, so a knee sweep there could
117
+ * only ever confirm what's already true.
118
+ *
119
+ * Trade-off of going per-block: a corpus-wide sweep's cluster-count
120
+ * ratios are diluted by thousands of ordinary pages, so one
121
+ * incidental outlier (e.g. a single page with an extra wrapper `div`
122
+ * from a stray widget) barely moves them. A small block's sweep has
123
+ * no such dilution — a similar outlier among only a handful of pages
124
+ * can itself clear `minKneeRatio` and produce a too-shallow cap for
125
+ * that block. Not yet observed on either real corpus checked so far
126
+ * (both corpora's small blocks happened to be uniform enough that
127
+ * this didn't come up), so no size-based guard is added speculatively;
128
+ * revisit if real data surfaces it.
129
+ *
130
+ * Composes with `contentBlockAttribute` rather than replacing it: both
131
+ * can be set at once — `removeContentBlocks` runs first, then
132
+ * `capContentDepth` on what's left — for a site whose CMS marks *some*
133
+ * blocks with a known attribute but still has other, unmarked
134
+ * freeform depth the attribute alone doesn't catch.
135
+ *
136
+ * Confirmed on real crawl data this can outperform
137
+ * `contentBlockAttribute` on its own, not just stand in for it when the
138
+ * attribute is unknown: on a 302-page corpus, `autoCapMainDepth` alone
139
+ * produced 20 final clusters versus 27 for
140
+ * `contentBlockAttribute: 'data-bgb'` together with
141
+ * `restrictStylesheetsToFirstParty` — the site's known CMS attribute
142
+ * doesn't mark every source of freeform depth, but the `<main>`
143
+ * boundary catches all of it uniformly. See `detectContentDepthCap`'s
144
+ * JSDoc for the real cost/accuracy numbers this per-block sweep
145
+ * measures on the same two corpora.
146
+ */
147
+ autoCapMainDepth?: boolean;
148
+ /**
149
+ * Re-key two or more otherwise-distinct clusters onto one shared key
150
+ * when every landmark type present on their pages is both identical
151
+ * and rare corpus-wide — see
152
+ * {@link ./merge-landmark-affined-clusters.js | mergeLandmarkAffinedClusters}'s
153
+ * JSDoc for the exact rule, the withdrawn earlier prototype this
154
+ * reimplements, and why "rare" (not merely "identical") is required.
155
+ * Defaults to `false`.
156
+ *
157
+ * Kept `false` by default: unlike `autoCapMainDepth`/
158
+ * `restrictStylesheetsToFirstParty`, this has not been run against
159
+ * real crawl data at all as of this change — only synthetic-fixture
160
+ * unit/regression tests. See `mergeLandmarkAffinedClusters`'s JSDoc
161
+ * for its cost profile before enabling this on a large corpus.
162
+ */
163
+ mergeRareLandmarkClusters?: boolean;
164
+ /** Forwarded to {@link ./merge-landmark-affined-clusters.js | mergeLandmarkAffinedClusters} as-is. */
165
+ landmarkRarityThreshold?: number;
166
+ /** Forwarded to {@link ./merge-landmark-affined-clusters.js | mergeLandmarkAffinedClusters} as-is. */
167
+ landmarkGateSimilarityThreshold?: number;
168
+ };
169
+ /**
170
+ * Connects the two stages this package otherwise leaves for the caller to
171
+ * wire together: {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}
172
+ * (coarse blocking by URL path or stylesheet set) and
173
+ * {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
174
+ * (exact structural clustering *within* one block). Returns one final key
175
+ * per page, in the same order as `pages`, unique across the whole input —
176
+ * not just within a block.
177
+ *
178
+ * `resolveStructuralClusterKeys` numbers its clusters `cluster:0`,
179
+ * `cluster:1`, ... independently every time it's called, so two different
180
+ * blocks' `cluster:0` are unrelated but identically named. Composing the
181
+ * block key and the per-block cluster label via `JSON.stringify` (rather
182
+ * than plain string concatenation, e.g. a `::` separator) rules out
183
+ * collisions regardless of what either half happens to contain, without
184
+ * depending on `resolveStructuralClusterKeys`'s label format never changing.
185
+ *
186
+ * `excludeLandmarks` and `similarityThreshold` interact: removing shared
187
+ * chrome makes every remaining comparison stricter (there's no more
188
+ * chrome-driven baseline similarity propping scores up), so a threshold
189
+ * tuned against raw, chrome-included tokens can become too strict once
190
+ * landmarks are excluded. Confirmed on real crawl data: a 4-page block where
191
+ * 3 same-template pages merged at the default `similarityThreshold` (0.8)
192
+ * using raw tokens split one of the 3 into its own singleton once landmarks
193
+ * were excluded, and re-merged correctly at `similarityThreshold: 0.6` — re-
194
+ * tune per site after switching this on, the same as `similarityThreshold`
195
+ * itself already needs.
196
+ *
197
+ * `reassignOrphans` only ever pools a `path:`-fallback orphan alongside a
198
+ * same-section `css:` block for `resolveStructuralClusterKeys` to compare —
199
+ * it never forces a merge itself. An orphan that turns out not to match
200
+ * anything in that pool (confirmed on real crawl data) correctly surfaces as
201
+ * its own singleton, the same as it would have without this option.
202
+ *
203
+ * `restrictStylesheetsToFirstParty` runs before `reassignOrphans`: a page
204
+ * whose only stylesheet reference was third-party becomes an orphan (no
205
+ * first-party stylesheet left) *because of* the filtering, and is then
206
+ * itself eligible for orphan reassignment — this is intentional, not an
207
+ * ordering accident, since the underlying reason both options exist is the
208
+ * same (a page's blocking key should reflect its template, not incidental
209
+ * third-party embeds or missing crawl data).
210
+ * @param pages
211
+ * @param options
212
+ * @example
213
+ * ```ts
214
+ * resolvePageClusterKeys([
215
+ * { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
216
+ * { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
217
+ * { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
218
+ * ]);
219
+ * // pages 0 and 1 (same block, same structure) share a key; page 2 (different block) gets its own
220
+ * ```
221
+ */
222
+ export declare function resolvePageClusterKeys(pages: readonly PageClusterSignals[], options?: ResolvePageClusterKeysOptions): string[];
@@ -0,0 +1,198 @@
1
+ import { capContentDepth } from './cap-content-depth.js';
2
+ import { detectContentDepthCap, validateDetectContentDepthCapOptions, } from './detect-content-depth-cap.js';
3
+ import { extractLandmarks } from './extract-landmarks.js';
4
+ import { filterFirstPartyStylesheetHrefs } from './filter-first-party-stylesheet-hrefs.js';
5
+ import { mergeLandmarkAffinedClusters, validateMergeLandmarkAffinedClustersOptions, } from './merge-landmark-affined-clusters.js';
6
+ import { reassignOrphanBlockKeys } from './reassign-orphan-block-keys.js';
7
+ import { removeContentBlocks } from './remove-content-blocks.js';
8
+ import { resolveBlockingGroupKeys } from './resolve-blocking-group-keys.js';
9
+ import { resolveStructuralClusterKeys } from './resolve-structural-cluster-keys.js';
10
+ import { tokenize } from './tokenize.js';
11
+ /**
12
+ * Reads `values[index]`, throwing instead of returning `undefined`. Every
13
+ * call site here indexes with a position this function generated itself
14
+ * (an entry from `Map#entries()`/`Array#entries()` over an array it just
15
+ * built), so the thrown branch is unreachable in practice; it exists to
16
+ * satisfy `noUncheckedIndexedAccess` without a non-null assertion. Not
17
+ * imported from `resolve-structural-cluster-keys.ts`'s own copy: that file's
18
+ * `export`s are its intended public API surface, and this ~7-line generic
19
+ * helper isn't worth carving an exception into that boundary for (same
20
+ * rationale as `readDpValue` in `array-edit-distance.ts` being its own
21
+ * independent copy rather than a shared import).
22
+ * @param values
23
+ * @param index
24
+ */
25
+ function requireIndex(values, index) {
26
+ const value = values[index];
27
+ if (value === undefined) {
28
+ throw new Error('resolvePageClusterKeys: index out of bounds');
29
+ }
30
+ return value;
31
+ }
32
+ /**
33
+ * Narrows `landmarks` from `readonly ExtractLandmarksResult[] | undefined` to
34
+ * defined, throwing instead of asserting non-null. Always defined in
35
+ * practice at every call site below: both call sites are only reached when
36
+ * `excludeLandmarks || mergeRareLandmarkClusters` is true, exactly the
37
+ * condition under which `landmarks` is populated. Exists only to satisfy the
38
+ * type checker without a non-null assertion (same rationale as
39
+ * `requireIndex` above).
40
+ * @param landmarks
41
+ */
42
+ function requireLandmarks(landmarks) {
43
+ if (landmarks === undefined) {
44
+ throw new Error('resolvePageClusterKeys: landmarks were not computed');
45
+ }
46
+ return landmarks;
47
+ }
48
+ /**
49
+ * Connects the two stages this package otherwise leaves for the caller to
50
+ * wire together: {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}
51
+ * (coarse blocking by URL path or stylesheet set) and
52
+ * {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
53
+ * (exact structural clustering *within* one block). Returns one final key
54
+ * per page, in the same order as `pages`, unique across the whole input —
55
+ * not just within a block.
56
+ *
57
+ * `resolveStructuralClusterKeys` numbers its clusters `cluster:0`,
58
+ * `cluster:1`, ... independently every time it's called, so two different
59
+ * blocks' `cluster:0` are unrelated but identically named. Composing the
60
+ * block key and the per-block cluster label via `JSON.stringify` (rather
61
+ * than plain string concatenation, e.g. a `::` separator) rules out
62
+ * collisions regardless of what either half happens to contain, without
63
+ * depending on `resolveStructuralClusterKeys`'s label format never changing.
64
+ *
65
+ * `excludeLandmarks` and `similarityThreshold` interact: removing shared
66
+ * chrome makes every remaining comparison stricter (there's no more
67
+ * chrome-driven baseline similarity propping scores up), so a threshold
68
+ * tuned against raw, chrome-included tokens can become too strict once
69
+ * landmarks are excluded. Confirmed on real crawl data: a 4-page block where
70
+ * 3 same-template pages merged at the default `similarityThreshold` (0.8)
71
+ * using raw tokens split one of the 3 into its own singleton once landmarks
72
+ * were excluded, and re-merged correctly at `similarityThreshold: 0.6` — re-
73
+ * tune per site after switching this on, the same as `similarityThreshold`
74
+ * itself already needs.
75
+ *
76
+ * `reassignOrphans` only ever pools a `path:`-fallback orphan alongside a
77
+ * same-section `css:` block for `resolveStructuralClusterKeys` to compare —
78
+ * it never forces a merge itself. An orphan that turns out not to match
79
+ * anything in that pool (confirmed on real crawl data) correctly surfaces as
80
+ * its own singleton, the same as it would have without this option.
81
+ *
82
+ * `restrictStylesheetsToFirstParty` runs before `reassignOrphans`: a page
83
+ * whose only stylesheet reference was third-party becomes an orphan (no
84
+ * first-party stylesheet left) *because of* the filtering, and is then
85
+ * itself eligible for orphan reassignment — this is intentional, not an
86
+ * ordering accident, since the underlying reason both options exist is the
87
+ * same (a page's blocking key should reflect its template, not incidental
88
+ * third-party embeds or missing crawl data).
89
+ * @param pages
90
+ * @param options
91
+ * @example
92
+ * ```ts
93
+ * resolvePageClusterKeys([
94
+ * { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
95
+ * { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
96
+ * { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
97
+ * ]);
98
+ * // pages 0 and 1 (same block, same structure) share a key; page 2 (different block) gets its own
99
+ * ```
100
+ */
101
+ export function resolvePageClusterKeys(pages, options) {
102
+ const excludeLandmarks = options?.excludeLandmarks ?? true;
103
+ const mergeRareLandmarkClusters = options?.mergeRareLandmarkClusters ?? false;
104
+ if (mergeRareLandmarkClusters) {
105
+ // Eager, same rationale as autoCapMainDepth's own eager validation
106
+ // below: this option's own validation is otherwise only reached from
107
+ // mergeLandmarkAffinedClusters's call at the very end of this
108
+ // function, which never runs at all for an empty `pages`.
109
+ validateMergeLandmarkAffinedClustersOptions(options);
110
+ }
111
+ // extractLandmarks parses the whole page once; excludeLandmarks needs
112
+ // remainderHtml and mergeRareLandmarkClusters needs the four landmark
113
+ // fields, so this computes it once up front whenever either is needed,
114
+ // rather than each option branch parsing independently.
115
+ const landmarks = excludeLandmarks || mergeRareLandmarkClusters
116
+ ? pages.map((page) => extractLandmarks(page.html))
117
+ : undefined;
118
+ const contentBlockAttribute = options?.contentBlockAttribute;
119
+ const preparedHtml = pages.map((page, index) => {
120
+ const landmarksExcised = excludeLandmarks
121
+ ? requireIndex(requireLandmarks(landmarks), index).remainderHtml
122
+ : page.html;
123
+ return contentBlockAttribute === undefined
124
+ ? landmarksExcised
125
+ : removeContentBlocks(landmarksExcised, { blockAttribute: contentBlockAttribute })
126
+ .remainderHtml;
127
+ });
128
+ const restrictStylesheetsToFirstParty = options?.restrictStylesheetsToFirstParty ?? true;
129
+ const blockingPages = restrictStylesheetsToFirstParty
130
+ ? filterFirstPartyStylesheetHrefs(pages)
131
+ : pages;
132
+ const reassignOrphans = options?.reassignOrphans ?? true;
133
+ const rawBlockKeys = resolveBlockingGroupKeys(blockingPages, options);
134
+ const blockKeys = reassignOrphans
135
+ ? reassignOrphanBlockKeys(blockingPages, rawBlockKeys, options?.pathDepth)
136
+ : rawBlockKeys;
137
+ const indicesByBlockKey = new Map();
138
+ for (const [index, blockKey] of blockKeys.entries()) {
139
+ const indices = indicesByBlockKey.get(blockKey);
140
+ if (indices) {
141
+ indices.push(index);
142
+ }
143
+ else {
144
+ indicesByBlockKey.set(blockKey, [index]);
145
+ }
146
+ }
147
+ const autoCapMainDepth = options?.autoCapMainDepth ?? false;
148
+ if (autoCapMainDepth) {
149
+ // Validated here, eagerly, because it's otherwise only reached from
150
+ // inside the per-block loop below — which never runs at all for an
151
+ // empty `pages` (no blocks), silently skipping a bad option instead
152
+ // of failing fast the way a direct `detectContentDepthCap` call
153
+ // always does.
154
+ validateDetectContentDepthCapOptions(options);
155
+ }
156
+ const finalKeys = Array.from({ length: pages.length });
157
+ for (const [blockKey, indices] of indicesByBlockKey) {
158
+ const blockPreparedHtml = indices.map((index) => requireIndex(preparedHtml, index));
159
+ // A block of 1 can never produce more than one cluster regardless of
160
+ // how it's tokenized — nothing to compare it against — so detecting a
161
+ // knee and capping for it would only spend a full multi-depth sweep
162
+ // (see detectContentDepthCap's own cost notes) to arrive back at the
163
+ // same single-cluster result. Skipped rather than swept.
164
+ const maxMainDepth = autoCapMainDepth && blockPreparedHtml.length > 1
165
+ ? detectContentDepthCap(blockPreparedHtml, options)
166
+ : undefined;
167
+ const blockTokenSets = blockPreparedHtml.map((html) => {
168
+ const capped = maxMainDepth === undefined
169
+ ? html
170
+ : capContentDepth(html, { landmark: 'main', maxDepth: maxMainDepth })
171
+ .remainderHtml;
172
+ return new Set(tokenize(capped, options).tokens);
173
+ });
174
+ const localLabels = resolveStructuralClusterKeys(blockTokenSets, options);
175
+ for (const [position, index] of indices.entries()) {
176
+ finalKeys[index] = JSON.stringify([blockKey, requireIndex(localLabels, position)]);
177
+ }
178
+ }
179
+ if (!mergeRareLandmarkClusters) {
180
+ return finalKeys;
181
+ }
182
+ // Deliberately not a reuse of blockTokenSets (this function's own
183
+ // primary-clustering token sets): those follow excludeLandmarks (raw
184
+ // HTML, landmarks included, when false) and resolveStructuralClusterKeys
185
+ // narrows them further via its internal deriveComparisonSets once a
186
+ // block reaches 10+ pages. mergeLandmarkAffinedClusters's secondary
187
+ // content-similarity gate is meant to be independent corroboration
188
+ // alongside the landmark match already used to select these pages — if
189
+ // it reused landmark-inclusive tokens, a bulky shared rare header's own
190
+ // tokens could inflate two otherwise-unrelated pages' similarity past
191
+ // the gate, and if it reused the frequency-narrowed set, the gate would
192
+ // silently test different content than its own JSDoc describes. Always
193
+ // tokenizing each page's landmark-excised remainderHtml here keeps the
194
+ // gate's evidence independent of both.
195
+ const resolvedLandmarks = requireLandmarks(landmarks);
196
+ const mergeGateContentTokenSets = resolvedLandmarks.map((entry) => new Set(tokenize(entry.remainderHtml, options).tokens));
197
+ return mergeLandmarkAffinedClusters(finalKeys, resolvedLandmarks, mergeGateContentTokenSets, options);
198
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @see resolveStructuralClusterKeys
3
+ */
4
+ export type ResolveStructuralClusterKeysOptions = {
5
+ /**
6
+ * Minimum `jaccardSimilarity()` score required between *every* pair of
7
+ * pages within a cluster (complete-linkage criterion) for those pages to
8
+ * be grouped together. Must be a number in `[0, 1]` (`RangeError`
9
+ * otherwise). 0.8 is a starting-point heuristic, not validated against
10
+ * real corpora — tune per site once real cluster boundaries are
11
+ * inspected.
12
+ */
13
+ similarityThreshold?: number;
14
+ };
15
+ /**
16
+ * Resolves, within a single already-blocked group of pages (e.g. one key
17
+ * from {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}),
18
+ * which pages share a structural template. Returns one cluster key per
19
+ * page, in the same order as `tokenSets`. Does not call
20
+ * {@link ./tokenize.js | tokenize} itself (callers pass pages already
21
+ * tokenized and turned into `Set`s, mirroring
22
+ * {@link ./compute-document-frequency.js | computeDocumentFrequency}'s
23
+ * contract) and does not orchestrate multiple blocks — a heterogeneous
24
+ * corpus should be split into blocks by the caller before reaching this
25
+ * function.
26
+ *
27
+ * MinHash/LSH-based approximation and medoid-based refinement of these
28
+ * clusters are intentionally out of scope: NN-chain already computes the
29
+ * exact complete-linkage clustering in O(n²), so there is no accuracy being
30
+ * traded away by not approximating, and no evidence yet that O(n²) is a
31
+ * real bottleneck at the block sizes this function actually sees.
32
+ *
33
+ * Before comparing, each page's token set is narrowed to its page-specific
34
+ * content via `splitTokensByFrequency` (see `deriveComparisonSets`) once
35
+ * `tokenSets.length` is large enough for that to be statistically meaningful
36
+ * — below that floor, chrome dilution is accepted as the lesser failure and
37
+ * comparison falls back to the raw sets.
38
+ * @param tokenSets
39
+ * @param options
40
+ * @example
41
+ * ```ts
42
+ * resolveStructuralClusterKeys([
43
+ * new Set(['body>header', 'body>main>.card', 'body>footer']),
44
+ * new Set(['body>header', 'body>main>.card', 'body>footer']),
45
+ * new Set(['body>nav', 'body>main>form']),
46
+ * ]);
47
+ * // ['cluster:0', 'cluster:0', 'cluster:1']
48
+ * ```
49
+ */
50
+ export declare function resolveStructuralClusterKeys(tokenSets: readonly ReadonlySet<string>[], options?: ResolveStructuralClusterKeysOptions): string[];