@d-zero/page-cluster 0.3.0 → 0.4.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 +131 -53
- package/dist/build-page-landmark-report.d.ts +50 -0
- package/dist/build-page-landmark-report.js +67 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +56 -6
- package/dist/extract-landmarks.d.ts +75 -14
- package/dist/extract-landmarks.js +64 -4
- package/dist/is-chrome-landmark-instance.d.ts +41 -0
- package/dist/is-chrome-landmark-instance.js +50 -0
- package/dist/merge-cross-block-clusters.js +5 -97
- package/dist/offset-to-line-column.d.ts +38 -0
- package/dist/offset-to-line-column.js +49 -0
- package/dist/per-page-landmark-signatures.d.ts +10 -1
- package/dist/per-page-landmark-signatures.js +16 -4
- package/dist/resolve-page-cluster-keys.d.ts +62 -1
- package/dist/resolve-page-cluster-keys.js +77 -84
- package/dist/shell-quorum.d.ts +70 -0
- package/dist/shell-quorum.js +110 -0
- package/package.json +4 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { autoCutThreshold } from './auto-cut-threshold.js';
|
|
2
|
+
import { buildPageLandmarkReport, } from './build-page-landmark-report.js';
|
|
2
3
|
import { capContentDepth } from './cap-content-depth.js';
|
|
3
4
|
import { detectContentDepthCap, validateDetectContentDepthCapOptions, } from './detect-content-depth-cap.js';
|
|
4
5
|
import { extractLandmarks } from './extract-landmarks.js';
|
|
@@ -8,6 +9,7 @@ import { mergeCrossBlockClusters } from './merge-cross-block-clusters.js';
|
|
|
8
9
|
import { groupIndicesByBlockKey, resolveBlockKeys } from './pass0-blocking.js';
|
|
9
10
|
import { computePerPageLandmarkInstances } from './per-page-landmark-signatures.js';
|
|
10
11
|
import { removeContentBlocks } from './remove-content-blocks.js';
|
|
12
|
+
import { shellQuorum } from './shell-quorum.js';
|
|
11
13
|
import { stageAPerBlock } from './stage-a-per-block.js';
|
|
12
14
|
import { tokenize } from './tokenize.js';
|
|
13
15
|
/**
|
|
@@ -162,8 +164,9 @@ function assignPageToNearestCluster(html, assignment, excludeLandmarks, contentB
|
|
|
162
164
|
*/
|
|
163
165
|
export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
164
166
|
const pageCount = landmarks.length;
|
|
165
|
-
if (pageCount === 0)
|
|
166
|
-
return { localSignatures: new Set(), localTokensByPage: [] };
|
|
167
|
+
if (pageCount === 0) {
|
|
168
|
+
return { localSignatures: new Set(), localTokensByPage: [], perPageInstances: [] };
|
|
169
|
+
}
|
|
167
170
|
const perPageInstances = computePerPageLandmarkInstances(landmarks, tokenizeOptions);
|
|
168
171
|
// Corpus-wide histogram: signature → { count, tokens }. tokens is the
|
|
169
172
|
// token set of any one occurrence of the signature (all occurrences are
|
|
@@ -184,6 +187,7 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
|
184
187
|
return {
|
|
185
188
|
localSignatures: new Set(),
|
|
186
189
|
localTokensByPage: landmarks.map(() => new Set()),
|
|
190
|
+
perPageInstances,
|
|
187
191
|
};
|
|
188
192
|
}
|
|
189
193
|
const frequencies = [];
|
|
@@ -202,6 +206,7 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
|
202
206
|
return {
|
|
203
207
|
localSignatures: new Set(),
|
|
204
208
|
localTokensByPage: landmarks.map(() => new Set()),
|
|
209
|
+
perPageInstances,
|
|
205
210
|
};
|
|
206
211
|
}
|
|
207
212
|
const localTokensByPage = perPageInstances.map((instances) => {
|
|
@@ -214,7 +219,7 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
|
214
219
|
}
|
|
215
220
|
return out;
|
|
216
221
|
});
|
|
217
|
-
return { localSignatures, localTokensByPage };
|
|
222
|
+
return { localSignatures, localTokensByPage, perPageInstances };
|
|
218
223
|
}
|
|
219
224
|
/**
|
|
220
225
|
* Reinjects each page's *local* (non-corpus-wide) landmark-instance tokens
|
|
@@ -232,6 +237,39 @@ export function computeLocalChromeArtifacts(landmarks, tokenizeOptions) {
|
|
|
232
237
|
export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
|
|
233
238
|
return [...computeLocalChromeArtifacts(landmarks, tokenizeOptions).localTokensByPage];
|
|
234
239
|
}
|
|
240
|
+
/**
|
|
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
|
|
252
|
+
*/
|
|
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);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
indicesByKey.set(key, [i]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
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);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return reports;
|
|
272
|
+
}
|
|
235
273
|
/**
|
|
236
274
|
* Corpus size at or below which the async factory-based
|
|
237
275
|
* `resolvePageClusterKeys` reads the entire input into an array and delegates
|
|
@@ -254,6 +292,21 @@ export function computeLocalLandmarkTokens(landmarks, tokenizeOptions) {
|
|
|
254
292
|
* anything above 20,000 is routed to streaming.
|
|
255
293
|
*/
|
|
256
294
|
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
|
+
}
|
|
257
310
|
/**
|
|
258
311
|
* Reservoir-sample size per block on the streaming path. Blocks larger than
|
|
259
312
|
* this have Stage A run on a random sample of `BLOCK_SAMPLE_SIZE` pages,
|
|
@@ -289,23 +342,6 @@ export const CORPUS_INLINE_THRESHOLD = 20_000;
|
|
|
289
342
|
* {@link CORPUS_INLINE_THRESHOLD} — sampling is streaming-mode only.
|
|
290
343
|
*/
|
|
291
344
|
export const BLOCK_SAMPLE_SIZE = 100;
|
|
292
|
-
/**
|
|
293
|
-
* Preserves the previous synchronous, array-in / array-out API of
|
|
294
|
-
* `resolvePageClusterKeys` under a new name so the factory-based async
|
|
295
|
-
* export can take the primary name while callers that already had a
|
|
296
|
-
* materialized page array (spec tests, the in-repo dogfood harness,
|
|
297
|
-
* downstream code that hasn't switched to streaming yet) retain the
|
|
298
|
-
* exact same behavior.
|
|
299
|
-
*
|
|
300
|
-
* Semantics: identical to the pre-refactor `resolvePageClusterKeys`.
|
|
301
|
-
* Corpus-wide chrome discovery, Stage B across every page, no memory
|
|
302
|
-
* bound — meant to be called on inputs already known to fit in memory.
|
|
303
|
-
* The async factory-based export delegates here whenever
|
|
304
|
-
* `pages.length ≤ CORPUS_INLINE_THRESHOLD`, guaranteeing existing corpora
|
|
305
|
-
* hit exactly this code path.
|
|
306
|
-
* @param pages
|
|
307
|
-
* @param options
|
|
308
|
-
*/
|
|
309
345
|
export function resolvePageClusterKeysInMemory(pages, options) {
|
|
310
346
|
const excludeLandmarks = options?.excludeLandmarks ?? true;
|
|
311
347
|
const similarityThreshold = options?.similarityThreshold ?? 0.8;
|
|
@@ -316,8 +352,11 @@ export function resolvePageClusterKeysInMemory(pages, options) {
|
|
|
316
352
|
// corroboration regardless of `excludeLandmarks`, and `remainderHtml` is
|
|
317
353
|
// needed whenever `excludeLandmarks` is true.
|
|
318
354
|
const landmarks = pages.map((page) => extractLandmarks(page.html));
|
|
319
|
-
// Corpus-level chrome discovery
|
|
320
|
-
|
|
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);
|
|
321
360
|
const contentBlockAttribute = options?.contentBlockAttribute;
|
|
322
361
|
const preparedHtml = pages.map((page, index) => {
|
|
323
362
|
const landmarksExcised = excludeLandmarks
|
|
@@ -363,7 +402,10 @@ export function resolvePageClusterKeysInMemory(pages, options) {
|
|
|
363
402
|
finalKeys[i] = rootKey;
|
|
364
403
|
}
|
|
365
404
|
}
|
|
366
|
-
|
|
405
|
+
if (!options?.includeLandmarkPositions)
|
|
406
|
+
return finalKeys;
|
|
407
|
+
const reports = buildLandmarkReportsByCluster(finalKeys, landmarks, perPageInstances);
|
|
408
|
+
return finalKeys.map((clusterKey, i) => ({ clusterKey, landmarks: reports[i] }));
|
|
367
409
|
}
|
|
368
410
|
/**
|
|
369
411
|
* Async twin of {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeysInMemory}
|
|
@@ -453,48 +495,6 @@ async function resolveSmallCorpusWithProgress(pages, onProgress, options) {
|
|
|
453
495
|
}
|
|
454
496
|
return finalKeys;
|
|
455
497
|
}
|
|
456
|
-
/**
|
|
457
|
-
* Streaming, memory-bounded version of `resolvePageClusterKeysInMemory`.
|
|
458
|
-
*
|
|
459
|
-
* ## Behavior gate
|
|
460
|
-
*
|
|
461
|
-
* - `pageCount ≤ CORPUS_INLINE_THRESHOLD` — reads the whole factory into an
|
|
462
|
-
* array, delegates to `resolvePageClusterKeysInMemory`. Same corpus-wide
|
|
463
|
-
* chrome discovery, same Stage B across every page. All previously
|
|
464
|
-
* validated corpora (302 / 1,416 / 8,936 / 89 pages) hit this path.
|
|
465
|
-
* - `pageCount > CORPUS_INLINE_THRESHOLD` — streaming path: reads the
|
|
466
|
-
* factory twice (once for blocking signals, once for HTML processing),
|
|
467
|
-
* dispatches HTML per block, runs Stage A per block, accumulates
|
|
468
|
-
* cross-block units, then runs Stage B across all accumulated units. Peak
|
|
469
|
-
* memory ≈ largest single block, not the whole corpus.
|
|
470
|
-
*
|
|
471
|
-
* ## Semantic differences in streaming mode
|
|
472
|
-
*
|
|
473
|
-
* - **Chrome discovery is per-block, not corpus-wide.** In the in-memory
|
|
474
|
-
* path, {@link ./resolve-page-cluster-keys.js | computeLocalLandmarkTokens}
|
|
475
|
-
* runs on all pages at once. In streaming mode the entire corpus cannot
|
|
476
|
-
* be held at once, so chrome discovery runs per block. A landmark
|
|
477
|
-
* signature that is rare corpus-wide but common within one block will
|
|
478
|
-
* be treated as global chrome in streaming mode, whereas the in-memory
|
|
479
|
-
* mode would treat it as local. This trade-off is why the threshold
|
|
480
|
-
* above is set generously — every real corpus historically validated
|
|
481
|
-
* here stays on the in-memory path.
|
|
482
|
-
* @param pages
|
|
483
|
-
* @param options
|
|
484
|
-
* @example
|
|
485
|
-
* ```ts
|
|
486
|
-
* // JSONL file source — factory can be re-invoked to re-open the file.
|
|
487
|
-
* import { createReadStream } from 'node:fs';
|
|
488
|
-
* import readline from 'node:readline';
|
|
489
|
-
*
|
|
490
|
-
* const keys = await resolvePageClusterKeys(() => {
|
|
491
|
-
* const lines = readline.createInterface({ input: createReadStream('pages.jsonl') });
|
|
492
|
-
* return (async function* () {
|
|
493
|
-
* for await (const line of lines) yield JSON.parse(line);
|
|
494
|
-
* })();
|
|
495
|
-
* });
|
|
496
|
-
* ```
|
|
497
|
-
*/
|
|
498
498
|
export async function resolvePageClusterKeys(pages, options) {
|
|
499
499
|
const onProgress = options?.onProgress;
|
|
500
500
|
// Pass 0: HTML-free — collect blocking signals (paths, stylesheetHrefs,
|
|
@@ -530,13 +530,22 @@ export async function resolvePageClusterKeys(pages, options) {
|
|
|
530
530
|
// into per-block progress, so delegate to the untouched sync path —
|
|
531
531
|
// keeping behavior byte-for-byte identical (and yield-overhead-free)
|
|
532
532
|
// to how library-only consumers experienced this before the CLI
|
|
533
|
-
// progress work landed.
|
|
534
|
-
|
|
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
|
|
536
|
+
// progress-emitting path for a reporting feature that has nothing to
|
|
537
|
+
// do with progress observability isn't worth the added surface.
|
|
538
|
+
if (onProgress === undefined || options?.includeLandmarkPositions) {
|
|
535
539
|
return resolvePageClusterKeysInMemory(fullPages, options);
|
|
536
540
|
}
|
|
537
541
|
return resolveSmallCorpusWithProgress(fullPages, onProgress, options);
|
|
538
542
|
}
|
|
539
|
-
// Large corpus: streaming path.
|
|
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
|
+
}
|
|
540
549
|
const excludeLandmarks = options?.excludeLandmarks ?? true;
|
|
541
550
|
const similarityThreshold = options?.similarityThreshold ?? 0.8;
|
|
542
551
|
if (!(similarityThreshold >= 0 && similarityThreshold <= 1)) {
|
|
@@ -728,22 +737,6 @@ export async function resolvePageClusterKeys(pages, options) {
|
|
|
728
737
|
}
|
|
729
738
|
return finalKeys;
|
|
730
739
|
}
|
|
731
|
-
/**
|
|
732
|
-
* Convenience wrapper that runs {@link ./resolve-page-cluster-keys.js | resolvePageClusterKeys}
|
|
733
|
-
* on a materialized array. Preserves the pre-refactor sync API for callers
|
|
734
|
-
* that already have all pages in memory, while flowing through the same
|
|
735
|
-
* async driver so behavior stays consistent across the two entry points.
|
|
736
|
-
* @param pages
|
|
737
|
-
* @param options
|
|
738
|
-
* @example
|
|
739
|
-
* ```ts
|
|
740
|
-
* const keys = await resolvePageClusterKeysFromArray([
|
|
741
|
-
* { paths: ['news', '1'], stylesheetHrefs: [], html: '<body><article>one</article></body>' },
|
|
742
|
-
* { paths: ['news', '2'], stylesheetHrefs: [], html: '<body><article>two</article></body>' },
|
|
743
|
-
* { paths: ['about'], stylesheetHrefs: [], html: '<body><section>about</section></body>' },
|
|
744
|
-
* ]);
|
|
745
|
-
* ```
|
|
746
|
-
*/
|
|
747
740
|
export function resolvePageClusterKeysFromArray(pages, options) {
|
|
748
741
|
return resolvePageClusterKeys(() => pages, options);
|
|
749
742
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js';
|
|
2
|
+
/**
|
|
3
|
+
* Discovers a unit's shell tokens by auto-cutting the per-*token* page-
|
|
4
|
+
* frequency histogram of every landmark instance's tokens. This is the same
|
|
5
|
+
* max-gap primitive used for Stage A merge-height cutoffs, applied
|
|
6
|
+
* recursively at the landmark-token layer.
|
|
7
|
+
*
|
|
8
|
+
* ## Why per-token and not per-signature
|
|
9
|
+
*
|
|
10
|
+
* An earlier iteration ran the histogram at the level of full landmark-
|
|
11
|
+
* instance signatures (canonicalized token sets). That failed on a real,
|
|
12
|
+
* common pattern: a shared site chrome whose markup carries a per-page
|
|
13
|
+
* distinguishing element (a breadcrumb, a page-title element with a page-
|
|
14
|
+
* specific class, a "current" state). All pages have most of the same
|
|
15
|
+
* tokens, but every page's full signature is distinct because tokens embed
|
|
16
|
+
* class names. Per-signature counting saw 5 signatures at freq 0.2 each,
|
|
17
|
+
* autoCutThreshold on the flat distribution returned the clamp, and the
|
|
18
|
+
* shell collapsed to empty even though every page shared the core header
|
|
19
|
+
* skeleton. Per-token counting handles the same case correctly — the shared
|
|
20
|
+
* skeleton tokens each hit freq 1.0.
|
|
21
|
+
*
|
|
22
|
+
* ## The histogram
|
|
23
|
+
*
|
|
24
|
+
* For every member page, all its landmark instances are tokenized and
|
|
25
|
+
* unioned into a single per-page token set (order-agnostic, deduped: a
|
|
26
|
+
* token appearing in two of the page's landmarks still counts once for
|
|
27
|
+
* that page). The corpus histogram is then "how many pages contain each
|
|
28
|
+
* token". Tokens that appear on nearly every page are the unit's chrome;
|
|
29
|
+
* tokens that appear on only a handful are page-specific content that
|
|
30
|
+
* happens to be tagged as a landmark.
|
|
31
|
+
*
|
|
32
|
+
* ## Why auto-cut instead of a hard-coded quorum
|
|
33
|
+
*
|
|
34
|
+
* A fixed 80% quorum (this file's earlier implementation) baked one
|
|
35
|
+
* threshold in for every unit. Real corpora don't obey a universal cutoff:
|
|
36
|
+
* a section-local landmark token that appears on 60% of a unit's pages is
|
|
37
|
+
* the section's chrome under any reasonable reading, but 80% quorum
|
|
38
|
+
* discards it. Auto-cut looks at the *shape* of the frequency distribution
|
|
39
|
+
* and picks the widest gap between adjacent frequencies — if the
|
|
40
|
+
* distribution is `{1.00, 1.00, 0.65, 0.03, 0.02}`, the gap between 0.65
|
|
41
|
+
* and 0.03 (0.62) dwarfs everything else and the cut lands mid-gap around
|
|
42
|
+
* 0.34, correctly grouping the 0.65 tokens with the site-wide 1.00 ones as
|
|
43
|
+
* "chrome for this unit". If instead the distribution is flat, the clamp
|
|
44
|
+
* to {@link SHELL_QUORUM_FALLBACK_FRACTION} keeps the threshold from being
|
|
45
|
+
* tighter than the fallback default.
|
|
46
|
+
*
|
|
47
|
+
* ## Fallbacks
|
|
48
|
+
*
|
|
49
|
+
* A single distinct token (`heights.length < 2`) or a perfectly flat
|
|
50
|
+
* distribution (`maxGap === 0`) returns the
|
|
51
|
+
* {@link SHELL_QUORUM_FALLBACK_FRACTION} clamp verbatim — exactly the same
|
|
52
|
+
* 80%-quorum behavior as before. So degenerate cases degrade to the old
|
|
53
|
+
* contract; only richer distributions get the auto-cut benefit.
|
|
54
|
+
*
|
|
55
|
+
* A page with no landmarks contributes an empty set, deliberately, so the
|
|
56
|
+
* shell-corroboration jaccard between two landmark-less pages is 0 rather
|
|
57
|
+
* than 1 (which it would be if we handed back a `<body></body>`-derived
|
|
58
|
+
* `{body}` fallback set to both sides).
|
|
59
|
+
*
|
|
60
|
+
* ## Reuse
|
|
61
|
+
*
|
|
62
|
+
* Originally private to {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
|
|
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}).
|
|
68
|
+
* @param perPageInstances
|
|
69
|
+
*/
|
|
70
|
+
export declare function shellQuorum(perPageInstances: readonly (readonly PerPageLandmarkInstance[])[]): ReadonlySet<string>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { autoCutThreshold } from './auto-cut-threshold.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fallback clamp for {@link ./auto-cut-threshold.js | autoCutThreshold} when
|
|
4
|
+
* run on the per-landmark-instance token-frequency distribution in
|
|
5
|
+
* {@link ./shell-quorum.js | shellQuorum}. Independently tunable from
|
|
6
|
+
* `merge-cross-block-clusters.ts`'s own `QUORUM_FRACTION` (same value today,
|
|
7
|
+
* 0.8, by coincidence of both having been validated against the same real
|
|
8
|
+
* crawl corpora — not because the two are meant to move together).
|
|
9
|
+
*/
|
|
10
|
+
const SHELL_QUORUM_FALLBACK_FRACTION = 0.8;
|
|
11
|
+
/**
|
|
12
|
+
* Discovers a unit's shell tokens by auto-cutting the per-*token* page-
|
|
13
|
+
* frequency histogram of every landmark instance's tokens. This is the same
|
|
14
|
+
* max-gap primitive used for Stage A merge-height cutoffs, applied
|
|
15
|
+
* recursively at the landmark-token layer.
|
|
16
|
+
*
|
|
17
|
+
* ## Why per-token and not per-signature
|
|
18
|
+
*
|
|
19
|
+
* An earlier iteration ran the histogram at the level of full landmark-
|
|
20
|
+
* instance signatures (canonicalized token sets). That failed on a real,
|
|
21
|
+
* common pattern: a shared site chrome whose markup carries a per-page
|
|
22
|
+
* distinguishing element (a breadcrumb, a page-title element with a page-
|
|
23
|
+
* specific class, a "current" state). All pages have most of the same
|
|
24
|
+
* tokens, but every page's full signature is distinct because tokens embed
|
|
25
|
+
* class names. Per-signature counting saw 5 signatures at freq 0.2 each,
|
|
26
|
+
* autoCutThreshold on the flat distribution returned the clamp, and the
|
|
27
|
+
* shell collapsed to empty even though every page shared the core header
|
|
28
|
+
* skeleton. Per-token counting handles the same case correctly — the shared
|
|
29
|
+
* skeleton tokens each hit freq 1.0.
|
|
30
|
+
*
|
|
31
|
+
* ## The histogram
|
|
32
|
+
*
|
|
33
|
+
* For every member page, all its landmark instances are tokenized and
|
|
34
|
+
* unioned into a single per-page token set (order-agnostic, deduped: a
|
|
35
|
+
* token appearing in two of the page's landmarks still counts once for
|
|
36
|
+
* that page). The corpus histogram is then "how many pages contain each
|
|
37
|
+
* token". Tokens that appear on nearly every page are the unit's chrome;
|
|
38
|
+
* tokens that appear on only a handful are page-specific content that
|
|
39
|
+
* happens to be tagged as a landmark.
|
|
40
|
+
*
|
|
41
|
+
* ## Why auto-cut instead of a hard-coded quorum
|
|
42
|
+
*
|
|
43
|
+
* A fixed 80% quorum (this file's earlier implementation) baked one
|
|
44
|
+
* threshold in for every unit. Real corpora don't obey a universal cutoff:
|
|
45
|
+
* a section-local landmark token that appears on 60% of a unit's pages is
|
|
46
|
+
* the section's chrome under any reasonable reading, but 80% quorum
|
|
47
|
+
* discards it. Auto-cut looks at the *shape* of the frequency distribution
|
|
48
|
+
* and picks the widest gap between adjacent frequencies — if the
|
|
49
|
+
* distribution is `{1.00, 1.00, 0.65, 0.03, 0.02}`, the gap between 0.65
|
|
50
|
+
* and 0.03 (0.62) dwarfs everything else and the cut lands mid-gap around
|
|
51
|
+
* 0.34, correctly grouping the 0.65 tokens with the site-wide 1.00 ones as
|
|
52
|
+
* "chrome for this unit". If instead the distribution is flat, the clamp
|
|
53
|
+
* to {@link SHELL_QUORUM_FALLBACK_FRACTION} keeps the threshold from being
|
|
54
|
+
* tighter than the fallback default.
|
|
55
|
+
*
|
|
56
|
+
* ## Fallbacks
|
|
57
|
+
*
|
|
58
|
+
* A single distinct token (`heights.length < 2`) or a perfectly flat
|
|
59
|
+
* distribution (`maxGap === 0`) returns the
|
|
60
|
+
* {@link SHELL_QUORUM_FALLBACK_FRACTION} clamp verbatim — exactly the same
|
|
61
|
+
* 80%-quorum behavior as before. So degenerate cases degrade to the old
|
|
62
|
+
* contract; only richer distributions get the auto-cut benefit.
|
|
63
|
+
*
|
|
64
|
+
* A page with no landmarks contributes an empty set, deliberately, so the
|
|
65
|
+
* shell-corroboration jaccard between two landmark-less pages is 0 rather
|
|
66
|
+
* than 1 (which it would be if we handed back a `<body></body>`-derived
|
|
67
|
+
* `{body}` fallback set to both sides).
|
|
68
|
+
*
|
|
69
|
+
* ## Reuse
|
|
70
|
+
*
|
|
71
|
+
* Originally private to {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters}'s
|
|
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}).
|
|
77
|
+
* @param perPageInstances
|
|
78
|
+
*/
|
|
79
|
+
export function shellQuorum(perPageInstances) {
|
|
80
|
+
const pageCount = perPageInstances.length;
|
|
81
|
+
if (pageCount === 0)
|
|
82
|
+
return new Set();
|
|
83
|
+
// Union all instance token sets per page (dedupe within page: a token
|
|
84
|
+
// present on both header and footer of the same page still counts once
|
|
85
|
+
// for that page's contribution).
|
|
86
|
+
const tokenPageCount = new Map();
|
|
87
|
+
for (const instances of perPageInstances) {
|
|
88
|
+
const perPageUnion = new Set();
|
|
89
|
+
for (const inst of instances) {
|
|
90
|
+
for (const token of inst.tokens)
|
|
91
|
+
perPageUnion.add(token);
|
|
92
|
+
}
|
|
93
|
+
for (const token of perPageUnion) {
|
|
94
|
+
tokenPageCount.set(token, (tokenPageCount.get(token) ?? 0) + 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (tokenPageCount.size === 0)
|
|
98
|
+
return new Set();
|
|
99
|
+
const frequencies = [];
|
|
100
|
+
for (const count of tokenPageCount.values()) {
|
|
101
|
+
frequencies.push(count / pageCount);
|
|
102
|
+
}
|
|
103
|
+
const cut = autoCutThreshold(frequencies, SHELL_QUORUM_FALLBACK_FRACTION);
|
|
104
|
+
const shell = new Set();
|
|
105
|
+
for (const [token, count] of tokenPageCount) {
|
|
106
|
+
if (count / pageCount >= cut)
|
|
107
|
+
shell.add(token);
|
|
108
|
+
}
|
|
109
|
+
return shell;
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d-zero/page-cluster",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
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": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"clean": "tsc --build --clean"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@d-zero/dealer": "1.10.
|
|
39
|
+
"@d-zero/dealer": "1.10.1",
|
|
40
40
|
"@d-zero/shared": "0.22.2",
|
|
41
41
|
"htmlparser2": "12.0.0"
|
|
42
42
|
},
|
|
@@ -45,5 +45,5 @@
|
|
|
45
45
|
"url": "https://github.com/d-zero-dev/tools.git",
|
|
46
46
|
"directory": "packages/@d-zero/page-cluster"
|
|
47
47
|
},
|
|
48
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "2df2273542928b0bd3e4507c161f80a4fffca9e0"
|
|
49
49
|
}
|