@d-zero/page-cluster 0.5.6 → 0.6.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 -0
- package/dist/build-mirrored-template-fixture.d.ts +61 -0
- package/dist/build-mirrored-template-fixture.js +127 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +134 -55
- package/dist/compute-cluster-cohesion.d.ts +59 -0
- package/dist/compute-cluster-cohesion.js +107 -0
- package/dist/detect-mirror-axis.d.ts +74 -0
- package/dist/detect-mirror-axis.js +0 -0
- package/dist/find-cross-cluster-duplicates.d.ts +94 -0
- package/dist/find-cross-cluster-duplicates.js +164 -0
- package/dist/merge-cross-block-clusters.d.ts +18 -0
- package/dist/merge-cross-block-clusters.js +256 -9
- package/dist/merge-validated-clusters.d.ts +27 -0
- package/dist/merge-validated-clusters.js +50 -0
- package/dist/normalize-href-by-mirror-axis.d.ts +25 -0
- package/dist/normalize-href-by-mirror-axis.js +30 -0
- package/dist/normalize-path-by-mirror-axis.d.ts +22 -0
- package/dist/normalize-path-by-mirror-axis.js +25 -0
- package/dist/resolve-page-cluster-keys.d.ts +43 -0
- package/dist/resolve-page-cluster-keys.js +99 -3
- package/dist/stage-a-per-block.js +1 -0
- package/dist/validate-cluster-partition.d.ts +70 -0
- package/dist/validate-cluster-partition.js +49 -0
- package/package.json +36 -4
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ jq -c '.[]' crawl-output.json | page-cluster > clusters.jsonl
|
|
|
79
79
|
|
|
80
80
|
- `--content-block-attribute <name>` — CMS が自由編集コンテンツブロックに付与している属性名(例: `data-bgb`)が分かっている場合に指定する。指定すると比較前にその属性を持つ要素配下を無視するので、同じテンプレートで本文構成だけ違うページを混同しなくなる。唯一の site-specific なオプションで、未指定でも `<main>` / `role="main"` を起点にした自動深さキャップが常時働く(詳細は `resolve-page-cluster-keys.ts` の JSDoc を参照)
|
|
81
81
|
- `--cluster-reasons-file <path>` — 上記の「クラスタ選定理由」を `<path>` に JSON として書き出す。ページ数の上限はない。20,000 ページ以下のコーパスでは、指定すると進捗表示(後述)は出なくなる(進捗を出さない非ストリーミング経路に常に振り分けられるため。20,000 ページ超のストリーミング経路では進捗表示・クラスタ理由の両方が動く)
|
|
82
|
+
- `--validation-file <path>` — 分類結果の事後検証レポート(下記「分類結果の事後検証」参照)を `<path>` に JSON として書き出す。指定すると、レポートの検出結果のうち安全に統合できるもの(構造トークン集合が完全一致、またはミラー軸による裏付けあり)が **stdout の `clusterKey` にも自動的に反映される**
|
|
82
83
|
- `--help` / `-h` — ヘルプを表示する
|
|
83
84
|
- `--version` / `-v` — バージョンを表示する
|
|
84
85
|
|
|
@@ -119,6 +120,13 @@ silence したい場合は `2>/dev/null`。ログに残したい場合は `2> pr
|
|
|
119
120
|
| `@d-zero/page-cluster/resolve-landmark-variant-keys` | `resolveLandmarkVariantKeys` — 特定ランドマークのデザインバリアントでページを分類 |
|
|
120
121
|
| `@d-zero/page-cluster/is-chrome-landmark-instance` | `isChromeLandmarkInstance` — 1 つの landmark インスタンスのトークン集合と `ClusterReason.landmarks[type].shellTokens` のようなシェルトークン集合を突き合わせて chrome/content を判定するステートレス関数 |
|
|
121
122
|
| `@d-zero/page-cluster/jaccard-similarity` | `jaccardSimilarity` — 2 つのトークン集合の Jaccard 類似度。`ClusterReason` 同士(`structuralCoreTokens` や `shellTokens`)を比較して兄弟クラスタとの差分を調べる用途などに使う |
|
|
123
|
+
| `@d-zero/page-cluster/validate-cluster-partition` | `ClusterPartitionReport` 型、`validateClusterPartition` — 分類済みの `clusterKey` を、分類そのものではなく分類結果同士を突き合わせて事後検証する(下記「分類結果の事後検証」参照)。通常は `resolvePageClusterKeys` の `onPartitionReport` 経由で使うので直接呼ぶ必要はない |
|
|
124
|
+
| `@d-zero/page-cluster/find-cross-cluster-duplicates` | `ClusteredPage` 型、`CrossClusterDuplicate` 型、`findCrossClusterDuplicates` — 別クラスタに分かれているが同一テンプレートらしいページ対を検出する |
|
|
125
|
+
| `@d-zero/page-cluster/compute-cluster-cohesion` | `ClusterCohesion` 型、`computeClusterCohesion` — クラスタ内メンバーが互いにどれだけ似ているかを分布として報告する(無関係なテンプレートが混ざったクラスタの検出に使う) |
|
|
126
|
+
| `@d-zero/page-cluster/detect-mirror-axis` | `MirrorAxis` 型、`detectMirrorAxis` — 言語ディレクトリなど、URL のあるセグメントだけを変えてサイトの一部をミラーしている「軸」を、事前知識なしに URL パス集合から発見する |
|
|
127
|
+
| `@d-zero/page-cluster/normalize-path-by-mirror-axis` | `normalizePathByMirrorAxis` — 検出した軸に沿ってページの URL パスを正規化し、ミラー元同士を同じ形状に揃える |
|
|
128
|
+
| `@d-zero/page-cluster/normalize-href-by-mirror-axis` | `normalizeHrefByMirrorAxis` — 検出した軸に沿って URL(主に stylesheet href)を正規化する |
|
|
129
|
+
| `@d-zero/page-cluster/merge-validated-clusters` | `mergeValidatedClusters` — 確認済みのクラスタ対の統合を `clusterKey` 配列に適用する純関数 |
|
|
122
130
|
|
|
123
131
|
```ts
|
|
124
132
|
import { resolvePageClusterKeysFromArray } from '@d-zero/page-cluster/resolve-page-cluster-keys';
|
|
@@ -192,6 +200,33 @@ flowchart TD
|
|
|
192
200
|
|
|
193
201
|
マージが起きるとユニットのメンバー構成が変わり、文書頻度も quorum core も変わる。そのため毎ラウンド、統合後のプールから全指標を**再計算**してマージを再試行する。fine stage・L2 stage の両方でマージが 1 件も出なくなった時点で不動点に到達したとみなして収束する(安全弁として最大 10 ラウンド。実データでは 7 ラウンド以内に収束)。L2 stage は fine stage が空振りしたラウンドでしか実行されない最後の粗い経路で、誤マージ防止のために shell(ランドマーク由来トークン)の相互裏付けを要求する。
|
|
194
202
|
|
|
203
|
+
fine stage・L2 stage いずれの経路で提案されたマージも、適用前に**凝集度ガード**を通る: 統合後の quorum core が統合前の core に対して一定比率を下回るなら、そのマージは破棄される。個々の経路が「統合前のペア類似度」だけを見て提案する一方、複数ラウンドにわたる連鎖的な統合は「統合後に実際どれだけまとまっているか」を悪化させ得る(無関係なテンプレート同士が少しずつ吸収し合う catch-all 化)ため、経路をまたいだ単一のチェックポイントとして機能する。あわせて、L2 stage 自体もラウンド開始前に判別力を検査し、参加ユニット全体が同一の署名形状に潰れている(`main` 直下の浅い階層しか手がかりが残っていない等)場合は、そのラウンドの L2 比較を丸ごとスキップする。詳細は `merge-cross-block-clusters.ts` の `filterMergesByCohesion` / `hasDiscriminatingL2Signatures` の JSDoc を参照。
|
|
204
|
+
|
|
195
205
|
### Self-tuning
|
|
196
206
|
|
|
197
207
|
閾値の多くは **max-gap auto-cut**(度数分布の隣接ギャップ最大の中点を境界とする)でデータから自己発見される。① Stage A のカット高、② Stage B の shell 判定、③ chrome discovery のグローバル/ローカル判定、④ Pass 0 の URL パス深さ選択、の 4 箇所で同一プリミティブを再利用しているので、サイトごとにハイパーパラメータをチューニングする必要はない。詳細は `autoCutThreshold` の JSDoc を参照。例外的に Stage B fine stage の complete-linkage だけは固定閾値 0.8 を使う(理由は `merge-cross-block-clusters.ts` の JSDoc を参照)。
|
|
208
|
+
|
|
209
|
+
## 分類結果の事後検証
|
|
210
|
+
|
|
211
|
+
Stage A/B は「クラスタリング中に」正しい判断をしようとするが、判断材料はその時点でのペア類似度に限られる。分類が終わった**あとで**、確定したパーティション同士を突き合わせて検証する方が、同じ種類の誤りをかえって見つけやすい場合がある: マージ中は局所的なペア情報とマージ順序しか持たないが、事後なら分割全体を一度に見られるからだ。この事後検証は Stage A/B のアルゴリズムに一切依存しないので、`resolvePageClusterKeys` 以外で作られた分類結果(保存済みの `clusterKey` をアーカイブから読み戻した場合など)にも使える。
|
|
212
|
+
|
|
213
|
+
3 つのチェックを行う。
|
|
214
|
+
|
|
215
|
+
- **クラスタ間重複検出**(`findCrossClusterDuplicates`) — 別クラスタに分かれているページ対のうち、構造トークン集合が完全一致するもの(軸の裏付けなしで確定)と、ミラー軸で裏付けられる近似一致のものを検出する
|
|
216
|
+
- **クラスタ内凝集度**(`computeClusterCohesion`) — クラスタ内のメンバー同士がどれだけ似ているかを中央値・10 パーセンタイル・最小値の分布として報告する。無関係なテンプレートが混ざったクラスタは、共通のシェル由来トークンだけが一致する形で `structuralCoreTokens` 自体は非空のまま残ることがあるため、core の有無だけでは過剰マージを検出できない
|
|
217
|
+
- **ミラー軸の自動発見**(`detectMirrorAxis`) — 言語ディレクトリのように、URL のあるセグメントだけを変えてサイトの一部をミラーしている構造を、言語コード等の事前知識なしに発見する。同じ値集合が何種類の異なるパス骨格にわたって反復するかを数え、`autoCutThreshold` で「たまたま値が 2 つ以上あるだけの兄弟ページ」と「サイト全体を貫く軸」を切り分ける
|
|
218
|
+
|
|
219
|
+
`validateClusterPartition` はこの 3 つをまとめて呼び出すエントリポイント。`ClusterReason` と同じく、判断結果ではなく構造化データだけを返す — 検出された重複をどう扱うか(統合するかどうか、`suspicious` フラグをどう解釈するか)は呼び出し側の責務。実際に統合を適用する場合は `mergeValidatedClusters` に確認済みのクラスタ対を渡す。
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { mergeValidatedClusters } from '@d-zero/page-cluster/merge-validated-clusters';
|
|
223
|
+
import { validateClusterPartition } from '@d-zero/page-cluster/validate-cluster-partition';
|
|
224
|
+
|
|
225
|
+
const report = validateClusterPartition(pages); // pages: { clusterKey, tokens, paths, stylesheetHrefs }[]
|
|
226
|
+
const safeToMerge = report.crossClusterDuplicates.filter(
|
|
227
|
+
(d) => d.similarity === 1 || d.corroboratedByMirrorAxis,
|
|
228
|
+
);
|
|
229
|
+
const mergedKeys = mergeValidatedClusters(clusterKeys, safeToMerge);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`resolvePageClusterKeys` に `onPartitionReport` コールバックを渡すと、この検証が Stage B 完了直後に自動的に走り、上記と同じ安全な統合ポリシー(完全一致、またはミラー軸による裏付けあり)が返り値の `clusterKey` にもそのまま反映される。`onClusterReason` と同じくオプトインで、渡さない限り既存の挙動・出力は一切変わらない。詳細はその JSDoc を参照。
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { PageClusterSignals } from './resolve-page-cluster-keys.js';
|
|
2
|
+
/**
|
|
3
|
+
* Options for {@link buildMirroredTemplateFixture}.
|
|
4
|
+
*/
|
|
5
|
+
export type MirroredTemplateFixtureOptions = {
|
|
6
|
+
/**
|
|
7
|
+
* URL segment values a whole section is mirrored under (e.g. language
|
|
8
|
+
* codes). Each value gets its own copy of every template, at its own
|
|
9
|
+
* per-value stylesheet href — modeling a site that duplicates a section's
|
|
10
|
+
* CSS per mirror instead of sharing one file.
|
|
11
|
+
*/
|
|
12
|
+
readonly axisValues?: readonly string[];
|
|
13
|
+
/** Pages generated per (template, axis value) pair. */
|
|
14
|
+
readonly pagesPerTemplate?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Tag wrapping each page's content. `'main'` reproduces the L2-stage
|
|
17
|
+
* signature collapse this fixture exists to catch (see
|
|
18
|
+
* `merge-cross-block-clusters.ts`'s `l2Signature` JSDoc): every template's
|
|
19
|
+
* content sits under a `main > article > div > section > div` chain, so
|
|
20
|
+
* `l2Signature`'s class-stripped, 2-level-past-`main` truncation reduces
|
|
21
|
+
* every template to the same key set. `'div'` (or any other tag) opts out
|
|
22
|
+
* of `l2Signature`'s `main`-anchoring, so the fine stage alone must
|
|
23
|
+
* separate the templates correctly.
|
|
24
|
+
*/
|
|
25
|
+
readonly wrapperTag?: 'main' | 'div';
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* One generated page's ground truth, alongside the `PageClusterSignals` a
|
|
29
|
+
* clustering call actually sees. Tests assert against `template`/`axisValue`
|
|
30
|
+
* rather than parsing them back out of a `clusterKey` string.
|
|
31
|
+
*/
|
|
32
|
+
export type MirroredTemplateFixturePage = {
|
|
33
|
+
readonly signals: PageClusterSignals;
|
|
34
|
+
readonly template: string;
|
|
35
|
+
readonly axisValue: string;
|
|
36
|
+
readonly page: number;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Builds a synthetic multi-template, multi-mirror site: every template
|
|
40
|
+
* shares a site-wide shell (header/gnav/footer) and shares
|
|
41
|
+
* breadcrumb/page-head/related content blocks, and each template's own
|
|
42
|
+
* content is a distinct ordered subset of a shared CMS module pool (see
|
|
43
|
+
* {@link MODULE_POOL}) so templates partially overlap in tokens rather than
|
|
44
|
+
* being trivially disjoint. Every template is duplicated once per
|
|
45
|
+
* `axisValues` entry, each with its own per-mirror stylesheet href — this
|
|
46
|
+
* shape collapses every template into one catch-all cluster when `main` is
|
|
47
|
+
* the wrapper tag (a `main`-anchored wrapper chain degenerates
|
|
48
|
+
* `l2Signature`), while the same site with content under a plain `<div>`
|
|
49
|
+
* clusters correctly (see `wrapperTag`'s own JSDoc).
|
|
50
|
+
* @param options
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const { pages } = buildMirroredTemplateFixture({ wrapperTag: 'main' });
|
|
54
|
+
* const keys = await resolvePageClusterKeysFromArray(pages.map((p) => p.signals));
|
|
55
|
+
* // keys collapse to a single value — the bug this fixture reproduces
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildMirroredTemplateFixture(options?: MirroredTemplateFixtureOptions): {
|
|
59
|
+
readonly pages: readonly MirroredTemplateFixturePage[];
|
|
60
|
+
readonly templates: readonly string[];
|
|
61
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param count
|
|
3
|
+
* @param render
|
|
4
|
+
*/
|
|
5
|
+
function repeatMarkup(count, render) {
|
|
6
|
+
let out = '';
|
|
7
|
+
for (let i = 0; i < count; i++)
|
|
8
|
+
out += render(i);
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Pool of CMS-style content blocks that {@link TEMPLATE_MODULES} composes
|
|
13
|
+
* templates from. Modeled after real CMS "block" systems: templates share
|
|
14
|
+
* modules with each other (a FAQ page and a contact page both use
|
|
15
|
+
* `notice`/`contact`), which is what makes this fixture exercise Stage B's
|
|
16
|
+
* cross-block containment/shape logic instead of trivially disjoint token
|
|
17
|
+
* sets.
|
|
18
|
+
*/
|
|
19
|
+
const MODULE_POOL = {
|
|
20
|
+
lead: () => `<div class="mod-lead"><p class="mod-lead__text">x</p><p class="mod-lead__note">n</p></div>`,
|
|
21
|
+
heading: () => `<div class="mod-heading"><h2 class="mod-heading__ttl">h</h2><p class="mod-heading__sub">s</p></div>`,
|
|
22
|
+
photo: () => `<figure class="mod-photo"><img class="mod-photo__img" src="p.jpg"><figcaption class="mod-photo__cap">c</figcaption></figure>`,
|
|
23
|
+
table: (n) => `<div class="mod-table"><table class="mod-table__table"><thead><tr><th class="mod-table__th">h</th></tr></thead><tbody>${repeatMarkup(n, (i) => `<tr class="mod-table__tr"><td class="mod-table__td">v${i}</td></tr>`)}</tbody></table></div>`,
|
|
24
|
+
accordion: (n) => `<div class="mod-accordion">${repeatMarkup(n, (i) => `<dl class="mod-accordion__item"><dt class="mod-accordion__q"><button class="mod-accordion__toggle">q${i}</button></dt><dd class="mod-accordion__a"><p class="mod-accordion__text">a${i}</p></dd></dl>`)}</div>`,
|
|
25
|
+
cards: (n) => `<ul class="mod-cards">${repeatMarkup(n, (i) => `<li class="mod-cards__item"><a class="mod-cards__link" href="#${i}"><span class="mod-cards__fig"><img class="mod-cards__img" src="c.jpg"></span><span class="mod-cards__ttl">c${i}</span></a></li>`)}</ul>`,
|
|
26
|
+
steps: (n) => `<ol class="mod-steps">${repeatMarkup(n, (i) => `<li class="mod-steps__item"><span class="mod-steps__num">${i}</span><h3 class="mod-steps__ttl">s${i}</h3><p class="mod-steps__text">d${i}</p></li>`)}</ol>`,
|
|
27
|
+
map: () => `<div class="mod-map"><iframe class="mod-map__frame" src="map"></iframe><p class="mod-map__note">m</p></div>`,
|
|
28
|
+
deflist: (n) => `<dl class="mod-deflist">${repeatMarkup(n, (i) => `<dt class="mod-deflist__dt">d${i}</dt><dd class="mod-deflist__dd">v${i}</dd>`)}</dl>`,
|
|
29
|
+
gallery: (n) => `<ul class="mod-gallery">${repeatMarkup(n, (i) => `<li class="mod-gallery__item"><figure class="mod-gallery__fig"><img class="mod-gallery__img" src="g.jpg"><figcaption class="mod-gallery__cap">g${i}</figcaption></figure></li>`)}</ul>`,
|
|
30
|
+
form: (n) => `<form class="mod-form" action="#">${repeatMarkup(n, (i) => `<div class="mod-form__row"><label class="mod-form__label">l${i}</label><input class="mod-form__input"></div>`)}<button class="mod-form__submit">go</button></form>`,
|
|
31
|
+
notice: () => `<div class="mod-notice"><p class="mod-notice__text">n</p><a class="mod-notice__link" href="#">more</a></div>`,
|
|
32
|
+
timetable: (n) => `<div class="mod-timetable"><table class="mod-timetable__table"><tbody>${repeatMarkup(n, (i) => `<tr class="mod-timetable__tr"><td class="mod-timetable__time">t${i}</td><td class="mod-timetable__dest">d${i}</td></tr>`)}</tbody></table></div>`,
|
|
33
|
+
price: (n) => `<div class="mod-price"><ul class="mod-price__list">${repeatMarkup(n, (i) => `<li class="mod-price__item"><span class="mod-price__name">n${i}</span><span class="mod-price__yen">y${i}</span></li>`)}</ul></div>`,
|
|
34
|
+
banner: () => `<div class="mod-banner"><a class="mod-banner__link" href="#"><img class="mod-banner__img" src="b.jpg"></a></div>`,
|
|
35
|
+
video: () => `<div class="mod-video"><video class="mod-video__el" src="v.mp4"></video></div>`,
|
|
36
|
+
tabs: (n) => `<div class="mod-tabs"><ul class="mod-tabs__nav">${repeatMarkup(n, (i) => `<li class="mod-tabs__navitem"><button class="mod-tabs__btn">t${i}</button></li>`)}</ul><div class="mod-tabs__panel"><p class="mod-tabs__text">p</p></div></div>`,
|
|
37
|
+
download: (n) => `<ul class="mod-download">${repeatMarkup(n, (i) => `<li class="mod-download__item"><a class="mod-download__link" href="f${i}.pdf"><span class="mod-download__icon"></span><span class="mod-download__name">f${i}</span></a></li>`)}</ul>`,
|
|
38
|
+
contact: () => `<div class="mod-contact"><p class="mod-contact__tel">tel</p><p class="mod-contact__hours">h</p><a class="mod-contact__mail" href="#">m</a></div>`,
|
|
39
|
+
anchorlinks: (n) => `<nav class="mod-anchor"><ul class="mod-anchor__list">${repeatMarkup(n, (i) => `<li class="mod-anchor__item"><a class="mod-anchor__link" href="#a${i}">a${i}</a></li>`)}</ul></nav>`,
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Templates as ordered subsets of {@link MODULE_POOL}, modeling how real CMS
|
|
43
|
+
* templates share some blocks and differ in others.
|
|
44
|
+
*/
|
|
45
|
+
const TEMPLATE_MODULES = {
|
|
46
|
+
faq: ['heading', 'anchorlinks', 'accordion', 'notice', 'contact'],
|
|
47
|
+
course: ['heading', 'photo', 'steps', 'map', 'deflist', 'notice'],
|
|
48
|
+
index: ['heading', 'lead', 'cards', 'banner', 'notice'],
|
|
49
|
+
fare: ['heading', 'lead', 'price', 'table', 'notice', 'contact'],
|
|
50
|
+
article: ['heading', 'lead', 'photo', 'deflist', 'download'],
|
|
51
|
+
gallery: ['heading', 'gallery', 'video', 'banner'],
|
|
52
|
+
search: ['heading', 'form', 'tabs', 'notice'],
|
|
53
|
+
access: ['heading', 'map', 'timetable', 'deflist', 'contact'],
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* @param n
|
|
57
|
+
* @param render
|
|
58
|
+
*/
|
|
59
|
+
function times(n, render) {
|
|
60
|
+
let out = '';
|
|
61
|
+
for (let i = 0; i < n; i++)
|
|
62
|
+
out += render(i);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
const HEADER = `<header class="site-header"><div class="site-header__inner"><p class="site-logo"><a class="site-logo__link" href="/"><img class="site-logo__img" src="logo.svg"></a></p><nav class="gnav"><ul class="gnav__list">${times(8, (i) => `<li class="gnav__item"><a class="gnav__link" href="/s${i}/">n${i}</a></li>`)}</ul></nav><div class="site-header__util"><a class="site-header__lang" href="#">lang</a></div></div></header>`;
|
|
66
|
+
const FOOTER = `<footer class="site-footer"><div class="site-footer__inner"><nav class="fnav"><ul class="fnav__list">${times(10, (i) => `<li class="fnav__item"><a class="fnav__link" href="/f${i}/">f${i}</a></li>`)}</ul></nav><p class="site-copyright">c</p></div></footer>`;
|
|
67
|
+
const BREADCRUMB = `<nav class="breadcrumb"><ol class="breadcrumb__list">${times(3, (i) => `<li class="breadcrumb__item"><a class="breadcrumb__link" href="/b${i}/">b${i}</a></li>`)}</ol></nav>`;
|
|
68
|
+
const PAGE_HEAD = `<div class="page-head"><div class="page-head__inner"><h1 class="page-head__ttl">t</h1><p class="page-head__lead">l</p></div></div>`;
|
|
69
|
+
const RELATED = `<aside class="related"><h2 class="related__ttl">r</h2><ul class="related__list">${times(4, (i) => `<li class="related__item"><a class="related__link" href="/r${i}/">r${i}</a></li>`)}</ul></aside>`;
|
|
70
|
+
/**
|
|
71
|
+
* Builds a synthetic multi-template, multi-mirror site: every template
|
|
72
|
+
* shares a site-wide shell (header/gnav/footer) and shares
|
|
73
|
+
* breadcrumb/page-head/related content blocks, and each template's own
|
|
74
|
+
* content is a distinct ordered subset of a shared CMS module pool (see
|
|
75
|
+
* {@link MODULE_POOL}) so templates partially overlap in tokens rather than
|
|
76
|
+
* being trivially disjoint. Every template is duplicated once per
|
|
77
|
+
* `axisValues` entry, each with its own per-mirror stylesheet href — this
|
|
78
|
+
* shape collapses every template into one catch-all cluster when `main` is
|
|
79
|
+
* the wrapper tag (a `main`-anchored wrapper chain degenerates
|
|
80
|
+
* `l2Signature`), while the same site with content under a plain `<div>`
|
|
81
|
+
* clusters correctly (see `wrapperTag`'s own JSDoc).
|
|
82
|
+
* @param options
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const { pages } = buildMirroredTemplateFixture({ wrapperTag: 'main' });
|
|
86
|
+
* const keys = await resolvePageClusterKeysFromArray(pages.map((p) => p.signals));
|
|
87
|
+
* // keys collapse to a single value — the bug this fixture reproduces
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function buildMirroredTemplateFixture(options) {
|
|
91
|
+
const axisValues = options?.axisValues ?? ['en', 'zh', 'ko', 'th'];
|
|
92
|
+
const pagesPerTemplate = options?.pagesPerTemplate ?? 6;
|
|
93
|
+
const wrapperTag = options?.wrapperTag ?? 'main';
|
|
94
|
+
const pages = [];
|
|
95
|
+
for (const [template, moduleNames] of Object.entries(TEMPLATE_MODULES)) {
|
|
96
|
+
for (const axisValue of axisValues) {
|
|
97
|
+
for (let page = 0; page < pagesPerTemplate; page++) {
|
|
98
|
+
// Every third page drops its last module — models conditional
|
|
99
|
+
// rendering (an optional section) without changing the template.
|
|
100
|
+
const mods = page % 3 === 2 ? moduleNames.slice(0, -1) : moduleNames;
|
|
101
|
+
const body = mods
|
|
102
|
+
.map((m) => `<div class="bg-base"><section class="sec sec--${m}"><div class="container">${MODULE_POOL[m](2 + (page % 3))}</div></section></div>`)
|
|
103
|
+
.join('');
|
|
104
|
+
const inner = `<article>${PAGE_HEAD}${BREADCRUMB}${body}${RELATED}</article>`;
|
|
105
|
+
const wrapped = wrapperTag === 'main'
|
|
106
|
+
? `<main>${inner}</main>`
|
|
107
|
+
: `<div class="main-area">${inner}</div>`;
|
|
108
|
+
pages.push({
|
|
109
|
+
template,
|
|
110
|
+
axisValue,
|
|
111
|
+
page,
|
|
112
|
+
signals: {
|
|
113
|
+
paths: [axisValue, template, `p${page}.html`],
|
|
114
|
+
stylesheetHrefs: [
|
|
115
|
+
'https://example.test/css/reset.css',
|
|
116
|
+
'https://example.test/css/style.css',
|
|
117
|
+
`https://example.test/${axisValue}/${template}/page.css`,
|
|
118
|
+
],
|
|
119
|
+
host: 'example.test',
|
|
120
|
+
html: `<html><body>${HEADER}${wrapped}${FOOTER}</body></html>`,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { pages, templates: Object.keys(TEMPLATE_MODULES) };
|
|
127
|
+
}
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
// animated header on a TTY, appended `[page-cluster] …` lines otherwise.
|
|
7
7
|
import { writeFile } from 'node:fs/promises';
|
|
8
8
|
import process from 'node:process';
|
|
9
|
+
import { unwrapSuppressedError } from '@d-zero/cli-core';
|
|
9
10
|
import { Lanes } from '@d-zero/dealer';
|
|
10
11
|
import { resolvePageClusterKeys } from './resolve-page-cluster-keys.js';
|
|
11
12
|
const HELP_TEXT = `Usage:
|
|
12
|
-
page-cluster [--content-block-attribute <name>] [--cluster-reasons-file <path>] < pages.jsonl > clusters.jsonl
|
|
13
|
+
page-cluster [--content-block-attribute <name>] [--cluster-reasons-file <path>] [--validation-file <path>] < pages.jsonl > clusters.jsonl
|
|
13
14
|
|
|
14
15
|
Input (JSONL, one page per line):
|
|
15
16
|
{
|
|
@@ -47,6 +48,29 @@ Output (JSONL, one line per input page, in input order):
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
With --validation-file <path>, a separate JSON file is written once
|
|
52
|
+
processing completes, checking the finished partition against itself:
|
|
53
|
+
whether pages that ended up in different clusters are nonetheless
|
|
54
|
+
structurally identical or near-identical (a likely over-split), and
|
|
55
|
+
whether any single cluster's own members actually agree with each other
|
|
56
|
+
(a likely over-merge). Registering this flag also applies a built-in
|
|
57
|
+
auto-merge to the clusterKeys in the stdout output above: any pair of
|
|
58
|
+
clusters found byte-identical, or near-identical and corroborated by a
|
|
59
|
+
detected URL mirror axis (e.g. a language directory), is merged before
|
|
60
|
+
output — see ClusterPartitionReport's own JSDoc for the exact policy.
|
|
61
|
+
{
|
|
62
|
+
"mirrorAxis": { "position": 0, "values": ["en", "zh"] },
|
|
63
|
+
"cohesion": [
|
|
64
|
+
{ "clusterKey": "...", "memberCount": 42, "sampledMemberCount": 40,
|
|
65
|
+
"medianPairSimilarity": 0.91, "p10PairSimilarity": 0.78,
|
|
66
|
+
"minPairSimilarity": 0.6, "suspicious": false }
|
|
67
|
+
],
|
|
68
|
+
"crossClusterDuplicates": [
|
|
69
|
+
{ "clusterKeyA": "...", "clusterKeyB": "...", "similarity": 1,
|
|
70
|
+
"corroboratedByMirrorAxis": true }
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
Options:
|
|
51
75
|
--content-block-attribute <name> CMS-provided attribute marking freeform
|
|
52
76
|
content blocks that should be stripped
|
|
@@ -55,6 +79,10 @@ Options:
|
|
|
55
79
|
object described above to <path> after
|
|
56
80
|
processing completes. No page-count
|
|
57
81
|
limit.
|
|
82
|
+
--validation-file <path> Write the partition-validation report
|
|
83
|
+
described above to <path>, and apply
|
|
84
|
+
its built-in auto-merge to the output
|
|
85
|
+
clusterKeys.
|
|
58
86
|
--help Print this help and exit.
|
|
59
87
|
--version Print the package version and exit.
|
|
60
88
|
|
|
@@ -121,6 +149,16 @@ export function parseArgs(argv) {
|
|
|
121
149
|
i++;
|
|
122
150
|
break;
|
|
123
151
|
}
|
|
152
|
+
case '--validation-file': {
|
|
153
|
+
const next = argv[i + 1];
|
|
154
|
+
if (next === undefined) {
|
|
155
|
+
out.unknownFlag = `${arg} requires a value`;
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
out.validationFile = next;
|
|
159
|
+
i++;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
124
162
|
default: {
|
|
125
163
|
out.unknownFlag = arg;
|
|
126
164
|
return out;
|
|
@@ -261,6 +299,20 @@ function errorLine(message) {
|
|
|
261
299
|
verbose: `error: ${message}`,
|
|
262
300
|
};
|
|
263
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Formats a caught error for `errorLine()`. `SuppressedError` (thrown when a
|
|
304
|
+
* `using`-scoped body error and a disposal error occur together) hides the
|
|
305
|
+
* real cause behind a generic message, so its underlying causes are
|
|
306
|
+
* unwrapped and joined into one line — `errorLine()`/`renderProgress()` must
|
|
307
|
+
* still be called exactly once per catch site, since Lanes' TTY repaint only
|
|
308
|
+
* keeps the latest frame (see {@link errorLine}'s JSDoc).
|
|
309
|
+
* @param error
|
|
310
|
+
*/
|
|
311
|
+
function formatErrorMessage(error) {
|
|
312
|
+
return unwrapSuppressedError(error)
|
|
313
|
+
.map((cause) => (cause instanceof Error ? cause.message : String(cause)))
|
|
314
|
+
.join(' / ');
|
|
315
|
+
}
|
|
264
316
|
/**
|
|
265
317
|
* Maps a library `ProgressEvent` to a human-facing `ProgressLine`. The
|
|
266
318
|
* verbose arm keeps the historical `pass0:` / `pass1:` / `pass1b:` /
|
|
@@ -299,6 +351,24 @@ function formatProgressLine(event, elapsedSec) {
|
|
|
299
351
|
}
|
|
300
352
|
}
|
|
301
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* `JSON.stringify` serializes a `Set` as `{}` — `ClusterPartitionReport`'s
|
|
356
|
+
* only non-JSON-safe field is `mirrorAxis.values`, so this is the one spot
|
|
357
|
+
* that needs converting before the report can be written to a file.
|
|
358
|
+
* @param report
|
|
359
|
+
*/
|
|
360
|
+
function toJsonSafePartitionReport(report) {
|
|
361
|
+
return {
|
|
362
|
+
mirrorAxis: report.mirrorAxis
|
|
363
|
+
? {
|
|
364
|
+
position: report.mirrorAxis.position,
|
|
365
|
+
values: [...report.mirrorAxis.values].toSorted(),
|
|
366
|
+
}
|
|
367
|
+
: null,
|
|
368
|
+
cohesion: report.cohesion,
|
|
369
|
+
crossClusterDuplicates: report.crossClusterDuplicates,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
302
372
|
/**
|
|
303
373
|
* Test-friendly entry point: takes the run's stdin/stdout/stderr streams
|
|
304
374
|
* and the parsed CLI flags rather than reading them out of the process
|
|
@@ -331,7 +401,10 @@ export async function runCli(options) {
|
|
|
331
401
|
// pass in) does — reading it defensively lets both real usage and
|
|
332
402
|
// unit-test doubles work without a separate `--no-progress` flag.
|
|
333
403
|
const useTty = options.stderr.isTTY === true;
|
|
334
|
-
|
|
404
|
+
// `using` により、この関数を抜けるすべての経路(下記の各 `return` は
|
|
405
|
+
// もちろん、想定外の例外を含む)で確実に lanes.close() が呼ばれ、
|
|
406
|
+
// Display の setTimeout タイマーが解放される。
|
|
407
|
+
using lanes = new Lanes({ stream: options.stderr, verbose: !useTty });
|
|
335
408
|
// Verbose Lanes prepends `#header` to every `update()` line. Without
|
|
336
409
|
// this seed call the header would be undefined and each progress line
|
|
337
410
|
// would begin with the literal string `undefined ` — bug caught by
|
|
@@ -342,71 +415,77 @@ export async function runCli(options) {
|
|
|
342
415
|
}
|
|
343
416
|
const startTime = Date.now();
|
|
344
417
|
const elapsed = () => Math.max(0, Math.round((Date.now() - startTime) / 1000));
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
418
|
+
renderProgress(lanes, useTty, READING_INPUT);
|
|
419
|
+
// Load every JSONL line into memory once so the ids array stays
|
|
420
|
+
// parallel to the pages array — the streaming driver reads its
|
|
421
|
+
// factory twice, and stdin is a one-shot pipe.
|
|
422
|
+
const ids = [];
|
|
423
|
+
const pages = [];
|
|
349
424
|
try {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
425
|
+
for await (const { id, page } of readJsonlPages(options.stdin)) {
|
|
426
|
+
ids.push(id);
|
|
427
|
+
pages.push(page);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
|
|
432
|
+
return 1;
|
|
433
|
+
}
|
|
434
|
+
renderProgress(lanes, useTty, readingDoneLine(pages.length));
|
|
435
|
+
// Only worth collecting when the caller asked for the file — a
|
|
436
|
+
// ClusterReason Map costs bookkeeping proportional to cluster count,
|
|
437
|
+
// not page count, but there's no reason to pay even that when unused.
|
|
438
|
+
const reasonsByClusterKey = args.clusterReasonsFile
|
|
439
|
+
? new Map()
|
|
440
|
+
: undefined;
|
|
441
|
+
// Same opt-in gate as `reasonsByClusterKey` — `onPartitionReport`
|
|
442
|
+
// itself gates the underlying validation pass (see its own JSDoc).
|
|
443
|
+
let partitionReport;
|
|
444
|
+
const resolveOptions = {
|
|
445
|
+
contentBlockAttribute: args.contentBlockAttribute,
|
|
446
|
+
onProgress: (event) => {
|
|
447
|
+
renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
|
|
448
|
+
},
|
|
449
|
+
onClusterReason: reasonsByClusterKey
|
|
450
|
+
? (key, reason) => reasonsByClusterKey.set(key, reason)
|
|
451
|
+
: undefined,
|
|
452
|
+
onPartitionReport: args.validationFile
|
|
453
|
+
? (report) => (partitionReport = report)
|
|
454
|
+
: undefined,
|
|
455
|
+
};
|
|
456
|
+
let clusterKeys;
|
|
457
|
+
try {
|
|
458
|
+
clusterKeys = await resolvePageClusterKeys(() => pages, resolveOptions);
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
|
|
462
|
+
return 1;
|
|
463
|
+
}
|
|
464
|
+
const clusterCount = new Set(clusterKeys).size;
|
|
465
|
+
renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
|
|
466
|
+
for (const [index, key] of clusterKeys.entries()) {
|
|
467
|
+
const row = { id: ids[index] ?? index, clusterKey: key };
|
|
468
|
+
options.stdout.write(`${JSON.stringify(row)}\n`);
|
|
469
|
+
}
|
|
470
|
+
if (args.clusterReasonsFile && reasonsByClusterKey) {
|
|
356
471
|
try {
|
|
357
|
-
|
|
358
|
-
ids.push(id);
|
|
359
|
-
pages.push(page);
|
|
360
|
-
}
|
|
472
|
+
await writeFile(args.clusterReasonsFile, JSON.stringify(Object.fromEntries(reasonsByClusterKey), null, 2));
|
|
361
473
|
}
|
|
362
474
|
catch (error) {
|
|
363
|
-
renderProgress(lanes, useTty, errorLine(error
|
|
475
|
+
renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
|
|
364
476
|
return 1;
|
|
365
477
|
}
|
|
366
|
-
|
|
367
|
-
|
|
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;
|
|
373
|
-
const resolveOptions = {
|
|
374
|
-
contentBlockAttribute: args.contentBlockAttribute,
|
|
375
|
-
onProgress: (event) => {
|
|
376
|
-
renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
|
|
377
|
-
},
|
|
378
|
-
onClusterReason: reasonsByClusterKey
|
|
379
|
-
? (key, reason) => reasonsByClusterKey.set(key, reason)
|
|
380
|
-
: undefined,
|
|
381
|
-
};
|
|
382
|
-
let clusterKeys;
|
|
478
|
+
}
|
|
479
|
+
if (args.validationFile && partitionReport) {
|
|
383
480
|
try {
|
|
384
|
-
|
|
481
|
+
await writeFile(args.validationFile, JSON.stringify(toJsonSafePartitionReport(partitionReport), null, 2));
|
|
385
482
|
}
|
|
386
483
|
catch (error) {
|
|
387
|
-
renderProgress(lanes, useTty, errorLine(error
|
|
484
|
+
renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
|
|
388
485
|
return 1;
|
|
389
486
|
}
|
|
390
|
-
const clusterCount = new Set(clusterKeys).size;
|
|
391
|
-
renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
|
|
392
|
-
for (const [index, key] of clusterKeys.entries()) {
|
|
393
|
-
const row = { id: ids[index] ?? index, clusterKey: key };
|
|
394
|
-
options.stdout.write(`${JSON.stringify(row)}\n`);
|
|
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
|
-
}
|
|
405
|
-
return 0;
|
|
406
|
-
}
|
|
407
|
-
finally {
|
|
408
|
-
lanes.close();
|
|
409
487
|
}
|
|
488
|
+
return 0;
|
|
410
489
|
}
|
|
411
490
|
/**
|
|
412
491
|
* Reads the package version out of `package.json` at runtime. Kept as a
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for {@link computeClusterCohesion}.
|
|
3
|
+
*/
|
|
4
|
+
export type ClusterCohesionOptions = {
|
|
5
|
+
/** @see DEFAULT_MAX_SAMPLE_SIZE */
|
|
6
|
+
readonly maxSampleSize?: number;
|
|
7
|
+
/** @see DEFAULT_SUSPICIOUS_MEDIAN_BELOW */
|
|
8
|
+
readonly suspiciousMedianBelow?: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* One cluster's internal-agreement summary: how similar its members'
|
|
12
|
+
* structural tokens actually are to each other, as opposed to
|
|
13
|
+
* {@link ./merge-cross-block-clusters.js | computeQuorumCore}'s frequency
|
|
14
|
+
* core, which only reports *which* tokens are shared, not *how much* of each
|
|
15
|
+
* member's tokens that core actually covers.
|
|
16
|
+
*/
|
|
17
|
+
export type ClusterCohesion = {
|
|
18
|
+
readonly clusterKey: string;
|
|
19
|
+
readonly memberCount: number;
|
|
20
|
+
/** How many of `memberCount` members the pairwise comparison sampled. */
|
|
21
|
+
readonly sampledMemberCount: number;
|
|
22
|
+
/** `null` when fewer than 2 members were sampled (nothing to compare). */
|
|
23
|
+
readonly medianPairSimilarity: number | null;
|
|
24
|
+
/** 10th percentile (nearest-rank) of sampled pairwise similarities. */
|
|
25
|
+
readonly p10PairSimilarity: number | null;
|
|
26
|
+
readonly minPairSimilarity: number | null;
|
|
27
|
+
/** `medianPairSimilarity !== null && medianPairSimilarity < suspiciousMedianBelow`. */
|
|
28
|
+
readonly suspicious: boolean;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Reports, per cluster, how similar its members' structural token sets
|
|
32
|
+
* actually are to each other — not just which tokens they share (that's
|
|
33
|
+
* {@link ./merge-cross-block-clusters.js | computeQuorumCore}'s job), but
|
|
34
|
+
* whether the members hang together at all. A cluster built by merging
|
|
35
|
+
* unrelated templates has member pairs that mostly disagree even though a
|
|
36
|
+
* small frequency core still exists among them; this surfaces that
|
|
37
|
+
* disagreement directly, as a distribution rather than a single score, since
|
|
38
|
+
* a single "average similarity" would be pulled toward the middle by exactly
|
|
39
|
+
* the kind of partial-overlap noise this is meant to catch.
|
|
40
|
+
*
|
|
41
|
+
* Sampling uses {@link ./reservoir-sample.js | reservoirSample} seeded by
|
|
42
|
+
* each cluster's own key, so repeated calls on the same partition sample the
|
|
43
|
+
* same members and produce the same report.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately returns only structured numbers, no verdict text — same
|
|
46
|
+
* design as {@link ./build-cluster-reason.js | ClusterReason} — so a caller
|
|
47
|
+
* decides what "suspicious" should mean for their own use (an interactive
|
|
48
|
+
* review queue vs. an automated gate might want different behavior for the
|
|
49
|
+
* same numbers).
|
|
50
|
+
* @param membersByKey Every cluster's member token sets, keyed by cluster key.
|
|
51
|
+
* @param options
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* const report = computeClusterCohesion(membersByKey);
|
|
55
|
+
* const worstFirst = [...report].filter((r) => r.suspicious)
|
|
56
|
+
* .toSorted((a, b) => (a.medianPairSimilarity ?? 0) - (b.medianPairSimilarity ?? 0));
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export declare function computeClusterCohesion(membersByKey: ReadonlyMap<string, readonly ReadonlySet<string>[]>, options?: ClusterCohesionOptions): readonly ClusterCohesion[];
|