@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,23 @@
1
+ /**
2
+ * Structural-similarity primitive shared by two downstream stages that are
3
+ * not yet implemented: base-cluster generation over `tokenize()` output
4
+ * turned into sets (MinHash/LSH candidate scoring approximates this same
5
+ * ratio) and the default distance for merging medoids in the eventual
6
+ * hierarchical clustering step. Both need "how much of these two token sets
7
+ * overlaps" as a plain, parameter-free calculation, independent of whichever
8
+ * hashing/banding scheme ends up approximating it at scale.
9
+ *
10
+ * Two empty sets return `1`, not `0` or `NaN`: an empty `<body>` compared
11
+ * against another empty `<body>` has no structural difference to report, so
12
+ * treating them as identical (rather than "undefined" or "no overlap") keeps
13
+ * the result usable directly as a similarity score without a caller-side
14
+ * special case.
15
+ * @param a
16
+ * @param b
17
+ * @example
18
+ * ```ts
19
+ * jaccardSimilarity(new Set(['body>ul>li']), new Set(['body>ul>li']));
20
+ * // 1
21
+ * ```
22
+ */
23
+ export declare function jaccardSimilarity(a: ReadonlySet<string>, b: ReadonlySet<string>): number;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Structural-similarity primitive shared by two downstream stages that are
3
+ * not yet implemented: base-cluster generation over `tokenize()` output
4
+ * turned into sets (MinHash/LSH candidate scoring approximates this same
5
+ * ratio) and the default distance for merging medoids in the eventual
6
+ * hierarchical clustering step. Both need "how much of these two token sets
7
+ * overlaps" as a plain, parameter-free calculation, independent of whichever
8
+ * hashing/banding scheme ends up approximating it at scale.
9
+ *
10
+ * Two empty sets return `1`, not `0` or `NaN`: an empty `<body>` compared
11
+ * against another empty `<body>` has no structural difference to report, so
12
+ * treating them as identical (rather than "undefined" or "no overlap") keeps
13
+ * the result usable directly as a similarity score without a caller-side
14
+ * special case.
15
+ * @param a
16
+ * @param b
17
+ * @example
18
+ * ```ts
19
+ * jaccardSimilarity(new Set(['body>ul>li']), new Set(['body>ul>li']));
20
+ * // 1
21
+ * ```
22
+ */
23
+ export function jaccardSimilarity(a, b) {
24
+ if (a.size === 0 && b.size === 0) {
25
+ return 1;
26
+ }
27
+ let intersectionSize = 0;
28
+ const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a];
29
+ for (const token of smaller) {
30
+ if (larger.has(token)) {
31
+ intersectionSize++;
32
+ }
33
+ }
34
+ const unionSize = a.size + b.size - intersectionSize;
35
+ return intersectionSize / unionSize;
36
+ }
@@ -0,0 +1,179 @@
1
+ import type { ExtractLandmarksResult } from './extract-landmarks.js';
2
+ import type { TokenizeOptions } from './types.js';
3
+ /**
4
+ * @see mergeLandmarkAffinedClusters
5
+ */
6
+ export type MergeLandmarkAffinedClustersOptions = TokenizeOptions & {
7
+ /**
8
+ * Reused as the landmark-variant identity threshold inside
9
+ * `computeLandmarkStatus`. Must be in `[0, 1]` (`RangeError` otherwise).
10
+ * Defaults to `0.8`, matching `resolveStructuralClusterKeys`'s own
11
+ * default — this file never calls that function, but the two thresholds
12
+ * represent the same concept ("how much token overlap counts as the same
13
+ * design").
14
+ *
15
+ * Deliberately not a separate, independent option: when
16
+ * `resolvePageClusterKeys` forwards its caller's single `options` object
17
+ * to both `resolveStructuralClusterKeys` (primary content clustering) and
18
+ * this file (landmark-variant identity), the same `similarityThreshold`
19
+ * value drives both, so loosening one to re-tune primary clustering (the
20
+ * documented `excludeLandmarks`/`similarityThreshold` interaction on
21
+ * `resolvePageClusterKeys`) also loosens landmark-variant matching. This
22
+ * mirrors `resolve-landmark-variant-keys.ts`'s own precedent
23
+ * (`resolveLandmarkVariantKeys` likewise forwards its caller's
24
+ * `options.similarityThreshold` straight into
25
+ * `resolveStructuralClusterKeys` with no landmark-specific override) and
26
+ * keeps the option surface to the three fields this file actually adds.
27
+ * Accepted as a known trade-off rather than split into its own knob until
28
+ * real-corpus tuning shows the two thresholds genuinely need to diverge —
29
+ * the same "starting heuristic, not yet corpus-validated" status this
30
+ * option's own default already carries.
31
+ */
32
+ similarityThreshold?: number;
33
+ /**
34
+ * Upper bound, as a fraction of the whole corpus (`[0, 1]`), on how many
35
+ * pages may share a given (landmark type, variant) pair before that
36
+ * variant is considered too common to serve as evidence of a genuine
37
+ * template affinity. Strictly `<` this fraction ("rare", not
38
+ * "rare-or-equal"). `RangeError` outside `[0, 1]`. Defaults to `0.05` —
39
+ * an unvalidated starting heuristic, the same status as
40
+ * `similarityThreshold`'s own `0.8` default (see this file's JSDoc for
41
+ * why real-corpus validation is out of scope for this change).
42
+ */
43
+ landmarkRarityThreshold?: number;
44
+ /**
45
+ * The secondary, looser complete-linkage content-similarity threshold
46
+ * applied only to pages whose landmark signature already qualifies (see
47
+ * `mergeLandmarkAffinedClusters`'s JSDoc). `RangeError` outside `[0, 1]`.
48
+ * Defaults to `0.6` — the value a withdrawn earlier prototype of this
49
+ * same mechanism proposed, and also the value
50
+ * `resolvePageClusterKeys`'s own `excludeLandmarks` JSDoc cites as having
51
+ * correctly re-merged a real 3-page block once landmarks were excluded
52
+ * and the raw-token `similarityThreshold` (`0.8`) became too strict.
53
+ */
54
+ landmarkGateSimilarityThreshold?: number;
55
+ };
56
+ /**
57
+ * Validates `similarityThreshold`/`landmarkRarityThreshold`/
58
+ * `landmarkGateSimilarityThreshold` without running
59
+ * `mergeLandmarkAffinedClusters` itself — exported so
60
+ * `resolvePageClusterKeys` can fail fast on bad options even when `pages` is
61
+ * empty (its own per-block loop never reaches this function at all in that
62
+ * case). Mirrors `detect-content-depth-cap.ts`'s
63
+ * `validateDetectContentDepthCapOptions` exact rationale and shape.
64
+ * @param options
65
+ * @example
66
+ * ```ts
67
+ * // Fails fast on a bad option even though nothing here would otherwise
68
+ * // call mergeLandmarkAffinedClusters yet (e.g. cluster keys haven't been
69
+ * // computed).
70
+ * validateMergeLandmarkAffinedClustersOptions({ landmarkRarityThreshold: -1 }); // throws RangeError
71
+ * ```
72
+ */
73
+ export declare function validateMergeLandmarkAffinedClustersOptions(options?: MergeLandmarkAffinedClustersOptions): void;
74
+ /**
75
+ * Re-keys the pages of two or more distinct
76
+ * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys} clusters
77
+ * onto one shared key when every landmark type present on their pages is
78
+ * both *identical* and *rare* corpus-wide, and their actual content clears a
79
+ * secondary, looser similarity threshold.
80
+ *
81
+ * Reimplements a mechanism previously prototyped under this same name and
82
+ * withdrawn (no trace survives in commit history — this JSDoc is the only
83
+ * record). The withdrawn version merged clusters whenever their
84
+ * header/footer/nav/aside matched, full stop. Validated against two real
85
+ * crawl corpora (302 and 8,936 pages), that produced runaway over-merging:
86
+ * header/footer/nav were present on 99%+ of pages and typically reused
87
+ * site-wide unchanged (see `extractLandmarks`'s own JSDoc for that figure),
88
+ * so "landmarks match" was true for nearly every page pair and carried no
89
+ * discriminative power at all. This reimplementation only ever treats a
90
+ * landmark match as merge evidence when that specific landmark *variant* is
91
+ * itself uncommon corpus-wide (`landmarkRarityThreshold`) — the condition
92
+ * the withdrawn attempt lacked.
93
+ *
94
+ * The match requirement is deliberately the most conservative option
95
+ * considered: *every* landmark type actually present on a page must both
96
+ * match its counterpart's variant and be rare — a page with even one common
97
+ * ("everybody has this exact header") present type contributes no evidence
98
+ * at all, rather than partially qualifying. A looser rule (e.g. "at least
99
+ * one shared rare type is enough") was rejected because it reintroduces a
100
+ * version of the original failure mode: a page could ride a single
101
+ * incidentally-rare landmark into a merge despite otherwise-ordinary,
102
+ * ubiquitous chrome elsewhere on the same page.
103
+ *
104
+ * Frequency is counted corpus-wide, not per-block: a `resolveStructuralClusterKeys`
105
+ * cluster label (`cluster:N`) is only unique within the block it was computed
106
+ * in, but rarity here needs one consistent count across the whole input, the
107
+ * same reason `resolvePageClusterKeys` itself composes `[blockKey,
108
+ * localLabel]` via `JSON.stringify` rather than reusing bare labels across
109
+ * blocks.
110
+ *
111
+ * A page with none of the four landmark types present is excluded from
112
+ * consideration entirely (`existingCount === 0` below) — without this, every
113
+ * landmark-less page across the whole corpus would share one large,
114
+ * unbounded "no landmarks" group, defeating the self-limiting cost bound
115
+ * `landmarkRarityThreshold` is otherwise supposed to guarantee (see
116
+ * `computeLandmarkStatus`'s JSDoc for the cost analysis this depends on).
117
+ *
118
+ * Once pages are grouped by matching-and-rare landmark signature, only
119
+ * signature groups spanning two or more distinct existing cluster keys do
120
+ * any further work. Within such a group, the *content* token sets of the
121
+ * group's distinct cluster keys are complete-linkage-merged at
122
+ * `landmarkGateSimilarityThreshold` — looser than
123
+ * `resolveStructuralClusterKeys`'s own `similarityThreshold`, since the
124
+ * whole point of this mechanism is to bridge clusters whose *content*
125
+ * similarity alone fell just short of the primary threshold. Complete-linkage
126
+ * (not single-linkage) is used for the same reason
127
+ * `resolveStructuralClusterKeys` itself uses it: single-linkage's chaining
128
+ * would let one loosely-matching pair bridge two genuinely-unrelated
129
+ * clusters transitively.
130
+ *
131
+ * The resulting merge is applied at *page* granularity, not by blanket-
132
+ * reassigning every page of the involved cluster keys: only the specific
133
+ * pages that were actually pooled into the qualifying signature group (and,
134
+ * transitively, any other page unioned with them via a different signature
135
+ * group) move onto the shared key. A cluster's pages that never carried the
136
+ * rare landmark evidence keep their original key untouched, even if some
137
+ * other page sharing that same cluster key did qualify and merge elsewhere.
138
+ * This is deliberate, not an incidental restriction: applying a merge
139
+ * decision to *every* page of the involved cluster keys — evidenced by only
140
+ * a small subset of them — would extrapolate a coincidental pairing (e.g.
141
+ * one outlier page in each of two otherwise-unrelated clusters happening to
142
+ * share a rare seasonal-campaign header) into force-merging the clusters'
143
+ * entire, otherwise-dissimilar membership. That is the withdrawn prototype's
144
+ * over-merging failure mode reappearing through a different mechanism
145
+ * (whole-cluster application of a single-pair signal) rather than the
146
+ * landmark-commonality mechanism this file was reimplemented to fix — see
147
+ * this function's own regression test for a worked example.
148
+ *
149
+ * Merged pages are re-keyed to `landmark-merge:${JSON.stringify(sortedKeys)}`
150
+ * (`sortedKeys` being the *original* cluster keys the merged pages came
151
+ * from) — a fresh prefix that cannot collide with `css:`/`path:`/
152
+ * `orphan-merge:` or `resolvePageClusterKeys`'s own `[blockKey, "cluster:N"]`
153
+ * pairs (mirrors `reassign-orphan-block-keys.ts`'s `orphan-merge:` prefix).
154
+ * @param clusterKeys - one existing final key per page, same order/length as `landmarks`/`contentTokenSets`
155
+ * @param landmarks - `extractLandmarks(page.html)`'s full result per page (all four fields, not just `remainderHtml`)
156
+ * @param contentTokenSets - per-page content token sets to use for the secondary similarity gate. Should be independent of whichever landmark markup qualified the page as evidence (e.g. always landmark-excised), so this gate is a genuine second signal rather than re-counting the same landmark tokens already used to select the page — see `resolvePageClusterKeys`'s own call site for how it builds these
157
+ * @param options
158
+ * @example
159
+ * ```ts
160
+ * // tokenize() discards visible text (see its own JSDoc), so the two
161
+ * // header variants below must differ structurally (child element/class),
162
+ * // not merely in text, to compare as different landmark variants.
163
+ * mergeLandmarkAffinedClusters(
164
+ * ['["css:a", "cluster:0"]', '["css:b", "cluster:0"]', 'path:other'],
165
+ * [
166
+ * { header: '<header><i class="mark-a"></i></header>', remainderHtml: '' },
167
+ * { header: '<header><i class="mark-a"></i></header>', remainderHtml: '' },
168
+ * { header: '<header><b class="mark-b"></b></header>', remainderHtml: '' },
169
+ * ],
170
+ * [new Set(['a', 'b']), new Set(['a', 'c']), new Set(['z'])],
171
+ * { landmarkRarityThreshold: 0.7, landmarkGateSimilarityThreshold: 0.3 },
172
+ * );
173
+ * // pages 0 and 1 share an identical header used by only 2 of the 3 pages
174
+ * // (a 2/3 ≈ 0.667 corpus frequency, rare at threshold 0.7) and their
175
+ * // content clears 0.3, so they merge onto one landmark-merge: key; page 2
176
+ * // (a structurally different header) is left untouched
177
+ * ```
178
+ */
179
+ export declare function mergeLandmarkAffinedClusters(clusterKeys: readonly string[], landmarks: readonly ExtractLandmarksResult[], contentTokenSets: readonly ReadonlySet<string>[], options?: MergeLandmarkAffinedClustersOptions): string[];