@d-zero/page-cluster 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +68 -0
  2. package/dist/array-edit-distance.d.ts +20 -0
  3. package/dist/array-edit-distance.js +52 -0
  4. package/dist/build-segment.d.ts +18 -0
  5. package/dist/build-segment.js +27 -0
  6. package/dist/cap-content-depth.d.ts +69 -0
  7. package/dist/cap-content-depth.js +161 -0
  8. package/dist/compute-document-frequency.d.ts +33 -0
  9. package/dist/compute-document-frequency.js +40 -0
  10. package/dist/create-frame.d.ts +16 -0
  11. package/dist/create-frame.js +29 -0
  12. package/dist/derive-path-group-key.d.ts +44 -0
  13. package/dist/derive-path-group-key.js +51 -0
  14. package/dist/derive-stylesheet-group-key.d.ts +36 -0
  15. package/dist/derive-stylesheet-group-key.js +41 -0
  16. package/dist/detect-content-depth-cap.d.ts +114 -0
  17. package/dist/detect-content-depth-cap.js +137 -0
  18. package/dist/escape-reg-exp.d.ts +11 -0
  19. package/dist/escape-reg-exp.js +13 -0
  20. package/dist/excise.d.ts +13 -0
  21. package/dist/excise.js +24 -0
  22. package/dist/extract-landmarks.d.ts +82 -0
  23. package/dist/extract-landmarks.js +104 -0
  24. package/dist/filter-first-party-stylesheet-hrefs.d.ts +73 -0
  25. package/dist/filter-first-party-stylesheet-hrefs.js +118 -0
  26. package/dist/find-shallowest-elements.d.ts +39 -0
  27. package/dist/find-shallowest-elements.js +121 -0
  28. package/dist/foldable-tags.d.ts +8 -0
  29. package/dist/foldable-tags.js +8 -0
  30. package/dist/format-bracket.d.ts +11 -0
  31. package/dist/format-bracket.js +17 -0
  32. package/dist/hash-content.d.ts +22 -0
  33. package/dist/hash-content.js +26 -0
  34. package/dist/html-region-utils.d.ts +74 -0
  35. package/dist/html-region-utils.js +96 -0
  36. package/dist/is-fold-candidate.d.ts +13 -0
  37. package/dist/is-fold-candidate.js +16 -0
  38. package/dist/is-genuine-close.d.ts +23 -0
  39. package/dist/is-genuine-close.js +27 -0
  40. package/dist/is-noise-class.d.ts +6 -0
  41. package/dist/is-noise-class.js +8 -0
  42. package/dist/jaccard-similarity.d.ts +23 -0
  43. package/dist/jaccard-similarity.js +36 -0
  44. package/dist/merge-landmark-affined-clusters.d.ts +179 -0
  45. package/dist/merge-landmark-affined-clusters.js +544 -0
  46. package/dist/merge-spans.d.ts +15 -0
  47. package/dist/merge-spans.js +22 -0
  48. package/dist/noise-class-patterns.d.ts +21 -0
  49. package/dist/noise-class-patterns.js +74 -0
  50. package/dist/normalize-for-hash.d.ts +10 -0
  51. package/dist/normalize-for-hash.js +12 -0
  52. package/dist/opaque-tags.d.ts +17 -0
  53. package/dist/opaque-tags.js +18 -0
  54. package/dist/parse-class-list.d.ts +10 -0
  55. package/dist/parse-class-list.js +23 -0
  56. package/dist/reassign-orphan-block-keys.d.ts +81 -0
  57. package/dist/reassign-orphan-block-keys.js +159 -0
  58. package/dist/remove-content-blocks.d.ts +67 -0
  59. package/dist/remove-content-blocks.js +150 -0
  60. package/dist/resolve-blocking-group-keys.d.ts +116 -0
  61. package/dist/resolve-blocking-group-keys.js +120 -0
  62. package/dist/resolve-closed-frame.d.ts +26 -0
  63. package/dist/resolve-closed-frame.js +33 -0
  64. package/dist/resolve-landmark-variant-keys.d.ts +66 -0
  65. package/dist/resolve-landmark-variant-keys.js +71 -0
  66. package/dist/resolve-options.d.ts +6 -0
  67. package/dist/resolve-options.js +10 -0
  68. package/dist/resolve-page-cluster-keys.d.ts +222 -0
  69. package/dist/resolve-page-cluster-keys.js +198 -0
  70. package/dist/resolve-structural-cluster-keys.d.ts +50 -0
  71. package/dist/resolve-structural-cluster-keys.js +287 -0
  72. package/dist/run-tokenizer.d.ts +33 -0
  73. package/dist/run-tokenizer.js +152 -0
  74. package/dist/split-tokens-by-frequency.d.ts +46 -0
  75. package/dist/split-tokens-by-frequency.js +88 -0
  76. package/dist/tokenize.d.ts +58 -0
  77. package/dist/tokenize.js +60 -0
  78. package/dist/types.d.ts +85 -0
  79. package/dist/types.js +1 -0
  80. package/package.json +102 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,6 @@
1
+ import type { ResolvedOptions, TokenizeOptions } from './types.js';
2
+ /**
3
+ * Applies defaults to {@link TokenizeOptions}.
4
+ * @param options
5
+ */
6
+ export declare function resolveOptions(options?: TokenizeOptions): ResolvedOptions;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Applies defaults to {@link TokenizeOptions}.
3
+ * @param options
4
+ */
5
+ export function resolveOptions(options) {
6
+ return {
7
+ filterNoiseClasses: options?.filterNoiseClasses ?? true,
8
+ includeComments: options?.includeComments ?? false,
9
+ };
10
+ }