@d-zero/page-cluster 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -38
- package/dist/build-cluster-reason.d.ts +119 -0
- package/dist/build-cluster-reason.js +63 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +54 -46
- package/dist/derive-blocking-reason.d.ts +32 -0
- package/dist/derive-blocking-reason.js +1 -0
- package/dist/merge-cross-block-clusters.d.ts +39 -3
- package/dist/merge-cross-block-clusters.js +23 -10
- package/dist/pass0-blocking.d.ts +10 -0
- package/dist/pass0-blocking.js +26 -46
- package/dist/per-page-landmark-signatures.d.ts +4 -5
- package/dist/reassign-orphan-block-keys.d.ts +9 -0
- package/dist/reassign-orphan-block-keys.js +5 -2
- package/dist/resolve-blocking-group-keys.d.ts +10 -0
- package/dist/resolve-blocking-group-keys.js +16 -86
- package/dist/resolve-page-cluster-keys.d.ts +28 -53
- package/dist/resolve-page-cluster-keys.js +183 -70
- package/dist/shell-quorum.d.ts +4 -4
- package/dist/shell-quorum.js +4 -4
- package/package.json +10 -2
- package/dist/build-page-landmark-report.d.ts +0 -50
- package/dist/build-page-landmark-report.js +0 -67
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>] [--
|
|
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
|
-
`--
|
|
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
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
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
|
-
|
|
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
|
-
- `--
|
|
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
|
|
123
|
-
| `@d-zero/page-cluster/
|
|
124
|
-
| `@d-zero/page-cluster/
|
|
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
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>] [--
|
|
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 --
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
"
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
--
|
|
47
|
-
above to
|
|
48
|
-
|
|
49
|
-
|
|
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 '--
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}):
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
}
|