@d-zero/page-cluster 0.5.5 → 0.5.7
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 +35 -0
- package/dist/build-mirrored-template-fixture.d.ts +61 -0
- package/dist/build-mirrored-template-fixture.js +127 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +71 -1
- package/dist/compute-cluster-cohesion.d.ts +59 -0
- package/dist/compute-cluster-cohesion.js +107 -0
- package/dist/detect-mirror-axis.d.ts +74 -0
- package/dist/detect-mirror-axis.js +0 -0
- package/dist/find-cross-cluster-duplicates.d.ts +94 -0
- package/dist/find-cross-cluster-duplicates.js +164 -0
- package/dist/merge-cross-block-clusters.d.ts +18 -0
- package/dist/merge-cross-block-clusters.js +256 -9
- package/dist/merge-validated-clusters.d.ts +27 -0
- package/dist/merge-validated-clusters.js +50 -0
- package/dist/normalize-href-by-mirror-axis.d.ts +25 -0
- package/dist/normalize-href-by-mirror-axis.js +30 -0
- package/dist/normalize-path-by-mirror-axis.d.ts +22 -0
- package/dist/normalize-path-by-mirror-axis.js +25 -0
- package/dist/resolve-page-cluster-keys.d.ts +43 -0
- package/dist/resolve-page-cluster-keys.js +99 -3
- package/dist/stage-a-per-block.js +1 -0
- package/dist/validate-cluster-partition.d.ts +70 -0
- package/dist/validate-cluster-partition.js +49 -0
- package/package.json +32 -4
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies a set of confirmed cluster-pair merges to a `clusterKey` array,
|
|
3
|
+
* via union-find over the distinct cluster keys. Every `duplicates` entry is
|
|
4
|
+
* merged unconditionally — deciding *which* {@link CrossClusterDuplicate}s
|
|
5
|
+
* are trustworthy enough to act on (e.g. `similarity === 1` or
|
|
6
|
+
* `corroboratedByMirrorAxis`) is the caller's job, same separation of
|
|
7
|
+
* detection from action as
|
|
8
|
+
* {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}
|
|
9
|
+
* itself.
|
|
10
|
+
*
|
|
11
|
+
* The surviving key for a merged group is its alphabetically smallest
|
|
12
|
+
* member — arbitrary but deterministic, so repeated calls on the same input
|
|
13
|
+
* produce the same output (mirrors the "lower index wins" rule
|
|
14
|
+
* {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s own
|
|
15
|
+
* union-find already uses).
|
|
16
|
+
* @param clusterKeys Every page's current cluster key, in input order.
|
|
17
|
+
* @param duplicates Cluster-pair merges to apply.
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const merged = mergeValidatedClusters(
|
|
21
|
+
* clusterKeys,
|
|
22
|
+
* duplicates.filter((d) => d.similarity === 1 || d.corroboratedByMirrorAxis),
|
|
23
|
+
* );
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function mergeValidatedClusters(clusterKeys, duplicates) {
|
|
27
|
+
const parent = new Map();
|
|
28
|
+
const find = (key) => {
|
|
29
|
+
let root = key;
|
|
30
|
+
while (parent.has(root))
|
|
31
|
+
root = parent.get(root);
|
|
32
|
+
// Path compression for cheap repeated lookups within this call.
|
|
33
|
+
let cur = key;
|
|
34
|
+
while (cur !== root) {
|
|
35
|
+
const next = parent.get(cur);
|
|
36
|
+
parent.set(cur, root);
|
|
37
|
+
cur = next;
|
|
38
|
+
}
|
|
39
|
+
return root;
|
|
40
|
+
};
|
|
41
|
+
for (const { clusterKeyA, clusterKeyB } of duplicates) {
|
|
42
|
+
const rootA = find(clusterKeyA);
|
|
43
|
+
const rootB = find(clusterKeyB);
|
|
44
|
+
if (rootA === rootB)
|
|
45
|
+
continue;
|
|
46
|
+
const [smaller, larger] = rootA < rootB ? [rootA, rootB] : [rootB, rootA];
|
|
47
|
+
parent.set(larger, smaller);
|
|
48
|
+
}
|
|
49
|
+
return clusterKeys.map((key) => (parent.has(key) ? find(key) : key));
|
|
50
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MirrorAxis } from './detect-mirror-axis.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reduces a URL (typically a stylesheet href) to a shape that is stable
|
|
4
|
+
* across a detected {@link MirrorAxis}, by replacing every `/<value>/`
|
|
5
|
+
* occurrence — for any of the axis's known values — with a fixed
|
|
6
|
+
* placeholder segment. Unlike {@link ./normalize-path-by-mirror-axis.js |
|
|
7
|
+
* normalizePathByMirrorAxis}, this does not anchor on `axis.position`: an
|
|
8
|
+
* href's own path structure (e.g. `/assets/en/style.css`) does not
|
|
9
|
+
* necessarily line up with the page's path depth (e.g. `/en/section/`), so
|
|
10
|
+
* matching is done by substring rather than by segment index. This is what
|
|
11
|
+
* lets a per-mirror stylesheet — the same template's CSS duplicated once per
|
|
12
|
+
* language directory instead of shared from one file — normalize to the
|
|
13
|
+
* same shape as its sibling mirrors, corroborating that two pages under
|
|
14
|
+
* different blocking keys are the same template mirrored rather than two
|
|
15
|
+
* different templates.
|
|
16
|
+
* @param href A URL string (typically a stylesheet href).
|
|
17
|
+
* @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const axis = { position: 0, values: new Set(['en', 'zh']) };
|
|
21
|
+
* normalizeHrefByMirrorAxis('https://example.test/en/faq/page.css', axis);
|
|
22
|
+
* // 'https://example.test/{axis}/faq/page.css'
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeHrefByMirrorAxis(href: string, axis: MirrorAxis): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reduces a URL (typically a stylesheet href) to a shape that is stable
|
|
3
|
+
* across a detected {@link MirrorAxis}, by replacing every `/<value>/`
|
|
4
|
+
* occurrence — for any of the axis's known values — with a fixed
|
|
5
|
+
* placeholder segment. Unlike {@link ./normalize-path-by-mirror-axis.js |
|
|
6
|
+
* normalizePathByMirrorAxis}, this does not anchor on `axis.position`: an
|
|
7
|
+
* href's own path structure (e.g. `/assets/en/style.css`) does not
|
|
8
|
+
* necessarily line up with the page's path depth (e.g. `/en/section/`), so
|
|
9
|
+
* matching is done by substring rather than by segment index. This is what
|
|
10
|
+
* lets a per-mirror stylesheet — the same template's CSS duplicated once per
|
|
11
|
+
* language directory instead of shared from one file — normalize to the
|
|
12
|
+
* same shape as its sibling mirrors, corroborating that two pages under
|
|
13
|
+
* different blocking keys are the same template mirrored rather than two
|
|
14
|
+
* different templates.
|
|
15
|
+
* @param href A URL string (typically a stylesheet href).
|
|
16
|
+
* @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const axis = { position: 0, values: new Set(['en', 'zh']) };
|
|
20
|
+
* normalizeHrefByMirrorAxis('https://example.test/en/faq/page.css', axis);
|
|
21
|
+
* // 'https://example.test/{axis}/faq/page.css'
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeHrefByMirrorAxis(href, axis) {
|
|
25
|
+
let normalized = href;
|
|
26
|
+
for (const value of axis.values) {
|
|
27
|
+
normalized = normalized.split(`/${value}/`).join('/{axis}/');
|
|
28
|
+
}
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MirrorAxis } from './detect-mirror-axis.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reduces a page's URL path to a shape that is stable across a detected
|
|
4
|
+
* {@link MirrorAxis} by replacing the segment at `axis.position` with a
|
|
5
|
+
* fixed placeholder whenever it is one of the axis's known values. Two pages
|
|
6
|
+
* that are mirrors of the same content under different axis values (e.g. the
|
|
7
|
+
* `en` and `zh` copies of the same page) normalize to the same shape; a page
|
|
8
|
+
* whose segment at that position is *not* one of the axis's values (so it
|
|
9
|
+
* isn't part of the mirror at all) is left untouched, since collapsing it
|
|
10
|
+
* too would falsely equate unrelated pages that merely share a path depth.
|
|
11
|
+
* @param paths A page's URL path segments (e.g. `ExURL.paths`).
|
|
12
|
+
* @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* const axis = { position: 0, values: new Set(['en', 'zh']) };
|
|
16
|
+
* normalizePathByMirrorAxis(['en', 'faq', 'index.html'], axis);
|
|
17
|
+
* // '{axis}/faq/index.html'
|
|
18
|
+
* normalizePathByMirrorAxis(['zh', 'faq', 'index.html'], axis);
|
|
19
|
+
* // '{axis}/faq/index.html' — same shape as the `en` page above
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizePathByMirrorAxis(paths: readonly string[], axis: MirrorAxis): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reduces a page's URL path to a shape that is stable across a detected
|
|
3
|
+
* {@link MirrorAxis} by replacing the segment at `axis.position` with a
|
|
4
|
+
* fixed placeholder whenever it is one of the axis's known values. Two pages
|
|
5
|
+
* that are mirrors of the same content under different axis values (e.g. the
|
|
6
|
+
* `en` and `zh` copies of the same page) normalize to the same shape; a page
|
|
7
|
+
* whose segment at that position is *not* one of the axis's values (so it
|
|
8
|
+
* isn't part of the mirror at all) is left untouched, since collapsing it
|
|
9
|
+
* too would falsely equate unrelated pages that merely share a path depth.
|
|
10
|
+
* @param paths A page's URL path segments (e.g. `ExURL.paths`).
|
|
11
|
+
* @param axis A `MirrorAxis` from {@link ./detect-mirror-axis.js | detectMirrorAxis}.
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const axis = { position: 0, values: new Set(['en', 'zh']) };
|
|
15
|
+
* normalizePathByMirrorAxis(['en', 'faq', 'index.html'], axis);
|
|
16
|
+
* // '{axis}/faq/index.html'
|
|
17
|
+
* normalizePathByMirrorAxis(['zh', 'faq', 'index.html'], axis);
|
|
18
|
+
* // '{axis}/faq/index.html' — same shape as the `en` page above
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function normalizePathByMirrorAxis(paths, axis) {
|
|
22
|
+
return paths
|
|
23
|
+
.map((segment, i) => i === axis.position && axis.values.has(segment) ? '{axis}' : segment)
|
|
24
|
+
.join('/');
|
|
25
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js'
|
|
|
4
4
|
import type { ResolveBlockingGroupKeysOptions } from './resolve-blocking-group-keys.js';
|
|
5
5
|
import type { ResolveStructuralClusterKeysOptions } from './resolve-structural-cluster-keys.js';
|
|
6
6
|
import type { TokenizeOptions } from './types.js';
|
|
7
|
+
import type { ClusterPartitionReport } from './validate-cluster-partition.js';
|
|
7
8
|
/**
|
|
8
9
|
* Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
|
|
9
10
|
* into its block token set for Stage A clustering, restoring exactly the
|
|
@@ -201,6 +202,48 @@ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGro
|
|
|
201
202
|
* ```
|
|
202
203
|
*/
|
|
203
204
|
onClusterReason?: (clusterKey: string, reason: ClusterReason) => void;
|
|
205
|
+
/**
|
|
206
|
+
* Optional observability hook invoked once per run with a
|
|
207
|
+
* {@link ClusterPartitionReport} — evidence that Stage A/B's finished
|
|
208
|
+
* partition may need correcting, from
|
|
209
|
+
* {@link ./validate-cluster-partition.js | validateClusterPartition}.
|
|
210
|
+
*
|
|
211
|
+
* Setting this option does two things, both gated on its presence the
|
|
212
|
+
* same way `onClusterReason` gates its own bookkeeping (existing
|
|
213
|
+
* callers that omit it pay nothing and see no behavior change):
|
|
214
|
+
*
|
|
215
|
+
* 1. Computes the report from Stage B's finished grouping (no
|
|
216
|
+
* re-tokenization — reuses the token sets Stage A/B already hold).
|
|
217
|
+
* 2. Applies a fixed, built-in auto-merge policy to the *actual*
|
|
218
|
+
* returned cluster keys before this callback (and
|
|
219
|
+
* `onClusterReason`) ever sees them: every
|
|
220
|
+
* `report.crossClusterDuplicates` entry with `similarity === 1` or
|
|
221
|
+
* `corroboratedByMirrorAxis: true` is merged via
|
|
222
|
+
* {@link ./merge-validated-clusters.js | mergeValidatedClusters}.
|
|
223
|
+
* This is the one part of this library where an option changes the
|
|
224
|
+
* returned `clusterKey`s themselves, not just side-channel
|
|
225
|
+
* metadata — a caller who wants a *different* merge policy (or
|
|
226
|
+
* none at all) should omit this option and call
|
|
227
|
+
* `validateClusterPartition`/`findCrossClusterDuplicates`/
|
|
228
|
+
* `mergeValidatedClusters` directly on this function's own output.
|
|
229
|
+
*
|
|
230
|
+
* On the streaming path (`pageCount > CORPUS_INLINE_THRESHOLD`), the
|
|
231
|
+
* report is built only from pages Stage A/B actually retained token
|
|
232
|
+
* sets for — reservoir-sampled block representatives, not pages
|
|
233
|
+
* assigned in Pass 1b — the same sampling trade-off Stage B itself
|
|
234
|
+
* already makes for corpora too large to hold in full. Any merge the
|
|
235
|
+
* sampled evidence justifies is still applied to every page sharing
|
|
236
|
+
* the merged keys, sampled or not.
|
|
237
|
+
* @example
|
|
238
|
+
* ```ts
|
|
239
|
+
* const keys = await resolvePageClusterKeys(pages, {
|
|
240
|
+
* onPartitionReport: (report) => {
|
|
241
|
+
* for (const c of report.cohesion) if (c.suspicious) console.warn(c);
|
|
242
|
+
* },
|
|
243
|
+
* });
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
onPartitionReport?: (report: ClusterPartitionReport) => void;
|
|
204
247
|
};
|
|
205
248
|
/**
|
|
206
249
|
* Corpus size at or below which the async factory-based
|
|
@@ -6,11 +6,13 @@ import { extractLandmarks } from './extract-landmarks.js';
|
|
|
6
6
|
import { filterFirstPartyStylesheetHrefs } from './filter-first-party-stylesheet-hrefs.js';
|
|
7
7
|
import { jaccardSimilarity } from './jaccard-similarity.js';
|
|
8
8
|
import { mergeCrossBlockClusters } from './merge-cross-block-clusters.js';
|
|
9
|
+
import { mergeValidatedClusters } from './merge-validated-clusters.js';
|
|
9
10
|
import { groupIndicesByBlockKey, resolveBlockKeys } from './pass0-blocking.js';
|
|
10
11
|
import { computePerPageLandmarkInstances } from './per-page-landmark-signatures.js';
|
|
11
12
|
import { removeContentBlocks } from './remove-content-blocks.js';
|
|
12
13
|
import { stageAPerBlock } from './stage-a-per-block.js';
|
|
13
14
|
import { tokenize } from './tokenize.js';
|
|
15
|
+
import { validateClusterPartition } from './validate-cluster-partition.js';
|
|
14
16
|
/**
|
|
15
17
|
* FNV-1a 32-bit hash of a string, used to seed the per-block PRNG so
|
|
16
18
|
* reservoir sampling on the streaming path is deterministic for a given
|
|
@@ -257,6 +259,92 @@ function resolveBlockKeysForClustering(blockingPages, options, needReasons) {
|
|
|
257
259
|
const result = resolveBlockKeys(blockingPages, { ...options, includeReasons: true });
|
|
258
260
|
return { blockKeys: result.blockKeys, reasonsByBlockKey: result.reasonsByBlockKey };
|
|
259
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Runs {@link ./validate-cluster-partition.js | validateClusterPartition}
|
|
264
|
+
* over Stage B's finished grouping and applies the built-in auto-merge
|
|
265
|
+
* policy (see `onPartitionReport`'s own JSDoc) directly to `finalKeys`,
|
|
266
|
+
* mutating it in place — the same in-place style the driver's own
|
|
267
|
+
* `rootByKey` rewrite loop already uses just before calling this.
|
|
268
|
+
*
|
|
269
|
+
* No-ops (returning `rootByKey`/`finalGroupsByRoot` unchanged) when
|
|
270
|
+
* `onPartitionReport` is `undefined` — the single gate every driver defers
|
|
271
|
+
* to, mirroring `emitClusterReasons`'s own gate for `onClusterReason`.
|
|
272
|
+
*
|
|
273
|
+
* When a merge is applied, `rootByKey` and `finalGroupsByRoot` are folded
|
|
274
|
+
* according to the same rename so `emitClusterReasons` (called with this
|
|
275
|
+
* function's return value, not the pre-merge originals) builds each
|
|
276
|
+
* survivor's `ClusterReason` from its *actual* post-merge membership —
|
|
277
|
+
* without this, a merged-away key's members would report `finalKeys[i]`
|
|
278
|
+
* pointing at the survivor while its own stale `ClusterReason.memberCount`
|
|
279
|
+
* still reflected only its pre-merge share.
|
|
280
|
+
* @param finalKeys Every page's current final cluster key, in input order.
|
|
281
|
+
* Mutated in place when a merge is applied.
|
|
282
|
+
* @param rootByKey Stage B's own result (see
|
|
283
|
+
* {@link ./merge-cross-block-clusters.js | MergeCrossBlockClustersResult}).
|
|
284
|
+
* @param finalGroupsByRoot Stage B's own result.
|
|
285
|
+
* @param getPageSignals Given an original page index, returns that page's
|
|
286
|
+
* `paths`/`stylesheetHrefs` — `undefined` for an index the caller has no
|
|
287
|
+
* record of (defensive; should not occur for indices Stage A/B actually
|
|
288
|
+
* retained).
|
|
289
|
+
* @param onPartitionReport
|
|
290
|
+
*/
|
|
291
|
+
function validateAndMergePartition(finalKeys, rootByKey, finalGroupsByRoot, getPageSignals, onPartitionReport) {
|
|
292
|
+
if (!onPartitionReport)
|
|
293
|
+
return { rootByKey, finalGroupsByRoot };
|
|
294
|
+
const clusteredPages = [];
|
|
295
|
+
for (const [finalKey, group] of finalGroupsByRoot) {
|
|
296
|
+
for (const [i, tokens] of group.tokenSets.entries()) {
|
|
297
|
+
const pageIndex = group.pageIndices[i] ?? -1;
|
|
298
|
+
if (pageIndex === -1)
|
|
299
|
+
continue;
|
|
300
|
+
const signals = getPageSignals(pageIndex);
|
|
301
|
+
if (!signals)
|
|
302
|
+
continue;
|
|
303
|
+
clusteredPages.push({
|
|
304
|
+
clusterKey: finalKey,
|
|
305
|
+
tokens,
|
|
306
|
+
paths: signals.paths,
|
|
307
|
+
stylesheetHrefs: signals.stylesheetHrefs,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const report = validateClusterPartition(clusteredPages);
|
|
312
|
+
onPartitionReport(report);
|
|
313
|
+
// `findCrossClusterDuplicates` only ever returns entries that already
|
|
314
|
+
// satisfy this condition (its exact-match pass sets `similarity: 1`
|
|
315
|
+
// unconditionally; its near-match pass requires `corroboratedByMirrorAxis`
|
|
316
|
+
// to accept a pair at all) — this filter is a defensive invariant check,
|
|
317
|
+
// not something that currently narrows the result, kept so this call site
|
|
318
|
+
// keeps working correctly if that policy ever loosens.
|
|
319
|
+
const safeToMerge = report.crossClusterDuplicates.filter((d) => d.similarity === 1 || d.corroboratedByMirrorAxis);
|
|
320
|
+
if (safeToMerge.length === 0)
|
|
321
|
+
return { rootByKey, finalGroupsByRoot };
|
|
322
|
+
const mergedKeys = mergeValidatedClusters(finalKeys, safeToMerge);
|
|
323
|
+
const renameMap = new Map();
|
|
324
|
+
for (const [i, mergedKey] of mergedKeys.entries()) {
|
|
325
|
+
if (finalKeys[i] !== mergedKey)
|
|
326
|
+
renameMap.set(finalKeys[i], mergedKey);
|
|
327
|
+
finalKeys[i] = mergedKey;
|
|
328
|
+
}
|
|
329
|
+
const mergedFinalGroupsByRoot = new Map();
|
|
330
|
+
for (const [key, group] of finalGroupsByRoot) {
|
|
331
|
+
const target = renameMap.get(key) ?? key;
|
|
332
|
+
const existing = mergedFinalGroupsByRoot.get(target);
|
|
333
|
+
mergedFinalGroupsByRoot.set(target, {
|
|
334
|
+
tokenSets: [...(existing?.tokenSets ?? []), ...group.tokenSets],
|
|
335
|
+
landmarkInstances: [
|
|
336
|
+
...(existing?.landmarkInstances ?? []),
|
|
337
|
+
...group.landmarkInstances,
|
|
338
|
+
],
|
|
339
|
+
pageIndices: [...(existing?.pageIndices ?? []), ...group.pageIndices],
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const mergedRootByKey = new Map();
|
|
343
|
+
for (const [unitKey, stageBRoot] of rootByKey) {
|
|
344
|
+
mergedRootByKey.set(unitKey, renameMap.get(stageBRoot) ?? stageBRoot);
|
|
345
|
+
}
|
|
346
|
+
return { rootByKey: mergedRootByKey, finalGroupsByRoot: mergedFinalGroupsByRoot };
|
|
347
|
+
}
|
|
260
348
|
/**
|
|
261
349
|
* Builds and emits one {@link ClusterReason} per final cluster via
|
|
262
350
|
* `onClusterReason`, from data Stage A/B already computed for clustering
|
|
@@ -459,7 +547,8 @@ export function resolvePageClusterKeysInMemory(pages, options) {
|
|
|
459
547
|
finalKeys[i] = rootKey;
|
|
460
548
|
}
|
|
461
549
|
}
|
|
462
|
-
|
|
550
|
+
const validated = validateAndMergePartition(finalKeys, rootByKey, finalGroupsByRoot, (i) => pages[i], options?.onPartitionReport);
|
|
551
|
+
emitClusterReasons(crossBlockUnits, validated.rootByKey, validated.finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
|
|
463
552
|
return finalKeys;
|
|
464
553
|
}
|
|
465
554
|
/**
|
|
@@ -557,7 +646,8 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
|
|
|
557
646
|
finalKeys[i] = rootKey;
|
|
558
647
|
}
|
|
559
648
|
}
|
|
560
|
-
|
|
649
|
+
const validated = validateAndMergePartition(finalKeys, rootByKey, finalGroupsByRoot, (i) => pages[i], options?.onPartitionReport);
|
|
650
|
+
emitClusterReasons(crossBlockUnits, validated.rootByKey, validated.finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
|
|
561
651
|
return finalKeys;
|
|
562
652
|
}
|
|
563
653
|
/**
|
|
@@ -844,7 +934,13 @@ export async function resolvePageClusterKeys(pages, options) {
|
|
|
844
934
|
finalKeys[i] = rootKey;
|
|
845
935
|
}
|
|
846
936
|
}
|
|
847
|
-
|
|
937
|
+
// `blockingSignals` (built during Pass 0, held for the whole call) has an
|
|
938
|
+
// entry for every page in the corpus, sampled or not — only the pages
|
|
939
|
+
// `finalGroupsByRoot` actually kept a token set for (reservoir-sampled
|
|
940
|
+
// block representatives) end up in the report, per `onPartitionReport`'s
|
|
941
|
+
// own JSDoc.
|
|
942
|
+
const validated = validateAndMergePartition(finalKeys, rootByKey, finalGroupsByRoot, (i) => blockingSignals[i], options?.onPartitionReport);
|
|
943
|
+
emitClusterReasons(crossBlockUnits, validated.rootByKey, validated.finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
|
|
848
944
|
return finalKeys;
|
|
849
945
|
}
|
|
850
946
|
/**
|
|
@@ -172,6 +172,7 @@ export function stageAPerBlock(input, options) {
|
|
|
172
172
|
key: unitKey,
|
|
173
173
|
memberTokenSets: sampledPositions.map((pos) => blockTokenSets[pos]),
|
|
174
174
|
memberLandmarkInstances: sampledPositions.map((pos) => memberLandmarkInstancesByPage[pos]),
|
|
175
|
+
memberPageIndices: sampledPositions.map((pos) => memberIndices[pos]),
|
|
175
176
|
});
|
|
176
177
|
}
|
|
177
178
|
return { pageKeys, crossBlockUnits };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ClusterCohesion, ClusterCohesionOptions } from './compute-cluster-cohesion.js';
|
|
2
|
+
import type { DetectMirrorAxisOptions, MirrorAxis } from './detect-mirror-axis.js';
|
|
3
|
+
import type { ClusteredPage, CrossClusterDuplicate } from './find-cross-cluster-duplicates.js';
|
|
4
|
+
/**
|
|
5
|
+
* Options for {@link validateClusterPartition}, forwarded to the three
|
|
6
|
+
* checks it composes.
|
|
7
|
+
*/
|
|
8
|
+
export type ValidateClusterPartitionOptions = {
|
|
9
|
+
readonly mirrorAxis?: DetectMirrorAxisOptions;
|
|
10
|
+
readonly cohesion?: ClusterCohesionOptions;
|
|
11
|
+
/**
|
|
12
|
+
* Only `corroboratedSimilarityThreshold` — `mirrorAxis` itself is always
|
|
13
|
+
* the axis this function just detected, not independently settable here.
|
|
14
|
+
*/
|
|
15
|
+
readonly crossClusterDuplicates?: {
|
|
16
|
+
readonly corroboratedSimilarityThreshold?: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Structured evidence that a clustering run's partition may need
|
|
21
|
+
* correcting: which pages are plausibly the same template despite ending up
|
|
22
|
+
* in different clusters, and which clusters plausibly mix unrelated
|
|
23
|
+
* templates together. Carries no verdict on whether to act — same design as
|
|
24
|
+
* {@link ./build-cluster-reason.js | ClusterReason} — a caller decides what
|
|
25
|
+
* to do with `crossClusterDuplicates` (e.g. via
|
|
26
|
+
* {@link ./merge-validated-clusters.js | mergeValidatedClusters}) and with
|
|
27
|
+
* `cohesion`'s `suspicious` flags.
|
|
28
|
+
*/
|
|
29
|
+
export type ClusterPartitionReport = {
|
|
30
|
+
/**
|
|
31
|
+
* The mirror axis detected across every page's `paths`, or `null` if
|
|
32
|
+
* none was found — see
|
|
33
|
+
* {@link ./detect-mirror-axis.js | detectMirrorAxis}.
|
|
34
|
+
*/
|
|
35
|
+
readonly mirrorAxis: MirrorAxis | null;
|
|
36
|
+
/** One entry per distinct cluster key present in `pages`. */
|
|
37
|
+
readonly cohesion: readonly ClusterCohesion[];
|
|
38
|
+
/** See {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}. */
|
|
39
|
+
readonly crossClusterDuplicates: readonly CrossClusterDuplicate[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Validates a finished clustering partition by checking it against itself,
|
|
43
|
+
* rather than trying to get the clustering right the first time: whether
|
|
44
|
+
* pages that ended up in different clusters are nonetheless structurally
|
|
45
|
+
* identical or near-identical (a likely over-split, see
|
|
46
|
+
* {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}),
|
|
47
|
+
* and whether any single cluster's members actually agree with each other
|
|
48
|
+
* (a likely over-merge, see
|
|
49
|
+
* {@link ./compute-cluster-cohesion.js | computeClusterCohesion}). Neither
|
|
50
|
+
* check depends on *how* the partition was produced — this is deliberately
|
|
51
|
+
* decoupled from `resolvePageClusterKeys`'s own Stage A/B internals so it
|
|
52
|
+
* can validate a partition regardless of its origin, including a stored one
|
|
53
|
+
* loaded back from an archive.
|
|
54
|
+
*
|
|
55
|
+
* Takes already-tokenized pages rather than raw HTML — a caller with a
|
|
56
|
+
* `resolvePageClusterKeys` result already has token sets on hand (or can
|
|
57
|
+
* derive them via {@link ./tokenize.js | tokenize}), and re-tokenizing here
|
|
58
|
+
* would cost a second full corpus pass for no benefit.
|
|
59
|
+
* @param pages
|
|
60
|
+
* @param options
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const report = validateClusterPartition(pages);
|
|
64
|
+
* const safeToMerge = report.crossClusterDuplicates.filter(
|
|
65
|
+
* (d) => d.similarity === 1 || d.corroboratedByMirrorAxis,
|
|
66
|
+
* );
|
|
67
|
+
* const mergedKeys = mergeValidatedClusters(clusterKeys, safeToMerge);
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare function validateClusterPartition(pages: readonly ClusteredPage[], options?: ValidateClusterPartitionOptions): ClusterPartitionReport;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { computeClusterCohesion } from './compute-cluster-cohesion.js';
|
|
2
|
+
import { detectMirrorAxis } from './detect-mirror-axis.js';
|
|
3
|
+
import { findCrossClusterDuplicates } from './find-cross-cluster-duplicates.js';
|
|
4
|
+
/**
|
|
5
|
+
* Validates a finished clustering partition by checking it against itself,
|
|
6
|
+
* rather than trying to get the clustering right the first time: whether
|
|
7
|
+
* pages that ended up in different clusters are nonetheless structurally
|
|
8
|
+
* identical or near-identical (a likely over-split, see
|
|
9
|
+
* {@link ./find-cross-cluster-duplicates.js | findCrossClusterDuplicates}),
|
|
10
|
+
* and whether any single cluster's members actually agree with each other
|
|
11
|
+
* (a likely over-merge, see
|
|
12
|
+
* {@link ./compute-cluster-cohesion.js | computeClusterCohesion}). Neither
|
|
13
|
+
* check depends on *how* the partition was produced — this is deliberately
|
|
14
|
+
* decoupled from `resolvePageClusterKeys`'s own Stage A/B internals so it
|
|
15
|
+
* can validate a partition regardless of its origin, including a stored one
|
|
16
|
+
* loaded back from an archive.
|
|
17
|
+
*
|
|
18
|
+
* Takes already-tokenized pages rather than raw HTML — a caller with a
|
|
19
|
+
* `resolvePageClusterKeys` result already has token sets on hand (or can
|
|
20
|
+
* derive them via {@link ./tokenize.js | tokenize}), and re-tokenizing here
|
|
21
|
+
* would cost a second full corpus pass for no benefit.
|
|
22
|
+
* @param pages
|
|
23
|
+
* @param options
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const report = validateClusterPartition(pages);
|
|
27
|
+
* const safeToMerge = report.crossClusterDuplicates.filter(
|
|
28
|
+
* (d) => d.similarity === 1 || d.corroboratedByMirrorAxis,
|
|
29
|
+
* );
|
|
30
|
+
* const mergedKeys = mergeValidatedClusters(clusterKeys, safeToMerge);
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function validateClusterPartition(pages, options) {
|
|
34
|
+
const mirrorAxis = detectMirrorAxis(pages.map((p) => p.paths), options?.mirrorAxis);
|
|
35
|
+
const membersByKey = new Map();
|
|
36
|
+
for (const page of pages) {
|
|
37
|
+
const members = membersByKey.get(page.clusterKey);
|
|
38
|
+
if (members)
|
|
39
|
+
members.push(page.tokens);
|
|
40
|
+
else
|
|
41
|
+
membersByKey.set(page.clusterKey, [page.tokens]);
|
|
42
|
+
}
|
|
43
|
+
const cohesion = computeClusterCohesion(membersByKey, options?.cohesion);
|
|
44
|
+
const crossClusterDuplicates = findCrossClusterDuplicates(pages, {
|
|
45
|
+
mirrorAxis,
|
|
46
|
+
corroboratedSimilarityThreshold: options?.crossClusterDuplicates?.corroboratedSimilarityThreshold,
|
|
47
|
+
});
|
|
48
|
+
return { mirrorAxis, cohesion, crossClusterDuplicates };
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d-zero/page-cluster",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Clusters crawled HTML pages by DOM-structure similarity — assigns the same key to pages sharing a template, ignoring text content. CLI-first, with library APIs.",
|
|
5
5
|
"author": "D-ZERO",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,6 +36,34 @@
|
|
|
36
36
|
"./build-cluster-reason": {
|
|
37
37
|
"import": "./dist/build-cluster-reason.js",
|
|
38
38
|
"types": "./dist/build-cluster-reason.d.ts"
|
|
39
|
+
},
|
|
40
|
+
"./detect-mirror-axis": {
|
|
41
|
+
"import": "./dist/detect-mirror-axis.js",
|
|
42
|
+
"types": "./dist/detect-mirror-axis.d.ts"
|
|
43
|
+
},
|
|
44
|
+
"./normalize-path-by-mirror-axis": {
|
|
45
|
+
"import": "./dist/normalize-path-by-mirror-axis.js",
|
|
46
|
+
"types": "./dist/normalize-path-by-mirror-axis.d.ts"
|
|
47
|
+
},
|
|
48
|
+
"./normalize-href-by-mirror-axis": {
|
|
49
|
+
"import": "./dist/normalize-href-by-mirror-axis.js",
|
|
50
|
+
"types": "./dist/normalize-href-by-mirror-axis.d.ts"
|
|
51
|
+
},
|
|
52
|
+
"./compute-cluster-cohesion": {
|
|
53
|
+
"import": "./dist/compute-cluster-cohesion.js",
|
|
54
|
+
"types": "./dist/compute-cluster-cohesion.d.ts"
|
|
55
|
+
},
|
|
56
|
+
"./find-cross-cluster-duplicates": {
|
|
57
|
+
"import": "./dist/find-cross-cluster-duplicates.js",
|
|
58
|
+
"types": "./dist/find-cross-cluster-duplicates.d.ts"
|
|
59
|
+
},
|
|
60
|
+
"./validate-cluster-partition": {
|
|
61
|
+
"import": "./dist/validate-cluster-partition.js",
|
|
62
|
+
"types": "./dist/validate-cluster-partition.d.ts"
|
|
63
|
+
},
|
|
64
|
+
"./merge-validated-clusters": {
|
|
65
|
+
"import": "./dist/merge-validated-clusters.js",
|
|
66
|
+
"types": "./dist/merge-validated-clusters.d.ts"
|
|
39
67
|
}
|
|
40
68
|
},
|
|
41
69
|
"bin": "dist/cli.js",
|
|
@@ -48,8 +76,8 @@
|
|
|
48
76
|
"clean": "tsc --build --clean"
|
|
49
77
|
},
|
|
50
78
|
"dependencies": {
|
|
51
|
-
"@d-zero/dealer": "1.10.
|
|
52
|
-
"@d-zero/shared": "0.22.
|
|
79
|
+
"@d-zero/dealer": "1.10.4",
|
|
80
|
+
"@d-zero/shared": "0.22.5",
|
|
53
81
|
"htmlparser2": "12.0.0"
|
|
54
82
|
},
|
|
55
83
|
"repository": {
|
|
@@ -57,5 +85,5 @@
|
|
|
57
85
|
"url": "https://github.com/d-zero-dev/tools.git",
|
|
58
86
|
"directory": "packages/@d-zero/page-cluster"
|
|
59
87
|
},
|
|
60
|
-
"gitHead": "
|
|
88
|
+
"gitHead": "1321d85f63db05896135599046924221f4f714c5"
|
|
61
89
|
}
|