@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,74 @@
1
+ /**
2
+ * Heuristics for auto-generated class names that change on every build even
3
+ * when the underlying template is unchanged (CSS Modules, styled-components,
4
+ * emotion, bundler content-hash suffixes). Left in place, these would make
5
+ * identical templates look structurally different across builds/deploys,
6
+ * defeating near-duplicate detection. The generic alphanumeric-hash pattern requires
7
+ * both a letter and a digit so real words (e.g. BEM modifiers like
8
+ * `card--active`) are not caught by accident; it is still the least precise
9
+ * entry here, which is why `filterNoiseClasses` can be turned off.
10
+ *
11
+ * The `sc-`/`css-`/generic-hex patterns below all follow the same
12
+ * "require a digit or uppercase letter" idiom to rule out real English words
13
+ * that happen to fit the hash's character-set shape (`sc-header`,
14
+ * `css-editor`, `section-facade`, ...). This trades a small, accepted
15
+ * false-negative rate for hash generators that occasionally produce an
16
+ * all-lowercase, all-letter run (same trade-off already made for the
17
+ * double-underscore pattern below) against eliminating false positives on
18
+ * ordinary class names, which is the more common and more disruptive
19
+ * failure for this package's purpose.
20
+ */
21
+ export const DEFAULT_NOISE_CLASS_PATTERNS = [
22
+ // CSS Modules bare hash suffix, e.g. `_a1b2c3`
23
+ /^_[a-z0-9]{5,}$/i,
24
+ // styled-components, e.g. `sc-bdVaJa`. Requires an uppercase letter or
25
+ // digit somewhere after `sc-`: real styled-components hashes are
26
+ // generated from a mixed-alphabet base, so they reliably contain one,
27
+ // while genuine author-written classes like `sc-header`/`sc-container`
28
+ // (all-lowercase English words) do not. Confirmed by running both
29
+ // against real examples before and after this fix. Length is
30
+ // deliberately left unbounded (`{5,}`, matching the pre-fix pattern) —
31
+ // an upper bound here would reject genuine, longer auto-generated hashes
32
+ // for no benefit, since length was never the discriminator; the
33
+ // lookahead is.
34
+ /^sc-(?=[a-zA-Z0-9]*[A-Z\d])[a-zA-Z0-9]{5,}$/,
35
+ // emotion, e.g. `css-1x2y3z`. Requires a digit somewhere after `css-`,
36
+ // for the same reason as `sc-` above (emotion's example already has
37
+ // digits; `css-editor`/`css-toolbar` do not).
38
+ /^css-(?=[a-z0-9]*\d)[a-z0-9]{5,8}$/i,
39
+ // bundler content-hash suffix, e.g. `chunk-a3f9c1`. Requires a true
40
+ // digit (0-9) somewhere in the hex-looking suffix: a hash is far more
41
+ // likely to contain one than a real English word that happens to be
42
+ // spelled entirely with a-f (e.g. `facade`, `decade` both previously
43
+ // misclassified as noise).
44
+ /^[a-z]+-(?=[a-f0-9]*\d)[a-f0-9]{6,8}$/i,
45
+ // generic alphanumeric hash, e.g. `k3j9zq2a` (must mix letters and digits).
46
+ // Deliberately case-sensitive (no `i` flag): auto-generated hash tokens are
47
+ // conventionally all-lowercase, and real CamelCase/mixed-case class names
48
+ // that happen to end in a digit (e.g. `Section1`, `Banner99`) must not be
49
+ // caught by this — the whole point of requiring both a letter and a digit.
50
+ /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,10}$/,
51
+ // webpack CSS Modules' default `[name]_[local]__[hash]` convention, e.g.
52
+ // `Layout_root__f3k9d`, `Header_title__3xJ9k`. Anchored on the trailing
53
+ // `__` (exactly two underscores, not one) rather than the whole string,
54
+ // since here the hash is a suffix of an otherwise-meaningful name.
55
+ // Deliberately does NOT also match a single underscore: real-world class
56
+ // names commonly use one underscore as a general-purpose separator
57
+ // followed by a short alphanumeric variant suffix that is not a hash at
58
+ // all — e.g. Divi Builder's `et_pb_gutters3` (gutter-width setting 3 of
59
+ // 8), or hypothetical `grid_col12`/`row_span24`. Matching those against
60
+ // this pattern with only one underscore required turned them into false
61
+ // positives (confirmed via the production-scale fixture corpus); double
62
+ // underscore is a much rarer, more specifically BEM/CSS-Modules-coded
63
+ // convention, so it's a safer anchor. This does mean single-underscore
64
+ // hash suffixes (e.g. HubSpot's `hsForm_9f8e7d6c`) are not caught — an
65
+ // accepted gap, since under-filtering a rare pattern is safer than
66
+ // over-filtering common ones. Requiring both a letter and a digit in the
67
+ // suffix (case-insensitive: real generators mix case, e.g. `q7Rp1`)
68
+ // keeps genuine short BEM-ish element names ending in one digit and
69
+ // nothing else (`col2`, 4 chars) below the 5-char floor from matching;
70
+ // residual risk of a real element name coincidentally looking like a
71
+ // 5-8 char mixed hash right after `__` (e.g. `__col12`) is accepted,
72
+ // same trade-off as the generic pattern above.
73
+ /__(?=[a-z0-9]{5,8}$)(?=[a-z0-9]*[a-z])(?=[a-z0-9]*\d)[a-z0-9]{5,8}$/i,
74
+ ];
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Normalizes raw `script`/`style`/`svg`/`noscript`/comment content before
3
+ * hashing so that formatting differences (indentation, line breaks, minified
4
+ * vs. pretty-printed) don't produce different hashes for otherwise-identical
5
+ * content. Whitespace runs collapse to a single space rather than being
6
+ * removed outright — removing them entirely would merge adjacent tokens
7
+ * (e.g. `"var a=1"` → `"vara=1"`) and change the content's meaning.
8
+ * @param raw
9
+ */
10
+ export declare function normalizeForHash(raw: string): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Normalizes raw `script`/`style`/`svg`/`noscript`/comment content before
3
+ * hashing so that formatting differences (indentation, line breaks, minified
4
+ * vs. pretty-printed) don't produce different hashes for otherwise-identical
5
+ * content. Whitespace runs collapse to a single space rather than being
6
+ * removed outright — removing them entirely would merge adjacent tokens
7
+ * (e.g. `"var a=1"` → `"vara=1"`) and change the content's meaning.
8
+ * @param raw
9
+ */
10
+ export function normalizeForHash(raw) {
11
+ return raw.trim().replaceAll(/\s+/g, ' ');
12
+ }
@@ -0,0 +1,17 @@
1
+ import type { OpaqueTagName } from './types.js';
2
+ /**
3
+ * Tags whose contents are opaque to structural analysis: `<script>`/`<style>`
4
+ * content is parsed as raw text by htmlparser2 (no `onopentag`/`onclosetag`
5
+ * fires inside them at all), so only `<svg>`/`<noscript>` actually need
6
+ * active self-nesting suppression in callers that track it — kept as a set
7
+ * of four for symmetry rather than because all four need the same handling.
8
+ * Shared by `run-tokenizer.ts` and `extract-landmarks.ts` so a future fix to
9
+ * this list (or to the self-nesting handling built on top of it) can't
10
+ * silently diverge between the two.
11
+ */
12
+ export declare const OPAQUE_TAGS: Set<OpaqueTagName>;
13
+ /**
14
+ *
15
+ * @param name
16
+ */
17
+ export declare function isOpaqueTagName(name: string): name is OpaqueTagName;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Tags whose contents are opaque to structural analysis: `<script>`/`<style>`
3
+ * content is parsed as raw text by htmlparser2 (no `onopentag`/`onclosetag`
4
+ * fires inside them at all), so only `<svg>`/`<noscript>` actually need
5
+ * active self-nesting suppression in callers that track it — kept as a set
6
+ * of four for symmetry rather than because all four need the same handling.
7
+ * Shared by `run-tokenizer.ts` and `extract-landmarks.ts` so a future fix to
8
+ * this list (or to the self-nesting handling built on top of it) can't
9
+ * silently diverge between the two.
10
+ */
11
+ export const OPAQUE_TAGS = new Set(['script', 'style', 'noscript', 'svg']);
12
+ /**
13
+ *
14
+ * @param name
15
+ */
16
+ export function isOpaqueTagName(name) {
17
+ return OPAQUE_TAGS.has(name);
18
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Splits a `class` attribute value into a deduplicated, optionally
3
+ * noise-filtered, case-insensitively sorted list.
4
+ *
5
+ * Sorting ignores case so `"Beta alpha"` reads as `["alpha", "Beta"]` rather
6
+ * than the code-point order a plain `.sort()` would give.
7
+ * @param classAttr
8
+ * @param filterNoise
9
+ */
10
+ export declare function parseClassList(classAttr: string | undefined, filterNoise: boolean): string[];
@@ -0,0 +1,23 @@
1
+ import { alphabeticalComparator } from '@d-zero/shared/sort/alphabetical';
2
+ import { isNoiseClass } from './is-noise-class.js';
3
+ import { DEFAULT_NOISE_CLASS_PATTERNS } from './noise-class-patterns.js';
4
+ /**
5
+ * Splits a `class` attribute value into a deduplicated, optionally
6
+ * noise-filtered, case-insensitively sorted list.
7
+ *
8
+ * Sorting ignores case so `"Beta alpha"` reads as `["alpha", "Beta"]` rather
9
+ * than the code-point order a plain `.sort()` would give.
10
+ * @param classAttr
11
+ * @param filterNoise
12
+ */
13
+ export function parseClassList(classAttr, filterNoise) {
14
+ if (!classAttr) {
15
+ return [];
16
+ }
17
+ const raw = classAttr.trim().split(/\s+/).filter(Boolean);
18
+ const deduped = [...new Set(raw)];
19
+ const filtered = filterNoise
20
+ ? deduped.filter((name) => !isNoiseClass(name, DEFAULT_NOISE_CLASS_PATTERNS))
21
+ : deduped;
22
+ return filtered.toSorted(alphabeticalComparator);
23
+ }
@@ -0,0 +1,81 @@
1
+ import type { PageBlockingSignals } from './resolve-blocking-group-keys.js';
2
+ /**
3
+ * Rewrites the `path:`-fallback key of an "orphan" page — one with no
4
+ * stylesheet references recorded at all — to match a same-URL-section `css:`
5
+ * key, when one exists that is itself confined to that same section.
6
+ *
7
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} commits
8
+ * every page to exactly one key: a page with zero stylesheet references can
9
+ * never produce a `css:` candidate, so it always falls back to `path:`, even
10
+ * when every other page of the same template loaded a distinctive stylesheet
11
+ * and landed on a shared `css:` key instead. That function's own JSDoc notes
12
+ * a literal OR-merge (letting a page carry both candidates) is deliberately
13
+ * deferred — but a literal OR-merge would not even apply here, since an
14
+ * orphan never had a `css:` candidate to OR with in the first place. This is
15
+ * a narrower, targeted fix for that specific gap, confirmed against a crawl
16
+ * (nitpicker) where a subset of same-template pages were missing stylesheet
17
+ * data entirely (a crawl-completeness gap, not a `resolveBlockingGroupKeys`
18
+ * logic error) and fragmented away from the rest of their template's `css:`
19
+ * block as a result.
20
+ *
21
+ * A `css:` block is only treated as a merge target when *every* one of its
22
+ * members shares the orphan's `derivePathGroupKey` value ("confined" to that
23
+ * section) — a `css:` block spanning multiple sections (e.g. a shared
24
+ * cross-department template) is left untouched, since there is no single
25
+ * section to merge it into. Non-orphan pages sharing the orphan's `path:` key
26
+ * (pages with a stylesheet set that was genuinely non-discriminative, not
27
+ * merely unrecorded) are deliberately left out of the rewritten key: complete-
28
+ * linkage clustering's min-linkage aggregation means a third point entering a
29
+ * comparison pool can change whether two *other* points end up merged (e.g.
30
+ * two pages at a pairwise similarity just above threshold can fail to merge
31
+ * once a third, more tightly-matching point joins the pool and "uses up" one
32
+ * of them first) — see this function's spec for a worked example. Folding in
33
+ * only the confirmed orphans, not the whole `path:` bucket, keeps this
34
+ * function from perturbing clustering decisions for pages it has no evidence
35
+ * about.
36
+ *
37
+ * Does not itself compare page content: it only decides which pages should
38
+ * be pooled together for {@link ./resolve-structural-cluster-keys.js |
39
+ * resolveStructuralClusterKeys} to adjudicate (via
40
+ * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}'s existing
41
+ * per-key grouping, unchanged) — an orphan folded into a `css:` block's pool
42
+ * still ends up in its own singleton cluster if its content doesn't actually
43
+ * match.
44
+ *
45
+ * A known, accepted trade-off: "confined" is checked against `pathKey` alone
46
+ * (the same coarse granularity `pathDepth` already gives `path:` keys), so
47
+ * multiple genuinely-different-template `css:` blocks that merely happen to
48
+ * share one broad URL section (e.g. everything under a large sub-site's
49
+ * top-level segment) get pooled into the *same* `resolveStructuralClusterKeys`
50
+ * call as each other, not just alongside the orphan that triggered the merge.
51
+ * Confirmed on real crawl data (8,936 pages): this correctly reunited an
52
+ * orphan with a 1,008-page same-template cluster it had been split from, and
53
+ * the corpus's cluster count improved net (1,984 → 1,972) — but as a side
54
+ * effect, 15 small clusters *unrelated* to any orphan (all inside one very
55
+ * large, already-confined `css:` block spanning a whole sub-site) came out
56
+ * differently than they would have without this option, because the extra
57
+ * pages pooled alongside them changed the NN-chain merge order (the same
58
+ * mechanism described above, just triggered by the pooling itself rather
59
+ * than by admitting a non-orphan page). A companion real-site run (302 pages)
60
+ * showed zero change. Narrowing "confined" to reduce this blast radius (e.g.
61
+ * preferring the `css:` block closest in size to the orphan count when
62
+ * several share a `pathKey`) is possible future work, not yet justified
63
+ * without more real-corpus evidence of it mattering in practice.
64
+ * @param pages
65
+ * @param blockKeys
66
+ * @param pathDepth
67
+ * @example
68
+ * ```ts
69
+ * const pages = [
70
+ * { paths: ['news', '1'], stylesheetHrefs: ['https://example.com/a.css'] },
71
+ * { paths: ['news', '2'], stylesheetHrefs: ['https://example.com/a.css'] },
72
+ * { paths: ['news', '3'], stylesheetHrefs: [] }, // same template, but crawl missed its <link>
73
+ * { paths: ['about'], stylesheetHrefs: ['https://example.com/b.css'] },
74
+ * ];
75
+ * const blockKeys = resolveBlockingGroupKeys(pages);
76
+ * // ['css:<hash of a.css>', 'css:<hash of a.css>', 'path:news', 'path:about']
77
+ * reassignOrphanBlockKeys(pages, blockKeys);
78
+ * // ['orphan-merge:news', 'orphan-merge:news', 'orphan-merge:news', 'path:about']
79
+ * ```
80
+ */
81
+ export declare function reassignOrphanBlockKeys(pages: readonly PageBlockingSignals[], blockKeys: readonly string[], pathDepth?: number): string[];
@@ -0,0 +1,159 @@
1
+ import { derivePathGroupKey } from './derive-path-group-key.js';
2
+ /**
3
+ * Prefix distinguishing a reassigned key from the `css:`/`path:` keys
4
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} itself
5
+ * produces, so the two families can never collide.
6
+ */
7
+ const REASSIGNED_KEY_PREFIX = 'orphan-merge:';
8
+ /**
9
+ * Reads `values[index]`, throwing instead of returning `undefined`. Every
10
+ * call site here indexes `pages`/`pathKeys`/`blockKeys` with a position
11
+ * derived from one of those same arrays' own `.entries()` or `.map()`, so the
12
+ * thrown branch is unreachable in practice; it exists to satisfy
13
+ * `noUncheckedIndexedAccess` without a non-null assertion (same rationale as
14
+ * `requireIndex` in `resolve-page-cluster-keys.ts` and
15
+ * `resolve-structural-cluster-keys.ts`, each kept as an independent copy for
16
+ * the same reason those two are).
17
+ * @param values
18
+ * @param index
19
+ */
20
+ function requireIndex(values, index) {
21
+ const value = values[index];
22
+ if (value === undefined) {
23
+ throw new Error('reassignOrphanBlockKeys: index out of bounds');
24
+ }
25
+ return value;
26
+ }
27
+ /**
28
+ * Rewrites the `path:`-fallback key of an "orphan" page — one with no
29
+ * stylesheet references recorded at all — to match a same-URL-section `css:`
30
+ * key, when one exists that is itself confined to that same section.
31
+ *
32
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} commits
33
+ * every page to exactly one key: a page with zero stylesheet references can
34
+ * never produce a `css:` candidate, so it always falls back to `path:`, even
35
+ * when every other page of the same template loaded a distinctive stylesheet
36
+ * and landed on a shared `css:` key instead. That function's own JSDoc notes
37
+ * a literal OR-merge (letting a page carry both candidates) is deliberately
38
+ * deferred — but a literal OR-merge would not even apply here, since an
39
+ * orphan never had a `css:` candidate to OR with in the first place. This is
40
+ * a narrower, targeted fix for that specific gap, confirmed against a crawl
41
+ * (nitpicker) where a subset of same-template pages were missing stylesheet
42
+ * data entirely (a crawl-completeness gap, not a `resolveBlockingGroupKeys`
43
+ * logic error) and fragmented away from the rest of their template's `css:`
44
+ * block as a result.
45
+ *
46
+ * A `css:` block is only treated as a merge target when *every* one of its
47
+ * members shares the orphan's `derivePathGroupKey` value ("confined" to that
48
+ * section) — a `css:` block spanning multiple sections (e.g. a shared
49
+ * cross-department template) is left untouched, since there is no single
50
+ * section to merge it into. Non-orphan pages sharing the orphan's `path:` key
51
+ * (pages with a stylesheet set that was genuinely non-discriminative, not
52
+ * merely unrecorded) are deliberately left out of the rewritten key: complete-
53
+ * linkage clustering's min-linkage aggregation means a third point entering a
54
+ * comparison pool can change whether two *other* points end up merged (e.g.
55
+ * two pages at a pairwise similarity just above threshold can fail to merge
56
+ * once a third, more tightly-matching point joins the pool and "uses up" one
57
+ * of them first) — see this function's spec for a worked example. Folding in
58
+ * only the confirmed orphans, not the whole `path:` bucket, keeps this
59
+ * function from perturbing clustering decisions for pages it has no evidence
60
+ * about.
61
+ *
62
+ * Does not itself compare page content: it only decides which pages should
63
+ * be pooled together for {@link ./resolve-structural-cluster-keys.js |
64
+ * resolveStructuralClusterKeys} to adjudicate (via
65
+ * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}'s existing
66
+ * per-key grouping, unchanged) — an orphan folded into a `css:` block's pool
67
+ * still ends up in its own singleton cluster if its content doesn't actually
68
+ * match.
69
+ *
70
+ * A known, accepted trade-off: "confined" is checked against `pathKey` alone
71
+ * (the same coarse granularity `pathDepth` already gives `path:` keys), so
72
+ * multiple genuinely-different-template `css:` blocks that merely happen to
73
+ * share one broad URL section (e.g. everything under a large sub-site's
74
+ * top-level segment) get pooled into the *same* `resolveStructuralClusterKeys`
75
+ * call as each other, not just alongside the orphan that triggered the merge.
76
+ * Confirmed on real crawl data (8,936 pages): this correctly reunited an
77
+ * orphan with a 1,008-page same-template cluster it had been split from, and
78
+ * the corpus's cluster count improved net (1,984 → 1,972) — but as a side
79
+ * effect, 15 small clusters *unrelated* to any orphan (all inside one very
80
+ * large, already-confined `css:` block spanning a whole sub-site) came out
81
+ * differently than they would have without this option, because the extra
82
+ * pages pooled alongside them changed the NN-chain merge order (the same
83
+ * mechanism described above, just triggered by the pooling itself rather
84
+ * than by admitting a non-orphan page). A companion real-site run (302 pages)
85
+ * showed zero change. Narrowing "confined" to reduce this blast radius (e.g.
86
+ * preferring the `css:` block closest in size to the orphan count when
87
+ * several share a `pathKey`) is possible future work, not yet justified
88
+ * without more real-corpus evidence of it mattering in practice.
89
+ * @param pages
90
+ * @param blockKeys
91
+ * @param pathDepth
92
+ * @example
93
+ * ```ts
94
+ * const pages = [
95
+ * { paths: ['news', '1'], stylesheetHrefs: ['https://example.com/a.css'] },
96
+ * { paths: ['news', '2'], stylesheetHrefs: ['https://example.com/a.css'] },
97
+ * { paths: ['news', '3'], stylesheetHrefs: [] }, // same template, but crawl missed its <link>
98
+ * { paths: ['about'], stylesheetHrefs: ['https://example.com/b.css'] },
99
+ * ];
100
+ * const blockKeys = resolveBlockingGroupKeys(pages);
101
+ * // ['css:<hash of a.css>', 'css:<hash of a.css>', 'path:news', 'path:about']
102
+ * reassignOrphanBlockKeys(pages, blockKeys);
103
+ * // ['orphan-merge:news', 'orphan-merge:news', 'orphan-merge:news', 'path:about']
104
+ * ```
105
+ */
106
+ export function reassignOrphanBlockKeys(pages, blockKeys, pathDepth) {
107
+ // Eagerly delegate pathDepth validation to derivePathGroupKey, instead of
108
+ // only discovering an invalid option once some page's data happens to
109
+ // reach that branch below — mirrors resolveBlockingGroupKeys's own eager
110
+ // `derivePathGroupKey([], pathDepth)` call, so this function fails fast
111
+ // on the same invalid input even when `pages` is empty.
112
+ derivePathGroupKey([], pathDepth);
113
+ const pathKeys = pages.map((page) => derivePathGroupKey(page.paths, pathDepth));
114
+ const isOrphan = (index) => requireIndex(blockKeys, index).startsWith('path:') &&
115
+ requireIndex(pages, index).stylesheetHrefs.length === 0;
116
+ const cssMemberIndicesByKey = new Map();
117
+ for (const [index, blockKey] of blockKeys.entries()) {
118
+ if (blockKey.startsWith('css:')) {
119
+ const indices = cssMemberIndicesByKey.get(blockKey);
120
+ if (indices) {
121
+ indices.push(index);
122
+ }
123
+ else {
124
+ cssMemberIndicesByKey.set(blockKey, [index]);
125
+ }
126
+ }
127
+ }
128
+ // A css: block is a merge target for pathKey `p` only when every one of
129
+ // its members shares `p` — collected as a single confined pathKey per
130
+ // block (or left unset if the block spans more than one).
131
+ const confinedPathKeyByCssKey = new Map();
132
+ for (const [cssKey, indices] of cssMemberIndicesByKey) {
133
+ const candidatePathKey = requireIndex(pathKeys, requireIndex(indices, 0));
134
+ if (indices.every((index) => requireIndex(pathKeys, index) === candidatePathKey)) {
135
+ confinedPathKeyByCssKey.set(cssKey, candidatePathKey);
136
+ }
137
+ }
138
+ const pathKeysWithConfinedCssBlock = new Set(confinedPathKeyByCssKey.values());
139
+ const orphanPathKeys = new Set();
140
+ for (const index of blockKeys.keys()) {
141
+ const pathKey = requireIndex(pathKeys, index);
142
+ if (isOrphan(index) && pathKeysWithConfinedCssBlock.has(pathKey)) {
143
+ orphanPathKeys.add(pathKey);
144
+ }
145
+ }
146
+ if (orphanPathKeys.size === 0) {
147
+ return [...blockKeys];
148
+ }
149
+ return blockKeys.map((blockKey, index) => {
150
+ const pathKey = requireIndex(pathKeys, index);
151
+ if (!orphanPathKeys.has(pathKey)) {
152
+ return blockKey;
153
+ }
154
+ const isConfinedCssMember = confinedPathKeyByCssKey.get(blockKey) === pathKey;
155
+ return isOrphan(index) || isConfinedCssMember
156
+ ? `${REASSIGNED_KEY_PREFIX}${pathKey}`
157
+ : blockKey;
158
+ });
159
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @see removeContentBlocks
3
+ */
4
+ export type RemoveContentBlocksOptions = {
5
+ /**
6
+ * The HTML attribute name that marks one instance of a freeform,
7
+ * block-editor-authored content region (e.g. a WYSIWYG CMS's own
8
+ * per-block marker attribute). No default: unlike
9
+ * {@link ./extract-landmarks.js | extractLandmarks}'s tag names and ARIA
10
+ * roles, there is no cross-site standard for this — every CMS/page
11
+ * builder invents its own convention, so the caller must name theirs.
12
+ */
13
+ blockAttribute: string;
14
+ };
15
+ /**
16
+ * Result of {@link ./remove-content-blocks.js | removeContentBlocks}.
17
+ */
18
+ export type RemoveContentBlocksResult = {
19
+ remainderHtml: string;
20
+ };
21
+ /**
22
+ * Removes every element in `html` carrying `options.blockAttribute`,
23
+ * including its full subtree, and returns what's left.
24
+ *
25
+ * Built for a specific real-crawl finding: two pages built from the same
26
+ * article template, but authored with a different mix of freeform CMS
27
+ * content blocks (e.g. one page uses a "wysiwyg" block then two "image"
28
+ * blocks, another uses a "title" block then one "image" block), tokenize to
29
+ * almost entirely disjoint leaf paths — each block's own internal markup
30
+ * differs, and that difference dominates the page's whole token set. This
31
+ * defeats {@link ./resolve-structural-cluster-keys.js |
32
+ * resolveStructuralClusterKeys}'s frequency-based chrome/content split
33
+ * (`deriveComparisonSets`), which only recognizes tokens common to *nearly
34
+ * every* page in a block as chrome; a block-editor region varies too much
35
+ * page-to-page to ever clear that bar, yet contributes no genuine
36
+ * template-identifying signal either. Confirmed on a real corpus (302
37
+ * pages): removing these regions before tokenizing cut structural-cluster
38
+ * count from 192 to 32 (with {@link ./extract-landmarks.js | extractLandmarks}
39
+ * and blocking-key fixes already applied) — the single largest lever found
40
+ * for that corpus.
41
+ *
42
+ * Unlike `extractLandmarks`, every matching element is removed (there is no
43
+ * "one instance per type" — a page can have any number of content blocks),
44
+ * and matching is by a single caller-supplied attribute rather than a fixed
45
+ * tag/role vocabulary — see `RemoveContentBlocksOptions.blockAttribute`'s
46
+ * JSDoc for why. `remainderHtml` drops each matched region's markup
47
+ * entirely (no placeholder), for the same reason `extractLandmarks` does:
48
+ * a placeholder string would itself become a token once tokenized.
49
+ *
50
+ * Only the first `<body>` is in scope and opaque tags
51
+ * (`script`/`style`/`svg`/`noscript`) are not searched inside, matching
52
+ * `extractLandmarks`'s and `tokenize()`'s own contracts. A candidate whose
53
+ * closing tag can't be confirmed as genuine (see `isGenuineClose`) is
54
+ * discarded rather than trusted, for the same safety reason `extractLandmarks`
55
+ * discards one.
56
+ * @param html
57
+ * @param options
58
+ * @example
59
+ * ```ts
60
+ * removeContentBlocks(
61
+ * '<body><main><div data-bgb="wysiwyg">free text</div><div data-bgb="image1">...</div></main></body>',
62
+ * { blockAttribute: 'data-bgb' },
63
+ * );
64
+ * // { remainderHtml: '<body><main></main></body>' }
65
+ * ```
66
+ */
67
+ export declare function removeContentBlocks(html: string, options: RemoveContentBlocksOptions): RemoveContentBlocksResult;
@@ -0,0 +1,150 @@
1
+ import { Parser } from 'htmlparser2';
2
+ import { excise } from './excise.js';
3
+ import { isGenuineClose } from './is-genuine-close.js';
4
+ import { isOpaqueTagName } from './opaque-tags.js';
5
+ /**
6
+ * Removes every element in `html` carrying `options.blockAttribute`,
7
+ * including its full subtree, and returns what's left.
8
+ *
9
+ * Built for a specific real-crawl finding: two pages built from the same
10
+ * article template, but authored with a different mix of freeform CMS
11
+ * content blocks (e.g. one page uses a "wysiwyg" block then two "image"
12
+ * blocks, another uses a "title" block then one "image" block), tokenize to
13
+ * almost entirely disjoint leaf paths — each block's own internal markup
14
+ * differs, and that difference dominates the page's whole token set. This
15
+ * defeats {@link ./resolve-structural-cluster-keys.js |
16
+ * resolveStructuralClusterKeys}'s frequency-based chrome/content split
17
+ * (`deriveComparisonSets`), which only recognizes tokens common to *nearly
18
+ * every* page in a block as chrome; a block-editor region varies too much
19
+ * page-to-page to ever clear that bar, yet contributes no genuine
20
+ * template-identifying signal either. Confirmed on a real corpus (302
21
+ * pages): removing these regions before tokenizing cut structural-cluster
22
+ * count from 192 to 32 (with {@link ./extract-landmarks.js | extractLandmarks}
23
+ * and blocking-key fixes already applied) — the single largest lever found
24
+ * for that corpus.
25
+ *
26
+ * Unlike `extractLandmarks`, every matching element is removed (there is no
27
+ * "one instance per type" — a page can have any number of content blocks),
28
+ * and matching is by a single caller-supplied attribute rather than a fixed
29
+ * tag/role vocabulary — see `RemoveContentBlocksOptions.blockAttribute`'s
30
+ * JSDoc for why. `remainderHtml` drops each matched region's markup
31
+ * entirely (no placeholder), for the same reason `extractLandmarks` does:
32
+ * a placeholder string would itself become a token once tokenized.
33
+ *
34
+ * Only the first `<body>` is in scope and opaque tags
35
+ * (`script`/`style`/`svg`/`noscript`) are not searched inside, matching
36
+ * `extractLandmarks`'s and `tokenize()`'s own contracts. A candidate whose
37
+ * closing tag can't be confirmed as genuine (see `isGenuineClose`) is
38
+ * discarded rather than trusted, for the same safety reason `extractLandmarks`
39
+ * discards one.
40
+ * @param html
41
+ * @param options
42
+ * @example
43
+ * ```ts
44
+ * removeContentBlocks(
45
+ * '<body><main><div data-bgb="wysiwyg">free text</div><div data-bgb="image1">...</div></main></body>',
46
+ * { blockAttribute: 'data-bgb' },
47
+ * );
48
+ * // { remainderHtml: '<body><main></main></body>' }
49
+ * ```
50
+ */
51
+ export function removeContentBlocks(html, options) {
52
+ const { blockAttribute } = options;
53
+ const stack = [];
54
+ const spans = [];
55
+ // Same rationale and shape as extractLandmarks's own `opaque` tracking:
56
+ // a different opaque tag opening/closing while already inside one must
57
+ // not perturb this counter. Also tracks whether the opaque tag itself
58
+ // (its own attributes, not any descendant's) carries `blockAttribute` —
59
+ // an opaque tag can be a content block's own root (e.g. an inline `<svg>`
60
+ // chart authored as one block), and diving into its children to look for
61
+ // the attribute would be pointless (they're never searched for landmarks
62
+ // or blocks either) but the opaque root itself must still be checked.
63
+ let opaque = null;
64
+ let bodyDone = false;
65
+ let ignoredBodyOpens = 0;
66
+ const parser = new Parser({
67
+ onopentag(name, attribs) {
68
+ if (opaque) {
69
+ if (name === opaque.tagName) {
70
+ opaque.depth++;
71
+ }
72
+ return;
73
+ }
74
+ if (stack.length === 0) {
75
+ if (name === 'body' && !bodyDone) {
76
+ // The whole page can never itself be "a content block" —
77
+ // unlike extractLandmarks's ARIA-role matching (a
78
+ // standardized vocabulary under which `<body role="banner">`
79
+ // is a legitimate whole-page landmark), `blockAttribute` is
80
+ // an arbitrary caller-chosen name that could collide with an
81
+ // unrelated attribute a CMS/theme happens to stamp onto
82
+ // `<body>` (a page-type marker, a `body_class()`-style hook).
83
+ // Treating that as a match would silently empty the page's
84
+ // entire remainderHtml.
85
+ stack.push({
86
+ tagName: name,
87
+ hasBlockAttribute: false,
88
+ startOffset: parser.startIndex,
89
+ });
90
+ }
91
+ // Ignore everything else outside <body>, same as
92
+ // extractLandmarks/run-tokenizer.ts.
93
+ return;
94
+ }
95
+ if (name === 'body') {
96
+ ignoredBodyOpens++;
97
+ return;
98
+ }
99
+ if (isOpaqueTagName(name)) {
100
+ opaque = {
101
+ tagName: name,
102
+ depth: 1,
103
+ matched: attribs[blockAttribute] !== undefined,
104
+ startOffset: parser.startIndex,
105
+ };
106
+ return;
107
+ }
108
+ stack.push({
109
+ tagName: name,
110
+ hasBlockAttribute: attribs[blockAttribute] !== undefined,
111
+ startOffset: parser.startIndex,
112
+ });
113
+ },
114
+ onclosetag(name) {
115
+ if (opaque) {
116
+ if (name === opaque.tagName) {
117
+ opaque.depth--;
118
+ if (opaque.depth === 0) {
119
+ const endOffset = parser.endIndex + 1;
120
+ if (opaque.matched && isGenuineClose(html, endOffset, opaque.tagName)) {
121
+ spans.push({ start: opaque.startOffset, end: endOffset });
122
+ }
123
+ opaque = null;
124
+ }
125
+ }
126
+ return;
127
+ }
128
+ if (name === 'body' && ignoredBodyOpens > 0) {
129
+ ignoredBodyOpens--;
130
+ return;
131
+ }
132
+ if (stack.length === 0) {
133
+ return;
134
+ }
135
+ const frame = stack.pop();
136
+ if (!frame) {
137
+ return;
138
+ }
139
+ const endOffset = parser.endIndex + 1;
140
+ if (frame.hasBlockAttribute && isGenuineClose(html, endOffset, frame.tagName)) {
141
+ spans.push({ start: frame.startOffset, end: endOffset });
142
+ }
143
+ if (stack.length === 0) {
144
+ bodyDone = true;
145
+ }
146
+ },
147
+ }, { decodeEntities: false });
148
+ parser.end(html);
149
+ return { remainderHtml: excise(html, spans) };
150
+ }