@d-zero/page-cluster 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,8 @@
1
1
  # `@d-zero/page-cluster`
2
2
 
3
- 大量クロール HTML の重複・類似ページを構造トークンで検出するパッケージ。CLI が主、ライブラリ関数群がオマケ。
3
+ 大量クロール HTML の重複・類似ページを構造トークンで検出するパッケージ。HTML ページ集合を受け取って、**同一テンプレートと判定できるページ**に同じクラスタキーを振る。テキストは無視して DOM 構造だけを見るので、本文が違っても同じテンプレートを使うページ群は 1 つのクラスタにまとまる。単一サイトで数万〜十数万ページ規模のクロール成果物を、テンプレート単位に畳んで概観したいときに使う。CLI が主、ライブラリ関数群がオマケ。
4
4
 
5
- ## What this does
6
-
7
- `page-cluster` は HTML ページ集合を受け取って、**同一テンプレートと判定できるページ**に同じキーを振る。テキストは無視して DOM 構造だけを見るので、記事本文が違うが同じテンプレートを使うページ群は 1 つのクラスタにまとまる。単一サイトで数万〜十数万ページ規模のクロール成果物を、テンプレート単位に畳んで概観したいときに使う。
8
-
9
- ## Install
5
+ ## Installation
10
6
 
11
7
  ```sh
12
8
  yarn add @d-zero/page-cluster
@@ -14,7 +10,13 @@ yarn add @d-zero/page-cluster
14
10
 
15
11
  インストールすると `page-cluster` コマンドが `node_modules/.bin/` 配下に入る。
16
12
 
17
- ## Quickstart (CLI)
13
+ ## Usage
14
+
15
+ ### CLI
16
+
17
+ ```sh
18
+ page-cluster [--content-block-attribute <name>] [--include-landmark-positions] < pages.jsonl > clusters.jsonl
19
+ ```
18
20
 
19
21
  **入力**: JSONL 1 行 1 ページ。フィールドは以下。`html` 以外はすべて任意(`paths` / `stylesheetHrefs` がないと粗い分類になる)。
20
22
 
@@ -34,33 +36,69 @@ yarn add @d-zero/page-cluster
34
36
  { "id": "任意の識別子", "clusterKey": "..." }
35
37
  ```
36
38
 
37
- ### クローラ出力(JSON 配列)を JSONL に変換して食わせる
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` を持たない。
38
40
 
39
- `jq` のワンライナーで配列を line-delimited にする典型例:
40
-
41
- ```sh
42
- jq -c '.[]' crawl-output.json | page-cluster > clusters.jsonl
41
+ ```json
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
+ }
56
+ ],
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
70
+ }
71
+ ]
72
+ }
73
+ }
43
74
  ```
44
75
 
45
- ### `--content-block-attribute`
76
+ 20,000 ページを超えるコーパス(ストリーミング経路)では `--include-landmark-positions` は使えない(エラーで終了する)。ストリーミング経路はリザーバサンプリングと近似割当を使うため、ページ単位の chrome 判定に必要な「そのページが属する最終クラスタの shell トークン」という概念を持たないため。
46
77
 
47
- CMS が自由編集コンテンツブロックに付与している属性名(例: `data-bgb`)が分かっている場合に指定する。指定すると比較前にその属性を持つ要素配下を無視するので、同じテンプレートで本文構成だけ違うページを混同しなくなる。
78
+ クローラ出力が JSON 配列の場合は `jq` で line-delimited に変換して食わせる:
48
79
 
49
80
  ```sh
50
- page-cluster --content-block-attribute data-bgb < pages.jsonl > clusters.jsonl
81
+ jq -c '.[]' crawl-output.json | page-cluster > clusters.jsonl
51
82
  ```
52
83
 
53
- ### 進捗
84
+ #### オプション
85
+
86
+ - `--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 ページ超のコーパスでは使えない。指定すると進捗表示(後述)は出なくなる(進捗を出さない非ストリーミング経路に常に振り分けられるため)
88
+ - `--help` / `-h` — ヘルプを表示する
89
+ - `--version` / `-v` — バージョンを表示する
90
+
91
+ #### 進捗表示
54
92
 
55
93
  処理中は stderr に進捗を出す。stdout の JSONL 出力は影響を受けない。
56
94
 
57
- **対話端末 (TTY)**: `%earth%` アニメ付きの単一ヘッダー行が in-place に書き換わり、現在のフェーズ・進捗・経過時間を表示する。
95
+ **対話端末(TTY)**: アニメーション付きの単一ヘッダー行が in-place に書き換わり、現在のフェーズ・進捗・経過時間を表示する。
58
96
 
59
97
  ```
60
98
  🌏 page-cluster — clustering 12/47 blocks (elapsed 23s)
61
99
  ```
62
100
 
63
- **非TTY (パイプ・ファイルリダイレクト・CI)**: `[page-cluster] ...` 形式の行を追記する。`pass0:` / `pass1:` / `pass1b:` / `stage-b:` の phase トークンを含むので `grep` / `awk` 互換。
101
+ **非 TTY(パイプ・ファイルリダイレクト・CI)**: `[page-cluster] ...` 形式の行を追記する。`pass0:` / `pass1:` / `pass1b:` / `stage-b:` の phase トークンを含むので `grep` / `awk` 互換。
64
102
 
65
103
  ```
66
104
  [page-cluster] reading input pages...
@@ -74,49 +112,89 @@ page-cluster --content-block-attribute data-bgb < pages.jsonl > clusters.jsonl
74
112
 
75
113
  silence したい場合は `2>/dev/null`。ログに残したい場合は `2> progress.log`。
76
114
 
77
- ## API (brief)
115
+ ### Library
116
+
117
+ サブパスエクスポート構成。import パスと提供関数の対応は以下。
118
+
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` — 特定ランドマークのデザインバリアントでページを分類 |
125
+
126
+ ```ts
127
+ import { resolvePageClusterKeysFromArray } from '@d-zero/page-cluster/resolve-page-cluster-keys';
128
+
129
+ const keys = await resolvePageClusterKeysFromArray([
130
+ {
131
+ paths: ['news', '1'],
132
+ stylesheetHrefs: ['/a.css'],
133
+ html: '<body><article>one</article></body>',
134
+ },
135
+ {
136
+ paths: ['news', '2'],
137
+ stylesheetHrefs: ['/a.css'],
138
+ html: '<body><article>two</article></body>',
139
+ },
140
+ {
141
+ paths: ['about'],
142
+ stylesheetHrefs: ['/a.css'],
143
+ html: '<body><section>about</section></body>',
144
+ },
145
+ ]);
146
+ // keys[0] === keys[1](同一テンプレート)、keys[2] は別クラスタ
147
+ ```
78
148
 
79
- すべての詳細は各関数の JSDoc にある。CLI 経由で十分な場合は読み飛ばして OK。
149
+ オプション・型・設計判断の WHY はすべて各関数の JSDoc に記載している。CLI 経由で十分な場合は読み飛ばして OK。
80
150
 
81
- - **`tokenize(html, options?)`** — `<body>` 配下の HTML を構造トークン列に変換する低レベルプリミティブ
82
- - **`resolvePageClusterKeys(pagesFactory, options?)`** — ページ集合からクラスタキーを返すメインエントリー。ファクトリ関数入力で大規模コーパスに対応
83
- - **`resolvePageClusterKeysFromArray(pages, options?)`** — メモリに全ページ載る前提の array 入力ラッパー
84
- - **`resolveLandmarkVariantKeys(htmlList, landmarkType, options?)`** — `header` / `footer` / `nav` / `aside` などのランドマークバリアント分類
85
- - **`extractLandmarks(html)`** — 1 ページから 6 種の HTML5 ランドマーク(header / footer / nav / aside / form / search)を抽出
151
+ ## アルゴリズム概観
86
152
 
87
- ## Algorithm
153
+ `clusterKey` がどう決まるかを知っておくと、出力の解釈(なぜこの 2 ページが同じキーなのか)とオプションの選択がしやすくなる。実装詳細の WHY は各ソースファイルの JSDoc が正。
88
154
 
89
- ```
90
- ┌────────────────────────────────────────┐
91
- │ Blocking (paths / stylesheet 集合) │
92
- └──────────────────┬─────────────────────┘
93
- │ 同じテンプレートを共有する候補群
94
-
95
- ┌────────────────────────────────────────┐
96
- │ Stage A: complete-linkage クラスタリング │
97
- │ (ブロック内、Jaccard 距離) │
98
- └──────────────────┬─────────────────────┘
99
- │ 各ブロックのクラスタ代表
100
-
101
- ┌────────────────────────────────────────┐
102
- │ Stage B: quorum-core cross-block merge │
103
- │ (ブロック境界を越えた再統合) │
104
- └────────────────────────────────────────┘
105
- ```
155
+ ### 全体パイプライン
106
156
 
107
- - **Blocking** — URL パスと stylesheet 集合を安価なブロッキング信号として粗く分割。同一ブロック内でだけ高価な構造比較を行うので、コーパス全体に対する比較コストを O(n²) から劇的に減らす
108
- - **Stage A** — ブロック内で `<main>` 配下のトークン列に対して complete-linkage 階層的クラスタリングを実行し、max-gap detection でカット高を選ぶ
109
- - **Stage B** 各クラスタの quorum-core(80% クォーラム)を代表としてブロック境界をまたぐ再統合を反復。complete-linkage、包含、shape-Jaccard、L2 signature 4 経路で融合を試みて不動点まで回す
110
- - **大規模自動切替** 20,000 ページ超で自動的に**ストリーミング経路**に切り替わる。ブロックごとにリザーバサンプルで代表を学ばせ、非サンプルページを Jaccard で最寄りクラスタに割当。メモリ使用量が最大ブロックのサイズに比例するようになる
157
+ ```mermaid
158
+ flowchart TD
159
+ IN[入力ページ集合] --> P0["Pass 0: ブロッキング<br>URL パス + first-party CSS 集合 blockKey<br>(orphan ページの再割当を含む)"]
160
+ P0 --> GATE{"ページ数 20,000?"}
111
161
 
112
- ### Self-tuning
162
+ GATE -- "yes(in-memory)" --> CHROME_ALL["chrome discovery(コーパス全体)<br>ランドマーク署名の度数分布に auto-cut<br>→ グローバル chrome 除外 / ローカル chrome 再注入"]
163
+ CHROME_ALL --> SA_ALL["Stage A × 全ブロック<br>深さキャップ → tokenize →<br>complete-linkage + auto-cut → 包含割当"]
164
+
165
+ GATE -- "no(ストリーミング)" --> RES["ブロックごとにリザーバサンプリング<br>(各ブロック最大 100 ページ、決定的シード)"]
166
+ RES --> SA_SAMPLE["chrome discovery + Stage A<br>(サンプルのみ、ブロック単位で逐次 flush)"]
167
+ SA_SAMPLE --> P1B["Pass 1b: 非サンプルページを<br>max-Jaccard で最寄りクラスタへ割当"]
113
168
 
114
- 閾値はすべて **max-gap auto-cut**(度数分布の最大ギャップの中点を境界とする)でデータから自己発見される。Stage A のマージ高さカット、Stage B のシェル判定、コーパス全体の共通クローム判定など、3 階層でこの同一プリミティブを再帰使用しているので、サイトごとにハイパーパラメータをチューニングする必要はない。詳細は `autoCutThreshold` の JSDoc を参照。
169
+ SA_ALL --> SB["Stage B: ブロック越えマージ<br>(不動点ループ、下図)"]
170
+ P1B --> SB
171
+ SB --> OUT["clusterKey を入力順に出力"]
172
+ ```
115
173
 
116
- ## Notes
174
+ - **Pass 0(ブロッキング)** — HTML を読まず、URL パスと first-party stylesheet 集合だけで粗く分割する。高価な構造比較を同一ブロック内に閉じ込め、コーパス全体の比較コストを O(n²) から劇的に減らす。stylesheet を持たない orphan ページは同一セクションの CSS ブロックへ再割当される
175
+ - **chrome discovery** — 全ページのランドマーク署名の度数分布に auto-cut を当て、閾値以上を「グローバル chrome」(サイト共通のヘッダー等)として比較から除外し、閾値未満かつ 2 ページ以上に出現するものを「ローカル chrome」(セクション固有のナビ等)としてトークン再注入する
176
+ - **Stage A(ブロック内クラスタリング)** — ブロックごとに直線的な処理。`<main>` の深さキャップ(候補深度を全走査して knee を探す自動選択)→ tokenize → complete-linkage 階層クラスタリング → max-gap auto-cut でカット高を決定 → 最後に包含関係にあるクラスタを吸収する包含割当(割当チェーンを辿り、循環はメンバー最大のクラスタをルートに選んで解決)
177
+ - **Pass 1b(ストリーミング時のみ)** — 20,000 ページ超では各ブロックをリザーバサンプリング(最大 100 ページ、ブロックキーをシードにした決定的乱数)で代表させ、サンプル外のページは Stage A 完了後に max-Jaccard で最寄りクラスタへ一括割当する。メモリ使用量はコーパス全体ではなくサンプルサイズに比例する
178
+ - **Stage B(ブロック越えマージ)** — ブロック分割はあくまで比較コスト削減のためなので、最後に同一テンプレートがブロックを跨いで分かれていないか再統合する。これが唯一の反復処理(次節)
179
+
180
+ ### Stage B: ブロック越え統合の不動点ループ
181
+
182
+ ```mermaid
183
+ flowchart TD
184
+ START["ラウンド開始(最大 10 ラウンド)"] --> CORE["現在のプール済みメンバーから再計算:<br>文書頻度 → distinctive tokens → quorum core(80%)"]
185
+ CORE --> FINE["fine stage(単一 union-find 上で 3 経路):<br>① complete-linkage(固定 0.8)<br>② 包含割当(0.9、チェーン走査 + サイクル解決)<br>③ shape-Jaccard(0.9、複数ページユニットのみ)"]
186
+ FINE --> Q1{"fine でマージ発生?"}
187
+ Q1 -- yes --> APPLY1["マージ適用(メンバー統合)"]
188
+ APPLY1 --> START
189
+ Q1 -- no --> L2["L2 stage:<br>L2 signature 包含 + shell 相互裏付け<br>(shell は auto-cut で自己発見)"]
190
+ L2 --> Q2{"L2 でマージ発生?"}
191
+ Q2 -- yes --> APPLY2["マージ適用"]
192
+ APPLY2 --> START
193
+ Q2 -- no --> DONE["収束 — 全ユニットのキーが不動点に到達"]
194
+ ```
117
195
 
118
- ### `contentBlockAttribute` の存在意義
196
+ マージが起きるとユニットのメンバー構成が変わり、文書頻度も quorum core も変わる。そのため毎ラウンド、統合後のプールから全指標を**再計算**してマージを再試行する。fine stage・L2 stage の両方でマージが 1 件も出なくなった時点で不動点に到達したとみなして収束する(安全弁として最大 10 ラウンド。実データでは 7 ラウンド以内に収束)。L2 stage は fine stage が空振りしたラウンドでしか実行されない最後の粗い経路で、誤マージ防止のために shell(ランドマーク由来トークン)の相互裏付けを要求する。
119
197
 
120
- このパッケージが持つ唯一の site-specific なオプション。CMS の自由編集ブロックに付与される属性名(例: `data-bgb`)は HTML から自動検知できないので外部知識として受け取る形にしている。指定された属性を持つ要素の配下は比較対象から除外され、同じテンプレート上で本文構成だけ違うページの誤分割を防ぐ。
198
+ ### Self-tuning
121
199
 
122
- 未指定でも大半のケースで動くよう、`<main>` / `role="main"` を起点にした自動深さキャップが常時有効になっている。
200
+ 閾値の多くは **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 を参照)。
@@ -0,0 +1,50 @@
1
+ import type { ExtractLandmarksResult, LandmarkPosition } from './extract-landmarks.js';
2
+ import type { TokenizeOptions } from './types.js';
3
+ /**
4
+ * A landmark instance's position plus whether
5
+ * {@link ./is-chrome-landmark-instance.js | isChromeLandmarkInstance} judged
6
+ * it shared site/section chrome (`true`) or page-specific content (`false`),
7
+ * against the unit's {@link ./shell-quorum.js | shellQuorum} shell tokens.
8
+ */
9
+ export type ReportedLandmarkInstance = LandmarkPosition & {
10
+ readonly isChrome: boolean;
11
+ };
12
+ /**
13
+ * Per-page landmark position report built by
14
+ * {@link ./build-page-landmark-report.js | buildPageLandmarkReport}. `main`
15
+ * carries no `isChrome` verdict — it never participates in chrome/shell
16
+ * discovery (see `extractLandmarks`'s "main handling" note) and is always
17
+ * content.
18
+ */
19
+ export type PageLandmarkReport = {
20
+ header: ReportedLandmarkInstance[];
21
+ footer: ReportedLandmarkInstance[];
22
+ nav: ReportedLandmarkInstance[];
23
+ aside: ReportedLandmarkInstance[];
24
+ form: ReportedLandmarkInstance[];
25
+ search: ReportedLandmarkInstance[];
26
+ main: LandmarkPosition[];
27
+ };
28
+ /**
29
+ * Builds a page's landmark position report: every landmark instance's
30
+ * location, with `header`/`footer`/`nav`/`aside`/`form`/`search` instances
31
+ * additionally classified as chrome or content against `shellTokens`.
32
+ *
33
+ * Reads `landmarks` directly — the full, non-deduplicated instance list
34
+ * `extractLandmarks` produced — rather than going through
35
+ * {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}'s
36
+ * per-page-deduplicated `PerPageLandmarkInstance[]`: that dedupe collapses
37
+ * same-signature instances to one entry, which would silently drop the
38
+ * position of every duplicate instance a position report needs to include.
39
+ * @param landmarks
40
+ * @param shellTokens The unit-level shell token set from
41
+ * {@link ./shell-quorum.js | shellQuorum}, computed once per final cluster
42
+ * and shared across every member page's report.
43
+ * @param tokenizeOptions
44
+ * @example
45
+ * ```ts
46
+ * const shellTokens = shellQuorum(clusterPerPageInstances);
47
+ * const report = buildPageLandmarkReport(extractLandmarks(page.html), shellTokens);
48
+ * ```
49
+ */
50
+ export declare function buildPageLandmarkReport(landmarks: ExtractLandmarksResult, shellTokens: ReadonlySet<string>, tokenizeOptions?: TokenizeOptions): PageLandmarkReport;
@@ -0,0 +1,67 @@
1
+ import { isChromeLandmarkInstance } from './is-chrome-landmark-instance.js';
2
+ import { ALL_LANDMARK_TYPES } from './per-page-landmark-signatures.js';
3
+ import { tokenize } from './tokenize.js';
4
+ /**
5
+ * Strips `html` off a {@link LandmarkInstance}, keeping only its position.
6
+ * `buildPageLandmarkReport`'s output is meant to be serialized per page
7
+ * across a whole corpus (the CLI's JSONL output), so the report
8
+ * deliberately excludes each instance's raw HTML to keep that payload from
9
+ * scaling with markup size — callers who also need the HTML already have
10
+ * `ExtractLandmarksResult` in hand.
11
+ * @param instance
12
+ */
13
+ function toPosition(instance) {
14
+ return {
15
+ startOffset: instance.startOffset,
16
+ endOffset: instance.endOffset,
17
+ startLine: instance.startLine,
18
+ startColumn: instance.startColumn,
19
+ endLine: instance.endLine,
20
+ endColumn: instance.endColumn,
21
+ };
22
+ }
23
+ /**
24
+ * Builds a page's landmark position report: every landmark instance's
25
+ * location, with `header`/`footer`/`nav`/`aside`/`form`/`search` instances
26
+ * additionally classified as chrome or content against `shellTokens`.
27
+ *
28
+ * Reads `landmarks` directly — the full, non-deduplicated instance list
29
+ * `extractLandmarks` produced — rather than going through
30
+ * {@link ./per-page-landmark-signatures.js | computePerPageLandmarkInstances}'s
31
+ * per-page-deduplicated `PerPageLandmarkInstance[]`: that dedupe collapses
32
+ * same-signature instances to one entry, which would silently drop the
33
+ * position of every duplicate instance a position report needs to include.
34
+ * @param landmarks
35
+ * @param shellTokens The unit-level shell token set from
36
+ * {@link ./shell-quorum.js | shellQuorum}, computed once per final cluster
37
+ * and shared across every member page's report.
38
+ * @param tokenizeOptions
39
+ * @example
40
+ * ```ts
41
+ * const shellTokens = shellQuorum(clusterPerPageInstances);
42
+ * const report = buildPageLandmarkReport(extractLandmarks(page.html), shellTokens);
43
+ * ```
44
+ */
45
+ export function buildPageLandmarkReport(landmarks, shellTokens, tokenizeOptions) {
46
+ const report = {
47
+ header: [],
48
+ footer: [],
49
+ nav: [],
50
+ aside: [],
51
+ form: [],
52
+ search: [],
53
+ main: landmarks.main.map(toPosition),
54
+ };
55
+ for (const type of ALL_LANDMARK_TYPES) {
56
+ for (const instance of landmarks[type]) {
57
+ const tokens = instance.html
58
+ ? new Set(tokenize(`<body>${instance.html}</body>`, tokenizeOptions).tokens)
59
+ : new Set();
60
+ report[type].push({
61
+ ...toPosition(instance),
62
+ isChrome: isChromeLandmarkInstance(tokens, shellTokens),
63
+ });
64
+ }
65
+ }
66
+ return report;
67
+ }
package/dist/cli.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  type CliArgs = {
7
7
  readonly contentBlockAttribute?: string;
8
+ readonly includeLandmarkPositions?: boolean;
8
9
  readonly help?: boolean;
9
10
  readonly version?: boolean;
10
11
  readonly unknownFlag?: string;
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import process from 'node:process';
8
8
  import { Lanes } from '@d-zero/dealer';
9
9
  import { resolvePageClusterKeys } from './resolve-page-cluster-keys.js';
10
10
  const HELP_TEXT = `Usage:
11
- page-cluster [--content-block-attribute <name>] < pages.jsonl > clusters.jsonl
11
+ page-cluster [--content-block-attribute <name>] [--include-landmark-positions] < pages.jsonl > clusters.jsonl
12
12
 
13
13
  Input (JSONL, one page per line):
14
14
  {
@@ -22,10 +22,35 @@ Input (JSONL, one page per line):
22
22
  Output (JSONL, one line per input page, in input order):
23
23
  { "id": "...", "clusterKey": "..." }
24
24
 
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:
30
+ {
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 }]
39
+ }
40
+ }
41
+
25
42
  Options:
26
43
  --content-block-attribute <name> CMS-provided attribute marking freeform
27
44
  content blocks that should be stripped
28
45
  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.
29
54
  --help Print this help and exit.
30
55
  --version Print the package version and exit.
31
56
 
@@ -82,6 +107,10 @@ export function parseArgs(argv) {
82
107
  i++;
83
108
  break;
84
109
  }
110
+ case '--include-landmark-positions': {
111
+ out.includeLandmarkPositions = true;
112
+ break;
113
+ }
85
114
  default: {
86
115
  out.unknownFlag = arg;
87
116
  return out;
@@ -331,18 +360,39 @@ export async function runCli(options) {
331
360
  renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
332
361
  },
333
362
  };
334
- let keys;
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
+ let clusterKeys;
368
+ let landmarksByIndex;
335
369
  try {
336
- keys = await resolvePageClusterKeys(() => pages, resolveOptions);
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
+ }
337
381
  }
338
382
  catch (error) {
339
383
  renderProgress(lanes, useTty, errorLine(error.message));
340
384
  return 1;
341
385
  }
342
- const clusterCount = new Set(keys).size;
386
+ const clusterCount = new Set(clusterKeys).size;
343
387
  renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
344
- for (const [index, key] of keys.entries()) {
345
- options.stdout.write(`${JSON.stringify({ id: ids[index] ?? index, clusterKey: key })}\n`);
388
+ 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];
395
+ options.stdout.write(`${JSON.stringify(row)}\n`);
346
396
  }
347
397
  return 0;
348
398
  }
@@ -11,16 +11,50 @@
11
11
  * have no implicit landmark role under HTML-AAM unless given an accessible
12
12
  * name). `search` is matched via both the `<search>` element (WHATWG
13
13
  * landmark shorthand) and `role="search"`.
14
+ *
15
+ * `main` is deliberately not a member of this union even though
16
+ * {@link ./extract-landmarks.js | extractLandmarks} reports it: this type is
17
+ * also the parameter type of `resolveLandmarkVariantKeys` and the vocabulary
18
+ * that chrome discovery (`computePerPageLandmarkInstances`'s
19
+ * `ALL_LANDMARK_TYPES`) iterates over. Admitting `'main'` here would let
20
+ * `resolveLandmarkVariantKeys(pages, 'main')` type-check while silently
21
+ * returning nothing (chrome discovery never looks at `main` instances) —
22
+ * exactly the kind of type-level lie this module avoids elsewhere. `main` is
23
+ * content, not chrome: it never participates in frequency-based chrome
24
+ * discovery, only in position reporting.
14
25
  */
15
26
  export type LandmarkType = 'header' | 'footer' | 'nav' | 'aside' | 'form' | 'search';
27
+ /**
28
+ * An instance's location within the HTML string it was extracted from, in
29
+ * both string-index and 1-based line/column form. Computed once per page by
30
+ * {@link ./extract-landmarks.js | extractLandmarks} via
31
+ * {@link ./offset-to-line-column.js | buildLineColumnIndex}/`offsetToLineColumn`
32
+ * and carried downstream as plain numbers — nothing recomputes it.
33
+ */
34
+ export type LandmarkPosition = {
35
+ readonly startOffset: number;
36
+ readonly endOffset: number;
37
+ readonly startLine: number;
38
+ readonly startColumn: number;
39
+ readonly endLine: number;
40
+ readonly endColumn: number;
41
+ };
42
+ /**
43
+ * One landmark instance: its raw HTML plus its {@link LandmarkPosition}
44
+ * within the page it was extracted from.
45
+ */
46
+ export type LandmarkInstance = LandmarkPosition & {
47
+ readonly html: string;
48
+ };
16
49
  /**
17
50
  * Result of {@link ./extract-landmarks.js | extractLandmarks}. Each landmark
18
- * field holds an array of the raw HTML of every genuinely-closed instance
19
- * of that region on the page, in document order. Empty array if the page
20
- * has none — or if every candidate found was malformed markup
21
- * `extractLandmarks` declined to trust (see its JSDoc's note on discarded
22
- * candidates). `remainderHtml` is the original HTML with every extracted
23
- * span excised, meant to be fed straight into
51
+ * field holds an array of every genuinely-closed instance of that region on
52
+ * the page, in document order. Empty array if the page has none — or if
53
+ * every candidate found was malformed markup `extractLandmarks` declined to
54
+ * trust (see its JSDoc's note on discarded candidates). `remainderHtml` is
55
+ * the original HTML with every extracted `header`/`footer`/`nav`/`aside`/
56
+ * `form`/`search` span excised (`main` is never excised see
57
+ * `extractLandmarks`'s "main handling" note), meant to be fed straight into
24
58
  * {@link ./tokenize.js | tokenize} as the page's content-only signal.
25
59
  *
26
60
  * Multiple instances per type are the norm, not the exception: real crawl
@@ -33,12 +67,13 @@ export type LandmarkType = 'header' | 'footer' | 'nav' | 'aside' | 'form' | 'sea
33
67
  * depth or ordering rule.
34
68
  */
35
69
  export type ExtractLandmarksResult = {
36
- header: string[];
37
- footer: string[];
38
- nav: string[];
39
- aside: string[];
40
- form: string[];
41
- search: string[];
70
+ header: LandmarkInstance[];
71
+ footer: LandmarkInstance[];
72
+ nav: LandmarkInstance[];
73
+ aside: LandmarkInstance[];
74
+ form: LandmarkInstance[];
75
+ search: LandmarkInstance[];
76
+ main: LandmarkInstance[];
42
77
  remainderHtml: string;
43
78
  };
44
79
  /**
@@ -113,14 +148,40 @@ export type ExtractLandmarksResult = {
113
148
  * segment then disappears from the surviving paths, shortening them by one
114
149
  * level. This is inherent to "delete the matched span, use whatever's
115
150
  * left" and is not treated as a bug.
151
+ *
152
+ * ## Main handling
153
+ *
154
+ * `main` (the `<main>` tag or `role="main"`) is collected the same way as
155
+ * the other six types — one entry per genuinely-closed instance, in
156
+ * document order — but is kept out of every mechanism the other six feed:
157
+ *
158
+ * - It is **never excised**: its span is never added to `remainderHtml`'s
159
+ * excise list, because `main` is the page's actual content, not chrome.
160
+ * Removing it would gut `remainderHtml` down to whatever sits outside
161
+ * `<main>` (nothing, on most real pages).
162
+ * - Its `keepOutermost` nesting sweep runs **separately** from the other six
163
+ * types'. If it shared the sweep, a `<main>` that wraps most of the page
164
+ * (as it typically does) would make every `header`/`nav`/`aside` nested
165
+ * inside it look "contained by main" and get dropped — destroying the
166
+ * section-local chrome detection this module exists to enable (see "Why
167
+ * collect every instance" above). Only nested `<main>`s (an edge case —
168
+ * HTML discourages more than one) are deduplicated against each other.
169
+ * - It never contributes to chrome/shell-frequency analysis (`main` is
170
+ * absent from `computePerPageLandmarkInstances`'s `ALL_LANDMARK_TYPES`):
171
+ * its instances are reported for position purposes only, never treated as
172
+ * candidate chrome.
116
173
  * @param html
117
174
  * @example
118
175
  * ```ts
119
176
  * extractLandmarks('<body><header>H</header><main>M</main><footer>F</footer></body>');
120
177
  * // {
121
- * // header: ['<header>H</header>'],
122
- * // footer: ['<footer>F</footer>'],
178
+ * // header: [{ html: '<header>H</header>', startOffset: 6, endOffset: 24,
179
+ * // startLine: 1, startColumn: 7, endLine: 1, endColumn: 25 }],
180
+ * // footer: [{ html: '<footer>F</footer>', startOffset: 38, endOffset: 56,
181
+ * // startLine: 1, startColumn: 39, endLine: 1, endColumn: 57 }],
123
182
  * // nav: [], aside: [], form: [], search: [],
183
+ * // main: [{ html: '<main>M</main>', startOffset: 24, endOffset: 38,
184
+ * // startLine: 1, startColumn: 25, endLine: 1, endColumn: 39 }],
124
185
  * // remainderHtml: '<body><main>M</main></body>',
125
186
  * // }
126
187
  * ```