@d-zero/page-cluster 0.2.0 → 0.3.1

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 +131 -39
  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 +6 -59
  48. package/dist/html-region-utils.d.ts +0 -74
  49. package/dist/html-region-utils.js +0 -96
  50. package/dist/merge-landmark-affined-clusters.d.ts +0 -179
  51. package/dist/merge-landmark-affined-clusters.js +0 -544
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Runs {@link ./derive-path-group-key.js | derivePathGroupKey} for every
3
+ * candidate depth in `1..MAX_PATH_DEPTH` and picks the depth whose
4
+ * increment* in distinct-key count over the previous depth is the largest
5
+ * data-driven gap. Same self-tuning primitive
6
+ * ({@link ./auto-cut-threshold.js | autoCutThreshold}) this package already
7
+ * uses for Stage A merge-height cutoffs, applied recursively at the URL-path
8
+ * layer.
9
+ *
10
+ * ## Why the increment, not the count itself
11
+ *
12
+ * Distinct-key count is monotonically non-decreasing in `depth` (a deeper
13
+ * key partitions strictly more finely), so the raw count only ever rises
14
+ * with `depth` and its max-gap point sits at the highest depth
15
+ * unconditionally. What we actually want is the depth at which adding one
16
+ * more segment *starts* fragmenting into per-page noise — i.e. the
17
+ * transition* between "still discriminating templates" and "just enumerating
18
+ * pages." Feeding the per-depth increment (`count[d] − count[d − 1]`) to
19
+ * `autoCutThreshold` picks the depth just below that transition: the sweep
20
+ * takes the widest jump as the signal that a structural boundary has been
21
+ * crossed and returns the depth immediately before it.
22
+ *
23
+ * ## Degenerate cases
24
+ *
25
+ * - Every page has the same top-level segment → count[1] = 1 for all
26
+ * depths' worth of comparable growth, so the max gap is (effectively) at
27
+ * depth 1 and this function returns depth 1
28
+ * - All depths produce identical counts (a pathologically uniform corpus)
29
+ * → falls back to depth 1
30
+ *
31
+ * ## Not wired into the main clustering path yet
32
+ *
33
+ * `resolvePageClusterKeys` continues to invoke `derivePathGroupKey` with the
34
+ * static default `depth: 1`. This function is exported as a building block
35
+ * for callers who want to opt in (via `pathDepth: 'auto'` on the blocking
36
+ * options), and for a future PR that flips the default after validating the
37
+ * data-driven depth against real corpora — same staged approach every
38
+ * previous auto-cut adoption in this package took.
39
+ * @param pagesPaths
40
+ * @example
41
+ * ```ts
42
+ * derivePathClusterKeys([
43
+ * ['dept-a', 'news', '1'],
44
+ * ['dept-a', 'news', '2'],
45
+ * ['dept-b', 'about'],
46
+ * ]);
47
+ * // { depth: 1, keys: ['dept-a', 'dept-a', 'dept-b'] }
48
+ * ```
49
+ */
50
+ export declare function derivePathClusterKeys(pagesPaths: readonly (readonly string[])[]): {
51
+ readonly depth: number;
52
+ readonly keys: string[];
53
+ };
@@ -0,0 +1,109 @@
1
+ import { autoCutThreshold } from './auto-cut-threshold.js';
2
+ import { derivePathGroupKey } from './derive-path-group-key.js';
3
+ /**
4
+ * Maximum leading URL-path depth `derivePathClusterKeys` will consider when
5
+ * auto-selecting a group-key depth. Empirically past 5 the sweep produces
6
+ * near-per-page keys that are useless as blocking signals for any real site,
7
+ * so this bounds the linear scan cost regardless of input path length.
8
+ */
9
+ const MAX_PATH_DEPTH = 5;
10
+ /**
11
+ * Below this page count `derivePathClusterKeys` falls back to depth 1
12
+ * unconditionally: the auto-cut needs at least a handful of pages to see a
13
+ * meaningful gap in the depth-vs-key-count curve, and every real
14
+ * "blocking-signal" use case for this function is over corpora larger than
15
+ * this. Chosen conservatively so that small-corpus regression tests stay
16
+ * on the shallowest-depth path they already validated.
17
+ */
18
+ const AUTO_CUT_MIN_PAGES = 20;
19
+ /**
20
+ * Runs {@link ./derive-path-group-key.js | derivePathGroupKey} for every
21
+ * candidate depth in `1..MAX_PATH_DEPTH` and picks the depth whose
22
+ * increment* in distinct-key count over the previous depth is the largest
23
+ * data-driven gap. Same self-tuning primitive
24
+ * ({@link ./auto-cut-threshold.js | autoCutThreshold}) this package already
25
+ * uses for Stage A merge-height cutoffs, applied recursively at the URL-path
26
+ * layer.
27
+ *
28
+ * ## Why the increment, not the count itself
29
+ *
30
+ * Distinct-key count is monotonically non-decreasing in `depth` (a deeper
31
+ * key partitions strictly more finely), so the raw count only ever rises
32
+ * with `depth` and its max-gap point sits at the highest depth
33
+ * unconditionally. What we actually want is the depth at which adding one
34
+ * more segment *starts* fragmenting into per-page noise — i.e. the
35
+ * transition* between "still discriminating templates" and "just enumerating
36
+ * pages." Feeding the per-depth increment (`count[d] − count[d − 1]`) to
37
+ * `autoCutThreshold` picks the depth just below that transition: the sweep
38
+ * takes the widest jump as the signal that a structural boundary has been
39
+ * crossed and returns the depth immediately before it.
40
+ *
41
+ * ## Degenerate cases
42
+ *
43
+ * - Every page has the same top-level segment → count[1] = 1 for all
44
+ * depths' worth of comparable growth, so the max gap is (effectively) at
45
+ * depth 1 and this function returns depth 1
46
+ * - All depths produce identical counts (a pathologically uniform corpus)
47
+ * → falls back to depth 1
48
+ *
49
+ * ## Not wired into the main clustering path yet
50
+ *
51
+ * `resolvePageClusterKeys` continues to invoke `derivePathGroupKey` with the
52
+ * static default `depth: 1`. This function is exported as a building block
53
+ * for callers who want to opt in (via `pathDepth: 'auto'` on the blocking
54
+ * options), and for a future PR that flips the default after validating the
55
+ * data-driven depth against real corpora — same staged approach every
56
+ * previous auto-cut adoption in this package took.
57
+ * @param pagesPaths
58
+ * @example
59
+ * ```ts
60
+ * derivePathClusterKeys([
61
+ * ['dept-a', 'news', '1'],
62
+ * ['dept-a', 'news', '2'],
63
+ * ['dept-b', 'about'],
64
+ * ]);
65
+ * // { depth: 1, keys: ['dept-a', 'dept-a', 'dept-b'] }
66
+ * ```
67
+ */
68
+ export function derivePathClusterKeys(pagesPaths) {
69
+ if (pagesPaths.length === 0)
70
+ return { depth: 1, keys: [] };
71
+ // Depth-1 keys are needed unconditionally — either as the return value
72
+ // itself (short-circuit / fallback) or as the count[1] entry for the
73
+ // auto-cut sweep.
74
+ const depth1Keys = pagesPaths.map((paths) => derivePathGroupKey(paths, 1));
75
+ if (pagesPaths.length < AUTO_CUT_MIN_PAGES) {
76
+ return { depth: 1, keys: depth1Keys };
77
+ }
78
+ // Distinct-key count at each candidate depth. Increments per depth:
79
+ // count[d] − count[d − 1] — the marginal fragmentation added by going
80
+ // one segment deeper.
81
+ const countsPerDepth = [new Set(depth1Keys).size];
82
+ const keysPerDepth = [depth1Keys];
83
+ for (let depth = 2; depth <= MAX_PATH_DEPTH; depth++) {
84
+ const keys = pagesPaths.map((paths) => derivePathGroupKey(paths, depth));
85
+ keysPerDepth.push(keys);
86
+ countsPerDepth.push(new Set(keys).size);
87
+ }
88
+ const incrementsFromDepth2 = [];
89
+ for (let i = 1; i < countsPerDepth.length; i++) {
90
+ incrementsFromDepth2.push((countsPerDepth[i] ?? 0) - (countsPerDepth[i - 1] ?? 0));
91
+ }
92
+ // autoCutThreshold picks the largest max-gap in the increments; the
93
+ // clamp fires only in the fully-degenerate "no gap" case, in which we
94
+ // stay at depth 1. When it finds a real gap, we pick the deepest depth
95
+ // whose increment is still below the cut (i.e. one step before the
96
+ // point where marginal fragmentation crosses into per-page noise).
97
+ const cut = autoCutThreshold(incrementsFromDepth2, 1);
98
+ if (!Number.isFinite(cut) || cut <= 0) {
99
+ return { depth: 1, keys: depth1Keys };
100
+ }
101
+ let chosen = 1;
102
+ for (const [i, element] of incrementsFromDepth2.entries()) {
103
+ const inc = element ?? 0;
104
+ if (inc >= cut)
105
+ break;
106
+ chosen = i + 2; // depth-2 for i=0, depth-3 for i=1, ...
107
+ }
108
+ return { depth: chosen, keys: keysPerDepth[chosen - 1] ?? depth1Keys };
109
+ }
@@ -1,80 +1,126 @@
1
1
  /**
2
- * The four structural regions this module knows how to carve out of a page.
2
+ * The six structural regions this module knows how to carve out of a page.
3
3
  * Chosen to match both the HTML5 sectioning-element vocabulary and the
4
4
  * corresponding ARIA landmark roles, since real sites use either or both
5
5
  * (confirmed on two real crawl archives, ~9,200 pages combined: `<header>`/
6
6
  * `<footer>`/`<nav>` present on 99%+ of pages; ARIA roles present on ~53% of
7
7
  * one of the two sites, layered on top of the tags rather than replacing
8
8
  * them).
9
+ *
10
+ * `form` is matched only via `role="form"` (not bare `<form>` tags, which
11
+ * have no implicit landmark role under HTML-AAM unless given an accessible
12
+ * name). `search` is matched via both the `<search>` element (WHATWG
13
+ * landmark shorthand) and `role="search"`.
9
14
  */
10
- export type LandmarkType = 'header' | 'footer' | 'nav' | 'aside';
15
+ export type LandmarkType = 'header' | 'footer' | 'nav' | 'aside' | 'form' | 'search';
11
16
  /**
12
17
  * Result of {@link ./extract-landmarks.js | extractLandmarks}. Each landmark
13
- * field holds the raw HTML of the single chosen instance of that region (see
14
- * `extractLandmarks`'s JSDoc for the "shallowest wins" selection rule);
15
- * absent if the page has none — or if the only candidate(s) found were
16
- * malformed markup `extractLandmarks` declined to trust (see its JSDoc's
17
- * note on discarded candidates). `remainderHtml` is the original HTML
18
- * with every chosen region's markup excised, meant to be fed straight into
18
+ * field holds an array of the raw HTML of every genuinely-closed instance
19
+ * of that region on the page, in document order. Empty array if the page
20
+ * has none — or if every candidate found was malformed markup
21
+ * `extractLandmarks` declined to trust (see its JSDoc's note on discarded
22
+ * candidates). `remainderHtml` is the original HTML with every extracted
23
+ * span excised, meant to be fed straight into
19
24
  * {@link ./tokenize.js | tokenize} as the page's content-only signal.
25
+ *
26
+ * Multiple instances per type are the norm, not the exception: real crawl
27
+ * data commonly has 2–3 `<nav>`s per page (site nav + local nav + related-
28
+ * articles nav), and one production page in the tuning corpora had 11
29
+ * `<header>` elements. Downstream is responsible for deciding which
30
+ * instances are chrome vs content via corpus/unit frequency analysis (see
31
+ * {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
32
+ * `shellQuorum`) rather than this module baking that judgment in with a
33
+ * depth or ordering rule.
20
34
  */
21
35
  export type ExtractLandmarksResult = {
22
- header?: string;
23
- footer?: string;
24
- nav?: string;
25
- aside?: string;
36
+ header: string[];
37
+ footer: string[];
38
+ nav: string[];
39
+ aside: string[];
40
+ form: string[];
41
+ search: string[];
26
42
  remainderHtml: string;
27
43
  };
28
44
  /**
29
- * Finds, for each of the four landmark types, the single best-matching
30
- * region in `html` (by tag name or ARIA role see `matchLandmarkTypes`),
31
- * and returns both that region's own HTML and the rest of the page with all
32
- * chosen regions removed.
45
+ * Collects every genuinely-closed landmark instance on the page one entry
46
+ * per type per instance, in document order and returns them alongside
47
+ * `remainderHtml`, the original HTML with every extracted span excised.
48
+ *
49
+ * ## Why collect every instance rather than a single "primary" one
50
+ *
51
+ * Earlier iterations of this function returned the shallowest match per
52
+ * type on the theory that the outermost `<nav>`/`<header>`/… was the "real"
53
+ * site-wide chrome and anything deeper was page-specific content. Real
54
+ * crawl data breaks that model on the third category: **section-local
55
+ * chrome**. On a real mid-sized crawl corpus, one URL section carried a
56
+ * section-local `<nav>` inside `<main>` that other sections didn't; with
57
+ * shallowest-wins that nav gets classified as content and left in
58
+ * `remainderHtml`, at which point the corpus-wide 90% frequency filter
59
+ * leaves it there (it's not frequent enough), and the section-local
60
+ * template variant disappears into the same cluster as the section without
61
+ * the local nav.
62
+ *
63
+ * Delegating chrome-vs-content judgment to downstream frequency analysis
64
+ * (see {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
65
+ * `shellQuorum`, which runs auto-cut over the per-landmark-token page-
66
+ * frequency histogram) lets the same primitive already used for merge-
67
+ * height cutoffs discover chrome across whatever scope it's applied at —
68
+ * corpus-wide, block-wide, unit-wide — without a hard-coded rule up here
69
+ * forcing the call one way.
33
70
  *
34
- * When a type has more than one candidate (confirmed on real crawl data: one
35
- * page had 11 `<header>` elements, most pages have 2-3 `<nav>` elements —
36
- * typically a site-wide nav plus in-content ones like a "related articles"
37
- * block), the shallowest one wins (fewest ancestors since `<body>`; ties
38
- * broken by document order). The rationale: the site-wide chrome instance is
39
- * structurally the outermost one — anything nested deeper inside `<main>`/
40
- * `<article>` content is, definitionally, part of the page's own content
41
- * rather than shared site chrome, even if it happens to reuse the same tag
42
- * or role.
71
+ * ## Why single-string concatenation was rejected
72
+ *
73
+ * A weaker variant of "collect every instance" concatenates all matches of
74
+ * the same type into one string and keeps the old single-string result
75
+ * shape. That was evaluated and rejected: it breaks
76
+ * `computeLandmarkStatus`'s `canonicalizeTokenSet` bucketing (article-
77
+ * specific `<header>`s vary per page, exploding buckets toward page count
78
+ * and killing the O(n²) O(bucket²) speedup), pollutes `shellQuorum` with
79
+ * in-content nav tokens (dismantling the L2 microsite guard), and still
80
+ * conflates site-wide vs local nav instances within the same type token set (the
81
+ * common-vocabulary tokens of a global nav bury the distinctive tokens of
82
+ * a section-local nav in any frequency filter downstream). Array shape
83
+ * with instance-level downstream handling avoids all three.
84
+ *
85
+ * ## Nested landmark handling
86
+ *
87
+ * `<nav>` nested inside a `<header>` is genuinely two landmarks by the
88
+ * HTML/ARIA vocabulary, but for the frequency-analysis use case they're
89
+ * one region of markup and must not be counted twice. `keepOutermost`
90
+ * drops the inner match on those grounds. An identical-span element that
91
+ * matches two types via tag+role (e.g. `<header role="navigation">`) is
92
+ * preserved under both types since neither strictly contains the other.
93
+ *
94
+ * ## Scoping and robustness
43
95
  *
44
96
  * Only the first `<body>` is in scope, matching `tokenize()`'s own contract
45
97
  * (`<head>` and anything outside body is ignored; a duplicated top-level
46
- * `<body>` from broken SSR/templating is ignored, same as
47
- * `run-tokenizer.ts`).
98
+ * `<body>` from broken SSR/templating is ignored). Nothing inside an opaque
99
+ * tag (`script`/`style`/`noscript`/`svg`) is searched. A candidate whose
100
+ * closing tag can't be confirmed as genuine (an unclosed or self-closed-
101
+ * with-`/>` landmark tag — see `isGenuineClose`) is discarded rather than
102
+ * trusted: safety against corrupting `remainderHtml` outweighs completeness.
48
103
  *
49
- * `remainderHtml` is built by excising the chosen regions' raw markup
104
+ * `remainderHtml` is built by excising the extracted spans' raw markup
50
105
  * outright — no placeholder is left in their place, since a placeholder
51
106
  * string would itself become a token once `remainderHtml` is tokenized,
52
107
  * reintroducing exactly the kind of synthetic chrome signal this function
53
- * exists to remove. One known, accepted side effect of this: if a chosen
108
+ * exists to remove. One known, accepted side effect: if an extracted
54
109
  * landmark and the remaining content share a class-less/role-less `<div>`/
55
110
  * `<span>` wrapper as siblings, removing the landmark can change that
56
111
  * wrapper's child count and flip it from "not fold-eligible" to
57
- * "fold-eligible" once `remainderHtml` is tokenized (see
58
- * `resolveClosedFrame`'s fold rule) the wrapper's own segment then
59
- * disappears from the surviving paths, shortening them by one level. This
60
- * is inherent to "delete the matched span, use whatever's left" and is not
61
- * treated as a bug.
62
- *
63
- * A candidate whose closing tag can't be confirmed as genuine (an unclosed
64
- * or self-closed-with-`/>` landmark tag — see `isGenuineClose`) is discarded
65
- * rather than trusted: safety against corrupting `remainderHtml` outweighs
66
- * completeness of landmark detection for malformed markup. That type then
67
- * falls back to another well-formed candidate of the same type if one
68
- * exists (regardless of its depth relative to the discarded one), or is
69
- * left absent if none do — instead of the page's real content being
70
- * silently deleted.
112
+ * "fold-eligible" once `remainderHtml` is tokenized. The wrapper's own
113
+ * segment then disappears from the surviving paths, shortening them by one
114
+ * level. This is inherent to "delete the matched span, use whatever's
115
+ * left" and is not treated as a bug.
71
116
  * @param html
72
117
  * @example
73
118
  * ```ts
74
119
  * extractLandmarks('<body><header>H</header><main>M</main><footer>F</footer></body>');
75
120
  * // {
76
- * // header: '<header>H</header>',
77
- * // footer: '<footer>F</footer>',
121
+ * // header: ['<header>H</header>'],
122
+ * // footer: ['<footer>F</footer>'],
123
+ * // nav: [], aside: [], form: [], search: [],
78
124
  * // remainderHtml: '<body><main>M</main></body>',
79
125
  * // }
80
126
  * ```
@@ -1,16 +1,19 @@
1
1
  import { excise } from './excise.js';
2
- import { findShallowestElements } from './find-shallowest-elements.js';
2
+ import { findMatchingElements, } from './find-shallowest-elements.js';
3
3
  const TAG_TO_TYPE = {
4
4
  header: 'header',
5
5
  footer: 'footer',
6
6
  nav: 'nav',
7
7
  aside: 'aside',
8
+ search: 'search',
8
9
  };
9
10
  const ROLE_TO_TYPE = {
10
11
  banner: 'header',
11
12
  contentinfo: 'footer',
12
13
  navigation: 'nav',
13
14
  complementary: 'aside',
15
+ form: 'form',
16
+ search: 'search',
14
17
  };
15
18
  /**
16
19
  * Determines which landmark type(s) an element matches by tag name or
@@ -38,67 +41,145 @@ function matchLandmarkTypes(tagName, role) {
38
41
  return types;
39
42
  }
40
43
  /**
41
- * Finds, for each of the four landmark types, the single best-matching
42
- * region in `html` (by tag name or ARIA role — see `matchLandmarkTypes`),
43
- * and returns both that region's own HTML and the rest of the page with all
44
- * chosen regions removed.
44
+ * Filters out any match whose whole-element span is strictly contained by
45
+ * another match's span, keeping only outermost instances. Runs across all
46
+ * landmark types together a `<nav>` nested inside a `<header>` gets
47
+ * dropped, since keeping both would let shell-token computations count the
48
+ * nav's markup twice (once as the header's inner HTML and once as the nav
49
+ * itself), skewing the frequency histograms that downstream chrome
50
+ * detection depends on. Ties (identical spans, e.g. `<header
51
+ * role="navigation">` matching both types) are preserved: neither strictly
52
+ * contains the other.
45
53
  *
46
- * When a type has more than one candidate (confirmed on real crawl data: one
47
- * page had 11 `<header>` elements, most pages have 2-3 `<nav>` elements —
48
- * typically a site-wide nav plus in-content ones like a "related articles"
49
- * block), the shallowest one wins (fewest ancestors since `<body>`; ties
50
- * broken by document order). The rationale: the site-wide chrome instance is
51
- * structurally the outermost one anything nested deeper inside `<main>`/
52
- * `<article>` content is, definitionally, part of the page's own content
53
- * rather than shared site chrome, even if it happens to reuse the same tag
54
- * or role.
54
+ * Input is expected pre-sorted by `startOffset` ascending, which is what
55
+ * `findMatchingElements` guarantees. In the worst case the inner scan over
56
+ * already-kept outers is O(n²) real crawl data has fewer than ~30
57
+ * landmark candidates per page so this is comfortably faster than the
58
+ * alternative approaches (interval tree, span sweep with an active-set
59
+ * stack) at page-scale sizes. If a real corpus with hundreds of landmarks
60
+ * per page ever appears, revisit.
61
+ * @param matches
62
+ */
63
+ function keepOutermost(matches) {
64
+ const result = [];
65
+ for (const match of matches) {
66
+ let contained = false;
67
+ for (const outer of result) {
68
+ if (outer.startOffset <= match.startOffset &&
69
+ match.endOffset <= outer.endOffset &&
70
+ // A strict containment; identical spans (same element matching
71
+ // two types via tag+role) are not dropped.
72
+ (outer.startOffset < match.startOffset || match.endOffset < outer.endOffset)) {
73
+ contained = true;
74
+ break;
75
+ }
76
+ }
77
+ if (!contained)
78
+ result.push(match);
79
+ }
80
+ return result;
81
+ }
82
+ /**
83
+ * Collects every genuinely-closed landmark instance on the page — one entry
84
+ * per type per instance, in document order — and returns them alongside
85
+ * `remainderHtml`, the original HTML with every extracted span excised.
86
+ *
87
+ * ## Why collect every instance rather than a single "primary" one
88
+ *
89
+ * Earlier iterations of this function returned the shallowest match per
90
+ * type on the theory that the outermost `<nav>`/`<header>`/… was the "real"
91
+ * site-wide chrome and anything deeper was page-specific content. Real
92
+ * crawl data breaks that model on the third category: **section-local
93
+ * chrome**. On a real mid-sized crawl corpus, one URL section carried a
94
+ * section-local `<nav>` inside `<main>` that other sections didn't; with
95
+ * shallowest-wins that nav gets classified as content and left in
96
+ * `remainderHtml`, at which point the corpus-wide 90% frequency filter
97
+ * leaves it there (it's not frequent enough), and the section-local
98
+ * template variant disappears into the same cluster as the section without
99
+ * the local nav.
100
+ *
101
+ * Delegating chrome-vs-content judgment to downstream frequency analysis
102
+ * (see {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
103
+ * `shellQuorum`, which runs auto-cut over the per-landmark-token page-
104
+ * frequency histogram) lets the same primitive already used for merge-
105
+ * height cutoffs discover chrome across whatever scope it's applied at —
106
+ * corpus-wide, block-wide, unit-wide — without a hard-coded rule up here
107
+ * forcing the call one way.
108
+ *
109
+ * ## Why single-string concatenation was rejected
110
+ *
111
+ * A weaker variant of "collect every instance" concatenates all matches of
112
+ * the same type into one string and keeps the old single-string result
113
+ * shape. That was evaluated and rejected: it breaks
114
+ * `computeLandmarkStatus`'s `canonicalizeTokenSet` bucketing (article-
115
+ * specific `<header>`s vary per page, exploding buckets toward page count
116
+ * and killing the O(n²) → O(bucket²) speedup), pollutes `shellQuorum` with
117
+ * in-content nav tokens (dismantling the L2 microsite guard), and still
118
+ * conflates site-wide vs local nav instances within the same type token set (the
119
+ * common-vocabulary tokens of a global nav bury the distinctive tokens of
120
+ * a section-local nav in any frequency filter downstream). Array shape
121
+ * with instance-level downstream handling avoids all three.
122
+ *
123
+ * ## Nested landmark handling
124
+ *
125
+ * `<nav>` nested inside a `<header>` is genuinely two landmarks by the
126
+ * HTML/ARIA vocabulary, but for the frequency-analysis use case they're
127
+ * one region of markup and must not be counted twice. `keepOutermost`
128
+ * drops the inner match on those grounds. An identical-span element that
129
+ * matches two types via tag+role (e.g. `<header role="navigation">`) is
130
+ * preserved under both types since neither strictly contains the other.
131
+ *
132
+ * ## Scoping and robustness
55
133
  *
56
134
  * Only the first `<body>` is in scope, matching `tokenize()`'s own contract
57
135
  * (`<head>` and anything outside body is ignored; a duplicated top-level
58
- * `<body>` from broken SSR/templating is ignored, same as
59
- * `run-tokenizer.ts`).
136
+ * `<body>` from broken SSR/templating is ignored). Nothing inside an opaque
137
+ * tag (`script`/`style`/`noscript`/`svg`) is searched. A candidate whose
138
+ * closing tag can't be confirmed as genuine (an unclosed or self-closed-
139
+ * with-`/>` landmark tag — see `isGenuineClose`) is discarded rather than
140
+ * trusted: safety against corrupting `remainderHtml` outweighs completeness.
60
141
  *
61
- * `remainderHtml` is built by excising the chosen regions' raw markup
142
+ * `remainderHtml` is built by excising the extracted spans' raw markup
62
143
  * outright — no placeholder is left in their place, since a placeholder
63
144
  * string would itself become a token once `remainderHtml` is tokenized,
64
145
  * reintroducing exactly the kind of synthetic chrome signal this function
65
- * exists to remove. One known, accepted side effect of this: if a chosen
146
+ * exists to remove. One known, accepted side effect: if an extracted
66
147
  * landmark and the remaining content share a class-less/role-less `<div>`/
67
148
  * `<span>` wrapper as siblings, removing the landmark can change that
68
149
  * wrapper's child count and flip it from "not fold-eligible" to
69
- * "fold-eligible" once `remainderHtml` is tokenized (see
70
- * `resolveClosedFrame`'s fold rule) the wrapper's own segment then
71
- * disappears from the surviving paths, shortening them by one level. This
72
- * is inherent to "delete the matched span, use whatever's left" and is not
73
- * treated as a bug.
74
- *
75
- * A candidate whose closing tag can't be confirmed as genuine (an unclosed
76
- * or self-closed-with-`/>` landmark tag — see `isGenuineClose`) is discarded
77
- * rather than trusted: safety against corrupting `remainderHtml` outweighs
78
- * completeness of landmark detection for malformed markup. That type then
79
- * falls back to another well-formed candidate of the same type if one
80
- * exists (regardless of its depth relative to the discarded one), or is
81
- * left absent if none do — instead of the page's real content being
82
- * silently deleted.
150
+ * "fold-eligible" once `remainderHtml` is tokenized. The wrapper's own
151
+ * segment then disappears from the surviving paths, shortening them by one
152
+ * level. This is inherent to "delete the matched span, use whatever's
153
+ * left" and is not treated as a bug.
83
154
  * @param html
84
155
  * @example
85
156
  * ```ts
86
157
  * extractLandmarks('<body><header>H</header><main>M</main><footer>F</footer></body>');
87
158
  * // {
88
- * // header: '<header>H</header>',
89
- * // footer: '<footer>F</footer>',
159
+ * // header: ['<header>H</header>'],
160
+ * // footer: ['<footer>F</footer>'],
161
+ * // nav: [], aside: [], form: [], search: [],
90
162
  * // remainderHtml: '<body><main>M</main></body>',
91
163
  * // }
92
164
  * ```
93
165
  */
94
166
  export function extractLandmarks(html) {
95
- const matches = findShallowestElements(html, matchLandmarkTypes);
96
- const result = { remainderHtml: html };
97
- const winnerSpans = [];
98
- for (const match of matches) {
99
- result[match.type] = html.slice(match.startOffset, match.endOffset);
100
- winnerSpans.push({ start: match.startOffset, end: match.endOffset });
167
+ const allMatches = findMatchingElements(html, matchLandmarkTypes);
168
+ const outermost = keepOutermost(allMatches);
169
+ const result = {
170
+ header: [],
171
+ footer: [],
172
+ nav: [],
173
+ aside: [],
174
+ form: [],
175
+ search: [],
176
+ remainderHtml: html,
177
+ };
178
+ const spans = [];
179
+ for (const match of outermost) {
180
+ result[match.type].push(html.slice(match.startOffset, match.endOffset));
181
+ spans.push({ start: match.startOffset, end: match.endOffset });
101
182
  }
102
- result.remainderHtml = excise(html, winnerSpans);
183
+ result.remainderHtml = excise(html, spans);
103
184
  return result;
104
185
  }