@d-zero/page-cluster 0.2.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.
Files changed (80) hide show
  1. package/README.md +68 -0
  2. package/dist/array-edit-distance.d.ts +20 -0
  3. package/dist/array-edit-distance.js +52 -0
  4. package/dist/build-segment.d.ts +18 -0
  5. package/dist/build-segment.js +27 -0
  6. package/dist/cap-content-depth.d.ts +69 -0
  7. package/dist/cap-content-depth.js +161 -0
  8. package/dist/compute-document-frequency.d.ts +33 -0
  9. package/dist/compute-document-frequency.js +40 -0
  10. package/dist/create-frame.d.ts +16 -0
  11. package/dist/create-frame.js +29 -0
  12. package/dist/derive-path-group-key.d.ts +44 -0
  13. package/dist/derive-path-group-key.js +51 -0
  14. package/dist/derive-stylesheet-group-key.d.ts +36 -0
  15. package/dist/derive-stylesheet-group-key.js +41 -0
  16. package/dist/detect-content-depth-cap.d.ts +114 -0
  17. package/dist/detect-content-depth-cap.js +137 -0
  18. package/dist/escape-reg-exp.d.ts +11 -0
  19. package/dist/escape-reg-exp.js +13 -0
  20. package/dist/excise.d.ts +13 -0
  21. package/dist/excise.js +24 -0
  22. package/dist/extract-landmarks.d.ts +82 -0
  23. package/dist/extract-landmarks.js +104 -0
  24. package/dist/filter-first-party-stylesheet-hrefs.d.ts +73 -0
  25. package/dist/filter-first-party-stylesheet-hrefs.js +118 -0
  26. package/dist/find-shallowest-elements.d.ts +39 -0
  27. package/dist/find-shallowest-elements.js +121 -0
  28. package/dist/foldable-tags.d.ts +8 -0
  29. package/dist/foldable-tags.js +8 -0
  30. package/dist/format-bracket.d.ts +11 -0
  31. package/dist/format-bracket.js +17 -0
  32. package/dist/hash-content.d.ts +22 -0
  33. package/dist/hash-content.js +26 -0
  34. package/dist/html-region-utils.d.ts +74 -0
  35. package/dist/html-region-utils.js +96 -0
  36. package/dist/is-fold-candidate.d.ts +13 -0
  37. package/dist/is-fold-candidate.js +16 -0
  38. package/dist/is-genuine-close.d.ts +23 -0
  39. package/dist/is-genuine-close.js +27 -0
  40. package/dist/is-noise-class.d.ts +6 -0
  41. package/dist/is-noise-class.js +8 -0
  42. package/dist/jaccard-similarity.d.ts +23 -0
  43. package/dist/jaccard-similarity.js +36 -0
  44. package/dist/merge-landmark-affined-clusters.d.ts +179 -0
  45. package/dist/merge-landmark-affined-clusters.js +544 -0
  46. package/dist/merge-spans.d.ts +15 -0
  47. package/dist/merge-spans.js +22 -0
  48. package/dist/noise-class-patterns.d.ts +21 -0
  49. package/dist/noise-class-patterns.js +74 -0
  50. package/dist/normalize-for-hash.d.ts +10 -0
  51. package/dist/normalize-for-hash.js +12 -0
  52. package/dist/opaque-tags.d.ts +17 -0
  53. package/dist/opaque-tags.js +18 -0
  54. package/dist/parse-class-list.d.ts +10 -0
  55. package/dist/parse-class-list.js +23 -0
  56. package/dist/reassign-orphan-block-keys.d.ts +81 -0
  57. package/dist/reassign-orphan-block-keys.js +159 -0
  58. package/dist/remove-content-blocks.d.ts +67 -0
  59. package/dist/remove-content-blocks.js +150 -0
  60. package/dist/resolve-blocking-group-keys.d.ts +116 -0
  61. package/dist/resolve-blocking-group-keys.js +120 -0
  62. package/dist/resolve-closed-frame.d.ts +26 -0
  63. package/dist/resolve-closed-frame.js +33 -0
  64. package/dist/resolve-landmark-variant-keys.d.ts +66 -0
  65. package/dist/resolve-landmark-variant-keys.js +71 -0
  66. package/dist/resolve-options.d.ts +6 -0
  67. package/dist/resolve-options.js +10 -0
  68. package/dist/resolve-page-cluster-keys.d.ts +222 -0
  69. package/dist/resolve-page-cluster-keys.js +198 -0
  70. package/dist/resolve-structural-cluster-keys.d.ts +50 -0
  71. package/dist/resolve-structural-cluster-keys.js +287 -0
  72. package/dist/run-tokenizer.d.ts +33 -0
  73. package/dist/run-tokenizer.js +152 -0
  74. package/dist/split-tokens-by-frequency.d.ts +46 -0
  75. package/dist/split-tokens-by-frequency.js +88 -0
  76. package/dist/tokenize.d.ts +58 -0
  77. package/dist/tokenize.js +60 -0
  78. package/dist/types.d.ts +85 -0
  79. package/dist/types.js +1 -0
  80. package/package.json +102 -0
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # `@d-zero/page-cluster`
2
+
3
+ 大量クローリングした HTML の重複・類似ページ検出のためのパッケージ。`tokenize()` は `<body>` 配下のHTMLを、テキストを除去した構造トークンに変換する。用途・設計判断のWHYは `src/tokenize.ts` の JSDoc を参照。
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ yarn add @d-zero/page-cluster
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { tokenize } from '@d-zero/page-cluster';
15
+
16
+ const { tokens, bodyClassList } = tokenize(
17
+ '<body class="law-page"><div class="card"><ul><li>A</li><li>B</li></ul></div></body>',
18
+ );
19
+ // tokens: ["body>.card>ul>li", "body>.card>ul>li"]
20
+ // bodyClassList: ["law-page"]
21
+ ```
22
+
23
+ ### オプション
24
+
25
+ ```ts
26
+ tokenize(html, {
27
+ filterNoiseClasses: true, // 既定値。ハッシュ的自動生成class名を除外する
28
+ includeComments: false, // 既定値。コメントノードをトークン化しない
29
+ });
30
+ ```
31
+
32
+ ### クラスタリング
33
+
34
+ クロールしたページ群から最終的なクラスタキーを得るには `resolvePageClusterKeys()` を使う。ブロッキング(URLパス/スタイルシートによる粗い絞り込み)と構造クラスタリング(ブロック内でのcomplete-linkage階層的クラスタリング)を内部で連結し、ブロックを跨いで一意なキーを返す。既定で各ページの`<header>`/`<footer>`/`<nav>`/`<aside>`(タグ名またはARIAランドマークロール)を比較対象から除外し、共通chromeの影響を受けにくくする。また、スタイルシート参照が記録されていない「孤児」ページを、同一URLセクションに閉じたスタイルシート・ブロックへ再割当する処理(`reassignOrphanBlockKeys()`)や、埋め込みコンテンツが引き込むサードパーティCSS参照をブロッキング判定から除外する処理(`filterFirstPartyStylesheetHrefs()`)も既定で有効。挙動の詳細・トレードオフはそれぞれのJSDocを参照。
35
+
36
+ 自由編集ブロックエディタ(CMSが各コンテンツブロックに固有のdata属性を付与するタイプ)を使うサイトでは、`contentBlockAttribute` オプションでその属性名を指定すると、ページごとに異なるブロック構成が構造比較のノイズになるのを防げる(既定は未指定=無効、サイトごとの属性名を推測できないため)。詳細は `removeContentBlocks()` のJSDocを参照。
37
+
38
+ CMSのブロック属性名が分からない・サイトごとに違う場合は `autoCapMainDepth: true` を使う。`<main>`/`role="main"`という標準タグを起点に、構造クラスタ数が急増する直前の深さをブロックごとに実データから自動検出して打ち切るため、サイト固有の設定が一切不要(既定はfalse。実データ検証では`contentBlockAttribute`より良い結果になる場合もあった一方、計算コストが実測で数倍〜1桁台後半になる。倍率はコーパスのブロック構成に依存する)。詳細は `detectContentDepthCap()` のJSDocを参照。
39
+
40
+ header/footer/nav/asideが一致するページ同士をさらに合流させたい場合は `mergeRareLandmarkClusters: true` を使う。ただし単純な一致判定は実データで過剰融合を招くことが分かっているため(header/footer/navは99%以上のページに存在し判別力を持たない)、コーパス全体で希少なランドマークバリアントが一致した場合に限り、より緩いコンテンツ類似度閾値(`landmarkGateSimilarityThreshold`)での合流を許可する(既定はfalse。実データでの検証は未実施で、合成フィクスチャでの単体・回帰テストのみ)。詳細・コスト特性は `mergeLandmarkAffinedClusters()` のJSDocを参照。
41
+
42
+ ```ts
43
+ import { resolvePageClusterKeys } from '@d-zero/page-cluster/resolve-page-cluster-keys';
44
+
45
+ const keys = resolvePageClusterKeys(
46
+ pages.map((page) => ({
47
+ paths: page.urlPathSegments,
48
+ stylesheetHrefs: page.stylesheetHrefs,
49
+ html: page.html,
50
+ })),
51
+ { contentBlockAttribute: 'data-bgb' }, // 使っているCMSのブロック属性名に合わせて指定
52
+ );
53
+ // pagesと同じ順序・同じ長さ。同じキーのページが同一テンプレートと判定されたページ群
54
+ ```
55
+
56
+ ### ランドマークバリアント分類
57
+
58
+ 「同一テンプレートか」ではなく「このページはどのヘッダー/フッター/ナビ/サイドナビを持っているか」というメタプロパティを個別に知りたい場合は `resolveLandmarkVariantKeys()` を使う。`resolvePageClusterKeys()` とは独立した戻り値で、両者の合成は呼び出し側の責務。
59
+
60
+ ```ts
61
+ import { resolveLandmarkVariantKeys } from '@d-zero/page-cluster/resolve-landmark-variant-keys';
62
+
63
+ const headerVariantKeys = resolveLandmarkVariantKeys(
64
+ pages.map((page) => page.html),
65
+ 'header',
66
+ );
67
+ // pagesと同じ順序・同じ長さ。同じキーのページが同じヘッダーデザインを持つページ群
68
+ ```
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Element-wise Levenshtein distance between two `tokenize()` outputs, for
3
+ * the small set of comparisons that need order/nesting to matter (refining
4
+ * a merge distance between near-duplicate candidates, spot-checking cluster
5
+ * quality) rather than the set-based similarity used for bulk narrowing.
6
+ * Operates on whole array elements, not characters, so a single differing
7
+ * path costs 1 edit regardless of its string length. This is the same
8
+ * O(n*m) dynamic-programming shape as tree edit distance, but requires no
9
+ * tree: run directly on the flat leaf-path arrays `tokenize()` already
10
+ * produces, which is why it stays viable at the scale this comparison is
11
+ * meant for (small numbers of already-narrowed candidates, not all-pairs).
12
+ * @param a
13
+ * @param b
14
+ * @example
15
+ * ```ts
16
+ * arrayEditDistance(['body>ul>li', 'body>ul>li'], ['body>ul>li']);
17
+ * // 1
18
+ * ```
19
+ */
20
+ export declare function arrayEditDistance(a: readonly string[], b: readonly string[]): number;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Reads `row[index]`, throwing instead of returning `undefined`. The DP loop
3
+ * below only ever indexes within bounds it just built, so the thrown branch
4
+ * is unreachable in practice; it exists to satisfy `noUncheckedIndexedAccess`
5
+ * without a non-null assertion. Named `readDpValue` rather than `at` to avoid
6
+ * reading as (and being confused for) `Array.prototype.at`, whose negative-
7
+ * index-from-end semantics this helper does not share.
8
+ * @param row
9
+ * @param index
10
+ */
11
+ function readDpValue(row, index) {
12
+ const value = row[index];
13
+ if (value === undefined) {
14
+ throw new Error('arrayEditDistance: DP row index out of bounds');
15
+ }
16
+ return value;
17
+ }
18
+ /**
19
+ * Element-wise Levenshtein distance between two `tokenize()` outputs, for
20
+ * the small set of comparisons that need order/nesting to matter (refining
21
+ * a merge distance between near-duplicate candidates, spot-checking cluster
22
+ * quality) rather than the set-based similarity used for bulk narrowing.
23
+ * Operates on whole array elements, not characters, so a single differing
24
+ * path costs 1 edit regardless of its string length. This is the same
25
+ * O(n*m) dynamic-programming shape as tree edit distance, but requires no
26
+ * tree: run directly on the flat leaf-path arrays `tokenize()` already
27
+ * produces, which is why it stays viable at the scale this comparison is
28
+ * meant for (small numbers of already-narrowed candidates, not all-pairs).
29
+ * @param a
30
+ * @param b
31
+ * @example
32
+ * ```ts
33
+ * arrayEditDistance(['body>ul>li', 'body>ul>li'], ['body>ul>li']);
34
+ * // 1
35
+ * ```
36
+ */
37
+ export function arrayEditDistance(a, b) {
38
+ const rowCount = a.length;
39
+ const colCount = b.length;
40
+ let previousRow = Array.from({ length: colCount + 1 }, (_, index) => index);
41
+ for (let row = 1; row <= rowCount; row++) {
42
+ const currentRow = [row];
43
+ for (let col = 1; col <= colCount; col++) {
44
+ currentRow.push(a[row - 1] === b[col - 1]
45
+ ? readDpValue(previousRow, col - 1)
46
+ : 1 +
47
+ Math.min(readDpValue(previousRow, col), readDpValue(currentRow, col - 1), readDpValue(previousRow, col - 1)));
48
+ }
49
+ previousRow = currentRow;
50
+ }
51
+ return readDpValue(previousRow, colCount);
52
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Builds the path segment string for one element.
3
+ *
4
+ * `div`/`span` drop their tag name in favor of `.class` when they carry a
5
+ * class list, since the class (not the generic wrapper tag) is what carries
6
+ * meaning for those two tags. Every other tag always keeps its name because
7
+ * the tag itself is semantically meaningful (e.g. `ul`, `table`, `button`).
8
+ *
9
+ * `role`/`type` are appended as a bracket suffix rather than folded into the
10
+ * class list: unlike `class`, they are single-valued attributes with their
11
+ * own semantics (ARIA role, form control kind), so keeping them visually
12
+ * distinct avoids collisions with an actual class named e.g. `button`.
13
+ * @param tagName
14
+ * @param classList
15
+ * @param role
16
+ * @param type
17
+ */
18
+ export declare function buildSegment(tagName: string, classList: readonly string[], role?: string, type?: string): string;
@@ -0,0 +1,27 @@
1
+ import { FOLDABLE_TAGS } from './foldable-tags.js';
2
+ import { formatBracket } from './format-bracket.js';
3
+ /**
4
+ * Builds the path segment string for one element.
5
+ *
6
+ * `div`/`span` drop their tag name in favor of `.class` when they carry a
7
+ * class list, since the class (not the generic wrapper tag) is what carries
8
+ * meaning for those two tags. Every other tag always keeps its name because
9
+ * the tag itself is semantically meaningful (e.g. `ul`, `table`, `button`).
10
+ *
11
+ * `role`/`type` are appended as a bracket suffix rather than folded into the
12
+ * class list: unlike `class`, they are single-valued attributes with their
13
+ * own semantics (ARIA role, form control kind), so keeping them visually
14
+ * distinct avoids collisions with an actual class named e.g. `button`.
15
+ * @param tagName
16
+ * @param classList
17
+ * @param role
18
+ * @param type
19
+ */
20
+ export function buildSegment(tagName, classList, role, type) {
21
+ const base = classList.length > 0
22
+ ? FOLDABLE_TAGS.has(tagName)
23
+ ? `.${classList.join('.')}`
24
+ : `${tagName}.${classList.join('.')}`
25
+ : tagName;
26
+ return `${base}${formatBracket({ role, type })}`;
27
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The only landmark this function knows how to depth-cap. A closed union
3
+ * (not an open `string`) because, unlike
4
+ * {@link ./remove-content-blocks.js | removeContentBlocks}'s caller-supplied
5
+ * CMS attribute, `<main>`/`role="main"` is an HTML5/ARIA standard — there is
6
+ * exactly one vocabulary to support, not one per site.
7
+ */
8
+ export type ContentDepthLandmark = 'main';
9
+ /**
10
+ * @see capContentDepth
11
+ */
12
+ export type CapContentDepthOptions = {
13
+ landmark: ContentDepthLandmark;
14
+ /**
15
+ * How many levels of elements *inside* the landmark to keep, counting the
16
+ * landmark's own direct children as depth 1. Must be a non-negative
17
+ * integer (0 keeps none of the landmark's content, only its own opening/
18
+ * closing tags). See {@link ./cap-content-depth.js | capContentDepth}'s
19
+ * JSDoc for how to choose this.
20
+ */
21
+ maxDepth: number;
22
+ };
23
+ /**
24
+ * Result of {@link ./cap-content-depth.js | capContentDepth}.
25
+ */
26
+ export type CapContentDepthResult = {
27
+ remainderHtml: string;
28
+ };
29
+ /**
30
+ * Excises the deepest content inside `options.landmark` (currently only
31
+ * `'main'`/`role="main"` is supported — see `ContentDepthLandmark`'s JSDoc),
32
+ * keeping up to `options.maxDepth` levels of nesting and returning what's
33
+ * left.
34
+ *
35
+ * Built for the same real-crawl finding {@link ./remove-content-blocks.js |
36
+ * removeContentBlocks} addresses — freeform CMS-block content dominating a
37
+ * page's token set and defeating structural similarity — but without
38
+ * needing the caller to know their CMS's own block-marker attribute.
39
+ * `<main>` is HTML5-standard, so this works without any per-site
40
+ * configuration at all. Confirmed on two unrelated real crawls (302 and
41
+ * ~4,100 pages): once nesting depth inside `<main>` passes a threshold
42
+ * (3, on both), the number of distinct structural clusters explodes (14x
43
+ * and 9x, respectively) — the "skeleton" (which template a page uses) lives
44
+ * in the shallow levels; the free-edited content that varies page-to-page
45
+ * lives deeper. See {@link ./detect-content-depth-cap.js |
46
+ * detectContentDepthCap} to find that threshold automatically instead of
47
+ * hardcoding `maxDepth`.
48
+ *
49
+ * `<main>`'s own opening/closing tags (and their attributes, e.g. a class
50
+ * that itself differs between a list-page and a detail-page `<main>`) are
51
+ * always kept — only what's *between* them past `maxDepth` is excised, for
52
+ * the same reason `extractLandmarks` never adds a placeholder: the tag
53
+ * itself is real structural signal, not something to erase.
54
+ *
55
+ * A page with no `<main>` and no `role="main"` element has nothing to cap;
56
+ * `remainderHtml` is returned unchanged, matching this package's convention
57
+ * for "nothing to do" (see `extractLandmarks`, `removeContentBlocks`).
58
+ * @param html
59
+ * @param options
60
+ * @example
61
+ * ```ts
62
+ * capContentDepth(
63
+ * '<body><main><div><div><div><div>too deep</div></div></div></div></main></body>',
64
+ * { landmark: 'main', maxDepth: 2 },
65
+ * );
66
+ * // { remainderHtml: '<body><main><div><div></div></div></main></body>' }
67
+ * ```
68
+ */
69
+ export declare function capContentDepth(html: string, options: CapContentDepthOptions): CapContentDepthResult;
@@ -0,0 +1,161 @@
1
+ import { Parser } from 'htmlparser2';
2
+ import { excise } from './excise.js';
3
+ import { findShallowestElements } from './find-shallowest-elements.js';
4
+ import { isGenuineClose } from './is-genuine-close.js';
5
+ import { isOpaqueTagName } from './opaque-tags.js';
6
+ const TAG_TO_LANDMARK = { main: 'main' };
7
+ const ROLE_TO_LANDMARK = { main: 'main' };
8
+ /**
9
+ * Finds the single shallowest `<main>`/`role="main"` element in `html` via
10
+ * {@link ./find-shallowest-elements.js | findShallowestElements} (same
11
+ * "shallowest wins" rule as {@link ./extract-landmarks.js | extractLandmarks}
12
+ * uses for its own four landmark types, for the same reason: the site-wide,
13
+ * outermost instance is the real one), and returns the offsets of its
14
+ * content (excluding its own opening/closing tags) — or `undefined` if there
15
+ * is no genuine one.
16
+ * @param html
17
+ * @param landmark
18
+ */
19
+ function findShallowestLandmarkContent(html, landmark) {
20
+ const [winner] = findShallowestElements(html, (name, role) => TAG_TO_LANDMARK[name] === landmark ||
21
+ (role !== undefined && ROLE_TO_LANDMARK[role] === landmark)
22
+ ? [landmark]
23
+ : []);
24
+ return winner
25
+ ? { contentStart: winner.contentStart, contentEnd: winner.contentEnd }
26
+ : undefined;
27
+ }
28
+ /**
29
+ * Within `html.slice(contentStart, contentEnd)`, finds every element whose
30
+ * nesting depth (the landmark's own direct children are depth 1) exceeds
31
+ * `maxDepth`, and returns their `[start, end)` spans (absolute offsets into
32
+ * the original `html`) for excision. Once a too-deep element is found, its
33
+ * subtree is not explored further — same reasoning as
34
+ * {@link ./remove-content-blocks.js | removeContentBlocks} not diving into
35
+ * an already-matched block: nothing inside a span already marked for
36
+ * removal needs its own depth checked.
37
+ * @param html
38
+ * @param contentStart
39
+ * @param contentEnd
40
+ * @param maxDepth
41
+ */
42
+ function collectDeepSpans(html, contentStart, contentEnd, maxDepth) {
43
+ const spans = [];
44
+ const stack = [];
45
+ let opaque = null;
46
+ // Set once a too-deep element opens; cleared when that same element
47
+ // closes. While set, every nested open/close (other than matching
48
+ // closes of the capped tag itself) is ignored, same shape as the
49
+ // `opaque` tracking above.
50
+ let cappedAt = null;
51
+ const parser = new Parser({
52
+ onopentag(name) {
53
+ if (opaque) {
54
+ if (name === opaque.tagName)
55
+ opaque.depth++;
56
+ return;
57
+ }
58
+ if (cappedAt) {
59
+ if (name === cappedAt.tagName)
60
+ cappedAt.depth++;
61
+ return;
62
+ }
63
+ if (isOpaqueTagName(name)) {
64
+ opaque = { tagName: name, depth: 1 };
65
+ return;
66
+ }
67
+ const depth = stack.length + 1;
68
+ if (depth > maxDepth) {
69
+ cappedAt = { tagName: name, depth: 1 };
70
+ spans.push({ start: contentStart + parser.startIndex, end: -1 });
71
+ return;
72
+ }
73
+ stack.push({ tagName: name, startOffset: parser.startIndex });
74
+ },
75
+ onclosetag(name) {
76
+ if (opaque) {
77
+ if (name === opaque.tagName) {
78
+ opaque.depth--;
79
+ if (opaque.depth === 0)
80
+ opaque = null;
81
+ }
82
+ return;
83
+ }
84
+ if (cappedAt) {
85
+ if (name === cappedAt.tagName) {
86
+ cappedAt.depth--;
87
+ if (cappedAt.depth === 0) {
88
+ const endOffset = contentStart + parser.endIndex + 1;
89
+ const open = spans.at(-1);
90
+ if (open) {
91
+ open.end = isGenuineClose(html, endOffset, name) ? endOffset : open.start;
92
+ }
93
+ cappedAt = null;
94
+ }
95
+ }
96
+ return;
97
+ }
98
+ const frame = stack.pop();
99
+ if (!frame)
100
+ return;
101
+ },
102
+ }, { decodeEntities: false });
103
+ parser.end(html.slice(contentStart, contentEnd));
104
+ // A capped span whose genuine close was never confirmed (malformed
105
+ // markup) collapses to a zero-length span at its own start — excise()
106
+ // treats start === end as a no-op slice, so nothing is corrupted, and
107
+ // that one candidate is simply not removed, the same safety trade-off
108
+ // extractLandmarks/removeContentBlocks make for unclosed tags.
109
+ return spans.filter((span) => span.end > span.start);
110
+ }
111
+ /**
112
+ * Excises the deepest content inside `options.landmark` (currently only
113
+ * `'main'`/`role="main"` is supported — see `ContentDepthLandmark`'s JSDoc),
114
+ * keeping up to `options.maxDepth` levels of nesting and returning what's
115
+ * left.
116
+ *
117
+ * Built for the same real-crawl finding {@link ./remove-content-blocks.js |
118
+ * removeContentBlocks} addresses — freeform CMS-block content dominating a
119
+ * page's token set and defeating structural similarity — but without
120
+ * needing the caller to know their CMS's own block-marker attribute.
121
+ * `<main>` is HTML5-standard, so this works without any per-site
122
+ * configuration at all. Confirmed on two unrelated real crawls (302 and
123
+ * ~4,100 pages): once nesting depth inside `<main>` passes a threshold
124
+ * (3, on both), the number of distinct structural clusters explodes (14x
125
+ * and 9x, respectively) — the "skeleton" (which template a page uses) lives
126
+ * in the shallow levels; the free-edited content that varies page-to-page
127
+ * lives deeper. See {@link ./detect-content-depth-cap.js |
128
+ * detectContentDepthCap} to find that threshold automatically instead of
129
+ * hardcoding `maxDepth`.
130
+ *
131
+ * `<main>`'s own opening/closing tags (and their attributes, e.g. a class
132
+ * that itself differs between a list-page and a detail-page `<main>`) are
133
+ * always kept — only what's *between* them past `maxDepth` is excised, for
134
+ * the same reason `extractLandmarks` never adds a placeholder: the tag
135
+ * itself is real structural signal, not something to erase.
136
+ *
137
+ * A page with no `<main>` and no `role="main"` element has nothing to cap;
138
+ * `remainderHtml` is returned unchanged, matching this package's convention
139
+ * for "nothing to do" (see `extractLandmarks`, `removeContentBlocks`).
140
+ * @param html
141
+ * @param options
142
+ * @example
143
+ * ```ts
144
+ * capContentDepth(
145
+ * '<body><main><div><div><div><div>too deep</div></div></div></div></main></body>',
146
+ * { landmark: 'main', maxDepth: 2 },
147
+ * );
148
+ * // { remainderHtml: '<body><main><div><div></div></div></main></body>' }
149
+ * ```
150
+ */
151
+ export function capContentDepth(html, options) {
152
+ if (!(Number.isInteger(options.maxDepth) && options.maxDepth >= 0)) {
153
+ throw new RangeError(`capContentDepth: maxDepth must be a non-negative integer, got ${options.maxDepth}`);
154
+ }
155
+ const content = findShallowestLandmarkContent(html, options.landmark);
156
+ if (!content) {
157
+ return { remainderHtml: html };
158
+ }
159
+ const spans = collectDeepSpans(html, content.contentStart, content.contentEnd, options.maxDepth);
160
+ return { remainderHtml: excise(html, spans) };
161
+ }
@@ -0,0 +1,33 @@
1
+ import type { DocumentFrequency } from './types.js';
2
+ /**
3
+ * Counts, for each token, how many of the given per-page token sets contain
4
+ * it. This is the first half of separating a page's shared site chrome
5
+ * (header/nav/footer) from its page-specific content: a token that recurs
6
+ * across nearly every page in `tokenSets` is chrome, one that shows up on
7
+ * only a handful of pages is content — see `splitTokensByFrequency`, which
8
+ * consumes this result to make that call per token.
9
+ *
10
+ * `tokenSets` must be a *homogeneous* page collection (typically one site,
11
+ * or one section of a large multi-template site), not an arbitrary pool.
12
+ * Real-data validation against a small single-layout corporate site (a few
13
+ * hundred pages) found a clean bimodal frequency split (site chrome tokens
14
+ * showed up on 95%+ of pages, content tokens on well under 50%, with
15
+ * nothing in between). The same computation against the *whole* crawl of a
16
+ * much larger site that turned out to be a federation of independent
17
+ * sub-sections (the largest covering under half of all pages) found no
18
+ * token crossing even a 50% document-frequency threshold: with no single
19
+ * dominant layout, frequency-based chrome detection needs a mostly-
20
+ * homogeneous input to work at all. Splitting such a site into its
21
+ * sections first (by URL path, or by a coarse structural clustering pass)
22
+ * and calling this function per section recovered the same clean bimodal
23
+ * split. Grouping heterogeneous pages before calling this function is the
24
+ * caller's responsibility; this function has no way to detect that its
25
+ * input mixes multiple layouts.
26
+ * @param tokenSets
27
+ * @example
28
+ * ```ts
29
+ * computeDocumentFrequency([new Set(['body>header>a']), new Set(['body>header>a'])]);
30
+ * // { documentFrequency: Map { 'body>header>a' => 2 }, pageCount: 2 }
31
+ * ```
32
+ */
33
+ export declare function computeDocumentFrequency(tokenSets: readonly ReadonlySet<string>[]): DocumentFrequency;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Counts, for each token, how many of the given per-page token sets contain
3
+ * it. This is the first half of separating a page's shared site chrome
4
+ * (header/nav/footer) from its page-specific content: a token that recurs
5
+ * across nearly every page in `tokenSets` is chrome, one that shows up on
6
+ * only a handful of pages is content — see `splitTokensByFrequency`, which
7
+ * consumes this result to make that call per token.
8
+ *
9
+ * `tokenSets` must be a *homogeneous* page collection (typically one site,
10
+ * or one section of a large multi-template site), not an arbitrary pool.
11
+ * Real-data validation against a small single-layout corporate site (a few
12
+ * hundred pages) found a clean bimodal frequency split (site chrome tokens
13
+ * showed up on 95%+ of pages, content tokens on well under 50%, with
14
+ * nothing in between). The same computation against the *whole* crawl of a
15
+ * much larger site that turned out to be a federation of independent
16
+ * sub-sections (the largest covering under half of all pages) found no
17
+ * token crossing even a 50% document-frequency threshold: with no single
18
+ * dominant layout, frequency-based chrome detection needs a mostly-
19
+ * homogeneous input to work at all. Splitting such a site into its
20
+ * sections first (by URL path, or by a coarse structural clustering pass)
21
+ * and calling this function per section recovered the same clean bimodal
22
+ * split. Grouping heterogeneous pages before calling this function is the
23
+ * caller's responsibility; this function has no way to detect that its
24
+ * input mixes multiple layouts.
25
+ * @param tokenSets
26
+ * @example
27
+ * ```ts
28
+ * computeDocumentFrequency([new Set(['body>header>a']), new Set(['body>header>a'])]);
29
+ * // { documentFrequency: Map { 'body>header>a' => 2 }, pageCount: 2 }
30
+ * ```
31
+ */
32
+ export function computeDocumentFrequency(tokenSets) {
33
+ const documentFrequency = new Map();
34
+ for (const tokens of tokenSets) {
35
+ for (const token of tokens) {
36
+ documentFrequency.set(token, (documentFrequency.get(token) ?? 0) + 1);
37
+ }
38
+ }
39
+ return { documentFrequency, pageCount: tokenSets.length };
40
+ }
@@ -0,0 +1,16 @@
1
+ import type { Frame, ResolvedOptions } from './types.js';
2
+ /**
3
+ * Builds the stack frame for a newly-opened element. `id`/`data-*`/every
4
+ * `aria-*` other than `role` are read from `attribs` implicitly by never
5
+ * being looked at: `id`/`data-*` tend to be per-instance/per-page values
6
+ * (breaking similarity detection the same way raw text would), and
7
+ * non-`role` `aria-*` attributes are either free-text (`aria-label`,
8
+ * `aria-describedby` — inconsistent with dropping visible text) or render
9
+ * state (`aria-current`, `aria-expanded`, `aria-selected` — the same
10
+ * per-page-varying-position problem as a `current`/`active` class, see
11
+ * `tokenize.ts`).
12
+ * @param tagName
13
+ * @param attribs
14
+ * @param options
15
+ */
16
+ export declare function createFrame(tagName: string, attribs: Record<string, string>, options: ResolvedOptions): Frame;
@@ -0,0 +1,29 @@
1
+ import { buildSegment } from './build-segment.js';
2
+ import { isFoldCandidate } from './is-fold-candidate.js';
3
+ import { parseClassList } from './parse-class-list.js';
4
+ /**
5
+ * Builds the stack frame for a newly-opened element. `id`/`data-*`/every
6
+ * `aria-*` other than `role` are read from `attribs` implicitly by never
7
+ * being looked at: `id`/`data-*` tend to be per-instance/per-page values
8
+ * (breaking similarity detection the same way raw text would), and
9
+ * non-`role` `aria-*` attributes are either free-text (`aria-label`,
10
+ * `aria-describedby` — inconsistent with dropping visible text) or render
11
+ * state (`aria-current`, `aria-expanded`, `aria-selected` — the same
12
+ * per-page-varying-position problem as a `current`/`active` class, see
13
+ * `tokenize.ts`).
14
+ * @param tagName
15
+ * @param attribs
16
+ * @param options
17
+ */
18
+ export function createFrame(tagName, attribs, options) {
19
+ const classList = parseClassList(attribs.class, options.filterNoiseClasses);
20
+ const role = attribs.role || undefined;
21
+ const type = attribs.type || undefined;
22
+ return {
23
+ tagName,
24
+ segment: buildSegment(tagName, classList, role, type),
25
+ isFoldCandidate: isFoldCandidate(tagName, classList, role, type),
26
+ childElementCount: 0,
27
+ pendingPaths: [],
28
+ };
29
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Derives a coarse grouping key from a page's URL path segments (e.g. the
3
+ * `paths` field of `@d-zero/shared/parse-url`'s `ExURL`), keeping only the
4
+ * leading `depth` segments. This is a *blocking key* in the record-linkage
5
+ * sense: a cheap, coarse partition applied before any expensive structural
6
+ * comparison (`jaccardSimilarity`/`arrayEditDistance` on `tokenize()`
7
+ * output), not a similarity score by itself. Real-data validation on a large
8
+ * multi-section site found that a single site-wide document-frequency
9
+ * computation ({@link ./compute-document-frequency.js | computeDocumentFrequency})
10
+ * fails when the site is actually a federation of independently-templated
11
+ * sub-sections; splitting pages by their top-level URL segment first and
12
+ * computing frequency per group recovered a working split. This function
13
+ * produces that split key.
14
+ *
15
+ * Deliberately returns only this one signal rather than merging it with
16
+ * other blocking signals (e.g. a stylesheet-based key) into a single
17
+ * composite key: literature on entity-resolution blocking (e.g. Michelson &
18
+ * Knoblock's DNF blocking scheme) finds that combining independent blocking
19
+ * predicates with AND into one key is inferior to keeping them independent
20
+ * and combining candidate pairs with OR — that combination decision belongs
21
+ * to the caller that actually groups pages, not to this function.
22
+ *
23
+ * Empty segments anywhere in `paths` are dropped before slicing, so the
24
+ * trailing `''` that `ExURL.paths` produces for a directory-style URL (one
25
+ * ending in `/`) doesn't fragment a section's key from the same section's
26
+ * non-trailing-slash URLs.
27
+ *
28
+ * `depth` must be a positive integer. A non-positive or fractional depth has
29
+ * no sensible interpretation as "how many leading segments to keep", so it
30
+ * is rejected eagerly rather than silently coerced or left to produce a
31
+ * confusing result (e.g. slicing with a negative or fractional length).
32
+ * @param paths
33
+ * @param depth
34
+ * @example
35
+ * ```ts
36
+ * derivePathGroupKey(['dept-a', 'news', '123']);
37
+ * // 'dept-a'
38
+ * derivePathGroupKey(['dept-a', 'news', '123'], 2);
39
+ * // 'dept-a/news'
40
+ * derivePathGroupKey([]);
41
+ * // ''
42
+ * ```
43
+ */
44
+ export declare function derivePathGroupKey(paths: readonly string[], depth?: number): string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Derives a coarse grouping key from a page's URL path segments (e.g. the
3
+ * `paths` field of `@d-zero/shared/parse-url`'s `ExURL`), keeping only the
4
+ * leading `depth` segments. This is a *blocking key* in the record-linkage
5
+ * sense: a cheap, coarse partition applied before any expensive structural
6
+ * comparison (`jaccardSimilarity`/`arrayEditDistance` on `tokenize()`
7
+ * output), not a similarity score by itself. Real-data validation on a large
8
+ * multi-section site found that a single site-wide document-frequency
9
+ * computation ({@link ./compute-document-frequency.js | computeDocumentFrequency})
10
+ * fails when the site is actually a federation of independently-templated
11
+ * sub-sections; splitting pages by their top-level URL segment first and
12
+ * computing frequency per group recovered a working split. This function
13
+ * produces that split key.
14
+ *
15
+ * Deliberately returns only this one signal rather than merging it with
16
+ * other blocking signals (e.g. a stylesheet-based key) into a single
17
+ * composite key: literature on entity-resolution blocking (e.g. Michelson &
18
+ * Knoblock's DNF blocking scheme) finds that combining independent blocking
19
+ * predicates with AND into one key is inferior to keeping them independent
20
+ * and combining candidate pairs with OR — that combination decision belongs
21
+ * to the caller that actually groups pages, not to this function.
22
+ *
23
+ * Empty segments anywhere in `paths` are dropped before slicing, so the
24
+ * trailing `''` that `ExURL.paths` produces for a directory-style URL (one
25
+ * ending in `/`) doesn't fragment a section's key from the same section's
26
+ * non-trailing-slash URLs.
27
+ *
28
+ * `depth` must be a positive integer. A non-positive or fractional depth has
29
+ * no sensible interpretation as "how many leading segments to keep", so it
30
+ * is rejected eagerly rather than silently coerced or left to produce a
31
+ * confusing result (e.g. slicing with a negative or fractional length).
32
+ * @param paths
33
+ * @param depth
34
+ * @example
35
+ * ```ts
36
+ * derivePathGroupKey(['dept-a', 'news', '123']);
37
+ * // 'dept-a'
38
+ * derivePathGroupKey(['dept-a', 'news', '123'], 2);
39
+ * // 'dept-a/news'
40
+ * derivePathGroupKey([]);
41
+ * // ''
42
+ * ```
43
+ */
44
+ export function derivePathGroupKey(paths, depth = 1) {
45
+ if (!(Number.isInteger(depth) && depth > 0)) {
46
+ throw new RangeError(`derivePathGroupKey: depth must be a positive integer, got ${depth}`);
47
+ }
48
+ // See the trailing-slash note above.
49
+ const segments = paths.filter((segment) => segment !== '');
50
+ return segments.slice(0, depth).join('/');
51
+ }