@d-zero/page-cluster 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +68 -0
  2. package/dist/array-edit-distance.d.ts +20 -0
  3. package/dist/array-edit-distance.js +52 -0
  4. package/dist/build-segment.d.ts +18 -0
  5. package/dist/build-segment.js +27 -0
  6. package/dist/cap-content-depth.d.ts +69 -0
  7. package/dist/cap-content-depth.js +161 -0
  8. package/dist/compute-document-frequency.d.ts +33 -0
  9. package/dist/compute-document-frequency.js +40 -0
  10. package/dist/create-frame.d.ts +16 -0
  11. package/dist/create-frame.js +29 -0
  12. package/dist/derive-path-group-key.d.ts +44 -0
  13. package/dist/derive-path-group-key.js +51 -0
  14. package/dist/derive-stylesheet-group-key.d.ts +36 -0
  15. package/dist/derive-stylesheet-group-key.js +41 -0
  16. package/dist/detect-content-depth-cap.d.ts +114 -0
  17. package/dist/detect-content-depth-cap.js +137 -0
  18. package/dist/escape-reg-exp.d.ts +11 -0
  19. package/dist/escape-reg-exp.js +13 -0
  20. package/dist/excise.d.ts +13 -0
  21. package/dist/excise.js +24 -0
  22. package/dist/extract-landmarks.d.ts +82 -0
  23. package/dist/extract-landmarks.js +104 -0
  24. package/dist/filter-first-party-stylesheet-hrefs.d.ts +73 -0
  25. package/dist/filter-first-party-stylesheet-hrefs.js +118 -0
  26. package/dist/find-shallowest-elements.d.ts +39 -0
  27. package/dist/find-shallowest-elements.js +121 -0
  28. package/dist/foldable-tags.d.ts +8 -0
  29. package/dist/foldable-tags.js +8 -0
  30. package/dist/format-bracket.d.ts +11 -0
  31. package/dist/format-bracket.js +17 -0
  32. package/dist/hash-content.d.ts +22 -0
  33. package/dist/hash-content.js +26 -0
  34. package/dist/html-region-utils.d.ts +74 -0
  35. package/dist/html-region-utils.js +96 -0
  36. package/dist/is-fold-candidate.d.ts +13 -0
  37. package/dist/is-fold-candidate.js +16 -0
  38. package/dist/is-genuine-close.d.ts +23 -0
  39. package/dist/is-genuine-close.js +27 -0
  40. package/dist/is-noise-class.d.ts +6 -0
  41. package/dist/is-noise-class.js +8 -0
  42. package/dist/jaccard-similarity.d.ts +23 -0
  43. package/dist/jaccard-similarity.js +36 -0
  44. package/dist/merge-landmark-affined-clusters.d.ts +179 -0
  45. package/dist/merge-landmark-affined-clusters.js +544 -0
  46. package/dist/merge-spans.d.ts +15 -0
  47. package/dist/merge-spans.js +22 -0
  48. package/dist/noise-class-patterns.d.ts +21 -0
  49. package/dist/noise-class-patterns.js +74 -0
  50. package/dist/normalize-for-hash.d.ts +10 -0
  51. package/dist/normalize-for-hash.js +12 -0
  52. package/dist/opaque-tags.d.ts +17 -0
  53. package/dist/opaque-tags.js +18 -0
  54. package/dist/parse-class-list.d.ts +10 -0
  55. package/dist/parse-class-list.js +23 -0
  56. package/dist/reassign-orphan-block-keys.d.ts +81 -0
  57. package/dist/reassign-orphan-block-keys.js +159 -0
  58. package/dist/remove-content-blocks.d.ts +67 -0
  59. package/dist/remove-content-blocks.js +150 -0
  60. package/dist/resolve-blocking-group-keys.d.ts +116 -0
  61. package/dist/resolve-blocking-group-keys.js +120 -0
  62. package/dist/resolve-closed-frame.d.ts +26 -0
  63. package/dist/resolve-closed-frame.js +33 -0
  64. package/dist/resolve-landmark-variant-keys.d.ts +66 -0
  65. package/dist/resolve-landmark-variant-keys.js +71 -0
  66. package/dist/resolve-options.d.ts +6 -0
  67. package/dist/resolve-options.js +10 -0
  68. package/dist/resolve-page-cluster-keys.d.ts +222 -0
  69. package/dist/resolve-page-cluster-keys.js +198 -0
  70. package/dist/resolve-structural-cluster-keys.d.ts +50 -0
  71. package/dist/resolve-structural-cluster-keys.js +287 -0
  72. package/dist/run-tokenizer.d.ts +33 -0
  73. package/dist/run-tokenizer.js +152 -0
  74. package/dist/split-tokens-by-frequency.d.ts +46 -0
  75. package/dist/split-tokens-by-frequency.js +88 -0
  76. package/dist/tokenize.d.ts +58 -0
  77. package/dist/tokenize.js +60 -0
  78. package/dist/types.d.ts +85 -0
  79. package/dist/types.js +1 -0
  80. package/package.json +102 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Reads the host (not the full origin — see `filterFirstPartyStylesheetHrefs`'s
3
+ * JSDoc for why) out of `href`, or `undefined` if it isn't a parseable
4
+ * absolute URL. `stylesheetHrefs` is already expected to be absolute (see
5
+ * {@link ./derive-stylesheet-group-key.js | deriveStylesheetGroupKey}'s own
6
+ * JSDoc) — this is defensive, not a normalization step.
7
+ * @param href
8
+ */
9
+ function tryGetHost(href) {
10
+ try {
11
+ return new URL(href).host;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /**
18
+ * Narrows every page's `stylesheetHrefs` down to just the hrefs whose host
19
+ * matches the single most common host across the whole batch (the site's own
20
+ * first-party domain), dropping every other host.
21
+ *
22
+ * Confirmed on real crawl data (302 pages): a handful of articles embedding
23
+ * a YouTube video pulled in `youtube.com`'s own player stylesheet plus a
24
+ * per-embed tracking URL that resembles a stylesheet reference; other
25
+ * articles embedding a particular widget pulled in two extra
26
+ * `fonts.googleapis.com` URLs beyond the site's usual one. Both are
27
+ * incidental to whatever third-party content a page happens to embed, not
28
+ * evidence of which template the page uses — but
29
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}'s
30
+ * document-frequency filtering has no way to tell "rare because it's a
31
+ * genuinely distinctive template" apart from "rare because almost no other
32
+ * page happens to embed this same third party," so it let these through as
33
+ * if they were real template signals, splitting a handful of otherwise-
34
+ * identical pages (confirmed via direct comparison: 100% token overlap with
35
+ * their section's main cluster) away from where they belonged. Filtering to
36
+ * first-party hrefs before blocking removes that false signal at the
37
+ * source, rather than trying to recognize its effects downstream.
38
+ *
39
+ * Determining "first-party" from the batch's own href distribution (rather
40
+ * than, say, comparing each href's host against each page's own URL) means
41
+ * this needs no extra per-page input beyond what
42
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} already
43
+ * takes — but it inherits that same function's "roughly homogeneous batch"
44
+ * precondition (see `computeDocumentFrequency`'s own JSDoc): a batch that
45
+ * mixes pages from more than one site in one call has no single genuine
46
+ * first-party host to find, and this function has no way to detect that
47
+ * it's been handed one — it will still confidently pick *a* dominant host
48
+ * (whichever site contributes more stylesheet-bearing pages) and silently
49
+ * strip every other site's real first-party hrefs. Splitting a
50
+ * multi-site/section batch into homogeneous groups before calling this is
51
+ * the caller's responsibility, same as it already is for
52
+ * `resolveBlockingGroupKeys`.
53
+ *
54
+ * The dominant host is picked by how many *pages* reference it at least
55
+ * once, not by how many stylesheet `<link>` tags reference it — a page
56
+ * loading one first-party stylesheet plus two third-party font requests
57
+ * must not let the font host outvote the actual first-party one just for
58
+ * appearing on more `<link>` tags. Compared by `host` (hostname + port),
59
+ * not the full origin (which also includes the scheme): the same first-party
60
+ * site served over both `http:` and `https:` (mid-migration, or a stray
61
+ * unresolved protocol-relative URL) is still one site, not two competing
62
+ * "hosts" splitting its own vote.
63
+ *
64
+ * The trade-off: a site that legitimately serves its own stylesheets from
65
+ * more than one first-party host (e.g. a CDN subdomain alongside the main
66
+ * domain) will have its non-dominant host's hrefs dropped too, same as any
67
+ * genuinely-third-party host — not yet observed on real data, but a known
68
+ * limitation of picking a single dominant host rather than a set.
69
+ *
70
+ * A batch where no page has any stylesheet href at all (or none of the
71
+ * hrefs are parseable absolute URLs) has no host to detect; every page's
72
+ * `stylesheetHrefs` is returned unchanged in that case, matching this
73
+ * function's job of narrowing signal, not fabricating it.
74
+ * @param pages
75
+ * @example
76
+ * ```ts
77
+ * filterFirstPartyStylesheetHrefs([
78
+ * { stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/b.css'] },
79
+ * { stylesheetHrefs: ['https://example.com/a.css', 'https://fonts.googleapis.com/css?family=x'] },
80
+ * ]);
81
+ * // [
82
+ * // { stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/b.css'] },
83
+ * // { stylesheetHrefs: ['https://example.com/a.css'] }, // fonts.googleapis.com dropped
84
+ * // ]
85
+ * ```
86
+ */
87
+ export function filterFirstPartyStylesheetHrefs(pages) {
88
+ const pageHrefHosts = pages.map((page) => ({
89
+ page,
90
+ hrefHosts: page.stylesheetHrefs.map((href) => ({ href, host: tryGetHost(href) })),
91
+ }));
92
+ const hostPageCounts = new Map();
93
+ for (const { hrefHosts } of pageHrefHosts) {
94
+ const distinctHosts = new Set(hrefHosts
95
+ .map(({ host }) => host)
96
+ .filter((host) => host !== undefined));
97
+ for (const host of distinctHosts) {
98
+ hostPageCounts.set(host, (hostPageCounts.get(host) ?? 0) + 1);
99
+ }
100
+ }
101
+ let dominantHost;
102
+ let dominantCount = 0;
103
+ for (const [host, count] of hostPageCounts) {
104
+ if (count > dominantCount) {
105
+ dominantHost = host;
106
+ dominantCount = count;
107
+ }
108
+ }
109
+ if (dominantHost === undefined) {
110
+ return [...pages];
111
+ }
112
+ return pageHrefHosts.map(({ page, hrefHosts }) => ({
113
+ ...page,
114
+ stylesheetHrefs: hrefHosts
115
+ .filter(({ host }) => host === dominantHost)
116
+ .map(({ href }) => href),
117
+ }));
118
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * One winning element for type `T`: the shallowest (fewest ancestors since
3
+ * `<body>`) genuinely-closed match, ties broken by document order. Both the
4
+ * whole-element span (`startOffset`/`endOffset`) and the inner-content span
5
+ * (`contentStart`/`contentEnd`, excluding the element's own opening/closing
6
+ * tags) are always computed — {@link ./extract-landmarks.js | extractLandmarks}
7
+ * only needs the former, {@link ./cap-content-depth.js | capContentDepth}
8
+ * only needs the latter, and computing both is cheap enough (two
9
+ * `indexOf`/`lastIndexOf` calls) that carrying the unused half costs nothing
10
+ * a caller need worry about.
11
+ */
12
+ export type ShallowestElementMatch<T extends string> = {
13
+ type: T;
14
+ startOffset: number;
15
+ endOffset: number;
16
+ contentStart: number;
17
+ contentEnd: number;
18
+ };
19
+ /**
20
+ * Shared walk behind {@link ./extract-landmarks.js | extractLandmarks} (which
21
+ * matches four landmark types per element in one pass) and
22
+ * {@link ./cap-content-depth.js | capContentDepth} (which matches a single
23
+ * landmark). Both need the identical "shallowest genuinely-closed match
24
+ * wins" search — same `<body>`-scoping, same opaque-tag skip, same malformed-
25
+ * markup discard via {@link ./is-genuine-close.js | isGenuineClose} — so a
26
+ * fix to one (e.g. the body-scoping edge case already fixed once in
27
+ * `extractLandmarks`) can't silently fail to apply to the other.
28
+ *
29
+ * Only the first `<body>` is in scope, and nothing inside an opaque tag
30
+ * (`script`/`style`/`noscript`/`svg`) is searched — see
31
+ * `extractLandmarks`/`capContentDepth`'s own JSDoc for why.
32
+ * @param html
33
+ * @param matchTypes Given an element's tag name and `role` attribute (already
34
+ * normalized: an empty/absent `role` arrives as `undefined`), returns every
35
+ * type `T` that element matches. Returning more than one lets a single
36
+ * element (e.g. `<header role="navigation">`) win more than one type at
37
+ * once.
38
+ */
39
+ export declare function findShallowestElements<T extends string>(html: string, matchTypes: (tagName: string, role: string | undefined) => readonly T[]): ShallowestElementMatch<T>[];
@@ -0,0 +1,121 @@
1
+ import { Parser } from 'htmlparser2';
2
+ import { isGenuineClose } from './is-genuine-close.js';
3
+ import { isOpaqueTagName } from './opaque-tags.js';
4
+ /**
5
+ * Shared walk behind {@link ./extract-landmarks.js | extractLandmarks} (which
6
+ * matches four landmark types per element in one pass) and
7
+ * {@link ./cap-content-depth.js | capContentDepth} (which matches a single
8
+ * landmark). Both need the identical "shallowest genuinely-closed match
9
+ * wins" search — same `<body>`-scoping, same opaque-tag skip, same malformed-
10
+ * markup discard via {@link ./is-genuine-close.js | isGenuineClose} — so a
11
+ * fix to one (e.g. the body-scoping edge case already fixed once in
12
+ * `extractLandmarks`) can't silently fail to apply to the other.
13
+ *
14
+ * Only the first `<body>` is in scope, and nothing inside an opaque tag
15
+ * (`script`/`style`/`noscript`/`svg`) is searched — see
16
+ * `extractLandmarks`/`capContentDepth`'s own JSDoc for why.
17
+ * @param html
18
+ * @param matchTypes Given an element's tag name and `role` attribute (already
19
+ * normalized: an empty/absent `role` arrives as `undefined`), returns every
20
+ * type `T` that element matches. Returning more than one lets a single
21
+ * element (e.g. `<header role="navigation">`) win more than one type at
22
+ * once.
23
+ */
24
+ export function findShallowestElements(html, matchTypes) {
25
+ const stack = [];
26
+ const candidates = [];
27
+ let opaque = null;
28
+ let bodyDone = false;
29
+ let ignoredBodyOpens = 0;
30
+ const parser = new Parser({
31
+ onopentag(name, attribs) {
32
+ if (opaque) {
33
+ if (name === opaque.tagName)
34
+ opaque.depth++;
35
+ return;
36
+ }
37
+ if (stack.length === 0) {
38
+ if (name === 'body' && !bodyDone) {
39
+ stack.push({
40
+ tagName: name,
41
+ matchedTypes: matchTypes(name, attribs.role || undefined),
42
+ startOffset: parser.startIndex,
43
+ });
44
+ }
45
+ return;
46
+ }
47
+ if (name === 'body') {
48
+ ignoredBodyOpens++;
49
+ return;
50
+ }
51
+ if (isOpaqueTagName(name)) {
52
+ opaque = { tagName: name, depth: 1 };
53
+ return;
54
+ }
55
+ stack.push({
56
+ tagName: name,
57
+ matchedTypes: matchTypes(name, attribs.role || undefined),
58
+ startOffset: parser.startIndex,
59
+ });
60
+ },
61
+ onclosetag(name) {
62
+ if (opaque) {
63
+ if (name === opaque.tagName) {
64
+ opaque.depth--;
65
+ if (opaque.depth === 0)
66
+ opaque = null;
67
+ }
68
+ return;
69
+ }
70
+ if (name === 'body' && ignoredBodyOpens > 0) {
71
+ ignoredBodyOpens--;
72
+ return;
73
+ }
74
+ if (stack.length === 0)
75
+ return;
76
+ const frame = stack.pop();
77
+ if (!frame)
78
+ return;
79
+ const depth = stack.length;
80
+ const endOffset = parser.endIndex + 1;
81
+ if (frame.matchedTypes.length > 0 &&
82
+ isGenuineClose(html, endOffset, frame.tagName)) {
83
+ const contentStart = html.indexOf('>', frame.startOffset) + 1;
84
+ const contentEnd = html.lastIndexOf('<', endOffset - 1);
85
+ for (const type of frame.matchedTypes) {
86
+ candidates.push({
87
+ type,
88
+ depth,
89
+ startOffset: frame.startOffset,
90
+ endOffset,
91
+ contentStart,
92
+ contentEnd,
93
+ });
94
+ }
95
+ }
96
+ if (stack.length === 0)
97
+ bodyDone = true;
98
+ },
99
+ }, { decodeEntities: false });
100
+ parser.end(html);
101
+ const winners = new Map();
102
+ for (const candidate of candidates) {
103
+ const current = winners.get(candidate.type);
104
+ if (current === undefined ||
105
+ candidate.depth < current.depth ||
106
+ (candidate.depth === current.depth && candidate.startOffset < current.startOffset)) {
107
+ winners.set(candidate.type, candidate);
108
+ }
109
+ }
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
+ return [...winners.values()].map(({ type, startOffset, endOffset, contentStart, contentEnd }) => ({
115
+ type,
116
+ startOffset,
117
+ endOffset,
118
+ contentStart,
119
+ contentEnd,
120
+ }));
121
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tags eligible to have their tag name replaced by `.class` in
3
+ * `build-segment.ts` and to be elided entirely from the path when they have
4
+ * exactly one element child in `is-fold-candidate.ts`. Kept as a single
5
+ * source of truth so the two modules can't silently disagree on which tags
6
+ * are "generic wrappers".
7
+ */
8
+ export declare const FOLDABLE_TAGS: Set<string>;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tags eligible to have their tag name replaced by `.class` in
3
+ * `build-segment.ts` and to be elided entirely from the path when they have
4
+ * exactly one element child in `is-fold-candidate.ts`. Kept as a single
5
+ * source of truth so the two modules can't silently disagree on which tags
6
+ * are "generic wrappers".
7
+ */
8
+ export const FOLDABLE_TAGS = new Set(['div', 'span']);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Formats a `[key=value,...]` suffix from named attributes, omitting
3
+ * `undefined` entries and sorting the `key=value` pairs alphabetically for a
4
+ * deterministic order. Shared by `build-segment.ts` (role/type on ordinary
5
+ * elements) and `run-tokenizer.ts` (role/type/sha on opaque
6
+ * `script`/`style`/`svg`/`noscript` elements), since an opaque element like
7
+ * `<svg role="img">` still carries a meaningful `role` alongside its content
8
+ * hash.
9
+ * @param attrs
10
+ */
11
+ export declare function formatBracket(attrs: Record<string, string | undefined>): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Formats a `[key=value,...]` suffix from named attributes, omitting
3
+ * `undefined` entries and sorting the `key=value` pairs alphabetically for a
4
+ * deterministic order. Shared by `build-segment.ts` (role/type on ordinary
5
+ * elements) and `run-tokenizer.ts` (role/type/sha on opaque
6
+ * `script`/`style`/`svg`/`noscript` elements), since an opaque element like
7
+ * `<svg role="img">` still carries a meaningful `role` alongside its content
8
+ * hash.
9
+ * @param attrs
10
+ */
11
+ export function formatBracket(attrs) {
12
+ const entries = Object.entries(attrs)
13
+ .filter((entry) => entry[1] !== undefined)
14
+ .map(([key, value]) => `${key}=${value}`)
15
+ .toSorted();
16
+ return entries.length > 0 ? `[${entries.join(',')}]` : '';
17
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 16 hex characters (8 bytes) of SHA-256. Full 64-character digests would
3
+ * make tokens unwieldy for no practical benefit here: this hash only needs
4
+ * to answer "did this script/style/svg/comment's content change", not
5
+ * resist deliberate collision attacks, so the reduced collision resistance
6
+ * of a truncated digest is an acceptable trade-off.
7
+ *
8
+ * Exported so other hashed-key producers in this package (e.g.
9
+ * `deriveStylesheetGroupKey`) use the same truncation length instead of
10
+ * picking their own.
11
+ */
12
+ export declare const HASH_LENGTH = 16;
13
+ /**
14
+ * Hashes `script`/`style`/`svg`/`noscript`/comment content instead of
15
+ * tokenizing it. The raw content is never retained in the output: keeping it
16
+ * verbatim would bloat tokens with implementation detail (JS/CSS source,
17
+ * SVG path data) that carries no structural signal for duplicate-page
18
+ * detection, and could leak sensitive inline content collected from crawled
19
+ * pages.
20
+ * @param raw
21
+ */
22
+ export declare function hashContent(raw: string): string;
@@ -0,0 +1,26 @@
1
+ import { hash } from '@d-zero/shared/hash';
2
+ import { normalizeForHash } from './normalize-for-hash.js';
3
+ /**
4
+ * 16 hex characters (8 bytes) of SHA-256. Full 64-character digests would
5
+ * make tokens unwieldy for no practical benefit here: this hash only needs
6
+ * to answer "did this script/style/svg/comment's content change", not
7
+ * resist deliberate collision attacks, so the reduced collision resistance
8
+ * of a truncated digest is an acceptable trade-off.
9
+ *
10
+ * Exported so other hashed-key producers in this package (e.g.
11
+ * `deriveStylesheetGroupKey`) use the same truncation length instead of
12
+ * picking their own.
13
+ */
14
+ export const HASH_LENGTH = 16;
15
+ /**
16
+ * Hashes `script`/`style`/`svg`/`noscript`/comment content instead of
17
+ * tokenizing it. The raw content is never retained in the output: keeping it
18
+ * verbatim would bloat tokens with implementation detail (JS/CSS source,
19
+ * SVG path data) that carries no structural signal for duplicate-page
20
+ * detection, and could leak sensitive inline content collected from crawled
21
+ * pages.
22
+ * @param raw
23
+ */
24
+ export function hashContent(raw) {
25
+ return hash(normalizeForHash(raw)).slice(0, HASH_LENGTH);
26
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shared by {@link ./extract-landmarks.js | extractLandmarks} and
3
+ * {@link ./remove-content-blocks.js | removeContentBlocks}: both walk `html`
4
+ * with htmlparser2 to find `[start, end)` spans of matched regions, then
5
+ * excise them from the original string. Not part of either file's own
6
+ * public API surface (unlike the smaller, ~7-line `requireIndex` copies
7
+ * duplicated elsewhere in this package), so it's factored out here instead
8
+ * of duplicated: this logic already had one real bug (see
9
+ * `escapeRegExp`/`isGenuineClose` below) found and fixed after
10
+ * `extractLandmarks` shipped, and a second independent copy would be a
11
+ * second place for that class of bug to reappear.
12
+ */
13
+ /**
14
+ * Escapes regex metacharacters in `text` so it can be interpolated into a
15
+ * `RegExp` literally. Needed because a tag name reaching {@link isGenuineClose}
16
+ * is not guaranteed to be a plain HTML tag name: htmlparser2 accepts
17
+ * characters like `(`/`[` inside a tag name (`<div(foo role="banner">`
18
+ * parses with tag name `"div(foo"`), which would otherwise either throw
19
+ * (an unbalanced `(` is an invalid regex) or silently change what the regex
20
+ * matches.
21
+ * @param text
22
+ */
23
+ export declare function escapeRegExp(text: string): string;
24
+ /**
25
+ * Whether `html` actually contains a literal closing tag for `tagName`
26
+ * ending at `endOffset`. htmlparser2 fires `onclosetag` not only for real
27
+ * closing tags but also when it force-closes a still-open ancestor to
28
+ * resolve a mismatch (e.g. `<header>H<main>...</main></body>` with no
29
+ * `</header>` ever written) — and in that forced case it reports the
30
+ * force-closed element's `endIndex` as wherever the *other*, unrelated
31
+ * closing tag that triggered the cascade happens to sit, not any position
32
+ * derived from the matched element itself (confirmed by direct htmlparser2
33
+ * event tracing: both the synthetic close and the real `body` close report
34
+ * the identical `endIndex`, because there is no real closing tag in the
35
+ * source for htmlparser2 to anchor a distinct position to). Trusting that
36
+ * offset would slice a candidate spanning all the way to wherever the
37
+ * unrelated tag ends, silently swallowing real content into the caller's
38
+ * remainder HTML. Checking that the text immediately preceding `endOffset`
39
+ * actually spells the expected closing tag catches exactly this: a genuine
40
+ * close always ends with it; a forced one ends with whatever unrelated tag
41
+ * forced it instead.
42
+ * @param html
43
+ * @param endOffset
44
+ * @param tagName
45
+ */
46
+ export declare function isGenuineClose(html: string, endOffset: number, tagName: string): boolean;
47
+ /**
48
+ * Merges a set of (possibly overlapping or nested) `[start, end)` spans into
49
+ * the smallest equivalent set of disjoint spans, sorted by start offset.
50
+ * Matched spans commonly nest in real markup (e.g. a site nav living inside
51
+ * the header, `<header><nav>...</nav></header>`) — merging first means the
52
+ * later excision pass never has to reason about overlap.
53
+ * @param spans
54
+ */
55
+ export declare function mergeSpans(spans: readonly {
56
+ start: number;
57
+ end: number;
58
+ }[]): {
59
+ start: number;
60
+ end: number;
61
+ }[];
62
+ /**
63
+ * Excises `spans` (merged via {@link mergeSpans}) from `html`, returning
64
+ * what's left. No placeholder is left in a span's place: a placeholder
65
+ * string would itself become a token once the remainder is tokenized,
66
+ * reintroducing exactly the kind of synthetic signal callers of this
67
+ * function exist to remove.
68
+ * @param html
69
+ * @param spans
70
+ */
71
+ export declare function excise(html: string, spans: readonly {
72
+ start: number;
73
+ end: number;
74
+ }[]): string;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared by {@link ./extract-landmarks.js | extractLandmarks} and
3
+ * {@link ./remove-content-blocks.js | removeContentBlocks}: both walk `html`
4
+ * with htmlparser2 to find `[start, end)` spans of matched regions, then
5
+ * excise them from the original string. Not part of either file's own
6
+ * public API surface (unlike the smaller, ~7-line `requireIndex` copies
7
+ * duplicated elsewhere in this package), so it's factored out here instead
8
+ * of duplicated: this logic already had one real bug (see
9
+ * `escapeRegExp`/`isGenuineClose` below) found and fixed after
10
+ * `extractLandmarks` shipped, and a second independent copy would be a
11
+ * second place for that class of bug to reappear.
12
+ */
13
+ /**
14
+ * Escapes regex metacharacters in `text` so it can be interpolated into a
15
+ * `RegExp` literally. Needed because a tag name reaching {@link isGenuineClose}
16
+ * is not guaranteed to be a plain HTML tag name: htmlparser2 accepts
17
+ * characters like `(`/`[` inside a tag name (`<div(foo role="banner">`
18
+ * parses with tag name `"div(foo"`), which would otherwise either throw
19
+ * (an unbalanced `(` is an invalid regex) or silently change what the regex
20
+ * matches.
21
+ * @param text
22
+ */
23
+ export function escapeRegExp(text) {
24
+ return text.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+ }
26
+ /**
27
+ * Whether `html` actually contains a literal closing tag for `tagName`
28
+ * ending at `endOffset`. htmlparser2 fires `onclosetag` not only for real
29
+ * closing tags but also when it force-closes a still-open ancestor to
30
+ * resolve a mismatch (e.g. `<header>H<main>...</main></body>` with no
31
+ * `</header>` ever written) — and in that forced case it reports the
32
+ * force-closed element's `endIndex` as wherever the *other*, unrelated
33
+ * closing tag that triggered the cascade happens to sit, not any position
34
+ * derived from the matched element itself (confirmed by direct htmlparser2
35
+ * event tracing: both the synthetic close and the real `body` close report
36
+ * the identical `endIndex`, because there is no real closing tag in the
37
+ * source for htmlparser2 to anchor a distinct position to). Trusting that
38
+ * offset would slice a candidate spanning all the way to wherever the
39
+ * unrelated tag ends, silently swallowing real content into the caller's
40
+ * remainder HTML. Checking that the text immediately preceding `endOffset`
41
+ * actually spells the expected closing tag catches exactly this: a genuine
42
+ * close always ends with it; a forced one ends with whatever unrelated tag
43
+ * forced it instead.
44
+ * @param html
45
+ * @param endOffset
46
+ * @param tagName
47
+ */
48
+ export function isGenuineClose(html, endOffset, tagName) {
49
+ const windowStart = Math.max(0, endOffset - tagName.length - 3);
50
+ return new RegExp(`</\\s*${escapeRegExp(tagName)}\\s*>$`, 'i').test(html.slice(windowStart, endOffset));
51
+ }
52
+ /**
53
+ * Merges a set of (possibly overlapping or nested) `[start, end)` spans into
54
+ * the smallest equivalent set of disjoint spans, sorted by start offset.
55
+ * Matched spans commonly nest in real markup (e.g. a site nav living inside
56
+ * the header, `<header><nav>...</nav></header>`) — merging first means the
57
+ * later excision pass never has to reason about overlap.
58
+ * @param spans
59
+ */
60
+ export function mergeSpans(spans) {
61
+ const sorted = [...spans].toSorted((a, b) => a.start - b.start);
62
+ const merged = [];
63
+ for (const span of sorted) {
64
+ const last = merged.at(-1);
65
+ if (last && span.start <= last.end) {
66
+ last.end = Math.max(last.end, span.end);
67
+ }
68
+ else {
69
+ merged.push({ ...span });
70
+ }
71
+ }
72
+ return merged;
73
+ }
74
+ /**
75
+ * Excises `spans` (merged via {@link mergeSpans}) from `html`, returning
76
+ * what's left. No placeholder is left in a span's place: a placeholder
77
+ * string would itself become a token once the remainder is tokenized,
78
+ * reintroducing exactly the kind of synthetic signal callers of this
79
+ * function exist to remove.
80
+ * @param html
81
+ * @param spans
82
+ */
83
+ export function excise(html, spans) {
84
+ if (spans.length === 0) {
85
+ return html;
86
+ }
87
+ const merged = mergeSpans(spans);
88
+ let remainder = '';
89
+ let cursor = 0;
90
+ for (const span of merged) {
91
+ remainder += html.slice(cursor, span.start);
92
+ cursor = span.end;
93
+ }
94
+ remainder += html.slice(cursor);
95
+ return remainder;
96
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Whether this element is eligible to be elided from the path when it turns
3
+ * out to have exactly one element child (see `resolve-closed-frame.ts`).
4
+ *
5
+ * Only class-less, role-less, type-less `div`/`span` qualify: a `class`,
6
+ * `role`, or `type` means the element carries structural or semantic
7
+ * information that would be lost if the element disappeared from the path.
8
+ * @param tagName
9
+ * @param classList
10
+ * @param role
11
+ * @param type
12
+ */
13
+ export declare function isFoldCandidate(tagName: string, classList: readonly string[], role?: string, type?: string): boolean;
@@ -0,0 +1,16 @@
1
+ import { FOLDABLE_TAGS } from './foldable-tags.js';
2
+ /**
3
+ * Whether this element is eligible to be elided from the path when it turns
4
+ * out to have exactly one element child (see `resolve-closed-frame.ts`).
5
+ *
6
+ * Only class-less, role-less, type-less `div`/`span` qualify: a `class`,
7
+ * `role`, or `type` means the element carries structural or semantic
8
+ * information that would be lost if the element disappeared from the path.
9
+ * @param tagName
10
+ * @param classList
11
+ * @param role
12
+ * @param type
13
+ */
14
+ export function isFoldCandidate(tagName, classList, role, type) {
15
+ return FOLDABLE_TAGS.has(tagName) && classList.length === 0 && !role && !type;
16
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Whether `html` actually contains a literal closing tag for `tagName`
3
+ * ending at `endOffset`. htmlparser2 fires `onclosetag` not only for real
4
+ * closing tags but also when it force-closes a still-open ancestor to
5
+ * resolve a mismatch (e.g. `<header>H<main>...</main></body>` with no
6
+ * `</header>` ever written) — and in that forced case it reports the
7
+ * force-closed element's `endIndex` as wherever the *other*, unrelated
8
+ * closing tag that triggered the cascade happens to sit, not any position
9
+ * derived from the matched element itself (confirmed by direct htmlparser2
10
+ * event tracing: both the synthetic close and the real `body` close report
11
+ * the identical `endIndex`, because there is no real closing tag in the
12
+ * source for htmlparser2 to anchor a distinct position to). Trusting that
13
+ * offset would slice a candidate spanning all the way to wherever the
14
+ * unrelated tag ends, silently swallowing real content into the caller's
15
+ * remainder HTML. Checking that the text immediately preceding `endOffset`
16
+ * actually spells the expected closing tag catches exactly this: a genuine
17
+ * close always ends with it; a forced one ends with whatever unrelated tag
18
+ * forced it instead.
19
+ * @param html
20
+ * @param endOffset
21
+ * @param tagName
22
+ */
23
+ export declare function isGenuineClose(html: string, endOffset: number, tagName: string): boolean;
@@ -0,0 +1,27 @@
1
+ import { escapeRegExp } from './escape-reg-exp.js';
2
+ /**
3
+ * Whether `html` actually contains a literal closing tag for `tagName`
4
+ * ending at `endOffset`. htmlparser2 fires `onclosetag` not only for real
5
+ * closing tags but also when it force-closes a still-open ancestor to
6
+ * resolve a mismatch (e.g. `<header>H<main>...</main></body>` with no
7
+ * `</header>` ever written) — and in that forced case it reports the
8
+ * force-closed element's `endIndex` as wherever the *other*, unrelated
9
+ * closing tag that triggered the cascade happens to sit, not any position
10
+ * derived from the matched element itself (confirmed by direct htmlparser2
11
+ * event tracing: both the synthetic close and the real `body` close report
12
+ * the identical `endIndex`, because there is no real closing tag in the
13
+ * source for htmlparser2 to anchor a distinct position to). Trusting that
14
+ * offset would slice a candidate spanning all the way to wherever the
15
+ * unrelated tag ends, silently swallowing real content into the caller's
16
+ * remainder HTML. Checking that the text immediately preceding `endOffset`
17
+ * actually spells the expected closing tag catches exactly this: a genuine
18
+ * close always ends with it; a forced one ends with whatever unrelated tag
19
+ * forced it instead.
20
+ * @param html
21
+ * @param endOffset
22
+ * @param tagName
23
+ */
24
+ export function isGenuineClose(html, endOffset, tagName) {
25
+ const windowStart = Math.max(0, endOffset - tagName.length - 3);
26
+ return new RegExp(`</\\s*${escapeRegExp(tagName)}\\s*>$`, 'i').test(html.slice(windowStart, endOffset));
27
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Whether `className` matches any of `patterns` (auto-generated/hash-like class names).
3
+ * @param className
4
+ * @param patterns
5
+ */
6
+ export declare function isNoiseClass(className: string, patterns: readonly RegExp[]): boolean;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Whether `className` matches any of `patterns` (auto-generated/hash-like class names).
3
+ * @param className
4
+ * @param patterns
5
+ */
6
+ export function isNoiseClass(className, patterns) {
7
+ return patterns.some((pattern) => pattern.test(className));
8
+ }