@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.
- package/README.md +35 -38
- package/dist/build-cluster-reason.d.ts +119 -0
- package/dist/build-cluster-reason.js +63 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +54 -46
- package/dist/derive-blocking-reason.d.ts +32 -0
- package/dist/derive-blocking-reason.js +1 -0
- package/dist/merge-cross-block-clusters.d.ts +39 -3
- package/dist/merge-cross-block-clusters.js +23 -10
- package/dist/pass0-blocking.d.ts +10 -0
- package/dist/pass0-blocking.js +26 -46
- package/dist/per-page-landmark-signatures.d.ts +4 -5
- package/dist/reassign-orphan-block-keys.d.ts +9 -0
- package/dist/reassign-orphan-block-keys.js +5 -2
- package/dist/resolve-blocking-group-keys.d.ts +10 -0
- package/dist/resolve-blocking-group-keys.js +16 -86
- package/dist/resolve-page-cluster-keys.d.ts +28 -53
- package/dist/resolve-page-cluster-keys.js +183 -70
- package/dist/shell-quorum.d.ts +4 -4
- package/dist/shell-quorum.js +4 -4
- package/package.json +10 -2
- package/dist/build-page-landmark-report.d.ts +0 -50
- package/dist/build-page-landmark-report.js +0 -67
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { autoCutThreshold } from './auto-cut-threshold.js';
|
|
2
|
-
import {
|
|
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
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* @param
|
|
250
|
-
* @param
|
|
251
|
-
* @param
|
|
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
|
|
254
|
-
const
|
|
255
|
-
for (const
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
264
|
+
unitKeysByRoot.set(root, [unit.key]);
|
|
262
265
|
}
|
|
263
266
|
}
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
353
|
-
// needed whenever `excludeLandmarks` is true.
|
|
377
|
+
// corroboration regardless of `excludeLandmarks`.
|
|
354
378
|
const landmarks = pages.map((page) => extractLandmarks(page.html));
|
|
355
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
437
|
+
const rootKey = rootByKey.get(currentKey);
|
|
401
438
|
if (rootKey !== undefined && rootKey !== currentKey) {
|
|
402
439
|
finalKeys[i] = rootKey;
|
|
403
440
|
}
|
|
404
441
|
}
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return finalKeys
|
|
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
|
|
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 =
|
|
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. `
|
|
534
|
-
//
|
|
535
|
-
//
|
|
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?.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
}
|
package/dist/shell-quorum.d.ts
CHANGED
|
@@ -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 ./
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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>;
|
package/dist/shell-quorum.js
CHANGED
|
@@ -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 ./
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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.
|
|
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": "
|
|
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
|
-
}
|