@d-zero/page-cluster 0.5.2 → 0.5.3

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.
@@ -165,10 +165,9 @@ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGro
165
165
  * sync helper to running a per-block async loop that emits
166
166
  * `pass1-block-complete` and `stage-b-start`. Omitting `onProgress`
167
167
  * keeps the small-corpus branch on the pre-refactor sync path with
168
- * zero yield overhead. Ignored when `onClusterReason` is set — that
169
- * option always routes the small-corpus branch through the sync
170
- * helper (see `onClusterReason`'s own JSDoc), so no progress events
171
- * fire in that combination.
168
+ * zero yield overhead. Independent of `onClusterReason` both hooks
169
+ * can be set together and each fires on its own schedule (see
170
+ * `onClusterReason`'s own JSDoc).
172
171
  */
173
172
  onProgress?: (event: ProgressEvent) => void;
174
173
  /**
@@ -188,10 +187,11 @@ export type ResolvePageClusterKeysOptions = TokenizeOptions & ResolveBlockingGro
188
187
  * discovery. Omitting `onClusterReason` skips that bookkeeping
189
188
  * entirely, so existing callers pay nothing for this option.
190
189
  *
191
- * On the async factory-based `resolvePageClusterKeys`, setting this
192
- * forces the small-corpus branch through the sync
193
- * `resolvePageClusterKeysInMemory` helper regardless of `onProgress`
194
- * see `onProgress`'s own JSDoc.
190
+ * Independent of `onProgress`: on the async factory-based
191
+ * `resolvePageClusterKeys`, the small-corpus branch still chooses
192
+ * between the sync and progress-emitting helpers based on `onProgress`
193
+ * alone, and both helpers derive `ClusterReason`s the same way when
194
+ * this option is set.
195
195
  * @example
196
196
  * ```ts
197
197
  * const reasons = new Map<string, ClusterReason>();
@@ -236,6 +236,27 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
236
236
  export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
237
237
  return [...computeLocalChromeArtifacts(landmarks, tokenizeOptions).localTokensByPage];
238
238
  }
239
+ /**
240
+ * Resolves block keys for a clustering pass, deriving each block key's
241
+ * `BlockingReason` in the same call (via `resolveBlockKeys`'s
242
+ * `includeReasons: true` overload) whenever a caller wants `ClusterReason`s.
243
+ * All three clustering drivers below (in-memory, small-corpus-with-progress,
244
+ * streaming) share this exact branch so the "does this caller need reasons"
245
+ * decision can't drift between them.
246
+ * @param blockingPages
247
+ * @param options
248
+ * @param needReasons
249
+ */
250
+ function resolveBlockKeysForClustering(blockingPages, options, needReasons) {
251
+ if (!needReasons) {
252
+ return {
253
+ blockKeys: resolveBlockKeys(blockingPages, options),
254
+ reasonsByBlockKey: undefined,
255
+ };
256
+ }
257
+ const result = resolveBlockKeys(blockingPages, { ...options, includeReasons: true });
258
+ return { blockKeys: result.blockKeys, reasonsByBlockKey: result.reasonsByBlockKey };
259
+ }
239
260
  /**
240
261
  * Builds and emits one {@link ClusterReason} per final cluster via
241
262
  * `onClusterReason`, from data Stage A/B already computed for clustering
@@ -245,6 +266,12 @@ export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
245
266
  * derived, and the per-block sibling-unit-key lists the driver accumulated
246
267
  * alongside its Stage A loop. No re-tokenization and no extra corpus pass —
247
268
  * this only re-groups references the driver already held.
269
+ *
270
+ * No-ops when any of `reasonsByBlockKey` / `siblingUnitKeysByBlock` /
271
+ * `onClusterReason` is `undefined` — the single gate for "was
272
+ * `onClusterReason` requested" that all three clustering drivers below defer
273
+ * to, instead of each repeating the same three-way null check before calling
274
+ * this function.
248
275
  * @param crossBlockUnits
249
276
  * @param rootByKey
250
277
  * @param finalGroupsByRoot
@@ -253,6 +280,8 @@ export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
253
280
  * @param onClusterReason
254
281
  */
255
282
  function emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason) {
283
+ if (!onClusterReason || !reasonsByBlockKey || !siblingUnitKeysByBlock)
284
+ return;
256
285
  const unitKeysByRoot = new Map();
257
286
  for (const unit of crossBlockUnits) {
258
287
  const root = rootByKey.get(unit.key) ?? unit.key;
@@ -395,16 +424,7 @@ export function resolvePageClusterKeysInMemory(pages, options) {
395
424
  // actually asked for `onClusterReason` — see that option's own JSDoc for
396
425
  // why this is the only place ClusterReason bookkeeping is opt-in.
397
426
  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
- }
427
+ const { blockKeys, reasonsByBlockKey } = resolveBlockKeysForClustering(blockingPages, options, onClusterReason !== undefined);
408
428
  const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
409
429
  // Validated here, eagerly, because it's otherwise only reached from
410
430
  // inside the per-block loop below — which never runs at all for an empty
@@ -439,9 +459,7 @@ export function resolvePageClusterKeysInMemory(pages, options) {
439
459
  finalKeys[i] = rootKey;
440
460
  }
441
461
  }
442
- if (onClusterReason && reasonsByBlockKey && siblingUnitKeysByBlock) {
443
- emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
444
- }
462
+ emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
445
463
  return finalKeys;
446
464
  }
447
465
  /**
@@ -457,7 +475,9 @@ export function resolvePageClusterKeysInMemory(pages, options) {
457
475
  * un-capped Stage B across the entire crossBlockUnits array. `finalKeys`
458
476
  * returned here must be byte-for-byte identical to what the sync path
459
477
  * would have produced for the same `pages` input — spec-enforced by
460
- * `resolve-page-cluster-keys-streaming.spec.ts`.
478
+ * `resolve-page-cluster-keys-streaming.spec.ts`. When `options.onClusterReason`
479
+ * is set, it derives and emits `ClusterReason`s the same way the sync path
480
+ * does, so the two hooks compose freely.
461
481
  *
462
482
  * The sync `resolvePageClusterKeysInMemory` is deliberately left in place
463
483
  * as its own implementation rather than being folded into a shared helper.
@@ -491,11 +511,17 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
491
511
  const blockingPages = restrictStylesheetsToFirstParty
492
512
  ? filterFirstPartyStylesheetHrefs(pages)
493
513
  : pages;
494
- const blockKeys = resolveBlockKeys(blockingPages, options);
514
+ // Reasons (blocking evidence) are only worth deriving when a caller
515
+ // actually asked for `onClusterReason` — same gate as the in-memory path.
516
+ const onClusterReason = options?.onClusterReason;
517
+ const { blockKeys, reasonsByBlockKey } = resolveBlockKeysForClustering(blockingPages, options, onClusterReason !== undefined);
495
518
  const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
496
519
  validateDetectContentDepthCapOptions(options);
497
520
  const finalKeys = Array.from({ length: pages.length });
498
521
  const crossBlockUnits = [];
522
+ const siblingUnitKeysByBlock = onClusterReason
523
+ ? new Map()
524
+ : undefined;
499
525
  const totalBlocks = indicesByBlockKey.size;
500
526
  let blocksProcessed = 0;
501
527
  for (const [blockKey, indices] of indicesByBlockKey) {
@@ -510,6 +536,7 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
510
536
  finalKeys[pageIndex] = key;
511
537
  }
512
538
  crossBlockUnits.push(...result.crossBlockUnits);
539
+ siblingUnitKeysByBlock?.set(blockKey, result.crossBlockUnits.map((u) => u.key));
513
540
  blocksProcessed++;
514
541
  onProgress({
515
542
  phase: 'pass1-block-complete',
@@ -522,7 +549,7 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
522
549
  await new Promise((resolve) => setImmediate(resolve));
523
550
  }
524
551
  onProgress({ phase: 'stage-b-start', unitCount: crossBlockUnits.length });
525
- const { rootByKey } = mergeCrossBlockClusters(crossBlockUnits, options);
552
+ const { rootByKey, finalGroupsByRoot } = mergeCrossBlockClusters(crossBlockUnits, options);
526
553
  for (let i = 0; i < finalKeys.length; i++) {
527
554
  const currentKey = finalKeys[i];
528
555
  const rootKey = rootByKey.get(currentKey);
@@ -530,6 +557,7 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
530
557
  finalKeys[i] = rootKey;
531
558
  }
532
559
  }
560
+ emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
533
561
  return finalKeys;
534
562
  }
535
563
  /**
@@ -609,12 +637,11 @@ export async function resolvePageClusterKeys(pages, options) {
609
637
  // into per-block progress, so delegate to the untouched sync path —
610
638
  // keeping behavior byte-for-byte identical (and yield-overhead-free)
611
639
  // to how library-only consumers experienced this before the CLI
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
615
- // progress-emitting path for a reporting feature that has nothing to
616
- // do with progress observability isn't worth the added surface.
617
- if (onProgress === undefined || options?.onClusterReason) {
640
+ // progress work landed. `onClusterReason` is independent of this
641
+ // choice: both the sync and progress-emitting paths derive
642
+ // ClusterReasons the same way, so passing it doesn't change which
643
+ // path handles a small corpus.
644
+ if (onProgress === undefined) {
618
645
  return resolvePageClusterKeysInMemory(fullPages, options);
619
646
  }
620
647
  return resolveSmallCorpusWithProgress(fullPages, onProgress, options);
@@ -634,19 +661,7 @@ export async function resolvePageClusterKeys(pages, options) {
634
661
  // keyed by distinct block key, not by page — but are only derived when a
635
662
  // caller actually asked for `onClusterReason`.
636
663
  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
- }
664
+ const { blockKeys, reasonsByBlockKey } = resolveBlockKeysForClustering(blockingPagesForKeys, options, onClusterReason !== undefined);
650
665
  const indicesByBlockKey = groupIndicesByBlockKey(blockKeys);
651
666
  const finalKeys = Array.from({ length: blockingSignals.length });
652
667
  const crossBlockUnits = [];
@@ -829,9 +844,7 @@ export async function resolvePageClusterKeys(pages, options) {
829
844
  finalKeys[i] = rootKey;
830
845
  }
831
846
  }
832
- if (onClusterReason && reasonsByBlockKey && siblingUnitKeysByBlock) {
833
- emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
834
- }
847
+ emitClusterReasons(crossBlockUnits, rootByKey, finalGroupsByRoot, reasonsByBlockKey, siblingUnitKeysByBlock, onClusterReason);
835
848
  return finalKeys;
836
849
  }
837
850
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d-zero/page-cluster",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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",
@@ -57,5 +57,5 @@
57
57
  "url": "https://github.com/d-zero-dev/tools.git",
58
58
  "directory": "packages/@d-zero/page-cluster"
59
59
  },
60
- "gitHead": "a2f4e4a764db0930911c5c6f81696228b31926cb"
60
+ "gitHead": "7be4609bd7a2ca80a34d87cde2ade18191c952f9"
61
61
  }