@d-zero/page-cluster 0.4.0 → 0.5.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.
@@ -1,5 +1,5 @@
1
1
  import { autoCutThreshold } from './auto-cut-threshold.js';
2
- import { buildPageLandmarkReport, } from './build-page-landmark-report.js';
2
+ import { buildClusterReason } from './build-cluster-reason.js';
3
3
  import { capContentDepth } from './cap-content-depth.js';
4
4
  import { detectContentDepthCap, validateDetectContentDepthCapOptions, } from './detect-content-depth-cap.js';
5
5
  import { extractLandmarks } from './extract-landmarks.js';
@@ -9,7 +9,6 @@ import { mergeCrossBlockClusters } from './merge-cross-block-clusters.js';
9
9
  import { groupIndicesByBlockKey, resolveBlockKeys } from './pass0-blocking.js';
10
10
  import { computePerPageLandmarkInstances } from './per-page-landmark-signatures.js';
11
11
  import { removeContentBlocks } from './remove-content-blocks.js';
12
- import { shellQuorum } from './shell-quorum.js';
13
12
  import { stageAPerBlock } from './stage-a-per-block.js';
14
13
  import { tokenize } from './tokenize.js';
15
14
  /**
@@ -238,37 +237,61 @@ export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
238
237
  return [...computeLocalChromeArtifacts(landmarks, tokenizeOptions).localTokensByPage];
239
238
  }
240
239
  /**
241
- * Builds every page's {@link PageLandmarkReport} for the `includeLandmarkPositions`
242
- * result path. Groups pages by their *final* cluster key (post–Stage-B), runs
243
- * {@link ./shell-quorum.js | shellQuorum} once per cluster over the pooled
244
- * member `PerPageLandmarkInstance`s (the same "shell tokens" concept Stage B
245
- * itself uses for L2 shell corroboration, just recomputed at the final-
246
- * cluster granularity rather than the pre-merge unit granularity), then
247
- * classifies each member page's own landmark instances against that
248
- * cluster-level shell.
249
- * @param finalKeys
250
- * @param landmarks
251
- * @param perPageInstances
240
+ * Builds and emits one {@link ClusterReason} per final cluster via
241
+ * `onClusterReason`, from data Stage A/B already computed for clustering
242
+ * itself: `crossBlockUnits` (Stage A's pre-merge units, each carrying its
243
+ * originating block key inside `JSON.parse(unit.key)[0]`), Stage B's
244
+ * `rootByKey`/`finalGroupsByRoot`, the per-block-key `BlockingReason`s Pass 0
245
+ * derived, and the per-block sibling-unit-key lists the driver accumulated
246
+ * alongside its Stage A loop. No re-tokenization and no extra corpus pass —
247
+ * this only re-groups references the driver already held.
248
+ * @param crossBlockUnits
249
+ * @param rootByKey
250
+ * @param finalGroupsByRoot
251
+ * @param reasonsByBlockKey
252
+ * @param siblingUnitKeysByBlock
253
+ * @param onClusterReason
252
254
  */
253
- function buildLandmarkReportsByCluster(finalKeys, landmarks, perPageInstances) {
254
- const indicesByKey = new Map();
255
- for (const [i, key] of finalKeys.entries()) {
256
- const indices = indicesByKey.get(key);
257
- if (indices) {
258
- indices.push(i);
255
+ function emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason) {
256
+ const unitKeysByRoot = new Map();
257
+ for (const unit of crossBlockUnits) {
258
+ const root = rootByKey.get(unit.key) ?? unit.key;
259
+ const list = unitKeysByRoot.get(root);
260
+ if (list) {
261
+ list.push(unit.key);
259
262
  }
260
263
  else {
261
- indicesByKey.set(key, [i]);
264
+ unitKeysByRoot.set(root, [unit.key]);
262
265
  }
263
266
  }
264
- const reports = Array.from({ length: finalKeys.length });
265
- for (const indices of indicesByKey.values()) {
266
- const shellTokens = shellQuorum(indices.map((i) => perPageInstances[i]));
267
- for (const i of indices) {
268
- reports[i] = buildPageLandmarkReport(landmarks[i], shellTokens);
267
+ for (const [rootKey, unitKeys] of unitKeysByRoot) {
268
+ const finalGroup = finalGroupsByRoot.get(rootKey);
269
+ if (!finalGroup)
270
+ continue;
271
+ const seenBlockKeys = new Set();
272
+ const blocking = [];
273
+ const siblingRoots = new Set();
274
+ for (const unitKey of unitKeys) {
275
+ const blockKey = JSON.parse(unitKey)[0];
276
+ if (!seenBlockKeys.has(blockKey)) {
277
+ seenBlockKeys.add(blockKey);
278
+ const reason = reasonsByBlockKey.get(blockKey);
279
+ if (reason)
280
+ blocking.push({ blockKey, reason });
281
+ }
282
+ for (const siblingUnitKey of siblingUnitKeysByBlock.get(blockKey) ?? []) {
283
+ const siblingRoot = rootByKey.get(siblingUnitKey) ?? siblingUnitKey;
284
+ if (siblingRoot !== rootKey)
285
+ siblingRoots.add(siblingRoot);
286
+ }
269
287
  }
288
+ onClusterReason(rootKey, buildClusterReason({
289
+ tokenSets: finalGroup.tokenSets,
290
+ landmarkInstances: finalGroup.landmarkInstances,
291
+ blocking,
292
+ siblingClusterKeys: [...siblingRoots].toSorted(),
293
+ }));
270
294
  }
271
- return reports;
272
295
  }
273
296
  /**
274
297
  * Corpus size at or below which the async factory-based
@@ -292,21 +315,6 @@ function buildLandmarkReportsByCluster(finalKeys, landmarks, perPageInstances) {
292
315
  * anything above 20,000 is routed to streaming.
293
316
  */
294
317
  export const CORPUS_INLINE_THRESHOLD = 20_000;
295
- /**
296
- * Throws when `includeLandmarkPositions` is combined with a corpus over
297
- * `threshold` pages (the streaming path — see `includeLandmarkPositions`'s
298
- * own JSDoc for why it has no sample-based equivalent there). Split out from
299
- * its call site so tests can exercise the boundary with a small injected
300
- * `threshold` instead of constructing a 20,001-page fixture to cross the
301
- * real {@link CORPUS_INLINE_THRESHOLD}.
302
- * @param pageCount
303
- * @param threshold
304
- */
305
- export function assertLandmarkPositionsSupportedForPageCount(pageCount, threshold) {
306
- if (pageCount > threshold) {
307
- throw new RangeError(`resolvePageClusterKeys: includeLandmarkPositions is not supported for corpora larger than ${threshold} pages (the streaming path) — this corpus has ${pageCount}`);
308
- }
309
- }
310
318
  /**
311
319
  * Reservoir-sample size per block on the streaming path. Blocks larger than
312
320
  * this have Stage A run on a random sample of `BLOCK_SAMPLE_SIZE` pages,
@@ -342,6 +350,23 @@ export function assertLandmarkPositionsSupportedForPageCount(pageCount, threshol
342
350
  * {@link CORPUS_INLINE_THRESHOLD} — sampling is streaming-mode only.
343
351
  */
344
352
  export const BLOCK_SAMPLE_SIZE = 100;
353
+ /**
354
+ * Preserves the previous synchronous, array-in / array-out API of
355
+ * `resolvePageClusterKeys` under a new name so the factory-based async
356
+ * export can take the primary name while callers that already had a
357
+ * materialized page array (spec tests, the in-repo dogfood harness,
358
+ * downstream code that hasn't switched to streaming yet) retain the
359
+ * exact same behavior.
360
+ *
361
+ * Semantics: identical to the pre-refactor `resolvePageClusterKeys`.
362
+ * Corpus-wide chrome discovery, Stage B across every page, no memory
363
+ * bound — meant to be called on inputs already known to fit in memory.
364
+ * The async factory-based export delegates here whenever
365
+ * `pages.length ≤ CORPUS_INLINE_THRESHOLD`, guaranteeing existing corpora
366
+ * hit exactly this code path.
367
+ * @param pages
368
+ * @param options
369
+ */
345
370
  export function resolvePageClusterKeysInMemory(pages, options) {
346
371
  const excludeLandmarks = options?.excludeLandmarks ?? true;
347
372
  const similarityThreshold = options?.similarityThreshold ?? 0.8;
@@ -349,14 +374,9 @@ export function resolvePageClusterKeysInMemory(pages, options) {
349
374
  throw new RangeError(`resolvePageClusterKeys: similarityThreshold must be between 0 and 1, got ${similarityThreshold}`);
350
375
  }
351
376
  // Always computed: landmark fields are needed by Stage B's shell
352
- // corroboration regardless of `excludeLandmarks`, and `remainderHtml` is
353
- // needed whenever `excludeLandmarks` is true.
377
+ // corroboration regardless of `excludeLandmarks`.
354
378
  const landmarks = pages.map((page) => extractLandmarks(page.html));
355
- // Corpus-level chrome discovery. `perPageInstances` is only consumed
356
- // below when `includeLandmarkPositions` is set — computed unconditionally
357
- // anyway since `computeLocalChromeArtifacts` already builds it internally
358
- // for chrome discovery, so exposing it here costs nothing extra.
359
- const { localTokensByPage: localLandmarkTokensByPage, perPageInstances } = computeLocalChromeArtifacts(landmarks, options);
379
+ const { localTokensByPage: localLandmarkTokensByPage } = computeLocalChromeArtifacts(landmarks, options);
360
380
  const contentBlockAttribute = options?.contentBlockAttribute;
361
381
  const preparedHtml = pages.map((page, index) => {
362
382
  const landmarksExcised = excludeLandmarks
@@ -371,7 +391,20 @@ export function resolvePageClusterKeysInMemory(pages, options) {
371
391
  const blockingPages = restrictStylesheetsToFirstParty
372
392
  ? filterFirstPartyStylesheetHrefs(pages)
373
393
  : pages;
374
- const blockKeys = resolveBlockKeys(blockingPages, options);
394
+ // Reasons (blocking evidence) are only worth deriving when a caller
395
+ // actually asked for `onClusterReason` — see that option's own JSDoc for
396
+ // why this is the only place ClusterReason bookkeeping is opt-in.
397
+ const onClusterReason = options?.onClusterReason;
398
+ let blockKeys;
399
+ let reasonsByBlockKey;
400
+ if (onClusterReason) {
401
+ const result = resolveBlockKeys(blockingPages, { ...options, includeReasons: true });
402
+ blockKeys = result.blockKeys;
403
+ reasonsByBlockKey = result.reasonsByBlockKey;
404
+ }
405
+ else {
406
+ blockKeys = resolveBlockKeys(blockingPages, options);
407
+ }
375
408
  const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
376
409
  // Validated here, eagerly, because it's otherwise only reached from
377
410
  // inside the per-block loop below — which never runs at all for an empty
@@ -380,6 +413,9 @@ export function resolvePageClusterKeysInMemory(pages, options) {
380
413
  validateDetectContentDepthCapOptions(options);
381
414
  const finalKeys = Array.from({ length: pages.length });
382
415
  const crossBlockUnits = [];
416
+ const siblingUnitKeysByBlock = onClusterReason
417
+ ? new Map()
418
+ : undefined;
383
419
  for (const [blockKey, indices] of indicesByBlockKey) {
384
420
  const result = stageAPerBlock({
385
421
  blockKey,
@@ -392,20 +428,21 @@ export function resolvePageClusterKeysInMemory(pages, options) {
392
428
  finalKeys[pageIndex] = key;
393
429
  }
394
430
  crossBlockUnits.push(...result.crossBlockUnits);
431
+ siblingUnitKeysByBlock?.set(blockKey, result.crossBlockUnits.map((u) => u.key));
395
432
  }
396
433
  // Stage B: cross-block merge — always runs regardless of options
397
- const stageBResult = mergeCrossBlockClusters(crossBlockUnits, options);
434
+ const { rootByKey, finalGroupsByRoot } = mergeCrossBlockClusters(crossBlockUnits, options);
398
435
  for (let i = 0; i < finalKeys.length; i++) {
399
436
  const currentKey = finalKeys[i];
400
- const rootKey = stageBResult.get(currentKey);
437
+ const rootKey = rootByKey.get(currentKey);
401
438
  if (rootKey !== undefined && rootKey !== currentKey) {
402
439
  finalKeys[i] = rootKey;
403
440
  }
404
441
  }
405
- if (!options?.includeLandmarkPositions)
406
- return finalKeys;
407
- const reports = buildLandmarkReportsByCluster(finalKeys, landmarks, perPageInstances);
408
- return finalKeys.map((clusterKey, i) => ({ clusterKey, landmarks: reports[i] }));
442
+ if (onClusterReason && reasonsByBlockKey && siblingUnitKeysByBlock) {
443
+ emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
444
+ }
445
+ return finalKeys;
409
446
  }
410
447
  /**
411
448
  * Async twin of {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysInMemory}
@@ -485,16 +522,58 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
485
522
  await new Promise((resolve) => setImmediate(resolve));
486
523
  }
487
524
  onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
488
- const stageBResult = mergeCrossBlockClusters(crossBlockUnits, options);
525
+ const { rootByKey } = mergeCrossBlockClusters(crossBlockUnits, options);
489
526
  for (let i = 0; i < finalKeys.length; i++) {
490
527
  const currentKey = finalKeys[i];
491
- const rootKey = stageBResult.get(currentKey);
528
+ const rootKey = rootByKey.get(currentKey);
492
529
  if (rootKey !== undefined && rootKey !== currentKey) {
493
530
  finalKeys[i] = rootKey;
494
531
  }
495
532
  }
496
533
  return finalKeys;
497
534
  }
535
+ /**
536
+ * Streaming, memory-bounded version of `resolvePageClusterKeysInMemory`.
537
+ *
538
+ * ## Behavior gate
539
+ *
540
+ * - `pageCount ≤ CORPUS_INLINE_THRESHOLD` — reads the whole factory into an
541
+ * array, delegates to `resolvePageClusterKeysInMemory`. Same corpus-wide
542
+ * chrome discovery, same Stage B across every page. All previously
543
+ * validated corpora (302 / 1,416 / 8,936 / 89 pages) hit this path.
544
+ * - `pageCount > CORPUS_INLINE_THRESHOLD` — streaming path: reads the
545
+ * factory twice (once for blocking signals, once for HTML processing),
546
+ * dispatches HTML per block, runs Stage A per block, accumulates
547
+ * cross-block units, then runs Stage B across all accumulated units. Peak
548
+ * memory ≈ largest single block, not the whole corpus.
549
+ *
550
+ * ## Semantic differences in streaming mode
551
+ *
552
+ * - **Chrome discovery is per-block, not corpus-wide.** In the in-memory
553
+ * path, {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkTokens}
554
+ * runs on all pages at once. In streaming mode the entire corpus cannot
555
+ * be held at once, so chrome discovery runs per block. A landmark
556
+ * signature that is rare corpus-wide but common within one block will
557
+ * be treated as global chrome in streaming mode, whereas the in-memory
558
+ * mode would treat it as local. This trade-off is why the threshold
559
+ * above is set generously — every real corpus historically validated
560
+ * here stays on the in-memory path.
561
+ * @param pages
562
+ * @param options
563
+ * @example
564
+ * ```ts
565
+ * // JSONL file source — factory can be re-invoked to re-open the file.
566
+ * import { createReadStream } from 'node:fs';
567
+ * import readline from 'node:readline';
568
+ *
569
+ * const keys = await resolvePageClusterKeys(() => {
570
+ * const lines = readline.createInterface({ input: createReadStream('pages.jsonl') });
571
+ * return (async function* () {
572
+ * for await (const line of lines) yield JSON.parse(line);
573
+ * })();
574
+ * });
575
+ * ```
576
+ */
498
577
  export async function resolvePageClusterKeys(pages, options) {
499
578
  const onProgress = options?.onProgress;
500
579
  // Pass 0: HTML-free — collect blocking signals (paths, stylesheetHrefs,
@@ -530,22 +609,17 @@ export async function resolvePageClusterKeys(pages, options) {
530
609
  // into per-block progress, so delegate to the untouched sync path —
531
610
  // keeping behavior byte-for-byte identical (and yield-overhead-free)
532
611
  // to how library-only consumers experienced this before the CLI
533
- // progress work landed. `includeLandmarkPositions` always routes here
534
- // too (see its own JSDoc): `resolveSmallCorpusWithProgress` has no
535
- // landmark-report support, and duplicating that logic into the
612
+ // progress work landed. `onClusterReason` always routes here too (see
613
+ // its own JSDoc): `resolveSmallCorpusWithProgress` has no
614
+ // cluster-reason support, and duplicating that logic into the
536
615
  // progress-emitting path for a reporting feature that has nothing to
537
616
  // do with progress observability isn't worth the added surface.
538
- if (onProgress === undefined || options?.includeLandmarkPositions) {
617
+ if (onProgress === undefined || options?.onClusterReason) {
539
618
  return resolvePageClusterKeysInMemory(fullPages, options);
540
619
  }
541
620
  return resolveSmallCorpusWithProgress(fullPages, onProgress, options);
542
621
  }
543
- // Large corpus: streaming path. includeLandmarkPositions has no sample-
544
- // based equivalent (see its own JSDoc) — fail fast rather than silently
545
- // ignoring the option or returning a semantically-wrong report.
546
- if (options?.includeLandmarkPositions) {
547
- assertLandmarkPositionsSupportedForPageCount(blockingSignals.length, CORPUS_INLINE_THRESHOLD);
548
- }
622
+ // Large corpus: streaming path.
549
623
  const excludeLandmarks = options?.excludeLandmarks ?? true;
550
624
  const similarityThreshold = options?.similarityThreshold ?? 0.8;
551
625
  if (!(similarityThreshold >= 0 && similarityThreshold <= 1)) {
@@ -556,11 +630,30 @@ export async function resolvePageClusterKeys(pages, options) {
556
630
  const blockingPagesForKeys = restrictStylesheetsToFirstParty
557
631
  ? filterFirstPartyStylesheetHrefs(blockingSignals)
558
632
  : blockingSignals;
559
- const blockKeys = resolveBlockKeys(blockingPagesForKeys, options);
633
+ // Reasons (blocking evidence) cost nothing beyond bookkeeping — a Map
634
+ // keyed by distinct block key, not by page — but are only derived when a
635
+ // caller actually asked for `onClusterReason`.
636
+ const onClusterReason = options?.onClusterReason;
637
+ let blockKeys;
638
+ let reasonsByBlockKey;
639
+ if (onClusterReason) {
640
+ const result = resolveBlockKeys(blockingPagesForKeys, {
641
+ ...options,
642
+ includeReasons: true,
643
+ });
644
+ blockKeys = result.blockKeys;
645
+ reasonsByBlockKey = result.reasonsByBlockKey;
646
+ }
647
+ else {
648
+ blockKeys = resolveBlockKeys(blockingPagesForKeys, options);
649
+ }
560
650
  const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
561
651
  const finalKeys = Array.from({ length: blockingSignals.length });
562
652
  const crossBlockUnits = [];
563
653
  const contentBlockAttribute = options?.contentBlockAttribute;
654
+ const siblingUnitKeysByBlock = onClusterReason
655
+ ? new Map()
656
+ : undefined;
564
657
  /** Non-sample page indices that need Pass 1b Jaccard-based assignment. */
565
658
  const pendingAssignmentBlockKeyByIndex = new Map();
566
659
  /** Block-level artifacts saved after Stage A runs on the sample. */
@@ -599,6 +692,7 @@ export async function resolvePageClusterKeys(pages, options) {
599
692
  finalKeys[idx] = key;
600
693
  }
601
694
  crossBlockUnits.push(...result.crossBlockUnits);
695
+ siblingUnitKeysByBlock?.set(bucket.blockKey, result.crossBlockUnits.map((u) => u.key));
602
696
  if (bucket.seenCount > bucket.reservoirIndices.length) {
603
697
  // Save assignment artifacts for Pass 1b.
604
698
  const maxMainDepth = bucket.reservoirPreparedHtml.length > 1
@@ -724,19 +818,38 @@ export async function resolvePageClusterKeys(pages, options) {
724
818
  if (onProgress) {
725
819
  onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
726
820
  }
727
- const stageBResult = mergeCrossBlockClusters(crossBlockUnits, {
821
+ const { rootByKey, finalGroupsByRoot } = mergeCrossBlockClusters(crossBlockUnits, {
728
822
  ...options,
729
823
  capMembers: BLOCK_SAMPLE_SIZE,
730
824
  });
731
825
  for (let i = 0; i < finalKeys.length; i++) {
732
826
  const currentKey = finalKeys[i];
733
- const rootKey = stageBResult.get(currentKey);
827
+ const rootKey = rootByKey.get(currentKey);
734
828
  if (rootKey !== undefined && rootKey !== currentKey) {
735
829
  finalKeys[i] = rootKey;
736
830
  }
737
831
  }
832
+ if (onClusterReason && reasonsByBlockKey && siblingUnitKeysByBlock) {
833
+ emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
834
+ }
738
835
  return finalKeys;
739
836
  }
837
+ /**
838
+ * Convenience wrapper that runs {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}
839
+ * on a materialized array. Preserves the pre-refactor sync API for callers
840
+ * that already have all pages in memory, while flowing through the same
841
+ * async driver so behavior stays consistent across the two entry points.
842
+ * @param pages
843
+ * @param options
844
+ * @example
845
+ * ```ts
846
+ * const keys = await resolvePageClusterKeysFromArray([
847
+ * { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
848
+ * { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
849
+ * { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
850
+ * ]);
851
+ * ```
852
+ */
740
853
  export function resolvePageClusterKeysFromArray(pages, options) {
741
854
  return resolvePageClusterKeys(() => pages, options);
742
855
  }
@@ -61,10 +61,10 @@ import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js'
61
61
  *
62
62
  * Originally private to {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
63
63
  * Stage B L2 corroboration; exported from its own module so
64
- * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysInMemory}'s
65
- * `includeLandmarkPositions` reporting path can run it once per final
66
- * cluster to classify individual landmark instances as chrome (see
67
- * {@link ./is-chrome-landmark-instance.js | isChromeLandmarkInstance}).
64
+ * {@link ./build-cluster-reason.js | buildClusterReason} can run it once per
65
+ * final cluster, per landmark type, to classify individual landmark
66
+ * instances as chrome (see {@link ./is-chrome-landmark-instance.js |
67
+ * isChromeLandmarkInstance}).
68
68
  * @param perPageInstances
69
69
  */
70
70
  export declare function shellQuorum(perPageInstances: readonly (readonly PerPageLandmarkInstance[])[]): ReadonlySet<string>;
@@ -70,10 +70,10 @@ const SHELL_QUORUM_FALLBACK_FRACTION = 0.8;
70
70
  *
71
71
  * Originally private to {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
72
72
  * Stage B L2 corroboration; exported from its own module so
73
- * {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysInMemory}'s
74
- * `includeLandmarkPositions` reporting path can run it once per final
75
- * cluster to classify individual landmark instances as chrome (see
76
- * {@link ./is-chrome-landmark-instance.js | isChromeLandmarkInstance}).
73
+ * {@link ./build-cluster-reason.js | buildClusterReason} can run it once per
74
+ * final cluster, per landmark type, to classify individual landmark
75
+ * instances as chrome (see {@link ./is-chrome-landmark-instance.js |
76
+ * isChromeLandmarkInstance}).
77
77
  * @param perPageInstances
78
78
  */
79
79
  export function shellQuorum(perPageInstances) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d-zero/page-cluster",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -24,6 +24,14 @@
24
24
  "./resolve-page-cluster-keys": {
25
25
  "import": "./dist/resolve-page-cluster-keys.js",
26
26
  "types": "./dist/resolve-page-cluster-keys.d.ts"
27
+ },
28
+ "./is-chrome-landmark-instance": {
29
+ "import": "./dist/is-chrome-landmark-instance.js",
30
+ "types": "./dist/is-chrome-landmark-instance.d.ts"
31
+ },
32
+ "./jaccard-similarity": {
33
+ "import": "./dist/jaccard-similarity.js",
34
+ "types": "./dist/jaccard-similarity.d.ts"
27
35
  }
28
36
  },
29
37
  "bin": "./dist/cli.js",
@@ -45,5 +53,5 @@
45
53
  "url": "https://github.com/d-zero-dev/tools.git",
46
54
  "directory": "packages/@d-zero/page-cluster"
47
55
  },
48
- "gitHead": "2df2273542928b0bd3e4507c161f80a4fffca9e0"
56
+ "gitHead": "956fa3488d875756ad57fb391bc4822704570f6b"
49
57
  }
@@ -1,50 +0,0 @@
1
- import type { ExtractLandmarksResult, LandmarkPosition } from './extract-landmarks.js';
2
- import type { TokenizeOptions } from './types.js';
3
- /**
4
- * A landmark instance's position plus whether
5
- * {@link ./is-chrome-landmark-instance.js | isChromeLandmarkInstance} judged
6
- * it shared site/section chrome (`true`) or page-specific content (`false`),
7
- * against the unit's {@link ./shell-quorum.js | shellQuorum} shell tokens.
8
- */
9
- export type ReportedLandmarkInstance = LandmarkPosition & {
10
- readonly isChrome: boolean;
11
- };
12
- /**
13
- * Per-page landmark position report built by
14
- * {@link ./build-page-landmark-report.js | buildPageLandmarkReport}. `main`
15
- * carries no `isChrome` verdict — it never participates in chrome/shell
16
- * discovery (see `extractLandmarks`'s "main handling" note) and is always
17
- * content.
18
- */
19
- export type PageLandmarkReport = {
20
- header: ReportedLandmarkInstance[];
21
- footer: ReportedLandmarkInstance[];
22
- nav: ReportedLandmarkInstance[];
23
- aside: ReportedLandmarkInstance[];
24
- form: ReportedLandmarkInstance[];
25
- search: ReportedLandmarkInstance[];
26
- main: LandmarkPosition[];
27
- };
28
- /**
29
- * Builds a page's landmark position report: every landmark instance's
30
- * location, with `header`/`footer`/`nav`/`aside`/`form`/`search` instances
31
- * additionally classified as chrome or content against `shellTokens`.
32
- *
33
- * Reads `landmarks` directly — the full, non-deduplicated instance list
34
- * `extractLandmarks` produced — rather than going through
35
- * {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}'s
36
- * per-page-deduplicated `PerPageLandmarkInstance[]`: that dedupe collapses
37
- * same-signature instances to one entry, which would silently drop the
38
- * position of every duplicate instance a position report needs to include.
39
- * @param landmarks
40
- * @param shellTokens The unit-level shell token set from
41
- * {@link ./shell-quorum.js | shellQuorum}, computed once per final cluster
42
- * and shared across every member page's report.
43
- * @param tokenizeOptions
44
- * @example
45
- * ```ts
46
- * const shellTokens = shellQuorum(clusterPerPageInstances);
47
- * const report = buildPageLandmarkReport(extractLandmarks(page.html), shellTokens);
48
- * ```
49
- */
50
- export declare function buildPageLandmarkReport(landmarks: ExtractLandmarksResult, shellTokens: ReadonlySet<string>, tokenizeOptions?: TokenizeOptions): PageLandmarkReport;
@@ -1,67 +0,0 @@
1
- import { isChromeLandmarkInstance } from './is-chrome-landmark-instance.js';
2
- import { ALL_LANDMARK_TYPES } from './per-page-landmark-signatures.js';
3
- import { tokenize } from './tokenize.js';
4
- /**
5
- * Strips `html` off a {@link LandmarkInstance}, keeping only its position.
6
- * `buildPageLandmarkReport`'s output is meant to be serialized per page
7
- * across a whole corpus (the CLI's JSONL output), so the report
8
- * deliberately excludes each instance's raw HTML to keep that payload from
9
- * scaling with markup size — callers who also need the HTML already have
10
- * `ExtractLandmarksResult` in hand.
11
- * @param instance
12
- */
13
- function toPosition(instance) {
14
- return {
15
- startOffset: instance.startOffset,
16
- endOffset: instance.endOffset,
17
- startLine: instance.startLine,
18
- startColumn: instance.startColumn,
19
- endLine: instance.endLine,
20
- endColumn: instance.endColumn,
21
- };
22
- }
23
- /**
24
- * Builds a page's landmark position report: every landmark instance's
25
- * location, with `header`/`footer`/`nav`/`aside`/`form`/`search` instances
26
- * additionally classified as chrome or content against `shellTokens`.
27
- *
28
- * Reads `landmarks` directly — the full, non-deduplicated instance list
29
- * `extractLandmarks` produced — rather than going through
30
- * {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}'s
31
- * per-page-deduplicated `PerPageLandmarkInstance[]`: that dedupe collapses
32
- * same-signature instances to one entry, which would silently drop the
33
- * position of every duplicate instance a position report needs to include.
34
- * @param landmarks
35
- * @param shellTokens The unit-level shell token set from
36
- * {@link ./shell-quorum.js | shellQuorum}, computed once per final cluster
37
- * and shared across every member page's report.
38
- * @param tokenizeOptions
39
- * @example
40
- * ```ts
41
- * const shellTokens = shellQuorum(clusterPerPageInstances);
42
- * const report = buildPageLandmarkReport(extractLandmarks(page.html), shellTokens);
43
- * ```
44
- */
45
- export function buildPageLandmarkReport(landmarks, shellTokens, tokenizeOptions) {
46
- const report = {
47
- header: [],
48
- footer: [],
49
- nav: [],
50
- aside: [],
51
- form: [],
52
- search: [],
53
- main: landmarks.main.map(toPosition),
54
- };
55
- for (const type of ALL_LANDMARK_TYPES) {
56
- for (const instance of landmarks[type]) {
57
- const tokens = instance.html
58
- ? new Set(tokenize(`<body>${instance.html}</body>`, tokenizeOptions).tokens)
59
- : new Set();
60
- report[type].push({
61
- ...toPosition(instance),
62
- isChrome: isChromeLandmarkInstance(tokens, shellTokens),
63
- });
64
- }
65
- }
66
- return report;
67
- }