@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
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Narrows every page's `stylesheetHrefs` down to just the hrefs whose host
|
|
3
|
-
* matches
|
|
4
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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-
|
|
49
|
-
* more than one first-party host (e.g. a CDN subdomain
|
|
50
|
-
* domain) will have its non-dominant host's hrefs
|
|
51
|
-
* genuinely-third-party host
|
|
52
|
-
*
|
|
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
|
|
55
|
-
*
|
|
56
|
-
* `stylesheetHrefs`
|
|
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
|
|
20
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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-
|
|
65
|
-
* more than one first-party host (e.g. a CDN subdomain
|
|
66
|
-
* domain) will have its non-dominant host's hrefs
|
|
67
|
-
* genuinely-third-party host
|
|
68
|
-
*
|
|
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
|
|
71
|
-
*
|
|
72
|
-
* `stylesheetHrefs`
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 ./
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* {@link ./
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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">`)
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* {@link ./
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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">`)
|
|
25
|
+
* element (e.g. `<header role="navigation">`) match more than one type at
|
|
22
26
|
* once.
|
|
23
27
|
*/
|
|
24
|
-
export function
|
|
28
|
+
export function findMatchingElements(html, matchTypes) {
|
|
25
29
|
const stack = [];
|
|
26
|
-
const
|
|
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
|
-
|
|
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
|
|
103
|
-
const current = winners.get(
|
|
126
|
+
for (const match of matches) {
|
|
127
|
+
const current = winners.get(match.type);
|
|
104
128
|
if (current === undefined ||
|
|
105
|
-
|
|
106
|
-
(
|
|
107
|
-
winners.set(
|
|
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>;
|