@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,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
|
+
}
|
package/dist/excise.d.ts
ADDED
|
@@ -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[];
|