@d-zero/page-cluster 0.5.6 → 0.6.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.
@@ -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
- emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
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
- emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
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
- emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
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,12 +1,15 @@
1
1
  {
2
2
  "name": "@d-zero/page-cluster",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
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",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
10
+ "engines": {
11
+ "node": ">=24.11.0"
12
+ },
10
13
  "type": "module",
11
14
  "exports": {
12
15
  ".": {
@@ -36,6 +39,34 @@
36
39
  "./build-cluster-reason": {
37
40
  "import": "./dist/build-cluster-reason.js",
38
41
  "types": "./dist/build-cluster-reason.d.ts"
42
+ },
43
+ "./detect-mirror-axis": {
44
+ "import": "./dist/detect-mirror-axis.js",
45
+ "types": "./dist/detect-mirror-axis.d.ts"
46
+ },
47
+ "./normalize-path-by-mirror-axis": {
48
+ "import": "./dist/normalize-path-by-mirror-axis.js",
49
+ "types": "./dist/normalize-path-by-mirror-axis.d.ts"
50
+ },
51
+ "./normalize-href-by-mirror-axis": {
52
+ "import": "./dist/normalize-href-by-mirror-axis.js",
53
+ "types": "./dist/normalize-href-by-mirror-axis.d.ts"
54
+ },
55
+ "./compute-cluster-cohesion": {
56
+ "import": "./dist/compute-cluster-cohesion.js",
57
+ "types": "./dist/compute-cluster-cohesion.d.ts"
58
+ },
59
+ "./find-cross-cluster-duplicates": {
60
+ "import": "./dist/find-cross-cluster-duplicates.js",
61
+ "types": "./dist/find-cross-cluster-duplicates.d.ts"
62
+ },
63
+ "./validate-cluster-partition": {
64
+ "import": "./dist/validate-cluster-partition.js",
65
+ "types": "./dist/validate-cluster-partition.d.ts"
66
+ },
67
+ "./merge-validated-clusters": {
68
+ "import": "./dist/merge-validated-clusters.js",
69
+ "types": "./dist/merge-validated-clusters.d.ts"
39
70
  }
40
71
  },
41
72
  "bin": "dist/cli.js",
@@ -48,8 +79,9 @@
48
79
  "clean": "tsc --build --clean"
49
80
  },
50
81
  "dependencies": {
51
- "@d-zero/dealer": "1.10.4",
52
- "@d-zero/shared": "0.22.5",
82
+ "@d-zero/cli-core": "1.4.0",
83
+ "@d-zero/dealer": "1.11.0",
84
+ "@d-zero/shared": "0.23.0",
53
85
  "htmlparser2": "12.0.0"
54
86
  },
55
87
  "repository": {
@@ -57,5 +89,5 @@
57
89
  "url": "https://github.com/d-zero-dev/tools.git",
58
90
  "directory": "packages/@d-zero/page-cluster"
59
91
  },
60
- "gitHead": "b06568f6812c4f560b4d8c3a1d280cd42ec264a5"
92
+ "gitHead": "d48a3ed9c01d32f0ca1a5b00e4abc36c376ce880"
61
93
  }