@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.
- package/README.md +68 -0
- package/dist/array-edit-distance.d.ts +20 -0
- package/dist/array-edit-distance.js +52 -0
- package/dist/build-segment.d.ts +18 -0
- package/dist/build-segment.js +27 -0
- package/dist/cap-content-depth.d.ts +69 -0
- package/dist/cap-content-depth.js +161 -0
- package/dist/compute-document-frequency.d.ts +33 -0
- package/dist/compute-document-frequency.js +40 -0
- package/dist/create-frame.d.ts +16 -0
- package/dist/create-frame.js +29 -0
- package/dist/derive-path-group-key.d.ts +44 -0
- package/dist/derive-path-group-key.js +51 -0
- package/dist/derive-stylesheet-group-key.d.ts +36 -0
- package/dist/derive-stylesheet-group-key.js +41 -0
- package/dist/detect-content-depth-cap.d.ts +114 -0
- package/dist/detect-content-depth-cap.js +137 -0
- package/dist/escape-reg-exp.d.ts +11 -0
- package/dist/escape-reg-exp.js +13 -0
- package/dist/excise.d.ts +13 -0
- package/dist/excise.js +24 -0
- package/dist/extract-landmarks.d.ts +82 -0
- package/dist/extract-landmarks.js +104 -0
- package/dist/filter-first-party-stylesheet-hrefs.d.ts +73 -0
- package/dist/filter-first-party-stylesheet-hrefs.js +118 -0
- package/dist/find-shallowest-elements.d.ts +39 -0
- package/dist/find-shallowest-elements.js +121 -0
- package/dist/foldable-tags.d.ts +8 -0
- package/dist/foldable-tags.js +8 -0
- package/dist/format-bracket.d.ts +11 -0
- package/dist/format-bracket.js +17 -0
- package/dist/hash-content.d.ts +22 -0
- package/dist/hash-content.js +26 -0
- package/dist/html-region-utils.d.ts +74 -0
- package/dist/html-region-utils.js +96 -0
- package/dist/is-fold-candidate.d.ts +13 -0
- package/dist/is-fold-candidate.js +16 -0
- package/dist/is-genuine-close.d.ts +23 -0
- package/dist/is-genuine-close.js +27 -0
- package/dist/is-noise-class.d.ts +6 -0
- package/dist/is-noise-class.js +8 -0
- package/dist/jaccard-similarity.d.ts +23 -0
- package/dist/jaccard-similarity.js +36 -0
- package/dist/merge-landmark-affined-clusters.d.ts +179 -0
- package/dist/merge-landmark-affined-clusters.js +544 -0
- package/dist/merge-spans.d.ts +15 -0
- package/dist/merge-spans.js +22 -0
- package/dist/noise-class-patterns.d.ts +21 -0
- package/dist/noise-class-patterns.js +74 -0
- package/dist/normalize-for-hash.d.ts +10 -0
- package/dist/normalize-for-hash.js +12 -0
- package/dist/opaque-tags.d.ts +17 -0
- package/dist/opaque-tags.js +18 -0
- package/dist/parse-class-list.d.ts +10 -0
- package/dist/parse-class-list.js +23 -0
- package/dist/reassign-orphan-block-keys.d.ts +81 -0
- package/dist/reassign-orphan-block-keys.js +159 -0
- package/dist/remove-content-blocks.d.ts +67 -0
- package/dist/remove-content-blocks.js +150 -0
- package/dist/resolve-blocking-group-keys.d.ts +116 -0
- package/dist/resolve-blocking-group-keys.js +120 -0
- package/dist/resolve-closed-frame.d.ts +26 -0
- package/dist/resolve-closed-frame.js +33 -0
- package/dist/resolve-landmark-variant-keys.d.ts +66 -0
- package/dist/resolve-landmark-variant-keys.js +71 -0
- package/dist/resolve-options.d.ts +6 -0
- package/dist/resolve-options.js +10 -0
- package/dist/resolve-page-cluster-keys.d.ts +222 -0
- package/dist/resolve-page-cluster-keys.js +198 -0
- package/dist/resolve-structural-cluster-keys.d.ts +50 -0
- package/dist/resolve-structural-cluster-keys.js +287 -0
- package/dist/run-tokenizer.d.ts +33 -0
- package/dist/run-tokenizer.js +152 -0
- package/dist/split-tokens-by-frequency.d.ts +46 -0
- package/dist/split-tokens-by-frequency.js +88 -0
- package/dist/tokenize.d.ts +58 -0
- package/dist/tokenize.js +60 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.js +1 -0
- 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
|
+
}
|