@d-zero/page-cluster 0.4.0 → 0.5.1

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 CHANGED
@@ -15,7 +15,7 @@ yarn add @d-zero/page-cluster
15
15
  ### CLI
16
16
 
17
17
  ```sh
18
- page-cluster [--content-block-attribute <name>] [--include-landmark-positions] < pages.jsonl > clusters.jsonl
18
+ page-cluster [--content-block-attribute <name>] [--cluster-reasons-file <path>] < pages.jsonl > clusters.jsonl
19
19
  ```
20
20
 
21
21
  **入力**: JSONL 1 行 1 ページ。フィールドは以下。`html` 以外はすべて任意(`paths` / `stylesheetHrefs` がないと粗い分類になる)。
@@ -36,44 +36,38 @@ page-cluster [--content-block-attribute <name>] [--include-landmark-positions] <
36
36
  { "id": "任意の識別子", "clusterKey": "..." }
37
37
  ```
38
38
 
39
- `--include-landmark-positions` を指定すると、各行に `landmarks` フィールドが追加される。header / footer / nav / aside / form / search / main のインスタンスごとに、HTML 内の位置(1-based の line/column と文字列オフセットの両方)を返す。header 〜 search の 6 種は追加で、そのページが属する最終クラスタ内での頻度分析(`shellQuorum`)に基づく `isChrome`(サイト/セクション共通の chrome か、ページ固有のコンテンツか)を持つ。`main` は常にコンテンツなので `isChrome` を持たない。
39
+ `--cluster-reasons-file <path>` を指定すると、処理完了後に別ファイルとして「クラスタ選定理由」を書き出す。ページ単位ではなく**クラスタ単位**(`clusterKey` をキーにしたオブジェクト、1 クラスタにつき 1 エントリ)なので、ファイルサイズはページ数ではなくクラスタ数に比例する ページ単位のレポートと違い、コーパスサイズの上限はない。各エントリは「なぜこのページ達が同じクラスタになったか」の根拠を構造化データとして返す: ブロッキング理由(共有 stylesheet 集合 or URL パスプレフィックス)、クラスタ内で共有されている DOM 構造トークンのコア、landmark タイプ(header/footer/nav/aside/form/search)ごとのクラスタ内共通性、そして同一ブロッキンググループ内で分岐した兄弟クラスタのキー一覧。
40
40
 
41
41
  ```json
42
42
  {
43
- "id": "任意の識別子",
44
- "clusterKey": "...",
45
- "landmarks": {
46
- "header": [
47
- {
48
- "startLine": 1,
49
- "startColumn": 7,
50
- "endLine": 1,
51
- "endColumn": 30,
52
- "startOffset": 6,
53
- "endOffset": 29,
54
- "isChrome": true
55
- }
43
+ "[\"path:news\",\"cluster:0\"]": {
44
+ "memberCount": 42,
45
+ "blocking": [
46
+ { "blockKey": "path:news", "reason": { "kind": "path", "pathKey": "news" } }
56
47
  ],
57
- "footer": [],
58
- "nav": [],
59
- "aside": [],
60
- "form": [],
61
- "search": [],
62
- "main": [
63
- {
64
- "startLine": 2,
65
- "startColumn": 1,
66
- "endLine": 10,
67
- "endColumn": 8,
68
- "startOffset": 40,
69
- "endOffset": 120
48
+ "structuralCoreTokens": ["body>main>article", "..."],
49
+ "landmarks": {
50
+ "header": {
51
+ "presenceRate": 1,
52
+ "chromeRate": 1,
53
+ "shellTokens": ["..."],
54
+ "memberCountWithInstance": 42
55
+ },
56
+ "aside": {
57
+ "presenceRate": 0.3,
58
+ "chromeRate": 0,
59
+ "shellTokens": [],
60
+ "memberCountWithInstance": 13
70
61
  }
71
- ]
62
+ },
63
+ "siblingClusterKeys": ["[\"path:news\",\"cluster:1\"]"]
72
64
  }
73
65
  }
74
66
  ```
75
67
 
76
- 20,000 ページを超えるコーパス(ストリーミング経路)では `--include-landmark-positions` は使えない(エラーで終了する)。ストリーミング経路はリザーバサンプリングと近似割当を使うため、ページ単位の chrome 判定に必要な「そのページが属する最終クラスタの shell トークン」という概念を持たないため。
68
+ 上の例は、header はクラスタ全体で共通の chrome(`chromeRate: 1`)である一方、aside は 42 ページ中 13 ページにしか無く chrome とは判定されていない(`chromeRate: 0`)ことを示す。つまり「ヘッダーは共通だが、サイドナビの有無で分かれているページがある」という状況を数値で表している。`siblingClusterKeys` は同じブロッキンググループ内で Stage A/B が結局別クラスタのままにした相手のキーで、それぞれの `ClusterReason` を突き合わせれば「何が違って分かれたか」を呼び出し側で解釈できる。理由は構造化データのみで、文言化(「ヘッダーが共通です」等)は呼び出し側の責務。
69
+
70
+ landmark の位置情報そのもの(HTML 内のどこにあるか)が必要な場合は、ライブラリの `extractLandmarks`(ステートレス・公開 API)をページの HTML に対して自分で呼び、その結果と `ClusterReason.landmarks[type].shellTokens` を突き合わせて `isChromeLandmarkInstance`(同じく公開 API)で chrome 判定すればよい。詳細は [Library](#library) 節を参照。
77
71
 
78
72
  クローラ出力が JSON 配列の場合は `jq` で line-delimited に変換して食わせる:
79
73
 
@@ -84,7 +78,7 @@ jq -c '.[]' crawl-output.json | page-cluster > clusters.jsonl
84
78
  #### オプション
85
79
 
86
80
  - `--content-block-attribute <name>` — CMS が自由編集コンテンツブロックに付与している属性名(例: `data-bgb`)が分かっている場合に指定する。指定すると比較前にその属性を持つ要素配下を無視するので、同じテンプレートで本文構成だけ違うページを混同しなくなる。唯一の site-specific なオプションで、未指定でも `<main>` / `role="main"` を起点にした自動深さキャップが常時働く(詳細は `resolve-page-cluster-keys.ts` の JSDoc を参照)
87
- - `--include-landmark-positions`出力の各行に上記の `landmarks` フィールドを追加する。20,000 ページ超のコーパスでは使えない。指定すると進捗表示(後述)は出なくなる(進捗を出さない非ストリーミング経路に常に振り分けられるため)
81
+ - `--cluster-reasons-file <path>` 上記の「クラスタ選定理由」を `<path>` に JSON として書き出す。ページ数の上限はない。20,000 ページ以下のコーパスでは、指定すると進捗表示(後述)は出なくなる(進捗を出さない非ストリーミング経路に常に振り分けられるため。20,000 ページ超のストリーミング経路では進捗表示・クラスタ理由の両方が動く)
88
82
  - `--help` / `-h` — ヘルプを表示する
89
83
  - `--version` / `-v` — バージョンを表示する
90
84
 
@@ -116,12 +110,15 @@ silence したい場合は `2>/dev/null`。ログに残したい場合は `2> pr
116
110
 
117
111
  サブパスエクスポート構成。import パスと提供関数の対応は以下。
118
112
 
119
- | import パス | 提供関数 |
120
- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
121
- | `@d-zero/page-cluster` | `tokenize` — `<body>` 配下を構造トークン列に変換する低レベルプリミティブ |
122
- | `@d-zero/page-cluster/resolve-page-cluster-keys` | `resolvePageClusterKeys`(非同期・ファクトリ入力・メモリ有界のメインエントリー)、`resolvePageClusterKeysFromArray`(array 入力ラッパー)、`resolvePageClusterKeysInMemory`(同期・array 入力)。いずれも `includeLandmarkPositions: true` を渡すと `clusterKey` に加えて位置情報つきの `landmarks`(`PageLandmarkReport`)を返す |
123
- | `@d-zero/page-cluster/extract-landmarks` | `extractLandmarks` — header / footer / nav / aside / form / search / main 7 種を抽出し、インスタンスごとの生 HTML と HTML 内の位置(line/column・文字列オフセット)を返す |
124
- | `@d-zero/page-cluster/resolve-landmark-variant-keys` | `resolveLandmarkVariantKeys` — 特定ランドマークのデザインバリアントでページを分類 |
113
+ | import パス | 提供関数 |
114
+ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `@d-zero/page-cluster` | `tokenize` — `<body>` 配下を構造トークン列に変換する低レベルプリミティブ |
116
+ | `@d-zero/page-cluster/resolve-page-cluster-keys` | `resolvePageClusterKeys`(非同期・ファクトリ入力・メモリ有界のメインエントリー)、`resolvePageClusterKeysFromArray`(array 入力ラッパー)、`resolvePageClusterKeysInMemory`(同期・array 入力)。`onClusterReason` コールバックを渡すと、確定したクラスタごとに 1 回だけ `ClusterReason` を通知する |
117
+ | `@d-zero/page-cluster/build-cluster-reason` | `ClusterReason` / `LandmarkClusterProfile` 型、`buildClusterReason` クラスタ選定理由の型定義と組み立て関数(通常は `resolvePageClusterKeys``onClusterReason` 経由で使うので直接呼ぶ必要はない) |
118
+ | `@d-zero/page-cluster/extract-landmarks` | `extractLandmarks` — header / footer / nav / aside / form / search / main の 7 種を抽出し、インスタンスごとの生 HTML と HTML 内の位置(line/column・文字列オフセット)を返す |
119
+ | `@d-zero/page-cluster/resolve-landmark-variant-keys` | `resolveLandmarkVariantKeys` — 特定ランドマークのデザインバリアントでページを分類 |
120
+ | `@d-zero/page-cluster/is-chrome-landmark-instance` | `isChromeLandmarkInstance` — 1 つの landmark インスタンスのトークン集合と `ClusterReason.landmarks[type].shellTokens` のようなシェルトークン集合を突き合わせて chrome/content を判定するステートレス関数 |
121
+ | `@d-zero/page-cluster/jaccard-similarity` | `jaccardSimilarity` — 2 つのトークン集合の Jaccard 類似度。`ClusterReason` 同士(`structuralCoreTokens` や `shellTokens`)を比較して兄弟クラスタとの差分を調べる用途などに使う |
125
122
 
126
123
  ```ts
127
124
  import { resolvePageClusterKeysFromArray } from '@d-zero/page-cluster/resolve-page-cluster-keys';
@@ -175,7 +172,7 @@ flowchart TD
175
172
  - **chrome discovery** — 全ページのランドマーク署名の度数分布に auto-cut を当て、閾値以上を「グローバル chrome」(サイト共通のヘッダー等)として比較から除外し、閾値未満かつ 2 ページ以上に出現するものを「ローカル chrome」(セクション固有のナビ等)としてトークン再注入する
176
173
  - **Stage A(ブロック内クラスタリング)** — ブロックごとに直線的な処理。`<main>` の深さキャップ(候補深度を全走査して knee を探す自動選択)→ tokenize → complete-linkage 階層クラスタリング → max-gap auto-cut でカット高を決定 → 最後に包含関係にあるクラスタを吸収する包含割当(割当チェーンを辿り、循環はメンバー最大のクラスタをルートに選んで解決)
177
174
  - **Pass 1b(ストリーミング時のみ)** — 20,000 ページ超では各ブロックをリザーバサンプリング(最大 100 ページ、ブロックキーをシードにした決定的乱数)で代表させ、サンプル外のページは Stage A 完了後に max-Jaccard で最寄りクラスタへ一括割当する。メモリ使用量はコーパス全体ではなくサンプルサイズに比例する
178
- - **Stage B(ブロック越えマージ)** — ブロック分割はあくまで比較コスト削減のためなので、最後に同一テンプレートがブロックを跨いで分かれていないか再統合する。これが唯一の反復処理(次節)
175
+ - **Stage B(ブロック越えマージ)** — ブロック分割はあくまで比較コスト削減のためなので、最後に同一テンプレートがブロックを跨いで分かれていないか再統合する。これが唯一の反復処理(次節)。収束後、`onClusterReason` が指定されていれば、確定した最終クラスタごとに `ClusterReason` を 1 回ずつ組み立てて通知する — 追加の全コーパススキャンではなく、Stage A/B が既に計算済みの中間データ(quorum core、landmark インスタンス、ブロッキング根拠)を再利用するだけなので、クラスタ数にしか比例しない
179
176
 
180
177
  ### Stage B: ブロック越え統合の不動点ループ
181
178
 
@@ -0,0 +1,119 @@
1
+ import type { BlockingReason } from './derive-blocking-reason.js';
2
+ import type { LandmarkType } from './extract-landmarks.js';
3
+ import type { PerPageLandmarkInstance } from './per-page-landmark-signatures.js';
4
+ /**
5
+ * One final cluster's common-vs-varying profile for a single landmark type
6
+ * (header/footer/nav/aside/form/search), derived by running
7
+ * {@link ./shell-quorum.js | shellQuorum} on just that type's instances across
8
+ * every member page. Absent from {@link ClusterReason}'s `landmarks` entirely
9
+ * when no member page carries an instance of the type.
10
+ */
11
+ export type LandmarkClusterProfile = {
12
+ /** Fraction (0–1) of the cluster's member pages carrying at least one instance of this type. */
13
+ readonly presenceRate: number;
14
+ /**
15
+ * Fraction (0–1) of this type's instances, across all member pages, that
16
+ * {@link ./is-chrome-landmark-instance.js | isChromeLandmarkInstance}
17
+ * classified as chrome against this cluster's own shell for the type —
18
+ * the "header/footer is shared chrome" half of the user-facing story.
19
+ *
20
+ * The denominator is per-page-deduplicated instances (same rule
21
+ * {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}
22
+ * always applies: two instances on the same page that tokenize to the
23
+ * same signature — a CMS-duplicated footer, or a `<header
24
+ * role="navigation">` matching both `header` and `nav` at the identical
25
+ * span — count once). A caller reconstructing a single instance's chrome
26
+ * verdict via `extractLandmarks` + `tokenize` +
27
+ * `isChromeLandmarkInstance` classifies that one instance correctly
28
+ * regardless of this rule; only a caller trying to reproduce this exact
29
+ * ratio by counting raw, un-deduplicated instances across a page would
30
+ * see a different number.
31
+ */
32
+ readonly chromeRate: number;
33
+ /**
34
+ * The shell token set `shellQuorum` discovered for this type within this
35
+ * cluster. Exposed as raw evidence — comparing two sibling clusters'
36
+ * `shellTokens` for the same type (e.g. via
37
+ * {@link ./jaccard-similarity.js | jaccardSimilarity}) is how a caller
38
+ * answers "do these two clusters share the same footer but differ in
39
+ * whether they have a sidebar nav".
40
+ */
41
+ readonly shellTokens: readonly string[];
42
+ /** How many member pages contributed at least one instance of this type. */
43
+ readonly memberCountWithInstance: number;
44
+ };
45
+ /**
46
+ * Structured, uninterpreted explanation of why a final cluster's member
47
+ * pages ended up together, and which sibling clusters (same Pass-0 block,
48
+ * different final cluster) they were nonetheless split from. Deliberately
49
+ * carries no human-readable text or verdicts ("these are the same template
50
+ * except for X") — that judgment belongs to the caller, which has the
51
+ * product context (and the localization requirements) `@d-zero/page-cluster`
52
+ * itself does not.
53
+ */
54
+ export type ClusterReason = {
55
+ /** Number of pages this final cluster contains. */
56
+ readonly memberCount: number;
57
+ /**
58
+ * The distinct Pass-0 blocking keys that fed into this final cluster, and
59
+ * the evidence behind each. Usually one entry; more than one means Stage B
60
+ * merged pages that started in different blocks — itself a notable part
61
+ * of the explanation (e.g. two differently-styled URL sections turned out
62
+ * to share the same DOM template).
63
+ */
64
+ readonly blocking: readonly {
65
+ readonly blockKey: string;
66
+ readonly reason: BlockingReason;
67
+ }[];
68
+ /**
69
+ * The frequency-quorum core of this cluster's DOM structural tokens (see
70
+ * {@link ./merge-cross-block-clusters.js | computeQuorumCore}) — the
71
+ * "this DOM structure is common" evidence, independent of CSS or
72
+ * landmarks.
73
+ */
74
+ readonly structuralCoreTokens: readonly string[];
75
+ /** Per-landmark-type commonality. Types absent from every member page are omitted. */
76
+ readonly landmarks: {
77
+ readonly [K in LandmarkType]?: LandmarkClusterProfile;
78
+ };
79
+ /**
80
+ * Final cluster keys that share at least one Pass-0 block with this
81
+ * cluster but were not merged into it by Stage A/B — candidates for "why
82
+ * did these split" comparison. Does not include clusters that started in
83
+ * a different block from this one.
84
+ */
85
+ readonly siblingClusterKeys: readonly string[];
86
+ };
87
+ /**
88
+ * Builds one final cluster's {@link ClusterReason} from data Stage A/B
89
+ * already computed for clustering itself — no re-tokenization, no re-running
90
+ * `shellQuorum`'s corpus-wide discovery pass, just re-deriving per-type shells
91
+ * and quorum cores from the same pooled member state
92
+ * {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters} already
93
+ * built and would otherwise have discarded.
94
+ * @param input
95
+ * @param input.tokenSets
96
+ * @param input.landmarkInstances
97
+ * @param input.blocking
98
+ * @param input.siblingClusterKeys
99
+ * @param input.chromeThreshold
100
+ * @example
101
+ * ```ts
102
+ * const reason = buildClusterReason({
103
+ * tokenSets: finalGroup.tokenSets,
104
+ * landmarkInstances: finalGroup.landmarkInstances,
105
+ * blocking: [{ blockKey: 'css:abc123', reason: { kind: 'css', distinctiveStylesheetHrefs: ['/a.css'] } }],
106
+ * siblingClusterKeys: ['["css:abc123","cluster:1"]'],
107
+ * });
108
+ * ```
109
+ */
110
+ export declare function buildClusterReason(input: {
111
+ readonly tokenSets: readonly ReadonlySet<string>[];
112
+ readonly landmarkInstances: readonly (readonly PerPageLandmarkInstance[])[];
113
+ readonly blocking: readonly {
114
+ readonly blockKey: string;
115
+ readonly reason: BlockingReason;
116
+ }[];
117
+ readonly siblingClusterKeys: readonly string[];
118
+ readonly chromeThreshold?: number;
119
+ }): ClusterReason;
@@ -0,0 +1,63 @@
1
+ import { DEFAULT_CHROME_OVERLAP_THRESHOLD, isChromeLandmarkInstance, } from './is-chrome-landmark-instance.js';
2
+ import { computeQuorumCore } from './merge-cross-block-clusters.js';
3
+ import { ALL_LANDMARK_TYPES } from './per-page-landmark-signatures.js';
4
+ import { shellQuorum } from './shell-quorum.js';
5
+ /**
6
+ * Builds one final cluster's {@link ClusterReason} from data Stage A/B
7
+ * already computed for clustering itself — no re-tokenization, no re-running
8
+ * `shellQuorum`'s corpus-wide discovery pass, just re-deriving per-type shells
9
+ * and quorum cores from the same pooled member state
10
+ * {@link ./merge-cross-block-clusters.js | mergeCrossBlockClusters} already
11
+ * built and would otherwise have discarded.
12
+ * @param input
13
+ * @param input.tokenSets
14
+ * @param input.landmarkInstances
15
+ * @param input.blocking
16
+ * @param input.siblingClusterKeys
17
+ * @param input.chromeThreshold
18
+ * @example
19
+ * ```ts
20
+ * const reason = buildClusterReason({
21
+ * tokenSets: finalGroup.tokenSets,
22
+ * landmarkInstances: finalGroup.landmarkInstances,
23
+ * blocking: [{ blockKey: 'css:abc123', reason: { kind: 'css', distinctiveStylesheetHrefs: ['/a.css'] } }],
24
+ * siblingClusterKeys: ['["css:abc123","cluster:1"]'],
25
+ * });
26
+ * ```
27
+ */
28
+ export function buildClusterReason(input) {
29
+ const memberCount = input.tokenSets.length;
30
+ const chromeThreshold = input.chromeThreshold ?? DEFAULT_CHROME_OVERLAP_THRESHOLD;
31
+ const structuralCoreTokens = [...computeQuorumCore(input.tokenSets)].toSorted();
32
+ const landmarks = {};
33
+ for (const type of ALL_LANDMARK_TYPES) {
34
+ const perMemberInstancesOfType = input.landmarkInstances.map((instances) => instances.filter((instance) => instance.type === type));
35
+ const membersWithInstance = perMemberInstancesOfType.filter((instances) => instances.length > 0);
36
+ if (membersWithInstance.length === 0)
37
+ continue;
38
+ const shellTokens = shellQuorum(perMemberInstancesOfType);
39
+ let chromeInstanceCount = 0;
40
+ let totalInstanceCount = 0;
41
+ for (const instances of membersWithInstance) {
42
+ for (const instance of instances) {
43
+ totalInstanceCount++;
44
+ if (isChromeLandmarkInstance(instance.tokens, shellTokens, chromeThreshold)) {
45
+ chromeInstanceCount++;
46
+ }
47
+ }
48
+ }
49
+ landmarks[type] = {
50
+ presenceRate: membersWithInstance.length / memberCount,
51
+ chromeRate: totalInstanceCount === 0 ? 0 : chromeInstanceCount / totalInstanceCount,
52
+ shellTokens: [...shellTokens].toSorted(),
53
+ memberCountWithInstance: membersWithInstance.length,
54
+ };
55
+ }
56
+ return {
57
+ memberCount,
58
+ blocking: input.blocking,
59
+ structuralCoreTokens,
60
+ landmarks,
61
+ siblingClusterKeys: input.siblingClusterKeys,
62
+ };
63
+ }
package/dist/cli.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  type CliArgs = {
7
7
  readonly contentBlockAttribute?: string;
8
- readonly includeLandmarkPositions?: boolean;
8
+ readonly clusterReasonsFile?: string;
9
9
  readonly help?: boolean;
10
10
  readonly version?: boolean;
11
11
  readonly unknownFlag?: string;
package/dist/cli.js CHANGED
@@ -4,11 +4,12 @@
4
4
  // writes JSONL to stdout (one cluster assignment per line, in input order),
5
5
  // and streams progress to stderr via `@d-zero/dealer`'s `Lanes` — in-place
6
6
  // animated header on a TTY, appended `[page-cluster] …` lines otherwise.
7
+ import { writeFile } from 'node:fs/promises';
7
8
  import process from 'node:process';
8
9
  import { Lanes } from '@d-zero/dealer';
9
10
  import { resolvePageClusterKeys } from './resolve-page-cluster-keys.js';
10
11
  const HELP_TEXT = `Usage:
11
- page-cluster [--content-block-attribute <name>] [--include-landmark-positions] < pages.jsonl > clusters.jsonl
12
+ page-cluster [--content-block-attribute <name>] [--cluster-reasons-file <path>] < pages.jsonl > clusters.jsonl
12
13
 
13
14
  Input (JSONL, one page per line):
14
15
  {
@@ -22,20 +23,27 @@ Input (JSONL, one page per line):
22
23
  Output (JSONL, one line per input page, in input order):
23
24
  { "id": "...", "clusterKey": "..." }
24
25
 
25
- With --include-landmark-positions, each line additionally carries a
26
- \`landmarks\` field: every header/footer/nav/aside/form/search/main
27
- instance's position (1-based line/column plus string offsets), with the
28
- six excisable types (all but main) also carrying an \`isChrome\` verdict
29
- against that page's final cluster:
26
+ With --cluster-reasons-file <path>, a separate JSON file is written once
27
+ processing completes: an object keyed by clusterKey, one entry per final
28
+ cluster (not per page a ClusterReason is sized by cluster count, so
29
+ this file has no page-count limit, unlike a per-page report would).
30
+ Each ClusterReason reports the blocking evidence that grouped the
31
+ cluster (shared stylesheet set or URL path prefix), the shared DOM-
32
+ structural token core, per-landmark-type (header/footer/nav/aside/form/
33
+ search) commonality within the cluster, and the sibling cluster keys it
34
+ was split from within the same blocking group — e.g. "header/footer are
35
+ common chrome across both clusters, but they differ in whether a
36
+ sidebar nav is present":
30
37
  {
31
- "id": "...", "clusterKey": "...",
32
- "landmarks": {
33
- "header": [{ "startLine": 1, "startColumn": 7, "endLine": 1,
34
- "endColumn": 30, "startOffset": 6, "endOffset": 29,
35
- "isChrome": true }],
36
- "footer": [...], "nav": [...], "aside": [...], "form": [...], "search": [...],
37
- "main": [{ "startLine": 2, "startColumn": 1, "endLine": 10,
38
- "endColumn": 8, "startOffset": 40, "endOffset": 120 }]
38
+ "[\\"path:news\\",\\"cluster:0\\"]": {
39
+ "memberCount": 42,
40
+ "blocking": [{ "blockKey": "path:news", "reason": { "kind": "path", "pathKey": "news" } }],
41
+ "structuralCoreTokens": ["body>main>article", "..."],
42
+ "landmarks": {
43
+ "header": { "presenceRate": 1, "chromeRate": 1, "shellTokens": ["..."], "memberCountWithInstance": 42 },
44
+ "aside": { "presenceRate": 0.3, "chromeRate": 0, "shellTokens": [], "memberCountWithInstance": 13 }
45
+ },
46
+ "siblingClusterKeys": ["[\\"path:news\\",\\"cluster:1\\"]"]
39
47
  }
40
48
  }
41
49
 
@@ -43,14 +51,10 @@ Options:
43
51
  --content-block-attribute <name> CMS-provided attribute marking freeform
44
52
  content blocks that should be stripped
45
53
  before comparison (e.g. \`data-bgb\`).
46
- --include-landmark-positions Add the \`landmarks\` field described
47
- above to every output line. Not
48
- supported for corpora over 20,000 pages
49
- (throws instead of streaming). Disables
50
- progress output on stderr — this option
51
- always routes through the same
52
- non-progress-emitting code path as a
53
- run without progress.
54
+ --cluster-reasons-file <path> Write the per-cluster ClusterReason
55
+ object described above to <path> after
56
+ processing completes. No page-count
57
+ limit.
54
58
  --help Print this help and exit.
55
59
  --version Print the package version and exit.
56
60
 
@@ -107,8 +111,14 @@ export function parseArgs(argv) {
107
111
  i++;
108
112
  break;
109
113
  }
110
- case '--include-landmark-positions': {
111
- out.includeLandmarkPositions = true;
114
+ case '--cluster-reasons-file': {
115
+ const next = argv[i + 1];
116
+ if (next === undefined) {
117
+ out.unknownFlag = `${arg} requires a value`;
118
+ return out;
119
+ }
120
+ out.clusterReasonsFile = next;
121
+ i++;
112
122
  break;
113
123
  }
114
124
  default: {
@@ -354,30 +364,24 @@ export async function runCli(options) {
354
364
  return 1;
355
365
  }
356
366
  renderProgress(lanes, useTty, readingDoneLine(pages.length));
367
+ // Only worth collecting when the caller asked for the file — a
368
+ // ClusterReason Map costs bookkeeping proportional to cluster count,
369
+ // not page count, but there's no reason to pay even that when unused.
370
+ const reasonsByClusterKey = args.clusterReasonsFile
371
+ ? new Map()
372
+ : undefined;
357
373
  const resolveOptions = {
358
374
  contentBlockAttribute: args.contentBlockAttribute,
359
375
  onProgress: (event) => {
360
376
  renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
361
377
  },
378
+ onClusterReason: reasonsByClusterKey
379
+ ? (key, reason) => reasonsByClusterKey.set(key, reason)
380
+ : undefined,
362
381
  };
363
- // `includeLandmarkPositions` always routes resolvePageClusterKeys
364
- // through its non-progress-emitting sync path (see that option's own
365
- // JSDoc), so the onProgress callback above is set but never invoked
366
- // in this branch — no separate "quiet" resolveOptions variant needed.
367
382
  let clusterKeys;
368
- let landmarksByIndex;
369
383
  try {
370
- if (args.includeLandmarkPositions) {
371
- const results = await resolvePageClusterKeys(() => pages, {
372
- ...resolveOptions,
373
- includeLandmarkPositions: true,
374
- });
375
- clusterKeys = results.map((r) => r.clusterKey);
376
- landmarksByIndex = results.map((r) => r.landmarks);
377
- }
378
- else {
379
- clusterKeys = await resolvePageClusterKeys(() => pages, resolveOptions);
380
- }
384
+ clusterKeys = await resolvePageClusterKeys(() => pages, resolveOptions);
381
385
  }
382
386
  catch (error) {
383
387
  renderProgress(lanes, useTty, errorLine(error.message));
@@ -386,14 +390,18 @@ export async function runCli(options) {
386
390
  const clusterCount = new Set(clusterKeys).size;
387
391
  renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
388
392
  for (const [index, key] of clusterKeys.entries()) {
389
- const row = {
390
- id: ids[index] ?? index,
391
- clusterKey: key,
392
- };
393
- if (landmarksByIndex)
394
- row.landmarks = landmarksByIndex[index];
393
+ const row = { id: ids[index] ?? index, clusterKey: key };
395
394
  options.stdout.write(`${JSON.stringify(row)}\n`);
396
395
  }
396
+ if (args.clusterReasonsFile && reasonsByClusterKey) {
397
+ try {
398
+ await writeFile(args.clusterReasonsFile, JSON.stringify(Object.fromEntries(reasonsByClusterKey), null, 2));
399
+ }
400
+ catch (error) {
401
+ renderProgress(lanes, useTty, errorLine(error.message));
402
+ return 1;
403
+ }
404
+ }
397
405
  return 0;
398
406
  }
399
407
  finally {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The evidence behind one Pass-0 blocking key — which signal
3
+ * {@link ./resolve-blocking-group-keys.js | resolveBlockingGroupKeys} (and,
4
+ * for the `orphanMerge` variant, {@link ./reassign-orphan-block-keys.js |
5
+ * reassignOrphanBlockKeys}) actually used to decide it, carried verbatim with
6
+ * no added interpretation. Every page sharing a `css:<hash>` blocking key
7
+ * shares the exact same `distinctiveStylesheetHrefs` set by construction (the
8
+ * hash is derived from that set), so one `BlockingReason` per distinct
9
+ * blocking key is enough — it does not need to vary per page.
10
+ */
11
+ export type BlockingReason = {
12
+ readonly kind: 'css';
13
+ /**
14
+ * The sorted, deduplicated, first-party stylesheet hrefs left after
15
+ * corpus-wide chrome removal — the exact set
16
+ * {@link ./derive-stylesheet-group-key.js | deriveStylesheetGroupKey}
17
+ * hashed into this blocking key.
18
+ */
19
+ readonly distinctiveStylesheetHrefs: readonly string[];
20
+ } | {
21
+ readonly kind: 'path';
22
+ /** The `derivePathGroupKey` result this blocking key was derived from. */
23
+ readonly pathKey: string;
24
+ } | {
25
+ readonly kind: 'orphanMerge';
26
+ /**
27
+ * The confined `path:` key this stylesheet-less page was folded into a
28
+ * same-section `css:` block under — see
29
+ * {@link ./reassign-orphan-block-keys.js | reassignOrphanBlockKeys}.
30
+ */
31
+ readonly pathKey: string;
32
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -18,11 +18,47 @@ export type CrossBlockUnit = {
18
18
  readonly memberTokenSets: readonly ReadonlySet<string>[];
19
19
  readonly memberLandmarkInstances: readonly (readonly PerPageLandmarkInstance[])[];
20
20
  };
21
+ /**
22
+ * Frequency-based token core of a group of member pages: a token must be
23
+ * present in at least `QUORUM_FRACTION` of members to enter the core, with a
24
+ * full-union fallback when no token clears that bar (see
25
+ * {@link mergeCrossBlockClusters}'s JSDoc for why quorum beats strict
26
+ * intersection or full union). Exported so callers building a `ClusterReason`
27
+ * (`build-cluster-reason.ts`) can re-derive a final group's structural core
28
+ * from `finalGroupsByRoot` without duplicating this logic.
29
+ * @param memberDistinctiveTokens
30
+ */
31
+ export declare function computeQuorumCore(memberDistinctiveTokens: readonly ReadonlySet<string>[]): ReadonlySet<string>;
32
+ /**
33
+ * One root key's pooled member state after Stage B converges: every
34
+ * `tokenSets`/`landmarkInstances` entry folded in from every unit merged into
35
+ * this root (down-sampled to `capMembers` when the caller opts in, same as
36
+ * during merging). This is the exact state `mergeCrossBlockClusters` already
37
+ * builds internally to run quorum-core/shell comparisons each round — it was
38
+ * discarded once `keyToRoot` was returned. Exposing it lets a `ClusterReason`
39
+ * (`build-cluster-reason.ts`) be built from data Stage B already computed,
40
+ * with no extra pass over the corpus.
41
+ */
42
+ export type FinalGroupMembers = {
43
+ readonly tokenSets: readonly ReadonlySet<string>[];
44
+ readonly landmarkInstances: readonly (readonly PerPageLandmarkInstance[])[];
45
+ };
46
+ /**
47
+ * `mergeCrossBlockClusters`'s result: the root-key mapping every caller
48
+ * needs for `clusterKey` resolution, plus each surviving root's final pooled
49
+ * member state for callers that also want to explain *why* (`ClusterReason`).
50
+ */
51
+ export type MergeCrossBlockClustersResult = {
52
+ /** Every input unit's `key` mapped to its final root key. Units not absorbed into any other unit map to themselves. */
53
+ readonly rootByKey: ReadonlyMap<string, string>;
54
+ /** Every surviving root key's final pooled member state. */
55
+ readonly finalGroupsByRoot: ReadonlyMap<string, FinalGroupMembers>;
56
+ };
21
57
  /**
22
58
  * Merges cross-block clusters (Stage B) via recursive quorum-core comparison.
23
59
  *
24
- * Returns a `Map` from each input unit's `key` to its final root key. Units
25
- * not absorbed into any other unit map to themselves.
60
+ * Returns the root-key mapping (see {@link MergeCrossBlockClustersResult}).
61
+ * Units not absorbed into any other unit map to themselves.
26
62
  *
27
63
  * Three merge mechanisms run per round, in order:
28
64
  * 1. **Fine stage** — complete-linkage at `CROSS_BLOCK_THRESHOLD` on quorum
@@ -58,4 +94,4 @@ export declare function mergeCrossBlockClusters(units: readonly CrossBlockUnit[]
58
94
  * full-membership merge behavior unchanged.
59
95
  */
60
96
  capMembers?: number;
61
- }): Map<string, string>;
97
+ }): MergeCrossBlockClustersResult;
@@ -83,10 +83,16 @@ const GENERIC_SEGMENTS = new Set([
83
83
  // Internal helpers
84
84
  // ---------------------------------------------------------------------------
85
85
  /**
86
- *
86
+ * Frequency-based token core of a group of member pages: a token must be
87
+ * present in at least `QUORUM_FRACTION` of members to enter the core, with a
88
+ * full-union fallback when no token clears that bar (see
89
+ * {@link mergeCrossBlockClusters}'s JSDoc for why quorum beats strict
90
+ * intersection or full union). Exported so callers building a `ClusterReason`
91
+ * (`build-cluster-reason.ts`) can re-derive a final group's structural core
92
+ * from `finalGroupsByRoot` without duplicating this logic.
87
93
  * @param memberDistinctiveTokens
88
94
  */
89
- function quorumCore(memberDistinctiveTokens) {
95
+ export function computeQuorumCore(memberDistinctiveTokens) {
90
96
  const n = memberDistinctiveTokens.length;
91
97
  if (n === 0)
92
98
  return new Set();
@@ -158,14 +164,11 @@ function l2Contained(xSig, ySig) {
158
164
  }
159
165
  return true;
160
166
  }
161
- // ---------------------------------------------------------------------------
162
- // Main function
163
- // ---------------------------------------------------------------------------
164
167
  /**
165
168
  * Merges cross-block clusters (Stage B) via recursive quorum-core comparison.
166
169
  *
167
- * Returns a `Map` from each input unit's `key` to its final root key. Units
168
- * not absorbed into any other unit map to themselves.
170
+ * Returns the root-key mapping (see {@link MergeCrossBlockClustersResult}).
171
+ * Units not absorbed into any other unit map to themselves.
169
172
  *
170
173
  * Three merge mechanisms run per round, in order:
171
174
  * 1. **Fine stage** — complete-linkage at `CROSS_BLOCK_THRESHOLD` on quorum
@@ -191,7 +194,13 @@ function l2Contained(xSig, ySig) {
191
194
  */
192
195
  export function mergeCrossBlockClusters(units, options) {
193
196
  if (units.length <= 1) {
194
- return new Map(units.map((u) => [u.key, u.key]));
197
+ return {
198
+ rootByKey: new Map(units.map((u) => [u.key, u.key])),
199
+ finalGroupsByRoot: new Map(units.map((u) => [
200
+ u.key,
201
+ { tokenSets: u.memberTokenSets, landmarkInstances: u.memberLandmarkInstances },
202
+ ])),
203
+ };
195
204
  }
196
205
  const threshold = options?.similarityThreshold ?? CROSS_BLOCK_THRESHOLD;
197
206
  const capMembers = options?.capMembers;
@@ -264,7 +273,7 @@ export function mergeCrossBlockClusters(units, options) {
264
273
  // Quorum core per group
265
274
  const cores = new Map();
266
275
  for (const key of groupKeys) {
267
- cores.set(key, quorumCore(groupDistinctive.get(key) ?? []));
276
+ cores.set(key, computeQuorumCore(groupDistinctive.get(key) ?? []));
268
277
  }
269
278
  // ---------------------------------------------------------------
270
279
  // Fine stage: union-find over group indices
@@ -450,5 +459,9 @@ export function mergeCrossBlockClusters(units, options) {
450
459
  break; // fully converged
451
460
  applyMerges(l2Merges);
452
461
  }
453
- return keyToRoot;
462
+ const finalGroupsByRoot = new Map([...groups.entries()].map(([root, g]) => [
463
+ root,
464
+ { tokenSets: g.tokenSets, landmarkInstances: g.landmarkInstances },
465
+ ]));
466
+ return { rootByKey: keyToRoot, finalGroupsByRoot };
454
467
  }