@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.
- package/README.md +68 -0
- package/dist/array-edit-distance.d.ts +20 -0
- package/dist/array-edit-distance.js +52 -0
- package/dist/build-segment.d.ts +18 -0
- package/dist/build-segment.js +27 -0
- package/dist/cap-content-depth.d.ts +69 -0
- package/dist/cap-content-depth.js +161 -0
- package/dist/compute-document-frequency.d.ts +33 -0
- package/dist/compute-document-frequency.js +40 -0
- package/dist/create-frame.d.ts +16 -0
- package/dist/create-frame.js +29 -0
- package/dist/derive-path-group-key.d.ts +44 -0
- package/dist/derive-path-group-key.js +51 -0
- package/dist/derive-stylesheet-group-key.d.ts +36 -0
- package/dist/derive-stylesheet-group-key.js +41 -0
- package/dist/detect-content-depth-cap.d.ts +114 -0
- package/dist/detect-content-depth-cap.js +137 -0
- package/dist/escape-reg-exp.d.ts +11 -0
- package/dist/escape-reg-exp.js +13 -0
- package/dist/excise.d.ts +13 -0
- package/dist/excise.js +24 -0
- package/dist/extract-landmarks.d.ts +82 -0
- package/dist/extract-landmarks.js +104 -0
- package/dist/filter-first-party-stylesheet-hrefs.d.ts +73 -0
- package/dist/filter-first-party-stylesheet-hrefs.js +118 -0
- package/dist/find-shallowest-elements.d.ts +39 -0
- package/dist/find-shallowest-elements.js +121 -0
- package/dist/foldable-tags.d.ts +8 -0
- package/dist/foldable-tags.js +8 -0
- package/dist/format-bracket.d.ts +11 -0
- package/dist/format-bracket.js +17 -0
- package/dist/hash-content.d.ts +22 -0
- package/dist/hash-content.js +26 -0
- package/dist/html-region-utils.d.ts +74 -0
- package/dist/html-region-utils.js +96 -0
- package/dist/is-fold-candidate.d.ts +13 -0
- package/dist/is-fold-candidate.js +16 -0
- package/dist/is-genuine-close.d.ts +23 -0
- package/dist/is-genuine-close.js +27 -0
- package/dist/is-noise-class.d.ts +6 -0
- package/dist/is-noise-class.js +8 -0
- package/dist/jaccard-similarity.d.ts +23 -0
- package/dist/jaccard-similarity.js +36 -0
- package/dist/merge-landmark-affined-clusters.d.ts +179 -0
- package/dist/merge-landmark-affined-clusters.js +544 -0
- package/dist/merge-spans.d.ts +15 -0
- package/dist/merge-spans.js +22 -0
- package/dist/noise-class-patterns.d.ts +21 -0
- package/dist/noise-class-patterns.js +74 -0
- package/dist/normalize-for-hash.d.ts +10 -0
- package/dist/normalize-for-hash.js +12 -0
- package/dist/opaque-tags.d.ts +17 -0
- package/dist/opaque-tags.js +18 -0
- package/dist/parse-class-list.d.ts +10 -0
- package/dist/parse-class-list.js +23 -0
- package/dist/reassign-orphan-block-keys.d.ts +81 -0
- package/dist/reassign-orphan-block-keys.js +159 -0
- package/dist/remove-content-blocks.d.ts +67 -0
- package/dist/remove-content-blocks.js +150 -0
- package/dist/resolve-blocking-group-keys.d.ts +116 -0
- package/dist/resolve-blocking-group-keys.js +120 -0
- package/dist/resolve-closed-frame.d.ts +26 -0
- package/dist/resolve-closed-frame.js +33 -0
- package/dist/resolve-landmark-variant-keys.d.ts +66 -0
- package/dist/resolve-landmark-variant-keys.js +71 -0
- package/dist/resolve-options.d.ts +6 -0
- package/dist/resolve-options.js +10 -0
- package/dist/resolve-page-cluster-keys.d.ts +222 -0
- package/dist/resolve-page-cluster-keys.js +198 -0
- package/dist/resolve-structural-cluster-keys.d.ts +50 -0
- package/dist/resolve-structural-cluster-keys.js +287 -0
- package/dist/run-tokenizer.d.ts +33 -0
- package/dist/run-tokenizer.js +152 -0
- package/dist/split-tokens-by-frequency.d.ts +46 -0
- package/dist/split-tokens-by-frequency.js +88 -0
- package/dist/tokenize.d.ts +58 -0
- package/dist/tokenize.js +60 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.js +1 -0
- 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[];
|