@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.
- package/LICENSE +21 -0
- package/README.md +95 -41
- package/dist/assign-contained-clusters.d.ts +42 -0
- package/dist/assign-contained-clusters.js +156 -0
- package/dist/auto-cut-threshold.d.ts +17 -0
- package/dist/auto-cut-threshold.js +36 -0
- package/dist/canonicalize-token-set.d.ts +17 -0
- package/dist/canonicalize-token-set.js +19 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.js +381 -0
- package/dist/collapse-anonymous-divs.d.ts +21 -0
- package/dist/collapse-anonymous-divs.js +42 -0
- package/dist/complete-linkage-dendrogram.d.ts +41 -0
- package/dist/complete-linkage-dendrogram.js +140 -0
- package/dist/derive-comparison-sets.d.ts +22 -0
- package/dist/derive-comparison-sets.js +33 -0
- package/dist/derive-path-cluster-keys.d.ts +53 -0
- package/dist/derive-path-cluster-keys.js +109 -0
- package/dist/extract-landmarks.d.ts +91 -45
- package/dist/extract-landmarks.js +122 -41
- package/dist/filter-first-party-stylesheet-hrefs.d.ts +58 -24
- package/dist/filter-first-party-stylesheet-hrefs.js +72 -33
- package/dist/find-shallowest-elements.d.ts +48 -11
- package/dist/find-shallowest-elements.js +41 -21
- package/dist/merge-cross-block-clusters.d.ts +61 -0
- package/dist/merge-cross-block-clusters.js +546 -0
- package/dist/pass0-blocking.d.ts +89 -0
- package/dist/pass0-blocking.js +87 -0
- package/dist/per-page-landmark-signatures.d.ts +48 -0
- package/dist/per-page-landmark-signatures.js +62 -0
- package/dist/reservoir-sample.d.ts +43 -0
- package/dist/reservoir-sample.js +98 -0
- package/dist/resolve-blocking-group-keys.d.ts +8 -2
- package/dist/resolve-blocking-group-keys.js +18 -4
- package/dist/resolve-landmark-variant-keys.d.ts +41 -20
- package/dist/resolve-landmark-variant-keys.js +69 -26
- package/dist/resolve-page-cluster-keys.d.ts +292 -191
- package/dist/resolve-page-cluster-keys.js +708 -157
- package/dist/resolve-structural-cluster-keys.d.ts +9 -0
- package/dist/resolve-structural-cluster-keys.js +14 -232
- package/dist/shape-token.d.ts +11 -0
- package/dist/shape-token.js +38 -0
- package/dist/stage-a-per-block.d.ts +133 -0
- package/dist/stage-a-per-block.js +178 -0
- package/dist/tokenize.d.ts +6 -0
- package/dist/tokenize.js +6 -0
- package/package.json +5 -58
- package/dist/html-region-utils.d.ts +0 -74
- package/dist/html-region-utils.js +0 -96
- package/dist/merge-landmark-affined-clusters.d.ts +0 -179
- 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
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
23
|
-
footer
|
|
24
|
-
nav
|
|
25
|
-
aside
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `<
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
47
|
-
* `
|
|
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
|
|
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
|
|
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
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
|
|
59
|
-
* `
|
|
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
|
|
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
|
|
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
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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,
|
|
183
|
+
result.remainderHtml = excise(html, spans);
|
|
103
184
|
return result;
|
|
104
185
|
}
|