@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,36 @@
1
+ /**
2
+ * Derives a coarse grouping key from the set of stylesheet URLs a page
3
+ * loads. This is a *blocking key* in the record-linkage sense (see
4
+ * {@link ./derive-path-group-key.js | derivePathGroupKey}): pages sharing
5
+ * the exact same stylesheet set are near-certainly the same template
6
+ * family, making this a strong but sparse signal — many pages load few or
7
+ * no stylesheets, so this key is meant to be used alongside, not instead
8
+ * of, weaker-but-always-present signals like a URL-path-based key.
9
+ *
10
+ * `stylesheetHrefs` must already be resolved to a form that is comparable
11
+ * across the whole corpus (e.g. absolute URLs). This function only compares
12
+ * the strings it is given: two pages that both reference the same
13
+ * unresolved relative href text (e.g. both link `href="style.css"`) but
14
+ * from different directories, and would therefore load different physical
15
+ * files, produce the same key here unless the caller has already resolved
16
+ * each href against its page's URL before calling.
17
+ *
18
+ * Input order does not affect the result: the arrangement of `<link>` tags
19
+ * in a document has no bearing on template identity, so hrefs are sorted
20
+ * (and deduplicated, since a repeated href contributes no extra information
21
+ * about what the page loads) before hashing. The sorted list is
22
+ * JSON-serialized before hashing rather than joined with a plain delimiter
23
+ * (e.g. `"\n"`) so that no character sequence inside one href can be
24
+ * mistaken for a boundary between two hrefs. Hashing (via SHA-256, reusing
25
+ * {@link ./hash-content.js | HASH_LENGTH} for the same truncation length the
26
+ * package's other hashed keys use) keeps the key a fixed size regardless of
27
+ * how many stylesheets a page loads or how long their URLs are.
28
+ * @param stylesheetHrefs
29
+ * @example
30
+ * ```ts
31
+ * deriveStylesheetGroupKey(['https://example.com/assets/site.css', 'https://example.com/assets/theme.css']);
32
+ * deriveStylesheetGroupKey(['https://example.com/assets/theme.css', 'https://example.com/assets/site.css']);
33
+ * // same result for both calls above — order-independent
34
+ * ```
35
+ */
36
+ export declare function deriveStylesheetGroupKey(stylesheetHrefs: readonly string[]): string;
@@ -0,0 +1,41 @@
1
+ import { hash } from '@d-zero/shared/hash';
2
+ import { HASH_LENGTH } from './hash-content.js';
3
+ /**
4
+ * Derives a coarse grouping key from the set of stylesheet URLs a page
5
+ * loads. This is a *blocking key* in the record-linkage sense (see
6
+ * {@link ./derive-path-group-key.js | derivePathGroupKey}): pages sharing
7
+ * the exact same stylesheet set are near-certainly the same template
8
+ * family, making this a strong but sparse signal — many pages load few or
9
+ * no stylesheets, so this key is meant to be used alongside, not instead
10
+ * of, weaker-but-always-present signals like a URL-path-based key.
11
+ *
12
+ * `stylesheetHrefs` must already be resolved to a form that is comparable
13
+ * across the whole corpus (e.g. absolute URLs). This function only compares
14
+ * the strings it is given: two pages that both reference the same
15
+ * unresolved relative href text (e.g. both link `href="style.css"`) but
16
+ * from different directories, and would therefore load different physical
17
+ * files, produce the same key here unless the caller has already resolved
18
+ * each href against its page's URL before calling.
19
+ *
20
+ * Input order does not affect the result: the arrangement of `<link>` tags
21
+ * in a document has no bearing on template identity, so hrefs are sorted
22
+ * (and deduplicated, since a repeated href contributes no extra information
23
+ * about what the page loads) before hashing. The sorted list is
24
+ * JSON-serialized before hashing rather than joined with a plain delimiter
25
+ * (e.g. `"\n"`) so that no character sequence inside one href can be
26
+ * mistaken for a boundary between two hrefs. Hashing (via SHA-256, reusing
27
+ * {@link ./hash-content.js | HASH_LENGTH} for the same truncation length the
28
+ * package's other hashed keys use) keeps the key a fixed size regardless of
29
+ * how many stylesheets a page loads or how long their URLs are.
30
+ * @param stylesheetHrefs
31
+ * @example
32
+ * ```ts
33
+ * deriveStylesheetGroupKey(['https://example.com/assets/site.css', 'https://example.com/assets/theme.css']);
34
+ * deriveStylesheetGroupKey(['https://example.com/assets/theme.css', 'https://example.com/assets/site.css']);
35
+ * // same result for both calls above — order-independent
36
+ * ```
37
+ */
38
+ export function deriveStylesheetGroupKey(stylesheetHrefs) {
39
+ const sorted = [...new Set(stylesheetHrefs)].toSorted();
40
+ return hash(JSON.stringify(sorted)).slice(0, HASH_LENGTH);
41
+ }
@@ -0,0 +1,114 @@
1
+ import type { ContentDepthLandmark } from './cap-content-depth.js';
2
+ import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
3
+ import type { TokenizeOptions } from './types.js';
4
+ /**
5
+ * @see detectContentDepthCap
6
+ */
7
+ export type DetectContentDepthCapOptions = TokenizeOptions & ResolveStructuralClusterKeysOptions & {
8
+ /** Forwarded to {@link ./cap-content-depth.js | capContentDepth}. Defaults to `'main'`. */
9
+ landmark?: ContentDepthLandmark;
10
+ /**
11
+ * Depths to try, in strictly ascending order (`RangeError` otherwise —
12
+ * the knee-detection loop below assumes each depth is deeper than the
13
+ * last). Defaults to `[1, 2, 3, 4, 5, 6, 8, 10]` — chosen to cover the
14
+ * range confirmed on real crawl data (the knee landed at 3 on both
15
+ * corpora checked) with a few extra steps past it to confirm the
16
+ * explosion is sustained, without trying every single depth up to an
17
+ * arbitrary ceiling.
18
+ */
19
+ candidateDepths?: readonly number[];
20
+ /**
21
+ * The minimum cluster-count ratio between two consecutive candidate
22
+ * depths (`clusterCount[i] / clusterCount[i-1]`) required to call that
23
+ * jump "the knee." Must be a finite number greater than 1 (`RangeError`
24
+ * otherwise — a ratio at or below 1 means "no growth," which can never
25
+ * meaningfully gate a knee). Defaults to `1.5` (a 50% jump). Below this,
26
+ * growth is treated as gradual/expected rather than evidence of a
27
+ * freeform-content boundary, and no cap is recommended.
28
+ */
29
+ minKneeRatio?: number;
30
+ };
31
+ /**
32
+ * Validates the `candidateDepths`/`minKneeRatio` parts of
33
+ * {@link DetectContentDepthCapOptions} without running the sweep itself.
34
+ * {@link detectContentDepthCap} always calls this on its own, so a direct
35
+ * caller never needs to; it's exported only so a caller that invokes
36
+ * `detectContentDepthCap` conditionally (e.g. once per block, skipped
37
+ * entirely for blocks too small to matter — see
38
+ * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}'s
39
+ * `autoCapMainDepth`) can still fail fast on a bad option even when that
40
+ * per-call skip means the sweep itself might never run for a given input
41
+ * (e.g. an empty page list has no blocks at all).
42
+ * @param options
43
+ * @example
44
+ * ```ts
45
+ * // Fails fast on a bad option even though nothing here would otherwise
46
+ * // call detectContentDepthCap yet (e.g. blocks haven't been computed).
47
+ * validateDetectContentDepthCapOptions({ minKneeRatio: 1 }); // throws RangeError
48
+ * ```
49
+ */
50
+ export declare function validateDetectContentDepthCapOptions(options?: DetectContentDepthCapOptions): void;
51
+ /**
52
+ * Finds the depth just before {@link ./cap-content-depth.js | capContentDepth}
53
+ * ("`maxDepth`") would start throwing away real structural signal, by trying
54
+ * each of `options.candidateDepths` in turn and looking for the first big
55
+ * jump in resulting cluster count.
56
+ *
57
+ * Confirmed on two unrelated real crawls (302 and ~4,100 pages, sharing no
58
+ * code or template lineage): the number of distinct
59
+ * {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
60
+ * clusters stays roughly flat (or grows gently) as `maxDepth` increases,
61
+ * then jumps sharply (14x and 9x respectively) at one specific depth — the
62
+ * point past which comparisons start seeing freeform, page-to-page-varying
63
+ * editorial content instead of shared template structure. That depth landed
64
+ * at 3 on both corpora, but this function doesn't hardcode that: it
65
+ * re-derives it per corpus, so a differently-nested template doesn't get
66
+ * the wrong number silently baked in.
67
+ *
68
+ * Returns the *last* candidate depth before the biggest qualifying jump
69
+ * (`options.minKneeRatio` or steeper) — i.e. the depth to actually cap
70
+ * at, already chosen so the jump lands past it. If no jump in
71
+ * `candidateDepths` clears `minKneeRatio` (growth looks gradual, or
72
+ * `htmlList` is too small/uniform to tell), the *largest* candidate depth is
73
+ * returned — deliberately not capping rather than guessing.
74
+ *
75
+ * Forwards `options`' `TokenizeOptions`/`ResolveStructuralClusterKeysOptions`
76
+ * fields (e.g. `filterNoiseClasses`, `similarityThreshold`) to every sweep's
77
+ * `tokenize`/`resolveStructuralClusterKeys` call, so the knee is detected
78
+ * against the same tokenization and clustering configuration
79
+ * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys} actually
80
+ * clusters with afterward — passing a different configuration here than
81
+ * downstream would pick a cap tuned for a comparison that's never actually
82
+ * performed.
83
+ *
84
+ * This calls {@link ./resolve-structural-cluster-keys.js |
85
+ * resolveStructuralClusterKeys} once per candidate depth (each an O(n²)
86
+ * comparison over `htmlList`), so cost scales with both `htmlList.length`
87
+ * and `candidateDepths.length`. Measured standalone on a real 4,085-page
88
+ * single-block corpus: ~4s per candidate depth, ~30s total for the default 8
89
+ * depths. {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}'s
90
+ * `autoCapMainDepth` option calls this once *per block* rather than once
91
+ * globally (different blocks can have different knees — see that option's
92
+ * own JSDoc for why this matters, not just for cost) — measured end to end on
93
+ * a real 8,936-page whole-site corpus (32 blocks, largest ~4,082 pages):
94
+ * ~119s total with `autoCapMainDepth` versus ~18s without it, cutting that
95
+ * corpus's final cluster count from 1,972 to 134. Partitioning the O(n²) cost
96
+ * across blocks rather than paying it once over the whole corpus is itself
97
+ * why this got *cheaper* than an earlier global-sweep design that measured
98
+ * ~5m50s for the same corpus (the sum of each block's `memberCount²` is far
99
+ * below `htmlList.length²` once a corpus splits into more than a couple of
100
+ * blocks). Sampling a single block's `htmlList` down before calling this
101
+ * (accepting a less precise knee estimate) is the natural next step if one
102
+ * particular block's cost becomes a problem, but isn't implemented here
103
+ * without real evidence it's needed.
104
+ * @param htmlList
105
+ * @param options
106
+ * @example
107
+ * ```ts
108
+ * const maxDepth = detectContentDepthCap(pages.map((p) => p.html));
109
+ * const tokenSets = pages.map(
110
+ * (p) => new Set(tokenize(capContentDepth(p.html, { landmark: 'main', maxDepth }).remainderHtml).tokens),
111
+ * );
112
+ * ```
113
+ */
114
+ export declare function detectContentDepthCap(htmlList: readonly string[], options?: DetectContentDepthCapOptions): number;
@@ -0,0 +1,137 @@
1
+ import { capContentDepth } from './cap-content-depth.js';
2
+ import { resolveStructuralClusterKeys } from './resolve-structural-cluster-keys.js';
3
+ import { tokenize } from './tokenize.js';
4
+ /**
5
+ * Validates the `candidateDepths`/`minKneeRatio` parts of
6
+ * {@link DetectContentDepthCapOptions} without running the sweep itself.
7
+ * {@link detectContentDepthCap} always calls this on its own, so a direct
8
+ * caller never needs to; it's exported only so a caller that invokes
9
+ * `detectContentDepthCap` conditionally (e.g. once per block, skipped
10
+ * entirely for blocks too small to matter — see
11
+ * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}'s
12
+ * `autoCapMainDepth`) can still fail fast on a bad option even when that
13
+ * per-call skip means the sweep itself might never run for a given input
14
+ * (e.g. an empty page list has no blocks at all).
15
+ * @param options
16
+ * @example
17
+ * ```ts
18
+ * // Fails fast on a bad option even though nothing here would otherwise
19
+ * // call detectContentDepthCap yet (e.g. blocks haven't been computed).
20
+ * validateDetectContentDepthCapOptions({ minKneeRatio: 1 }); // throws RangeError
21
+ * ```
22
+ */
23
+ export function validateDetectContentDepthCapOptions(options) {
24
+ const candidateDepths = options?.candidateDepths ?? [1, 2, 3, 4, 5, 6, 8, 10];
25
+ const minKneeRatio = options?.minKneeRatio ?? 1.5;
26
+ if (candidateDepths.length === 0) {
27
+ throw new RangeError('detectContentDepthCap: candidateDepths must not be empty');
28
+ }
29
+ let previousDepth = -Infinity;
30
+ for (const depth of candidateDepths) {
31
+ if (depth <= previousDepth) {
32
+ throw new RangeError(`detectContentDepthCap: candidateDepths must be in strictly ascending order, got ${JSON.stringify(candidateDepths)}`);
33
+ }
34
+ previousDepth = depth;
35
+ }
36
+ if (!(Number.isFinite(minKneeRatio) && minKneeRatio > 1)) {
37
+ throw new RangeError(`detectContentDepthCap: minKneeRatio must be a finite number greater than 1, got ${minKneeRatio}`);
38
+ }
39
+ }
40
+ /**
41
+ * Finds the depth just before {@link ./cap-content-depth.js | capContentDepth}
42
+ * ("`maxDepth`") would start throwing away real structural signal, by trying
43
+ * each of `options.candidateDepths` in turn and looking for the first big
44
+ * jump in resulting cluster count.
45
+ *
46
+ * Confirmed on two unrelated real crawls (302 and ~4,100 pages, sharing no
47
+ * code or template lineage): the number of distinct
48
+ * {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
49
+ * clusters stays roughly flat (or grows gently) as `maxDepth` increases,
50
+ * then jumps sharply (14x and 9x respectively) at one specific depth — the
51
+ * point past which comparisons start seeing freeform, page-to-page-varying
52
+ * editorial content instead of shared template structure. That depth landed
53
+ * at 3 on both corpora, but this function doesn't hardcode that: it
54
+ * re-derives it per corpus, so a differently-nested template doesn't get
55
+ * the wrong number silently baked in.
56
+ *
57
+ * Returns the *last* candidate depth before the biggest qualifying jump
58
+ * (`options.minKneeRatio` or steeper) — i.e. the depth to actually cap
59
+ * at, already chosen so the jump lands past it. If no jump in
60
+ * `candidateDepths` clears `minKneeRatio` (growth looks gradual, or
61
+ * `htmlList` is too small/uniform to tell), the *largest* candidate depth is
62
+ * returned — deliberately not capping rather than guessing.
63
+ *
64
+ * Forwards `options`' `TokenizeOptions`/`ResolveStructuralClusterKeysOptions`
65
+ * fields (e.g. `filterNoiseClasses`, `similarityThreshold`) to every sweep's
66
+ * `tokenize`/`resolveStructuralClusterKeys` call, so the knee is detected
67
+ * against the same tokenization and clustering configuration
68
+ * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys} actually
69
+ * clusters with afterward — passing a different configuration here than
70
+ * downstream would pick a cap tuned for a comparison that's never actually
71
+ * performed.
72
+ *
73
+ * This calls {@link ./resolve-structural-cluster-keys.js |
74
+ * resolveStructuralClusterKeys} once per candidate depth (each an O(n²)
75
+ * comparison over `htmlList`), so cost scales with both `htmlList.length`
76
+ * and `candidateDepths.length`. Measured standalone on a real 4,085-page
77
+ * single-block corpus: ~4s per candidate depth, ~30s total for the default 8
78
+ * depths. {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}'s
79
+ * `autoCapMainDepth` option calls this once *per block* rather than once
80
+ * globally (different blocks can have different knees — see that option's
81
+ * own JSDoc for why this matters, not just for cost) — measured end to end on
82
+ * a real 8,936-page whole-site corpus (32 blocks, largest ~4,082 pages):
83
+ * ~119s total with `autoCapMainDepth` versus ~18s without it, cutting that
84
+ * corpus's final cluster count from 1,972 to 134. Partitioning the O(n²) cost
85
+ * across blocks rather than paying it once over the whole corpus is itself
86
+ * why this got *cheaper* than an earlier global-sweep design that measured
87
+ * ~5m50s for the same corpus (the sum of each block's `memberCount²` is far
88
+ * below `htmlList.length²` once a corpus splits into more than a couple of
89
+ * blocks). Sampling a single block's `htmlList` down before calling this
90
+ * (accepting a less precise knee estimate) is the natural next step if one
91
+ * particular block's cost becomes a problem, but isn't implemented here
92
+ * without real evidence it's needed.
93
+ * @param htmlList
94
+ * @param options
95
+ * @example
96
+ * ```ts
97
+ * const maxDepth = detectContentDepthCap(pages.map((p) => p.html));
98
+ * const tokenSets = pages.map(
99
+ * (p) => new Set(tokenize(capContentDepth(p.html, { landmark: 'main', maxDepth }).remainderHtml).tokens),
100
+ * );
101
+ * ```
102
+ */
103
+ export function detectContentDepthCap(htmlList, options) {
104
+ validateDetectContentDepthCapOptions(options);
105
+ const landmark = options?.landmark ?? 'main';
106
+ const candidateDepths = options?.candidateDepths ?? [1, 2, 3, 4, 5, 6, 8, 10];
107
+ const minKneeRatio = options?.minKneeRatio ?? 1.5;
108
+ const clusterCounts = candidateDepths.map((maxDepth) => {
109
+ const tokenSets = htmlList.map((html) => {
110
+ const capped = capContentDepth(html, { landmark, maxDepth }).remainderHtml;
111
+ return new Set(tokenize(capped, options).tokens);
112
+ });
113
+ return new Set(resolveStructuralClusterKeys(tokenSets, options)).size;
114
+ });
115
+ // bestRatio starts below any possible ratio (rather than at minKneeRatio
116
+ // itself) so a jump that exactly *meets* minKneeRatio still qualifies —
117
+ // this option's own JSDoc only calls growth "gradual" (i.e. rejected)
118
+ // when it's *below* the threshold, not at or above it.
119
+ let bestRatio = -Infinity;
120
+ let kneeIndex = -1;
121
+ for (let i = 1; i < clusterCounts.length; i++) {
122
+ const previous = clusterCounts[i - 1];
123
+ const current = clusterCounts[i];
124
+ if (previous === undefined || current === undefined || previous === 0) {
125
+ continue;
126
+ }
127
+ const ratio = current / previous;
128
+ if (ratio >= minKneeRatio && ratio > bestRatio) {
129
+ bestRatio = ratio;
130
+ kneeIndex = i;
131
+ }
132
+ }
133
+ if (kneeIndex === -1) {
134
+ return candidateDepths.at(-1) ?? 0;
135
+ }
136
+ return candidateDepths[kneeIndex - 1] ?? 0;
137
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Escapes regex metacharacters in `text` so it can be interpolated into a
3
+ * `RegExp` literally. Needed because a tag name reaching
4
+ * {@link ./is-genuine-close.js | isGenuineClose} is not guaranteed to be a
5
+ * plain HTML tag name: htmlparser2 accepts characters like `(`/`[` inside a
6
+ * tag name (`<div(foo role="banner">` parses with tag name `"div(foo"`),
7
+ * which would otherwise either throw (an unbalanced `(` is an invalid regex)
8
+ * or silently change what the regex matches.
9
+ * @param text
10
+ */
11
+ export declare function escapeRegExp(text: string): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Escapes regex metacharacters in `text` so it can be interpolated into a
3
+ * `RegExp` literally. Needed because a tag name reaching
4
+ * {@link ./is-genuine-close.js | isGenuineClose} is not guaranteed to be a
5
+ * plain HTML tag name: htmlparser2 accepts characters like `(`/`[` inside a
6
+ * tag name (`<div(foo role="banner">` parses with tag name `"div(foo"`),
7
+ * which would otherwise either throw (an unbalanced `(` is an invalid regex)
8
+ * or silently change what the regex matches.
9
+ * @param text
10
+ */
11
+ export function escapeRegExp(text) {
12
+ return text.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Excises `spans` (merged via {@link ./merge-spans.js | mergeSpans}) from
3
+ * `html`, returning what's left. No placeholder is left in a span's place: a
4
+ * placeholder string would itself become a token once the remainder is
5
+ * tokenized, reintroducing exactly the kind of synthetic signal callers of
6
+ * this function exist to remove.
7
+ * @param html
8
+ * @param spans
9
+ */
10
+ export declare function excise(html: string, spans: readonly {
11
+ start: number;
12
+ end: number;
13
+ }[]): string;
package/dist/excise.js ADDED
@@ -0,0 +1,24 @@
1
+ import { mergeSpans } from './merge-spans.js';
2
+ /**
3
+ * Excises `spans` (merged via {@link ./merge-spans.js | mergeSpans}) from
4
+ * `html`, returning what's left. No placeholder is left in a span's place: a
5
+ * placeholder string would itself become a token once the remainder is
6
+ * tokenized, reintroducing exactly the kind of synthetic signal callers of
7
+ * this function exist to remove.
8
+ * @param html
9
+ * @param spans
10
+ */
11
+ export function excise(html, spans) {
12
+ if (spans.length === 0) {
13
+ return html;
14
+ }
15
+ const merged = mergeSpans(spans);
16
+ let remainder = '';
17
+ let cursor = 0;
18
+ for (const span of merged) {
19
+ remainder += html.slice(cursor, span.start);
20
+ cursor = span.end;
21
+ }
22
+ remainder += html.slice(cursor);
23
+ return remainder;
24
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The four structural regions this module knows how to carve out of a page.
3
+ * Chosen to match both the HTML5 sectioning-element vocabulary and the
4
+ * corresponding ARIA landmark roles, since real sites use either or both
5
+ * (confirmed on two real crawl archives, ~9,200 pages combined: `<header>`/
6
+ * `<footer>`/`<nav>` present on 99%+ of pages; ARIA roles present on ~53% of
7
+ * one of the two sites, layered on top of the tags rather than replacing
8
+ * them).
9
+ */
10
+ export type LandmarkType = 'header' | 'footer' | 'nav' | 'aside';
11
+ /**
12
+ * Result of {@link ./extract-landmarks.js | extractLandmarks}. Each landmark
13
+ * field holds the raw HTML of the single chosen instance of that region (see
14
+ * `extractLandmarks`'s JSDoc for the "shallowest wins" selection rule);
15
+ * absent if the page has none — or if the only candidate(s) found were
16
+ * malformed markup `extractLandmarks` declined to trust (see its JSDoc's
17
+ * note on discarded candidates). `remainderHtml` is the original HTML
18
+ * with every chosen region's markup excised, meant to be fed straight into
19
+ * {@link ./tokenize.js | tokenize} as the page's content-only signal.
20
+ */
21
+ export type ExtractLandmarksResult = {
22
+ header?: string;
23
+ footer?: string;
24
+ nav?: string;
25
+ aside?: string;
26
+ remainderHtml: string;
27
+ };
28
+ /**
29
+ * Finds, for each of the four landmark types, the single best-matching
30
+ * region in `html` (by tag name or ARIA role — see `matchLandmarkTypes`),
31
+ * and returns both that region's own HTML and the rest of the page with all
32
+ * chosen regions removed.
33
+ *
34
+ * When a type has more than one candidate (confirmed on real crawl data: one
35
+ * page had 11 `<header>` elements, most pages have 2-3 `<nav>` elements —
36
+ * typically a site-wide nav plus in-content ones like a "related articles"
37
+ * block), the shallowest one wins (fewest ancestors since `<body>`; ties
38
+ * broken by document order). The rationale: the site-wide chrome instance is
39
+ * structurally the outermost one — anything nested deeper inside `<main>`/
40
+ * `<article>` content is, definitionally, part of the page's own content
41
+ * rather than shared site chrome, even if it happens to reuse the same tag
42
+ * or role.
43
+ *
44
+ * Only the first `<body>` is in scope, matching `tokenize()`'s own contract
45
+ * (`<head>` and anything outside body is ignored; a duplicated top-level
46
+ * `<body>` from broken SSR/templating is ignored, same as
47
+ * `run-tokenizer.ts`).
48
+ *
49
+ * `remainderHtml` is built by excising the chosen regions' raw markup
50
+ * outright — no placeholder is left in their place, since a placeholder
51
+ * string would itself become a token once `remainderHtml` is tokenized,
52
+ * reintroducing exactly the kind of synthetic chrome signal this function
53
+ * exists to remove. One known, accepted side effect of this: if a chosen
54
+ * landmark and the remaining content share a class-less/role-less `<div>`/
55
+ * `<span>` wrapper as siblings, removing the landmark can change that
56
+ * wrapper's child count and flip it from "not fold-eligible" to
57
+ * "fold-eligible" once `remainderHtml` is tokenized (see
58
+ * `resolveClosedFrame`'s fold rule) — the wrapper's own segment then
59
+ * disappears from the surviving paths, shortening them by one level. This
60
+ * is inherent to "delete the matched span, use whatever's left" and is not
61
+ * treated as a bug.
62
+ *
63
+ * A candidate whose closing tag can't be confirmed as genuine (an unclosed
64
+ * or self-closed-with-`/>` landmark tag — see `isGenuineClose`) is discarded
65
+ * rather than trusted: safety against corrupting `remainderHtml` outweighs
66
+ * completeness of landmark detection for malformed markup. That type then
67
+ * falls back to another well-formed candidate of the same type if one
68
+ * exists (regardless of its depth relative to the discarded one), or is
69
+ * left absent if none do — instead of the page's real content being
70
+ * silently deleted.
71
+ * @param html
72
+ * @example
73
+ * ```ts
74
+ * extractLandmarks('<body><header>H</header><main>M</main><footer>F</footer></body>');
75
+ * // {
76
+ * // header: '<header>H</header>',
77
+ * // footer: '<footer>F</footer>',
78
+ * // remainderHtml: '<body><main>M</main></body>',
79
+ * // }
80
+ * ```
81
+ */
82
+ export declare function extractLandmarks(html: string): ExtractLandmarksResult;
@@ -0,0 +1,104 @@
1
+ import { excise } from './excise.js';
2
+ import { findShallowestElements } from './find-shallowest-elements.js';
3
+ const TAG_TO_TYPE = {
4
+ header: 'header',
5
+ footer: 'footer',
6
+ nav: 'nav',
7
+ aside: 'aside',
8
+ };
9
+ const ROLE_TO_TYPE = {
10
+ banner: 'header',
11
+ contentinfo: 'footer',
12
+ navigation: 'nav',
13
+ complementary: 'aside',
14
+ };
15
+ /**
16
+ * Determines which landmark type(s) an element matches by tag name or
17
+ * `role`. Deliberately returns every match rather than the first: a
18
+ * `<header role="navigation">` is simultaneously a `header` candidate (by
19
+ * tag) and a `nav` candidate (by role) — both are independently correct
20
+ * answers to "where is this page's header" and "where is this page's nav",
21
+ * so both must be recorded from the same element.
22
+ * @param tagName
23
+ * @param role
24
+ */
25
+ function matchLandmarkTypes(tagName, role) {
26
+ const types = [];
27
+ const byTag = TAG_TO_TYPE[tagName];
28
+ if (byTag) {
29
+ types.push(byTag);
30
+ }
31
+ // role is matched as a single exact literal, same limitation as
32
+ // `create-frame.ts`'s own `attribs.role` handling: no whitespace-
33
+ // separated multi-role splitting, no case normalization.
34
+ const byRole = role ? ROLE_TO_TYPE[role] : undefined;
35
+ if (byRole && !types.includes(byRole)) {
36
+ types.push(byRole);
37
+ }
38
+ return types;
39
+ }
40
+ /**
41
+ * Finds, for each of the four landmark types, the single best-matching
42
+ * region in `html` (by tag name or ARIA role — see `matchLandmarkTypes`),
43
+ * and returns both that region's own HTML and the rest of the page with all
44
+ * chosen regions removed.
45
+ *
46
+ * When a type has more than one candidate (confirmed on real crawl data: one
47
+ * page had 11 `<header>` elements, most pages have 2-3 `<nav>` elements —
48
+ * typically a site-wide nav plus in-content ones like a "related articles"
49
+ * block), the shallowest one wins (fewest ancestors since `<body>`; ties
50
+ * broken by document order). The rationale: the site-wide chrome instance is
51
+ * structurally the outermost one — anything nested deeper inside `<main>`/
52
+ * `<article>` content is, definitionally, part of the page's own content
53
+ * rather than shared site chrome, even if it happens to reuse the same tag
54
+ * or role.
55
+ *
56
+ * Only the first `<body>` is in scope, matching `tokenize()`'s own contract
57
+ * (`<head>` and anything outside body is ignored; a duplicated top-level
58
+ * `<body>` from broken SSR/templating is ignored, same as
59
+ * `run-tokenizer.ts`).
60
+ *
61
+ * `remainderHtml` is built by excising the chosen regions' raw markup
62
+ * outright — no placeholder is left in their place, since a placeholder
63
+ * string would itself become a token once `remainderHtml` is tokenized,
64
+ * reintroducing exactly the kind of synthetic chrome signal this function
65
+ * exists to remove. One known, accepted side effect of this: if a chosen
66
+ * landmark and the remaining content share a class-less/role-less `<div>`/
67
+ * `<span>` wrapper as siblings, removing the landmark can change that
68
+ * wrapper's child count and flip it from "not fold-eligible" to
69
+ * "fold-eligible" once `remainderHtml` is tokenized (see
70
+ * `resolveClosedFrame`'s fold rule) — the wrapper's own segment then
71
+ * disappears from the surviving paths, shortening them by one level. This
72
+ * is inherent to "delete the matched span, use whatever's left" and is not
73
+ * treated as a bug.
74
+ *
75
+ * A candidate whose closing tag can't be confirmed as genuine (an unclosed
76
+ * or self-closed-with-`/>` landmark tag — see `isGenuineClose`) is discarded
77
+ * rather than trusted: safety against corrupting `remainderHtml` outweighs
78
+ * completeness of landmark detection for malformed markup. That type then
79
+ * falls back to another well-formed candidate of the same type if one
80
+ * exists (regardless of its depth relative to the discarded one), or is
81
+ * left absent if none do — instead of the page's real content being
82
+ * silently deleted.
83
+ * @param html
84
+ * @example
85
+ * ```ts
86
+ * extractLandmarks('<body><header>H</header><main>M</main><footer>F</footer></body>');
87
+ * // {
88
+ * // header: '<header>H</header>',
89
+ * // footer: '<footer>F</footer>',
90
+ * // remainderHtml: '<body><main>M</main></body>',
91
+ * // }
92
+ * ```
93
+ */
94
+ export function extractLandmarks(html) {
95
+ const matches = findShallowestElements(html, matchLandmarkTypes);
96
+ const result = { remainderHtml: html };
97
+ const winnerSpans = [];
98
+ for (const match of matches) {
99
+ result[match.type] = html.slice(match.startOffset, match.endOffset);
100
+ winnerSpans.push({ start: match.startOffset, end: match.endOffset });
101
+ }
102
+ result.remainderHtml = excise(html, winnerSpans);
103
+ return result;
104
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Narrows every page's `stylesheetHrefs` down to just the hrefs whose host
3
+ * matches the single most common host across the whole batch (the site's own
4
+ * first-party domain), dropping every other host.
5
+ *
6
+ * Confirmed on real crawl data (302 pages): a handful of articles embedding
7
+ * a YouTube video pulled in `youtube.com`'s own player stylesheet plus a
8
+ * per-embed tracking URL that resembles a stylesheet reference; other
9
+ * articles embedding a particular widget pulled in two extra
10
+ * `fonts.googleapis.com` URLs beyond the site's usual one. Both are
11
+ * incidental to whatever third-party content a page happens to embed, not
12
+ * evidence of which template the page uses — but
13
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys}'s
14
+ * document-frequency filtering has no way to tell "rare because it's a
15
+ * genuinely distinctive template" apart from "rare because almost no other
16
+ * page happens to embed this same third party," so it let these through as
17
+ * if they were real template signals, splitting a handful of otherwise-
18
+ * identical pages (confirmed via direct comparison: 100% token overlap with
19
+ * their section's main cluster) away from where they belonged. Filtering to
20
+ * first-party hrefs before blocking removes that false signal at the
21
+ * source, rather than trying to recognize its effects downstream.
22
+ *
23
+ * Determining "first-party" from the batch's own href distribution (rather
24
+ * than, say, comparing each href's host against each page's own URL) means
25
+ * this needs no extra per-page input beyond what
26
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} already
27
+ * takes — but it inherits that same function's "roughly homogeneous batch"
28
+ * precondition (see `computeDocumentFrequency`'s own JSDoc): a batch that
29
+ * mixes pages from more than one site in one call has no single genuine
30
+ * first-party host to find, and this function has no way to detect that
31
+ * it's been handed one — it will still confidently pick *a* dominant host
32
+ * (whichever site contributes more stylesheet-bearing pages) and silently
33
+ * strip every other site's real first-party hrefs. Splitting a
34
+ * multi-site/section batch into homogeneous groups before calling this is
35
+ * the caller's responsibility, same as it already is for
36
+ * `resolveBlockingGroupKeys`.
37
+ *
38
+ * The dominant host is picked by how many *pages* reference it at least
39
+ * once, not by how many stylesheet `<link>` tags reference it — a page
40
+ * loading one first-party stylesheet plus two third-party font requests
41
+ * must not let the font host outvote the actual first-party one just for
42
+ * appearing on more `<link>` tags. Compared by `host` (hostname + port),
43
+ * not the full origin (which also includes the scheme): the same first-party
44
+ * site served over both `http:` and `https:` (mid-migration, or a stray
45
+ * unresolved protocol-relative URL) is still one site, not two competing
46
+ * "hosts" splitting its own vote.
47
+ *
48
+ * The trade-off: a site that legitimately serves its own stylesheets from
49
+ * more than one first-party host (e.g. a CDN subdomain alongside the main
50
+ * domain) will have its non-dominant host's hrefs dropped too, same as any
51
+ * genuinely-third-party host — not yet observed on real data, but a known
52
+ * limitation of picking a single dominant host rather than a set.
53
+ *
54
+ * A batch where no page has any stylesheet href at all (or none of the
55
+ * hrefs are parseable absolute URLs) has no host to detect; every page's
56
+ * `stylesheetHrefs` is returned unchanged in that case, matching this
57
+ * function's job of narrowing signal, not fabricating it.
58
+ * @param pages
59
+ * @example
60
+ * ```ts
61
+ * filterFirstPartyStylesheetHrefs([
62
+ * { stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/b.css'] },
63
+ * { stylesheetHrefs: ['https://example.com/a.css', 'https://fonts.googleapis.com/css?family=x'] },
64
+ * ]);
65
+ * // [
66
+ * // { stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/b.css'] },
67
+ * // { stylesheetHrefs: ['https://example.com/a.css'] }, // fonts.googleapis.com dropped
68
+ * // ]
69
+ * ```
70
+ */
71
+ export declare function filterFirstPartyStylesheetHrefs<T extends {
72
+ stylesheetHrefs: readonly string[];
73
+ }>(pages: readonly T[]): T[];