@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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two blocking signals {@link ./derive-path-group-key.js | derivePathGroupKey}
|
|
3
|
+
* and {@link ./derive-stylesheet-group-key.js | deriveStylesheetGroupKey} need,
|
|
4
|
+
* bundled per page so `resolveBlockingGroupKeys` can compute both without the
|
|
5
|
+
* caller re-deriving them separately.
|
|
6
|
+
*/
|
|
7
|
+
export type PageBlockingSignals = {
|
|
8
|
+
paths: readonly string[];
|
|
9
|
+
stylesheetHrefs: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* @see resolveBlockingGroupKeys
|
|
13
|
+
*/
|
|
14
|
+
export type ResolveBlockingGroupKeysOptions = {
|
|
15
|
+
/** Forwarded to `derivePathGroupKey` as-is. */
|
|
16
|
+
pathDepth?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Minimum number of pages that must share a stylesheet-derived key before
|
|
19
|
+
* it's trusted as real evidence, rather than a coincidence. Must be at
|
|
20
|
+
* least 2: a page always "shares" its own key with itself, so 1 would
|
|
21
|
+
* accept every stylesheet-bearing page unconditionally and make this
|
|
22
|
+
* check a no-op. This is a structural floor (below 2, no pair of distinct
|
|
23
|
+
* pages can exist at all), not a statistically-derived
|
|
24
|
+
* confidence threshold — entity-resolution blocking literature has no
|
|
25
|
+
* closed-form value for "how many shared pages prove a true match", so
|
|
26
|
+
* this is a starting default to be tuned against real corpora, not a
|
|
27
|
+
* validated constant.
|
|
28
|
+
*/
|
|
29
|
+
minCssGroupSize?: number;
|
|
30
|
+
/** Forwarded to `splitTokensByFrequency` as-is. */
|
|
31
|
+
hrefCommonThreshold?: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Resolves, per page, which of the two independent blocking signals — the
|
|
35
|
+
* exact stylesheet set or the URL path — to actually use as that page's
|
|
36
|
+
* grouping key. Returns one key per page, in the same order as `pages`.
|
|
37
|
+
*
|
|
38
|
+
* Literature on entity-resolution blocking (Michelson & Knoblock's DNF
|
|
39
|
+
* scheme, canopy clustering, ensemble blocking) combines independent
|
|
40
|
+
* blocking predicates with OR to generate *candidate pairs* for a later
|
|
41
|
+
* similarity/classification pass. This function instead commits each page to
|
|
42
|
+
* exactly one final key: `resolve-page-cluster-keys.js`'s
|
|
43
|
+
* `resolvePageClusterKeys` *does* run a later refinement step
|
|
44
|
+
* (`resolveStructuralClusterKeys`) on top of whichever key a page lands on,
|
|
45
|
+
* but only within that one key's candidate pool — it has no way to pull in
|
|
46
|
+
* a page that this function routed to a different key. So this function's
|
|
47
|
+
* per-page choice is still effectively final for blocking purposes: a page
|
|
48
|
+
* assigned to the wrong key here never re-enters the correct key's pool
|
|
49
|
+
* downstream. A true OR-merge (letting a page carry both the stylesheet and
|
|
50
|
+
* path candidates, deferring to the refinement step to reconcile overlapping
|
|
51
|
+
* results across them) would close that gap, but is deliberately deferred —
|
|
52
|
+
* it needs the same literature-plus-real-data validation cycle this
|
|
53
|
+
* package's linkage-criterion and NN-chain choices already went through, not
|
|
54
|
+
* a change bundled in alongside unrelated fixes. Until then, a
|
|
55
|
+
* priority-with-fallback decision — try the strong signal, fall back to the
|
|
56
|
+
* weak one — is the applicable pattern here, not OR-merge: a union of
|
|
57
|
+
* equivalence relations can only ever coarsen a partition, never split it,
|
|
58
|
+
* but the whole point of preferring the stylesheet signal is that it *splits*
|
|
59
|
+
* pages a URL-path-only grouping would otherwise lump together (confirmed
|
|
60
|
+
* against real crawl data: a single page embedded under an otherwise-uniform
|
|
61
|
+
* URL section, but loading a completely different stylesheet set, is exactly
|
|
62
|
+
* the case a path-only key misses and a stylesheet key catches).
|
|
63
|
+
*
|
|
64
|
+
* Before comparing stylesheet sets, this reuses
|
|
65
|
+
* {@link ./compute-document-frequency.js | computeDocumentFrequency} and
|
|
66
|
+
* {@link ./split-tokens-by-frequency.js | splitTokensByFrequency} — originally
|
|
67
|
+
* built to separate a page's site-wide chrome from its page-specific HTML
|
|
68
|
+
* structure — to strip stylesheet hrefs that recur across most of `pages`
|
|
69
|
+
* (e.g. a shared reset/font stylesheet) before hashing. Without this, two
|
|
70
|
+
* pages from otherwise-unrelated sections that happen to load only that one
|
|
71
|
+
* shared stylesheet would satisfy `minCssGroupSize` and be wrongly treated as
|
|
72
|
+
* the same template family: the problem there isn't too few pages sharing
|
|
73
|
+
* the key (raising `minCssGroupSize` doesn't fix it), it's that the key
|
|
74
|
+
* itself carries no discriminative information. A page whose stylesheet set
|
|
75
|
+
* is empty, or becomes empty after this filtering, always falls back to the
|
|
76
|
+
* path key — loading no distinctive stylesheet is an absence of evidence,
|
|
77
|
+
* not evidence of a shared template, so it must never itself become a
|
|
78
|
+
* matching signal.
|
|
79
|
+
*
|
|
80
|
+
* Document frequency is computed only over pages that load at least one
|
|
81
|
+
* stylesheet: including stylesheet-less pages in the denominator would dilute
|
|
82
|
+
* every href's frequency ratio (e.g. a stylesheet loaded by 100% of the pages
|
|
83
|
+
* that load *any* stylesheet would read as a low, "distinctive" frequency if
|
|
84
|
+
* most pages in the batch load none), letting a genuinely non-discriminative,
|
|
85
|
+
* site-wide stylesheet slip through the common-href filter.
|
|
86
|
+
*
|
|
87
|
+
* Like `computeDocumentFrequency` itself, this expects `pages` to be a
|
|
88
|
+
* roughly homogeneous batch (one site, or one section of a large
|
|
89
|
+
* multi-template site) — see that function's JSDoc for why a federation of
|
|
90
|
+
* independently-templated sub-sections defeats frequency-based filtering.
|
|
91
|
+
* Splitting a heterogeneous crawl into sections before calling this function
|
|
92
|
+
* is the caller's responsibility.
|
|
93
|
+
*
|
|
94
|
+
* This filtering needs enough stylesheet-bearing pages to tell "loaded by
|
|
95
|
+
* every page that has any stylesheet" apart from "coincidentally the only
|
|
96
|
+
* stylesheet two pages happen to load": with only two stylesheet-bearing
|
|
97
|
+
* pages in the whole batch and nothing else to contrast against, any
|
|
98
|
+
* stylesheet they share reads as 100% common and gets filtered out,
|
|
99
|
+
* producing a path-key fallback even when the two pages are a genuine
|
|
100
|
+
* template match. A third, differently-styled page (as in the example below)
|
|
101
|
+
* is what gives the shared stylesheet a frequency below the common-href
|
|
102
|
+
* cutoff.
|
|
103
|
+
* @param pages
|
|
104
|
+
* @param options
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* resolveBlockingGroupKeys([
|
|
108
|
+
* { paths: ['dept-a', 'news', '1'], stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/common.css'] },
|
|
109
|
+
* { paths: ['dept-a', 'news', '2'], stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/common.css'] },
|
|
110
|
+
* { paths: ['dept-b', 'about'], stylesheetHrefs: ['https://example.com/common.css'] },
|
|
111
|
+
* ]);
|
|
112
|
+
* // ['css:<hash of a.css>', 'css:<hash of a.css>', 'path:dept-b']
|
|
113
|
+
* // common.css is loaded by all 3 pages and is filtered out as non-discriminative chrome.
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export declare function resolveBlockingGroupKeys(pages: readonly PageBlockingSignals[], options?: ResolveBlockingGroupKeysOptions): string[];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { computeDocumentFrequency } from './compute-document-frequency.js';
|
|
2
|
+
import { derivePathGroupKey } from './derive-path-group-key.js';
|
|
3
|
+
import { deriveStylesheetGroupKey } from './derive-stylesheet-group-key.js';
|
|
4
|
+
import { splitTokensByFrequency } from './split-tokens-by-frequency.js';
|
|
5
|
+
const DEFAULT_MIN_CSS_GROUP_SIZE = 2;
|
|
6
|
+
/**
|
|
7
|
+
* Resolves, per page, which of the two independent blocking signals — the
|
|
8
|
+
* exact stylesheet set or the URL path — to actually use as that page's
|
|
9
|
+
* grouping key. Returns one key per page, in the same order as `pages`.
|
|
10
|
+
*
|
|
11
|
+
* Literature on entity-resolution blocking (Michelson & Knoblock's DNF
|
|
12
|
+
* scheme, canopy clustering, ensemble blocking) combines independent
|
|
13
|
+
* blocking predicates with OR to generate *candidate pairs* for a later
|
|
14
|
+
* similarity/classification pass. This function instead commits each page to
|
|
15
|
+
* exactly one final key: `resolve-page-cluster-keys.js`'s
|
|
16
|
+
* `resolvePageClusterKeys` *does* run a later refinement step
|
|
17
|
+
* (`resolveStructuralClusterKeys`) on top of whichever key a page lands on,
|
|
18
|
+
* but only within that one key's candidate pool — it has no way to pull in
|
|
19
|
+
* a page that this function routed to a different key. So this function's
|
|
20
|
+
* per-page choice is still effectively final for blocking purposes: a page
|
|
21
|
+
* assigned to the wrong key here never re-enters the correct key's pool
|
|
22
|
+
* downstream. A true OR-merge (letting a page carry both the stylesheet and
|
|
23
|
+
* path candidates, deferring to the refinement step to reconcile overlapping
|
|
24
|
+
* results across them) would close that gap, but is deliberately deferred —
|
|
25
|
+
* it needs the same literature-plus-real-data validation cycle this
|
|
26
|
+
* package's linkage-criterion and NN-chain choices already went through, not
|
|
27
|
+
* a change bundled in alongside unrelated fixes. Until then, a
|
|
28
|
+
* priority-with-fallback decision — try the strong signal, fall back to the
|
|
29
|
+
* weak one — is the applicable pattern here, not OR-merge: a union of
|
|
30
|
+
* equivalence relations can only ever coarsen a partition, never split it,
|
|
31
|
+
* but the whole point of preferring the stylesheet signal is that it *splits*
|
|
32
|
+
* pages a URL-path-only grouping would otherwise lump together (confirmed
|
|
33
|
+
* against real crawl data: a single page embedded under an otherwise-uniform
|
|
34
|
+
* URL section, but loading a completely different stylesheet set, is exactly
|
|
35
|
+
* the case a path-only key misses and a stylesheet key catches).
|
|
36
|
+
*
|
|
37
|
+
* Before comparing stylesheet sets, this reuses
|
|
38
|
+
* {@link ./compute-document-frequency.js | computeDocumentFrequency} and
|
|
39
|
+
* {@link ./split-tokens-by-frequency.js | splitTokensByFrequency} — originally
|
|
40
|
+
* built to separate a page's site-wide chrome from its page-specific HTML
|
|
41
|
+
* structure — to strip stylesheet hrefs that recur across most of `pages`
|
|
42
|
+
* (e.g. a shared reset/font stylesheet) before hashing. Without this, two
|
|
43
|
+
* pages from otherwise-unrelated sections that happen to load only that one
|
|
44
|
+
* shared stylesheet would satisfy `minCssGroupSize` and be wrongly treated as
|
|
45
|
+
* the same template family: the problem there isn't too few pages sharing
|
|
46
|
+
* the key (raising `minCssGroupSize` doesn't fix it), it's that the key
|
|
47
|
+
* itself carries no discriminative information. A page whose stylesheet set
|
|
48
|
+
* is empty, or becomes empty after this filtering, always falls back to the
|
|
49
|
+
* path key — loading no distinctive stylesheet is an absence of evidence,
|
|
50
|
+
* not evidence of a shared template, so it must never itself become a
|
|
51
|
+
* matching signal.
|
|
52
|
+
*
|
|
53
|
+
* Document frequency is computed only over pages that load at least one
|
|
54
|
+
* stylesheet: including stylesheet-less pages in the denominator would dilute
|
|
55
|
+
* every href's frequency ratio (e.g. a stylesheet loaded by 100% of the pages
|
|
56
|
+
* that load *any* stylesheet would read as a low, "distinctive" frequency if
|
|
57
|
+
* most pages in the batch load none), letting a genuinely non-discriminative,
|
|
58
|
+
* site-wide stylesheet slip through the common-href filter.
|
|
59
|
+
*
|
|
60
|
+
* Like `computeDocumentFrequency` itself, this expects `pages` to be a
|
|
61
|
+
* roughly homogeneous batch (one site, or one section of a large
|
|
62
|
+
* multi-template site) — see that function's JSDoc for why a federation of
|
|
63
|
+
* independently-templated sub-sections defeats frequency-based filtering.
|
|
64
|
+
* Splitting a heterogeneous crawl into sections before calling this function
|
|
65
|
+
* is the caller's responsibility.
|
|
66
|
+
*
|
|
67
|
+
* This filtering needs enough stylesheet-bearing pages to tell "loaded by
|
|
68
|
+
* every page that has any stylesheet" apart from "coincidentally the only
|
|
69
|
+
* stylesheet two pages happen to load": with only two stylesheet-bearing
|
|
70
|
+
* pages in the whole batch and nothing else to contrast against, any
|
|
71
|
+
* stylesheet they share reads as 100% common and gets filtered out,
|
|
72
|
+
* producing a path-key fallback even when the two pages are a genuine
|
|
73
|
+
* template match. A third, differently-styled page (as in the example below)
|
|
74
|
+
* is what gives the shared stylesheet a frequency below the common-href
|
|
75
|
+
* cutoff.
|
|
76
|
+
* @param pages
|
|
77
|
+
* @param options
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* resolveBlockingGroupKeys([
|
|
81
|
+
* { paths: ['dept-a', 'news', '1'], stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/common.css'] },
|
|
82
|
+
* { paths: ['dept-a', 'news', '2'], stylesheetHrefs: ['https://example.com/a.css', 'https://example.com/common.css'] },
|
|
83
|
+
* { paths: ['dept-b', 'about'], stylesheetHrefs: ['https://example.com/common.css'] },
|
|
84
|
+
* ]);
|
|
85
|
+
* // ['css:<hash of a.css>', 'css:<hash of a.css>', 'path:dept-b']
|
|
86
|
+
* // common.css is loaded by all 3 pages and is filtered out as non-discriminative chrome.
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function resolveBlockingGroupKeys(pages, options) {
|
|
90
|
+
const pathDepth = options?.pathDepth;
|
|
91
|
+
const minCssGroupSize = options?.minCssGroupSize ?? DEFAULT_MIN_CSS_GROUP_SIZE;
|
|
92
|
+
const hrefCommonThreshold = options?.hrefCommonThreshold;
|
|
93
|
+
if (!(Number.isInteger(minCssGroupSize) && minCssGroupSize >= 2)) {
|
|
94
|
+
throw new RangeError(`resolveBlockingGroupKeys: minCssGroupSize must be an integer >= 2, got ${minCssGroupSize}`);
|
|
95
|
+
}
|
|
96
|
+
// Eagerly delegate pathDepth/hrefCommonThreshold validation to the
|
|
97
|
+
// functions that own it, instead of only discovering an invalid option
|
|
98
|
+
// once some page's data happens to reach that branch below.
|
|
99
|
+
derivePathGroupKey([], pathDepth);
|
|
100
|
+
splitTokensByFrequency(new Set(), { documentFrequency: new Map(), pageCount: 0 }, hrefCommonThreshold);
|
|
101
|
+
const hrefSets = pages.map((page) => new Set(page.stylesheetHrefs));
|
|
102
|
+
// Pages with no stylesheets at all must not count toward the denominator:
|
|
103
|
+
// see the JSDoc note above on document-frequency dilution.
|
|
104
|
+
const corpusFrequency = computeDocumentFrequency(hrefSets.filter((hrefSet) => hrefSet.size > 0));
|
|
105
|
+
const distinctiveHrefs = hrefSets.map((hrefSet) => splitTokensByFrequency(hrefSet, corpusFrequency, hrefCommonThreshold).contentTokens);
|
|
106
|
+
const cssKeys = distinctiveHrefs.map((hrefs) => hrefs.size === 0 ? undefined : deriveStylesheetGroupKey([...hrefs]));
|
|
107
|
+
const cssKeyCounts = new Map();
|
|
108
|
+
for (const cssKey of cssKeys) {
|
|
109
|
+
if (cssKey !== undefined) {
|
|
110
|
+
cssKeyCounts.set(cssKey, (cssKeyCounts.get(cssKey) ?? 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return pages.map((page, index) => {
|
|
114
|
+
const cssKey = cssKeys[index];
|
|
115
|
+
if (cssKey !== undefined && (cssKeyCounts.get(cssKey) ?? 0) >= minCssGroupSize) {
|
|
116
|
+
return `css:${cssKey}`;
|
|
117
|
+
}
|
|
118
|
+
return `path:${derivePathGroupKey(page.paths, pathDepth)}`;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Frame } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves a frame once its element has closed, returning the leaf paths it
|
|
4
|
+
* contributes to its parent (still relative to the parent — the parent
|
|
5
|
+
* prefixes its own segment, if any, the next time *it* closes).
|
|
6
|
+
*
|
|
7
|
+
* Whether a `div`/`span` folds away can only be known once it closes (its
|
|
8
|
+
* final child count isn't settled until then), which is why this resolution
|
|
9
|
+
* happens here rather than eagerly when the element opens — see the module
|
|
10
|
+
* doc on `run-tokenizer.ts` for why a naive "emit on open" SAX pass cannot
|
|
11
|
+
* implement folding at all.
|
|
12
|
+
*
|
|
13
|
+
* An element with nothing in `pendingPaths` (e.g. an empty `<div class="spacer">`,
|
|
14
|
+
* or one with only text/whitespace children) is itself a leaf and returns its
|
|
15
|
+
* own segment; a folded wrapper contributes nothing of its own, passing its
|
|
16
|
+
* children's paths straight through so the wrapper's nesting depth carries no
|
|
17
|
+
* structural information (see the "何を捨てたか" note in `tokenize.ts`).
|
|
18
|
+
*
|
|
19
|
+
* Leaf-ness is judged by `pendingPaths`, not `childElementCount`: a comment
|
|
20
|
+
* (when `includeComments` is on) lands in `pendingPaths` without incrementing
|
|
21
|
+
* `childElementCount` (comments don't count toward fold eligibility either),
|
|
22
|
+
* so an element containing only a comment still has something to prefix and
|
|
23
|
+
* must not be treated as a plain leaf that discards it.
|
|
24
|
+
* @param frame
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveClosedFrame(frame: Frame): string[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a frame once its element has closed, returning the leaf paths it
|
|
3
|
+
* contributes to its parent (still relative to the parent — the parent
|
|
4
|
+
* prefixes its own segment, if any, the next time *it* closes).
|
|
5
|
+
*
|
|
6
|
+
* Whether a `div`/`span` folds away can only be known once it closes (its
|
|
7
|
+
* final child count isn't settled until then), which is why this resolution
|
|
8
|
+
* happens here rather than eagerly when the element opens — see the module
|
|
9
|
+
* doc on `run-tokenizer.ts` for why a naive "emit on open" SAX pass cannot
|
|
10
|
+
* implement folding at all.
|
|
11
|
+
*
|
|
12
|
+
* An element with nothing in `pendingPaths` (e.g. an empty `<div class="spacer">`,
|
|
13
|
+
* or one with only text/whitespace children) is itself a leaf and returns its
|
|
14
|
+
* own segment; a folded wrapper contributes nothing of its own, passing its
|
|
15
|
+
* children's paths straight through so the wrapper's nesting depth carries no
|
|
16
|
+
* structural information (see the "何を捨てたか" note in `tokenize.ts`).
|
|
17
|
+
*
|
|
18
|
+
* Leaf-ness is judged by `pendingPaths`, not `childElementCount`: a comment
|
|
19
|
+
* (when `includeComments` is on) lands in `pendingPaths` without incrementing
|
|
20
|
+
* `childElementCount` (comments don't count toward fold eligibility either),
|
|
21
|
+
* so an element containing only a comment still has something to prefix and
|
|
22
|
+
* must not be treated as a plain leaf that discards it.
|
|
23
|
+
* @param frame
|
|
24
|
+
*/
|
|
25
|
+
export function resolveClosedFrame(frame) {
|
|
26
|
+
if (frame.pendingPaths.length === 0) {
|
|
27
|
+
return [frame.segment];
|
|
28
|
+
}
|
|
29
|
+
if (frame.isFoldCandidate && frame.childElementCount === 1) {
|
|
30
|
+
return frame.pendingPaths;
|
|
31
|
+
}
|
|
32
|
+
return frame.pendingPaths.map((path) => `${frame.segment}>${path}`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { LandmarkType } from './extract-landmarks.js';
|
|
2
|
+
import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
|
|
3
|
+
import type { TokenizeOptions } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* @see resolveLandmarkVariantKeys
|
|
6
|
+
*/
|
|
7
|
+
export type ResolveLandmarkVariantKeysOptions = TokenizeOptions & ResolveStructuralClusterKeysOptions;
|
|
8
|
+
/**
|
|
9
|
+
* Classifies which *variant* of a single landmark type (e.g. "which header
|
|
10
|
+
* design") each page has, independently of
|
|
11
|
+
* {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}'s overall
|
|
12
|
+
* per-page template key. This is metadata to attach to a page ("does it have
|
|
13
|
+
* header variant X?"), not a replacement for template clustering — callers
|
|
14
|
+
* that want both call this once per `landmarkType` alongside
|
|
15
|
+
* `resolvePageClusterKeys` and combine the results themselves; this function
|
|
16
|
+
* does not know about, or merge with, the other one's output.
|
|
17
|
+
*
|
|
18
|
+
* Each call re-runs {@link ./extract-landmarks.js | extractLandmarks} over
|
|
19
|
+
* the entire `htmlList`, keeping only the one field matching `landmarkType`
|
|
20
|
+
* and discarding the other three it also computed. Calling this once per
|
|
21
|
+
* `landmarkType` (as the paragraph above suggests, for a caller that wants
|
|
22
|
+
* more than one) therefore re-parses every page once per type requested. A
|
|
23
|
+
* caller for whom that cost is material should call `extractLandmarks`
|
|
24
|
+
* itself once per page, read all four fields off the single result, and feed
|
|
25
|
+
* each field's token sets to
|
|
26
|
+
* {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
|
|
27
|
+
* directly (with the same empty-set sentinel for a missing field) instead of
|
|
28
|
+
* calling this function multiple times.
|
|
29
|
+
*
|
|
30
|
+
* A page with no match for `landmarkType` (per
|
|
31
|
+
* {@link ./extract-landmarks.js | extractLandmarks}) compares as an empty
|
|
32
|
+
* token set. `jaccardSimilarity`'s documented treatment of two empty sets as
|
|
33
|
+
* similarity `1` (see its JSDoc) means every landmark-less page lands in the
|
|
34
|
+
* same "has no such landmark" group with no extra branching needed here, and
|
|
35
|
+
* unambiguously in a different group from every page that does have one
|
|
36
|
+
* (`jaccardSimilarity(∅, nonEmpty)` is always `0`). A landmark that exists
|
|
37
|
+
* but is empty (e.g. `<header></header>`) never collides with this sentinel:
|
|
38
|
+
* `tokenize` still emits at least the element's own segment for it.
|
|
39
|
+
*
|
|
40
|
+
* Does not block by URL path or stylesheet first (unlike
|
|
41
|
+
* `resolvePageClusterKeys`): the same header design is normally reused
|
|
42
|
+
* across a site's independent URL sections, so blocking on those signals
|
|
43
|
+
* would work against this function's purpose. `resolveStructuralClusterKeys`
|
|
44
|
+
* is therefore given the full `htmlList` as one pool, which means this
|
|
45
|
+
* function inherits its O(n²) cost with no blocking to shrink `n` first —
|
|
46
|
+
* intended for batches of up to a few thousand pages (validated against an
|
|
47
|
+
* 800-page real sample), not for handing it an entire unblocked crawl.
|
|
48
|
+
* @param htmlList
|
|
49
|
+
* @param landmarkType
|
|
50
|
+
* @param options
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* resolveLandmarkVariantKeys(
|
|
54
|
+
* [
|
|
55
|
+
* '<body><header><nav>A</nav></header></body>',
|
|
56
|
+
* '<body><header><nav>A</nav></header></body>',
|
|
57
|
+
* '<body><header><a>B</a></header></body>',
|
|
58
|
+
* ],
|
|
59
|
+
* 'header',
|
|
60
|
+
* );
|
|
61
|
+
* // pages 0 and 1 (structurally identical header) share a key; page 2 (a
|
|
62
|
+
* // different header structure) gets its own — text content alone (e.g.
|
|
63
|
+
* // the "A" vs "B" text) would not, since tokenize() discards visible text.
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveLandmarkVariantKeys(htmlList: readonly string[], landmarkType: LandmarkType, options?: ResolveLandmarkVariantKeysOptions): string[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { extractLandmarks } from './extract-landmarks.js';
|
|
2
|
+
import { resolveStructuralClusterKeys } from './resolve-structural-cluster-keys.js';
|
|
3
|
+
import { tokenize } from './tokenize.js';
|
|
4
|
+
/**
|
|
5
|
+
* Classifies which *variant* of a single landmark type (e.g. "which header
|
|
6
|
+
* design") each page has, independently of
|
|
7
|
+
* {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}'s overall
|
|
8
|
+
* per-page template key. This is metadata to attach to a page ("does it have
|
|
9
|
+
* header variant X?"), not a replacement for template clustering — callers
|
|
10
|
+
* that want both call this once per `landmarkType` alongside
|
|
11
|
+
* `resolvePageClusterKeys` and combine the results themselves; this function
|
|
12
|
+
* does not know about, or merge with, the other one's output.
|
|
13
|
+
*
|
|
14
|
+
* Each call re-runs {@link ./extract-landmarks.js | extractLandmarks} over
|
|
15
|
+
* the entire `htmlList`, keeping only the one field matching `landmarkType`
|
|
16
|
+
* and discarding the other three it also computed. Calling this once per
|
|
17
|
+
* `landmarkType` (as the paragraph above suggests, for a caller that wants
|
|
18
|
+
* more than one) therefore re-parses every page once per type requested. A
|
|
19
|
+
* caller for whom that cost is material should call `extractLandmarks`
|
|
20
|
+
* itself once per page, read all four fields off the single result, and feed
|
|
21
|
+
* each field's token sets to
|
|
22
|
+
* {@link ./resolve-structural-cluster-keys.js | resolveStructuralClusterKeys}
|
|
23
|
+
* directly (with the same empty-set sentinel for a missing field) instead of
|
|
24
|
+
* calling this function multiple times.
|
|
25
|
+
*
|
|
26
|
+
* A page with no match for `landmarkType` (per
|
|
27
|
+
* {@link ./extract-landmarks.js | extractLandmarks}) compares as an empty
|
|
28
|
+
* token set. `jaccardSimilarity`'s documented treatment of two empty sets as
|
|
29
|
+
* similarity `1` (see its JSDoc) means every landmark-less page lands in the
|
|
30
|
+
* same "has no such landmark" group with no extra branching needed here, and
|
|
31
|
+
* unambiguously in a different group from every page that does have one
|
|
32
|
+
* (`jaccardSimilarity(∅, nonEmpty)` is always `0`). A landmark that exists
|
|
33
|
+
* but is empty (e.g. `<header></header>`) never collides with this sentinel:
|
|
34
|
+
* `tokenize` still emits at least the element's own segment for it.
|
|
35
|
+
*
|
|
36
|
+
* Does not block by URL path or stylesheet first (unlike
|
|
37
|
+
* `resolvePageClusterKeys`): the same header design is normally reused
|
|
38
|
+
* across a site's independent URL sections, so blocking on those signals
|
|
39
|
+
* would work against this function's purpose. `resolveStructuralClusterKeys`
|
|
40
|
+
* is therefore given the full `htmlList` as one pool, which means this
|
|
41
|
+
* function inherits its O(n²) cost with no blocking to shrink `n` first —
|
|
42
|
+
* intended for batches of up to a few thousand pages (validated against an
|
|
43
|
+
* 800-page real sample), not for handing it an entire unblocked crawl.
|
|
44
|
+
* @param htmlList
|
|
45
|
+
* @param landmarkType
|
|
46
|
+
* @param options
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* resolveLandmarkVariantKeys(
|
|
50
|
+
* [
|
|
51
|
+
* '<body><header><nav>A</nav></header></body>',
|
|
52
|
+
* '<body><header><nav>A</nav></header></body>',
|
|
53
|
+
* '<body><header><a>B</a></header></body>',
|
|
54
|
+
* ],
|
|
55
|
+
* 'header',
|
|
56
|
+
* );
|
|
57
|
+
* // pages 0 and 1 (structurally identical header) share a key; page 2 (a
|
|
58
|
+
* // different header structure) gets its own — text content alone (e.g.
|
|
59
|
+
* // the "A" vs "B" text) would not, since tokenize() discards visible text.
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function resolveLandmarkVariantKeys(htmlList, landmarkType, options) {
|
|
63
|
+
const tokenSets = htmlList.map((html) => {
|
|
64
|
+
const region = extractLandmarks(html)[landmarkType];
|
|
65
|
+
if (region === undefined) {
|
|
66
|
+
return new Set();
|
|
67
|
+
}
|
|
68
|
+
return new Set(tokenize(`<body>${region}</body>`, options).tokens);
|
|
69
|
+
});
|
|
70
|
+
return resolveStructuralClusterKeys(tokenSets, options);
|
|
71
|
+
}
|