@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.
@@ -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
- const localLandmarkTokensByPage = computeLocalLandmarkTokens(landmarks, options);
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
- return finalKeys;
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
- if (onProgress === undefined) {
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.3.0",
4
- "description": "Tokenizes an HTML document's body into a structural signature for duplicate/near-duplicate page detection at crawl scale",
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.0",
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": "5429d79837cca262ae5b74b32922eae6f7ef2649"
48
+ "gitHead": "2df2273542928b0bd3e4507c161f80a4fffca9e0"
49
49
  }