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