Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ crawler/src/
- **`getPageDetail`**: 単一ページの詳細情報(メタデータ、アウトバウンド/インバウンドリンク、リダイレクト元)
- **`getPageHtml`**: HTML スナップショット取得(truncation サポート)
- **`listLinks`**: リンク分析(`type: 'broken' | 'external'`、anchor 単位 = 1 行 1 `<a>` タグ、重複排除なし)。dest は `pages.redirectDestId` 経由で canonical destination まで解決した上で broken/external 判定(`includeRedirectSources: true` で解決を無効化し literal を見る)。関数自体は変更していないため CLI/MCP は従来通り `type: 'external'` で anchor 単位の生データを取得できるが、**viewer の `/api/links?type=external` だけは `listExternalLinks` に切り替え済み**(後述) — 「外部リンク」ビューは宛先ごとに集約した一覧を必要とするため
- **`listExternalLinks`**: viewer の「外部リンク」ビュー専用。外部リンク先を canonical destination(`listLinks` と同じ `COALESCE(canonical.*, dest.*)` 解決パターン)ごとに `GROUP BY` で重複排除し、`referrerCount`(`COUNT(DISTINCT source.id)` — 同一ページからの複数アンカーは 1 件として数える)を付与した一覧。ページネーションの `total` は distinct 宛先数(anchor 数ではない)を GROUP BY サブクエリでラップして算出 — `paginateQuery` ヘルパーは素朴な `count(idColumn)` のため GROUP BY 済みクエリと非互換で使えない。宛先の詳細(参照元ページ一覧)は新規ビューを作らず既存の `getPageDetail`(`isExternal`/`scraped` 制約なし)の `inboundLinks` をそのまま再利用する
- **`listExternalLinks`**: viewer の「外部リンク」ビュー用の legacy 経路(read model が無い/古いアーカイブのフォールバック)。外部リンク先を canonical destination(`listLinks` と同じ `COALESCE(canonical.*, dest.*)` 解決パターン)ごとに `GROUP BY` で重複排除し、`referrerCount`(`COUNT(DISTINCT source.id)` — 同一ページからの複数アンカーは 1 件として数える)を付与した一覧。ページネーションの `total` は distinct 宛先数(anchor 数ではない)を GROUP BY サブクエリでラップして算出 — `paginateQuery` ヘルパーは素朴な `count(idColumn)` のため GROUP BY 済みクエリと非互換で使えない。宛先の詳細(参照元ページ一覧)は新規ビューを作らず既存の `getPageDetail`(`isExternal`/`scraped` 制約なし)の `inboundLinks` をそのまま再利用する。**`viewer_external_links` read model が current な場合は `listViewerExternalLinks` に切り替わる**(後述の「設計注意(外部リンク read model)」参照)— この関数自体はそのフォールバックとして無変更のまま残る
- **`listViewerExternalLinks`**: `viewer_external_links` read model 専用の fast path。`listExternalLinks` と同じオプション/レスポンス形だが、集計(JOIN + GROUP BY + COUNT DISTINCT)は read model ビルド時に1回だけ実行済みなので、実行時は単純な indexed SELECT + `paginateQuery`(GROUP BY 不要になったため素朴な helper がそのまま使える)
- **`listIsolatedPages`** / **`listIsolatedClusters`** / **`getIsolatedCluster`**: inventory subgraph の **完全孤立** (singleton) / **孤立集合** (connected component, size ≥ 2)。crawled-wins downgrade の不変量により crawled 行は定義上 isolated 判定から除外される。cluster の edge は redirect 解決済み anchor を無向で見た weakly connected component(共通ヘルパー `compute-isolated-clusters.ts` が `resolve-redirect-chain` + union-find で計算)
- **`listResources`**: サブリソース一覧(CSS, JS, 画像、フォント)
- **`listImages`**: 画像一覧(alt 欠損、寸法欠損、オーバーサイズ検出)
Expand Down Expand Up @@ -254,7 +255,7 @@ nitpicker viewer <file>
→ SIGINT/SIGTERM: manager.closeAll() → server.close() → resolve(CLI が exit)
```

**REST API(アーカイブは起動時固定なので archiveId 不要):** `GET /api/summary`, `/api/pages`(`hasCSP`/`hasXFrameOptions`/`hasXContentTypeOptions`/`hasHSTS` の 4 列を含む。旧 `/api/headers`・「Headers」ビューは「ページ」ビューへ統合済み、CLI/MCP 向けの `checkHeaders` 自体は残存), `/api/pages/detail?url=`, `/api/pages/html?url=`, `/api/links?type=`(`broken` は `listLinks` 経由で anchor 単位のまま、canonical destination が HTTP 404 のみ。403/5xx/未取得(NULL) は broken 扱いしない。`external` は `listExternalLinks` 経由で canonical destination ごとに重複排除され `referrerCount` を返す — 宛先の参照元一覧は新規エンドポイントを作らず既存の `/api/pages/detail` の inboundLinks を再利用する), `/api/resources`, `/api/resources/referrers?resourceUrl=`, `/api/images`, `/api/violations`, `/api/duplicates`, `/api/mismatches`, `/api/graph`(内部ページのリンクグラフ、`getLinkGraph`), `/api/directory-tree`(全 root の初期 3 depth ツリー、`getDirectoryTree`), `/api/directory-tree/children?nodeId=`(1 ノード直下の子ディレクトリ、`listDirectoryChildren`), `/api/directory-tree/pages?nodeId=&cursor=&limit=`(1 ディレクトリ直下ページの cursor 一覧、`listDirectoryPages`), `/api/info`(開いているアーカイブの絶対パス、フッター表示用)。クエリパラメータ → query options 変換は `query-params/to-number.ts` / `to-boolean.ts`、エラーは `sanitize-error-message.ts` で絶対パスを伏せて JSON 返却(mcp-server と同方針)。旧 `/api/page-links`(`listPageLinks`)は「ページリンク」ビューの廃止に伴い削除 — per-page の status/referrers/redirect-from は Page Detail ビュー(`/api/pages/detail`)の inbound/outbound/redirectFrom で個別ページ単位に確認する。`getPageDetail` は `isSkipped`/`skipReason`(robots.txt / `excludeUrls` による除外理由)も返すようになり、URL 既知の場合は除外理由を引き続き確認できる。**受容したギャップ**: `listPages` / `listPagesByTag` / `listPagesByJsonLdType` はすべて `scraped = 1` 前提のため、「除外されて一度も取得されていない URL 一覧」を一括列挙する手段は無くなった(旧 `listPageLinks` だけが `scraped` 制約なしだった)。URL が分かっていれば `getPageDetail` で確認できるが、一括把握が必要な場合は `nitpicker query error-kinds` や archive の `pages` テーブルを直接クエリすること。
**REST API(アーカイブは起動時固定なので archiveId 不要):** `GET /api/summary`, `/api/pages`(`hasCSP`/`hasXFrameOptions`/`hasXContentTypeOptions`/`hasHSTS` の 4 列を含む。旧 `/api/headers`・「Headers」ビューは「ページ」ビューへ統合済み、CLI/MCP 向けの `checkHeaders` 自体は残存), `/api/pages/detail?url=`, `/api/pages/html?url=`, `/api/links?type=`(`broken` は `listLinks` 経由で anchor 単位のまま、canonical destination が HTTP 404 のみ。403/5xx/未取得(NULL) は broken 扱いしない。`external` は canonical destination ごとに重複排除され `referrerCount` を返す — read model が current なら `listViewerExternalLinks`、そうでなければ `listExternalLinks` にフォールバック(`/api/pages` と同じ二層構成)。宛先の参照元一覧は新規エンドポイントを作らず既存の `/api/pages/detail` の inboundLinks を再利用する), `/api/resources`, `/api/resources/referrers?resourceUrl=`, `/api/images`, `/api/violations`, `/api/duplicates`, `/api/mismatches`, `/api/graph`(内部ページのリンクグラフ、`getLinkGraph`), `/api/directory-tree`(全 root の初期 3 depth ツリー、`getDirectoryTree`), `/api/directory-tree/children?nodeId=`(1 ノード直下の子ディレクトリ、`listDirectoryChildren`), `/api/directory-tree/pages?nodeId=&cursor=&limit=`(1 ディレクトリ直下ページの cursor 一覧、`listDirectoryPages`), `/api/info`(開いているアーカイブの絶対パス、フッター表示用)。クエリパラメータ → query options 変換は `query-params/to-number.ts` / `to-boolean.ts`、エラーは `sanitize-error-message.ts` で絶対パスを伏せて JSON 返却(mcp-server と同方針)。旧 `/api/page-links`(`listPageLinks`)は「ページリンク」ビューの廃止に伴い削除 — per-page の status/referrers/redirect-from は Page Detail ビュー(`/api/pages/detail`)の inbound/outbound/redirectFrom で個別ページ単位に確認する。`getPageDetail` は `isSkipped`/`skipReason`(robots.txt / `excludeUrls` による除外理由)も返すようになり、URL 既知の場合は除外理由を引き続き確認できる。**受容したギャップ**: `listPages` / `listPagesByTag` / `listPagesByJsonLdType` はすべて `scraped = 1` 前提のため、「除外されて一度も取得されていない URL 一覧」を一括列挙する手段は無くなった(旧 `listPageLinks` だけが `scraped` 制約なしだった)。URL が分かっていれば `getPageDetail` で確認できるが、一括把握が必要な場合は `nitpicker query error-kinds` や archive の `pages` テーブルを直接クエリすること。

**バイナリ:** なし(CLI の `viewer` サブコマンド経由で起動)

Expand Down Expand Up @@ -340,6 +341,14 @@ nitpicker viewer <file>
>
> **`getDirectoryTree` の ORDER BY は `path_sort_key` 単独、`root_key` を含めない**: 全 root を 1 クエリで返す設計上、`root_key` の等価フィルタが存在しないため、`vdn_root_depth_path (root_key, depth, path_sort_key, node_id)` のような `root_key` 先頭 index は `depth <= 3` という range 条件との組み合わせで一切活用できず、`EXPLAIN QUERY PLAN` で実測すると `USE TEMP B-TREE FOR LAST TERM OF ORDER BY` が付く(PR #96 の `idx_pages_listfilter` column 順ミスと同型の教訓)。`path_sort_key` を先頭に置いた `vdn_path_depth (path_sort_key, depth, node_id)` に張り替え、`ORDER BY path_sort_key` のみに変更することで `SCAN ... USING INDEX vdn_path_depth`(sort 無し、`depth` は残差フィルタ)に収まることを確認済み。root_key を ORDER BY から外しても、grouping は JS 側で `Map` に振り分けるだけなので各 root 内の相対順序(`path_sort_key` 昇順)は保たれる。**検索キーワード**: 「directory-tree」「ディレクトリツリー」「has_children」「vdn_path_depth」「USE TEMP B-TREE」。

> **設計注意(外部リンク read model):** `listExternalLinks`(PR #153)は `anchors JOIN pages(source) JOIN pages(dest) LEFT JOIN pages(canonical)` を `COALESCE` 計算列で `GROUP BY` し `COUNT(DISTINCT source.id)` を求める形で、リクエストごとにこの JOIN+集計を(`total` 用サブクエリと data 用の)2 回実行していた。SQLite は `COUNT(DISTINCT ...)` で既存 index を使わず別の b-tree を都度構築することが知られており(SQLite forum 実測: `count(distinct id)` 単体 6.4 秒、他の集約と同一クエリに混ぜると 55.2 秒まで悪化する例が報告されている)、`GROUP BY` も式インデックス(`CREATE INDEX` の式と `WHERE`/`GROUP BY` の式が構文的に完全一致しないと使われない)では確実に解決できない。回避策として同フォーラムが推奨するのは集計をあらかじめ一時テーブルに書き出す方式で、これは本リポジトリの `viewer_pages`/`viewer_directory_nodes`(issue #106〜#112)と同じ「read model を作って計測してから最適化する」方針そのものである。
>
> `viewer_external_links`(`dest_page_id` PK / `dest_url` / `status` / `referrer_count`)は `buildViewerReadModel` の同じトランザクション内で `computeExternalLinkRows`(`viewer-read-model/compute-external-link-rows.ts`)が構築する。集計ロジック(`COALESCE` 解決・`COUNT(DISTINCT source.id)`)は `listExternalLinks` から一切変更せずそのまま移植 — `referrerCount` は `getPageDetail.inboundLinks`(#71)と同じ数え方(重複アンカーは 1 referrer)を保つ契約があるため。`viewer_pages`/directory tree と違い、`sourceRows`(`pages` のみ)を再利用できず `anchors` への専用クエリが必要(リンク情報は `anchors` にしかない)。
>
> **keyset cursor ではなく `paginateQuery`(offset ベース)を使う**: `viewer_pages` が `status_sort_key`/`status_desc_key`/`NULL_STATUS_SENTINEL` という仕掛けを持つのは keyset cursor 特有の要件(SQL の 3 値論理で `NULL` 比較が壊れる、`DESC` を常に `ASC` 方向スキャンにする必要がある)で、`/api/links?type=external` の REST 契約はそもそも offset ベースのまま変更していないため、この複雑さは不要。`viewer_external_links` の 3 index(`vel_url` / `vel_status` / `vel_referrer_count`)はいずれも単純な単方向 index で、`DESC` は同じ index の逆順スキャンで足りる。
>
> **fast path / legacy の二層構成**: `register-links-route.ts` は `/api/pages` と同じパターンで `isViewerReadModelCurrent` を見て `listViewerExternalLinks`(fast path)と `listExternalLinks`(legacy、無変更のまま残存)を切り替える。`urlPattern`/`status` はどちらの経路でも同じ列に対応するため、`/api/pages` の `hasCSP` 等のような「特定フィルタ指定時は強制 legacy」という除外条件は無い。スキーマ変更を伴うため `VIEWER_READ_MODEL_SCHEMA_VERSION` を 4→5 に bump し、旧バージョンの read model は自動再ビルド対象にした。**検索キーワード**: 「external links」「外部リンク」「COUNT DISTINCT」「viewer_external_links」「GROUP BY 遅い」。

### @nitpicker/cli

`@d-zero/roar` ベースの統合 CLI。7つのサブコマンドを提供。全 analyze プラグインを `dependencies` に含んでおり、`npx` 実行時に `@nitpicker/core` の動的 `import()` がプラグインモジュールを解決できるようにしている。
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ packages/

> **Note (ディレクトリツリー read model、issue #107)**: `viewer_directory_nodes` / `viewer_directory_pages` は `viewer_pages` を返す `sourceRows` を再利用し `buildDirectoryTreeRows` が純粋関数としてメモリ上に構築する。**root_key はホスト単位、ただし internal ページを 1 件も持たないホストは除外**(外部リンク先ドメインの無意味な 1 ページツリーを防ぐ)。**ディレクトリ/ページ境界は末尾スラッシュで判定**(`/blog/2024/post-1` と `/blog/2024/` は同じ `/blog/2024/` ノードに着地)。**`has_children` は `direct_child_dir_count > 0` のみ**(`direct_page_count` を含めると構築ロジック上絶対に `false` にならないため、UI の展開矢印が意味を持つよう子ディレクトリの有無だけを見る)。この機能に legacy フォールバックは存在しないため、3関数(`getDirectoryTree`/`listDirectoryChildren`/`listDirectoryPages`)とも `hasViewerReadModel` ではなく `isViewerReadModelCurrent` を guard に使う。詳細は ARCHITECTURE.md の `@nitpicker/viewer` 節「設計注意(ディレクトリツリー read model...)」を正とする。

> **Note (外部リンク read model)**: `listExternalLinks`(PR #153)は `anchors` の JOIN + `COALESCE` 計算列での `GROUP BY` + `COUNT(DISTINCT source.id)` をリクエストごとに(`total` 用と data 用で)2 回実行していた。SQLite の `COUNT(DISTINCT ...)` は既存 index を使わず別 b-tree を都度構築する既知のパフォーマンス病理を持つため(実測: 単体 6.4 秒、他の集約と混ぜると 55.2 秒まで悪化する例が SQLite forum に報告されている)、`viewer_pages`/`viewer_directory_nodes` と同じ read model パターンに乗せた。`viewer_external_links`(`dest_page_id` PK / `dest_url` / `status` / `referrer_count`)は `buildViewerReadModel` 内で `computeExternalLinkRows` が `anchors` への専用クエリ(`sourceRows` 再利用不可 — リンク情報は `pages` にはない)で1回だけ集計して構築する。集計ロジック自体(`COALESCE` 解決、referrer 重複排除)は `listExternalLinks` から無変更で移植 — `getPageDetail.inboundLinks`(#71)とのカウント粒度契約を崩さないため。ページネーションは keyset cursor ではなく `paginateQuery`(offset ベース、REST 契約が offset のままなので不要な複雑さを持ち込まない)。`register-links-route.ts` は `/api/pages` と同じ二層構成で `isViewerReadModelCurrent` を見て `listViewerExternalLinks`(fast path)↔ `listExternalLinks`(legacy、無変更で残存)を切り替える。スキーマ変更のため `VIEWER_READ_MODEL_SCHEMA_VERSION` を 4→5 に bump。詳細は ARCHITECTURE.md の `@nitpicker/viewer` 節「設計注意(外部リンク read model)」を正とする。

## CLI コマンド

```sh
Expand Down
Loading
Loading