@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
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Narrows every page's `stylesheetHrefs` down to just the hrefs whose host
3
- * matches the single most common host across the whole batch (the site's own
4
- * first-party domain), dropping every other host.
3
+ * matches that page's own `host` field (direct comparison), when the caller
4
+ * provides it. Falls back to the single most common host across the whole
5
+ * batch (the site's own first-party domain, inferred rather than given) for
6
+ * any page that omits `host` — see this function's own JSDoc further down
7
+ * for that fallback's known limitations.
5
8
  *
6
9
  * Confirmed on real crawl data (302 pages): a handful of articles embedding
7
10
  * a YouTube video pulled in `youtube.com`'s own player stylesheet plus a
@@ -20,20 +23,37 @@
20
23
  * first-party hrefs before blocking removes that false signal at the
21
24
  * source, rather than trying to recognize its effects downstream.
22
25
  *
23
- * Determining "first-party" from the batch's own href distribution (rather
24
- * than, say, comparing each href's host against each page's own URL) means
25
- * this needs no extra per-page input beyond what
26
- * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} already
27
- * takes but it inherits that same function's "roughly homogeneous batch"
28
- * precondition (see `computeDocumentFrequency`'s own JSDoc): a batch that
29
- * mixes pages from more than one site in one call has no single genuine
30
- * first-party host to find, and this function has no way to detect that
31
- * it's been handed one it will still confidently pick *a* dominant host
32
- * (whichever site contributes more stylesheet-bearing pages) and silently
33
- * strip every other site's real first-party hrefs. Splitting a
34
- * multi-site/section batch into homogeneous groups before calling this is
35
- * the caller's responsibility, same as it already is for
36
- * `resolveBlockingGroupKeys`.
26
+ * `host`, when provided, is compared directly against each of that page's
27
+ * own stylesheet hrefs no batch-wide inference involved, so this path
28
+ * cannot mistake a third party for the first party regardless of how many
29
+ * pages happen to also embed it. Added after a real crawl (302 pages) hit
30
+ * the dominant-host fallback's tie case: every single page loaded both its
31
+ * own first-party stylesheet *and* the same `fonts.googleapis.com` webfont
32
+ * request (a common sitewide pattern, not a rare misconfiguration), so both
33
+ * hosts tied at "referenced by 100% of pages" and the fallback's `>`-only
34
+ * tie-break (see below) picked whichever host happened to be counted first
35
+ * silently the wrong one on that crawl. A caller that already knows each
36
+ * page's own host (e.g. it has the page's URL on hand, as most crawlers do)
37
+ * should always provide it; the inferred fallback exists only for callers
38
+ * that don't have that information available.
39
+ *
40
+ * The dominant-host fallback (used per-page whenever that page omits `host`)
41
+ * determines "first-party" from the batch's own href distribution instead
42
+ * — comparing each href's host against each page's own URL wasn't possible
43
+ * for callers that only ever had `stylesheetHrefs` on hand, not full page
44
+ * URLs (this is still true for anything built directly on
45
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}'s
46
+ * `PageBlockingSignals`, which never carried a host field until this
47
+ * `host` option was added). It inherits `resolveBlockingGroupKeys`'s own
48
+ * "roughly homogeneous batch" precondition (see `computeDocumentFrequency`'s
49
+ * own JSDoc): a batch that mixes pages from more than one site in one call
50
+ * has no single genuine first-party host to find, and this function has no
51
+ * way to detect that it's been handed one — it will still confidently pick
52
+ * a* dominant host (whichever site contributes more stylesheet-bearing
53
+ * pages) and silently strip every other site's real first-party hrefs.
54
+ * Splitting a multi-site/section batch into homogeneous groups before
55
+ * calling this (or simply providing `host` per page) is the caller's
56
+ * responsibility, same as it already is for `resolveBlockingGroupKeys`.
37
57
  *
38
58
  * The dominant host is picked by how many *pages* reference it at least
39
59
  * once, not by how many stylesheet `<link>` tags reference it — a page
@@ -45,19 +65,23 @@
45
65
  * unresolved protocol-relative URL) is still one site, not two competing
46
66
  * "hosts" splitting its own vote.
47
67
  *
48
- * The trade-off: a site that legitimately serves its own stylesheets from
49
- * more than one first-party host (e.g. a CDN subdomain alongside the main
50
- * domain) will have its non-dominant host's hrefs dropped too, same as any
51
- * genuinely-third-party host not yet observed on real data, but a known
52
- * limitation of picking a single dominant host rather than a set.
68
+ * The fallback's remaining trade-offs: a site that legitimately serves its
69
+ * own stylesheets from more than one first-party host (e.g. a CDN subdomain
70
+ * alongside the main domain) will have its non-dominant host's hrefs
71
+ * dropped too, same as any genuinely-third-party host; and a tie between
72
+ * two equally-common hosts silently keeps whichever was counted first
73
+ * (confirmed above to include real, common sitewide third parties, not
74
+ * just a theoretical edge case) — both are avoided entirely by providing
75
+ * `host`.
53
76
  *
54
- * A batch where no page has any stylesheet href at all (or none of the
55
- * hrefs are parseable absolute URLs) has no host to detect; every page's
56
- * `stylesheetHrefs` is returned unchanged in that case, matching this
77
+ * A page with neither a provided `host` nor any batch-wide dominant host to
78
+ * fall back on (no page in the batch has any parseable stylesheet href at
79
+ * all) has its `stylesheetHrefs` returned unchanged, matching this
57
80
  * function's job of narrowing signal, not fabricating it.
58
81
  * @param pages
59
82
  * @example
60
83
  * ```ts
84
+ * // Without `host`: falls back to dominant-host inference (ties possible).
61
85
  * filterFirstPartyStylesheetHrefs([
62
86
  * { stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/b.css'] },
63
87
  * { stylesheetHrefs: ['https://example.com/a.css', 'https://fonts.googleapis.com/css?family=x'] },
@@ -66,8 +90,18 @@
66
90
  * // { stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/b.css'] },
67
91
  * // { stylesheetHrefs: ['https://example.com/a.css'] }, // fonts.googleapis.com dropped
68
92
  * // ]
93
+ *
94
+ * // With `host`: direct per-page comparison, immune to ties.
95
+ * filterFirstPartyStylesheetHrefs([
96
+ * {
97
+ * host: 'example.com',
98
+ * stylesheetHrefs: ['https://example.com/a.css', 'https://fonts.googleapis.com/css?family=x'],
99
+ * },
100
+ * ]);
101
+ * // [{ host: 'example.com', stylesheetHrefs: ['https://example.com/a.css'] }]
69
102
  * ```
70
103
  */
71
104
  export declare function filterFirstPartyStylesheetHrefs<T extends {
72
105
  stylesheetHrefs: readonly string[];
106
+ host?: string;
73
107
  }>(pages: readonly T[]): T[];
@@ -16,8 +16,11 @@ function tryGetHost(href) {
16
16
  }
17
17
  /**
18
18
  * Narrows every page's `stylesheetHrefs` down to just the hrefs whose host
19
- * matches the single most common host across the whole batch (the site's own
20
- * first-party domain), dropping every other host.
19
+ * matches that page's own `host` field (direct comparison), when the caller
20
+ * provides it. Falls back to the single most common host across the whole
21
+ * batch (the site's own first-party domain, inferred rather than given) for
22
+ * any page that omits `host` — see this function's own JSDoc further down
23
+ * for that fallback's known limitations.
21
24
  *
22
25
  * Confirmed on real crawl data (302 pages): a handful of articles embedding
23
26
  * a YouTube video pulled in `youtube.com`'s own player stylesheet plus a
@@ -36,20 +39,37 @@ function tryGetHost(href) {
36
39
  * first-party hrefs before blocking removes that false signal at the
37
40
  * source, rather than trying to recognize its effects downstream.
38
41
  *
39
- * Determining "first-party" from the batch's own href distribution (rather
40
- * than, say, comparing each href's host against each page's own URL) means
41
- * this needs no extra per-page input beyond what
42
- * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} already
43
- * takes but it inherits that same function's "roughly homogeneous batch"
44
- * precondition (see `computeDocumentFrequency`'s own JSDoc): a batch that
45
- * mixes pages from more than one site in one call has no single genuine
46
- * first-party host to find, and this function has no way to detect that
47
- * it's been handed one it will still confidently pick *a* dominant host
48
- * (whichever site contributes more stylesheet-bearing pages) and silently
49
- * strip every other site's real first-party hrefs. Splitting a
50
- * multi-site/section batch into homogeneous groups before calling this is
51
- * the caller's responsibility, same as it already is for
52
- * `resolveBlockingGroupKeys`.
42
+ * `host`, when provided, is compared directly against each of that page's
43
+ * own stylesheet hrefs no batch-wide inference involved, so this path
44
+ * cannot mistake a third party for the first party regardless of how many
45
+ * pages happen to also embed it. Added after a real crawl (302 pages) hit
46
+ * the dominant-host fallback's tie case: every single page loaded both its
47
+ * own first-party stylesheet *and* the same `fonts.googleapis.com` webfont
48
+ * request (a common sitewide pattern, not a rare misconfiguration), so both
49
+ * hosts tied at "referenced by 100% of pages" and the fallback's `>`-only
50
+ * tie-break (see below) picked whichever host happened to be counted first
51
+ * silently the wrong one on that crawl. A caller that already knows each
52
+ * page's own host (e.g. it has the page's URL on hand, as most crawlers do)
53
+ * should always provide it; the inferred fallback exists only for callers
54
+ * that don't have that information available.
55
+ *
56
+ * The dominant-host fallback (used per-page whenever that page omits `host`)
57
+ * determines "first-party" from the batch's own href distribution instead
58
+ * — comparing each href's host against each page's own URL wasn't possible
59
+ * for callers that only ever had `stylesheetHrefs` on hand, not full page
60
+ * URLs (this is still true for anything built directly on
61
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}'s
62
+ * `PageBlockingSignals`, which never carried a host field until this
63
+ * `host` option was added). It inherits `resolveBlockingGroupKeys`'s own
64
+ * "roughly homogeneous batch" precondition (see `computeDocumentFrequency`'s
65
+ * own JSDoc): a batch that mixes pages from more than one site in one call
66
+ * has no single genuine first-party host to find, and this function has no
67
+ * way to detect that it's been handed one — it will still confidently pick
68
+ * a* dominant host (whichever site contributes more stylesheet-bearing
69
+ * pages) and silently strip every other site's real first-party hrefs.
70
+ * Splitting a multi-site/section batch into homogeneous groups before
71
+ * calling this (or simply providing `host` per page) is the caller's
72
+ * responsibility, same as it already is for `resolveBlockingGroupKeys`.
53
73
  *
54
74
  * The dominant host is picked by how many *pages* reference it at least
55
75
  * once, not by how many stylesheet `<link>` tags reference it — a page
@@ -61,19 +81,23 @@ function tryGetHost(href) {
61
81
  * unresolved protocol-relative URL) is still one site, not two competing
62
82
  * "hosts" splitting its own vote.
63
83
  *
64
- * The trade-off: a site that legitimately serves its own stylesheets from
65
- * more than one first-party host (e.g. a CDN subdomain alongside the main
66
- * domain) will have its non-dominant host's hrefs dropped too, same as any
67
- * genuinely-third-party host not yet observed on real data, but a known
68
- * limitation of picking a single dominant host rather than a set.
84
+ * The fallback's remaining trade-offs: a site that legitimately serves its
85
+ * own stylesheets from more than one first-party host (e.g. a CDN subdomain
86
+ * alongside the main domain) will have its non-dominant host's hrefs
87
+ * dropped too, same as any genuinely-third-party host; and a tie between
88
+ * two equally-common hosts silently keeps whichever was counted first
89
+ * (confirmed above to include real, common sitewide third parties, not
90
+ * just a theoretical edge case) — both are avoided entirely by providing
91
+ * `host`.
69
92
  *
70
- * A batch where no page has any stylesheet href at all (or none of the
71
- * hrefs are parseable absolute URLs) has no host to detect; every page's
72
- * `stylesheetHrefs` is returned unchanged in that case, matching this
93
+ * A page with neither a provided `host` nor any batch-wide dominant host to
94
+ * fall back on (no page in the batch has any parseable stylesheet href at
95
+ * all) has its `stylesheetHrefs` returned unchanged, matching this
73
96
  * function's job of narrowing signal, not fabricating it.
74
97
  * @param pages
75
98
  * @example
76
99
  * ```ts
100
+ * // Without `host`: falls back to dominant-host inference (ties possible).
77
101
  * filterFirstPartyStylesheetHrefs([
78
102
  * { stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/b.css'] },
79
103
  * { stylesheetHrefs: ['https://example.com/a.css', 'https://fonts.googleapis.com/css?family=x'] },
@@ -82,6 +106,15 @@ function tryGetHost(href) {
82
106
  * // { stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/b.css'] },
83
107
  * // { stylesheetHrefs: ['https://example.com/a.css'] }, // fonts.googleapis.com dropped
84
108
  * // ]
109
+ *
110
+ * // With `host`: direct per-page comparison, immune to ties.
111
+ * filterFirstPartyStylesheetHrefs([
112
+ * {
113
+ * host: 'example.com',
114
+ * stylesheetHrefs: ['https://example.com/a.css', 'https://fonts.googleapis.com/css?family=x'],
115
+ * },
116
+ * ]);
117
+ * // [{ host: 'example.com', stylesheetHrefs: ['https://example.com/a.css'] }]
85
118
  * ```
86
119
  */
87
120
  export function filterFirstPartyStylesheetHrefs(pages) {
@@ -106,13 +139,19 @@ export function filterFirstPartyStylesheetHrefs(pages) {
106
139
  dominantCount = count;
107
140
  }
108
141
  }
109
- if (dominantHost === undefined) {
110
- return [...pages];
111
- }
112
- return pageHrefHosts.map(({ page, hrefHosts }) => ({
113
- ...page,
114
- stylesheetHrefs: hrefHosts
115
- .filter(({ host }) => host === dominantHost)
116
- .map(({ href }) => href),
117
- }));
142
+ return pageHrefHosts.map(({ page, hrefHosts }) => {
143
+ // A page that supplies its own host is judged against that host
144
+ // alone, bypassing the batch-wide dominant-host inference (and its
145
+ // tie-breaking pitfall) entirely see this function's own JSDoc.
146
+ const expectedHost = page.host ?? dominantHost;
147
+ if (expectedHost === undefined) {
148
+ return { ...page };
149
+ }
150
+ return {
151
+ ...page,
152
+ stylesheetHrefs: hrefHosts
153
+ .filter(({ host }) => host === expectedHost)
154
+ .map(({ href }) => href),
155
+ };
156
+ });
118
157
  }
@@ -1,10 +1,29 @@
1
+ /**
2
+ * One genuinely-closed match for type `T`, carrying the depth used for
3
+ * shallowest-wins selection and the element's whole-element and inner-content
4
+ * spans. `depth` is 0 for `<body>` itself, 1 for a direct child, and so on.
5
+ *
6
+ * Returned by {@link ./find-shallowest-elements.js | findMatchingElements},
7
+ * which is the "collect every candidate" primitive shared by every landmark-
8
+ * scanning use site. Callers that want a single winner per type consume the
9
+ * matches through {@link ./find-shallowest-elements.js | findShallowestElements}
10
+ * (which drops `depth` since it's an internal selection artifact once the
11
+ * shallowest has been chosen).
12
+ */
13
+ export type MatchingElement<T extends string> = {
14
+ type: T;
15
+ depth: number;
16
+ startOffset: number;
17
+ endOffset: number;
18
+ contentStart: number;
19
+ contentEnd: number;
20
+ };
1
21
  /**
2
22
  * One winning element for type `T`: the shallowest (fewest ancestors since
3
23
  * `<body>`) genuinely-closed match, ties broken by document order. Both the
4
24
  * whole-element span (`startOffset`/`endOffset`) and the inner-content span
5
25
  * (`contentStart`/`contentEnd`, excluding the element's own opening/closing
6
- * tags) are always computed — {@link ./extract-landmarks.js | extractLandmarks}
7
- * only needs the former, {@link ./cap-content-depth.js | capContentDepth}
26
+ * tags) are always computed — {@link ./cap-content-depth.js | capContentDepth}
8
27
  * only needs the latter, and computing both is cheap enough (two
9
28
  * `indexOf`/`lastIndexOf` calls) that carrying the unused half costs nothing
10
29
  * a caller need worry about.
@@ -17,23 +36,41 @@ export type ShallowestElementMatch<T extends string> = {
17
36
  contentEnd: number;
18
37
  };
19
38
  /**
20
- * Shared walk behind {@link ./extract-landmarks.js | extractLandmarks} (which
21
- * matches four landmark types per element in one pass) and
22
- * {@link ./cap-content-depth.js | capContentDepth} (which matches a single
23
- * landmark). Both need the identical "shallowest genuinely-closed match
24
- * wins" search same `<body>`-scoping, same opaque-tag skip, same malformed-
25
- * markup discard via {@link ./is-genuine-close.js | isGenuineClose} so a
26
- * fix to one (e.g. the body-scoping edge case already fixed once in
27
- * `extractLandmarks`) can't silently fail to apply to the other.
39
+ * The shared HTML walk behind every landmark-scanning use site: collects
40
+ * every genuinely-closed match for every requested type, without picking a
41
+ * winner. {@link ./find-shallowest-elements.js | findShallowestElements}
42
+ * layers shallowest-per-type selection on top of this; extractLandmarks
43
+ * consumes the full list so downstream data-driven frequency filters can
44
+ * decide which matches are chrome vs content instead of a hard-coded depth
45
+ * rule making that call up here.
28
46
  *
29
47
  * Only the first `<body>` is in scope, and nothing inside an opaque tag
30
48
  * (`script`/`style`/`noscript`/`svg`) is searched — see
31
49
  * `extractLandmarks`/`capContentDepth`'s own JSDoc for why.
50
+ *
51
+ * Results are sorted by `startOffset` ascending (document order of the
52
+ * opening tag), which is what extractLandmarks needs both for document-order
53
+ * concatenation and for the outer-before-inner sweep used to filter out
54
+ * nested landmarks so they aren't double-counted in shell tokens.
32
55
  * @param html
33
56
  * @param matchTypes Given an element's tag name and `role` attribute (already
34
57
  * normalized: an empty/absent `role` arrives as `undefined`), returns every
35
58
  * type `T` that element matches. Returning more than one lets a single
36
- * element (e.g. `<header role="navigation">`) win more than one type at
59
+ * element (e.g. `<header role="navigation">`) match more than one type at
37
60
  * once.
38
61
  */
62
+ export declare function findMatchingElements<T extends string>(html: string, matchTypes: (tagName: string, role: string | undefined) => readonly T[]): MatchingElement<T>[];
63
+ /**
64
+ * Picks the single shallowest (fewest ancestors since `<body>`) match per
65
+ * type from {@link ./find-shallowest-elements.js | findMatchingElements},
66
+ * ties broken by document order. Used by
67
+ * {@link ./cap-content-depth.js | capContentDepth} to locate the one `<main>`
68
+ * element per page (HTML spec discourages multiple `<main>`s, so shallowest-
69
+ * wins is semantically correct for that use).
70
+ *
71
+ * `depth` is dropped from the returned shape because it's an internal
72
+ * selection artifact that no consumer of the winner needs.
73
+ * @param html
74
+ * @param matchTypes
75
+ */
39
76
  export declare function findShallowestElements<T extends string>(html: string, matchTypes: (tagName: string, role: string | undefined) => readonly T[]): ShallowestElementMatch<T>[];
@@ -2,28 +2,32 @@ import { Parser } from 'htmlparser2';
2
2
  import { isGenuineClose } from './is-genuine-close.js';
3
3
  import { isOpaqueTagName } from './opaque-tags.js';
4
4
  /**
5
- * Shared walk behind {@link ./extract-landmarks.js | extractLandmarks} (which
6
- * matches four landmark types per element in one pass) and
7
- * {@link ./cap-content-depth.js | capContentDepth} (which matches a single
8
- * landmark). Both need the identical "shallowest genuinely-closed match
9
- * wins" search same `<body>`-scoping, same opaque-tag skip, same malformed-
10
- * markup discard via {@link ./is-genuine-close.js | isGenuineClose} so a
11
- * fix to one (e.g. the body-scoping edge case already fixed once in
12
- * `extractLandmarks`) can't silently fail to apply to the other.
5
+ * The shared HTML walk behind every landmark-scanning use site: collects
6
+ * every genuinely-closed match for every requested type, without picking a
7
+ * winner. {@link ./find-shallowest-elements.js | findShallowestElements}
8
+ * layers shallowest-per-type selection on top of this; extractLandmarks
9
+ * consumes the full list so downstream data-driven frequency filters can
10
+ * decide which matches are chrome vs content instead of a hard-coded depth
11
+ * rule making that call up here.
13
12
  *
14
13
  * Only the first `<body>` is in scope, and nothing inside an opaque tag
15
14
  * (`script`/`style`/`noscript`/`svg`) is searched — see
16
15
  * `extractLandmarks`/`capContentDepth`'s own JSDoc for why.
16
+ *
17
+ * Results are sorted by `startOffset` ascending (document order of the
18
+ * opening tag), which is what extractLandmarks needs both for document-order
19
+ * concatenation and for the outer-before-inner sweep used to filter out
20
+ * nested landmarks so they aren't double-counted in shell tokens.
17
21
  * @param html
18
22
  * @param matchTypes Given an element's tag name and `role` attribute (already
19
23
  * normalized: an empty/absent `role` arrives as `undefined`), returns every
20
24
  * type `T` that element matches. Returning more than one lets a single
21
- * element (e.g. `<header role="navigation">`) win more than one type at
25
+ * element (e.g. `<header role="navigation">`) match more than one type at
22
26
  * once.
23
27
  */
24
- export function findShallowestElements(html, matchTypes) {
28
+ export function findMatchingElements(html, matchTypes) {
25
29
  const stack = [];
26
- const candidates = [];
30
+ const matches = [];
27
31
  let opaque = null;
28
32
  let bodyDone = false;
29
33
  let ignoredBodyOpens = 0;
@@ -83,7 +87,7 @@ export function findShallowestElements(html, matchTypes) {
83
87
  const contentStart = html.indexOf('>', frame.startOffset) + 1;
84
88
  const contentEnd = html.lastIndexOf('<', endOffset - 1);
85
89
  for (const type of frame.matchedTypes) {
86
- candidates.push({
90
+ matches.push({
87
91
  type,
88
92
  depth,
89
93
  startOffset: frame.startOffset,
@@ -98,19 +102,35 @@ export function findShallowestElements(html, matchTypes) {
98
102
  },
99
103
  }, { decodeEntities: false });
100
104
  parser.end(html);
105
+ // Close-tag order is post-order (inner before outer); flip to
106
+ // startOffset-ascending so callers get document-order-of-opening.
107
+ matches.sort((a, b) => a.startOffset - b.startOffset);
108
+ return matches;
109
+ }
110
+ /**
111
+ * Picks the single shallowest (fewest ancestors since `<body>`) match per
112
+ * type from {@link ./find-shallowest-elements.js | findMatchingElements},
113
+ * ties broken by document order. Used by
114
+ * {@link ./cap-content-depth.js | capContentDepth} to locate the one `<main>`
115
+ * element per page (HTML spec discourages multiple `<main>`s, so shallowest-
116
+ * wins is semantically correct for that use).
117
+ *
118
+ * `depth` is dropped from the returned shape because it's an internal
119
+ * selection artifact that no consumer of the winner needs.
120
+ * @param html
121
+ * @param matchTypes
122
+ */
123
+ export function findShallowestElements(html, matchTypes) {
124
+ const matches = findMatchingElements(html, matchTypes);
101
125
  const winners = new Map();
102
- for (const candidate of candidates) {
103
- const current = winners.get(candidate.type);
126
+ for (const match of matches) {
127
+ const current = winners.get(match.type);
104
128
  if (current === undefined ||
105
- candidate.depth < current.depth ||
106
- (candidate.depth === current.depth && candidate.startOffset < current.startOffset)) {
107
- winners.set(candidate.type, candidate);
129
+ match.depth < current.depth ||
130
+ (match.depth === current.depth && match.startOffset < current.startOffset)) {
131
+ winners.set(match.type, match);
108
132
  }
109
133
  }
110
- // `depth` (Candidate's own tie-break field) is deliberately not part of
111
- // ShallowestElementMatch: it's an internal selection detail, not
112
- // something either caller (extractLandmarks, capContentDepth) uses once
113
- // the winner is chosen.
114
134
  return [...winners.values()].map(({ type, startOffset, endOffset, contentStart, contentEnd }) => ({
115
135
  type,
116
136
  startOffset,
@@ -0,0 +1,61 @@
1
+ import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js';
2
+ /**
3
+ * One cluster (post-Stage-A) entering cross-block comparison.
4
+ *
5
+ * ## Why `memberLandmarkInstances` rather than raw `ExtractLandmarksResult[]`
6
+ *
7
+ * Stage B never reads the full `ExtractLandmarksResult` — it only needs each
8
+ * member page's landmark instances (per-type, per-signature, per-token-set)
9
+ * for `shellQuorum`'s per-token page-frequency histogram. Callers pre-run
10
+ * {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}
11
+ * once at unit creation and hand Stage B the compact instance list instead
12
+ * of the ~10× larger raw landmark HTML strings. This is the single largest
13
+ * memory reduction the streaming path relies on: the difference between a
14
+ * 176k-page corpus fitting in an 8 GB heap and exhausting a 12 GB heap.
15
+ */
16
+ export type CrossBlockUnit = {
17
+ readonly key: string;
18
+ readonly memberTokenSets: readonly ReadonlySet<string>[];
19
+ readonly memberLandmarkInstances: readonly (readonly PerPageLandmarkInstance[])[];
20
+ };
21
+ /**
22
+ * Merges cross-block clusters (Stage B) via recursive quorum-core comparison.
23
+ *
24
+ * Returns a `Map` from each input unit's `key` to its final root key. Units
25
+ * not absorbed into any other unit map to themselves.
26
+ *
27
+ * Three merge mechanisms run per round, in order:
28
+ * 1. **Fine stage** — complete-linkage at `CROSS_BLOCK_THRESHOLD` on quorum
29
+ * cores, then containment assignment (0.9), then shape-Jaccard (0.9) for
30
+ * class-name-only differences.
31
+ * 2. **L2 stage** (only when fine found nothing) — multiset containment on
32
+ * `main`-anchored 2-level shape signatures, with shell corroboration
33
+ * (header+nav+footer quorum Jaccard ≥ 0.8) required.
34
+ *
35
+ * Rounds continue until neither stage finds anything, or `MAX_ROUNDS` is hit.
36
+ * Each round re-derives quorum cores from pooled members of merged units.
37
+ *
38
+ * Why quorum cores instead of strict intersection or full union:
39
+ * strict intersection degenerated on real crawl data (89 articles → 1 shared
40
+ * distinctive token → jaccard 1.0 false merges). Full union was shell-dominated
41
+ * (298-page avalanche into 4 clusters). Both failure modes are documented in
42
+ * `@d-zero/page-cluster` source JSDoc; quorum 80% + page-frequency shell
43
+ * removal was validated on two real crawl corpora.
44
+ * @param units Post-Stage-A clusters.
45
+ * @param options Forwarded `similarityThreshold` (defaults to 0.8).
46
+ * @param options.similarityThreshold
47
+ * @param options.capMembers
48
+ */
49
+ export declare function mergeCrossBlockClusters(units: readonly CrossBlockUnit[], options?: {
50
+ similarityThreshold?: number;
51
+ /**
52
+ * Opt-in post-merge cap on a group's retained member count. When
53
+ * set, each merged group is reservoir-sampled back down to this
54
+ * cap after every merge so a chain of merges cannot balloon past
55
+ * the per-unit cap Stage A applied at creation. Callers on the
56
+ * streaming path pass the same value as `capMembers` used in
57
+ * Stage A; the in-memory path omits it so validated corpora keep
58
+ * full-membership merge behavior unchanged.
59
+ */
60
+ capMembers?: number;
61
+ }): Map<string, string>;