Nitpicker は Web サイト全体のデータを取得するクローラー+監査ツール。ヘッドレスブラウザで各ページをレンダリングし、メタデータ・リンク構造・ネットワークリソース・HTML スナップショットを .nitpicker アーカイブ(tar 形式)に保存する。さらに、保存したアーカイブに対して各種 analyze プラグインを実行し、Google Sheets にレポートを出力できる。
Lerna + Yarn Workspaces のモノレポ構成で、@nitpicker スコープ配下の全パッケージ + E2E テストサーバーから成る。
packages/
├── @nitpicker/
│ ├── cli/ # 統合 CLI (bin: nitpicker)
│ ├── crawler/ # クローラーエンジン(オーケストレーター + アーカイブ + ユーティリティ)
│ ├── core/ # 監査エンジン(Nitpicker クラス + プラグインごとの WorkerPool による並列処理)
│ ├── types/ # 監査型定義(Report, ConfigJSON)
│ ├── query/ # アーカイブクエリ API(SQL レベルのフィルタ・集計)
│ ├── mcp-server/ # MCP サーバー(AI アシスタント連携、bin: nitpicker-mcp)
│ ├── analyze-axe/ # アクセシビリティ監査
│ ├── analyze-lighthouse/ # Lighthouse 監査
│ ├── analyze-main-contents/ # メインコンテンツ検出
│ ├── analyze-markuplint/ # マークアップ検証
│ ├── analyze-search/ # キーワード検索
│ ├── analyze-textlint/ # テキスト校正
│ ├── report-google-sheets/ # Google Sheets レポーター
│ └── viewer/ # ローカルブラウザビューア(Hono API + React SPA、CLI: nitpicker viewer)
└── test-server/ # E2E テスト用 Hono サーバー
@d-zero/beholder(外部)
↑
└── crawler ── @nitpicker/cli ← @d-zero/roar(外部)
↑ ↑ ↑ ↑ ↑
│ │ core │ report-google-sheets
│ │ ↑ │
│ │ analyze-* プラグイン
│ └── query
│ ↑ ↑
│ │ mcp-server ← @modelcontextprotocol/sdk(外部)
│ └── viewer ← @hono/node-server + react + @tanstack/*(外部)
└── @d-zero/dealer(外部)
Note: CLI は analyze プラグインに直接依存する(
npx実行時のモジュール解決のため)。新規 analyze プラグイン追加時は@nitpicker/cli/package.jsonのdependenciesにも追加すること。
Note:
viewerは Hono バックエンド(@nitpicker/queryを再利用)+ React SPA(Vite ビルド)の単一パッケージ。CLI にviewerサブコマンドとして統合され、@nitpicker/cli/package.jsonのdependenciesに含まれる。他コマンドがバッチ型(実行→完了→process.exit)なのに対し、viewer だけは常駐サーバなのでcli.ts末尾のprocess.exitを回避する例外扱いになる(startViewerは SIGINT/SIGTERM まで resolve しない)。ビルドはtsc(backend)+vite build(frontend)の 2 段。
npx @nitpicker/cli crawl <URL> [<URL>...] [options] # Web サイトをクロールして .nitpicker ファイルを生成(複数 URL で multi-root)
npx @nitpicker/cli crawl <archive> --append <URL> [--append <URL>...] # 既存アーカイブに新しい起点 URL を追加クロール
npx @nitpicker/cli crawl <archive> --retry-failed [--no-recursive] # 既存アーカイブの失敗ページ(status -1/NULL・content-type NULL・5xx)を再取得
npx @nitpicker/cli analyze <file> [options] # .nitpicker ファイルに対して analyze プラグインを実行
npx @nitpicker/cli report <file> [options] # .nitpicker ファイルから Google Sheets レポートを生成
npx @nitpicker/cli pipeline <URL> [options] # crawl → analyze → report を直列実行
npx @nitpicker/cli query <file> <sub-command> [options] # .nitpicker ファイルに対してクエリを実行し JSON 出力
npx @nitpicker/cli viewer <file-or-stub-dir> [options] # ローカルブラウザビューアを起動(.nitpicker ファイル / stub tmpDir の両方を受け付ける、Ctrl-C で停止)
npx @nitpicker/cli -v | --version # `@nitpicker/cli` のバージョンを出力して exit 0Multi-root クロール: 位置引数で複数 URL を渡すと、それぞれが「再帰クロールの起点」かつ「scope エントリ」として扱われ、1 つの
.nitpickerに集約される。例:crawl https://www.example.com/blog/ https://www.example.com/news/→ 両配下が internal として記録される。(hostname, port, path)トリプルで scope 一致を判定するため、localhost:3000とlocalhost:8080は別 scope として分離される(auth が混入しない)。
--append <URL>: 位置引数で指定された既存.nitpickerを開き、--appendの URL を新しい起点として追加クロールする(--appendは繰り返し指定で複数 URL 可)。新スコープに該当する旧 external ページは internal として再スクレイプされる。失敗時は<archive>.bakから自動復元、成功時は.bak削除。--resume/--diff/--output/--list/--list-file/--singleとの同時指定は不可。
--retry-failed: 位置引数で指定された既存.nitpickerを開き、前回クロールで失敗したページだけを pending に戻して再取得する。失敗の定義はstatus = -1(ハード失敗 sentinel)/status IS NULL/contentType IS NULL/statusが 5xx(4xx は確定応答なので対象外)。internal/external 両方が対象で、external は scope 判定により metadata-only として再取得される。実装は append と同じ「再オープン+.bak+Crawler.resume()+crawling()」フローだが、新起点を足す代わりにArchive.resetFailedPages()(→Database.resetFailedPages)で失敗ページをscraped=0に戻し、archived roots を seed にして失敗ページを resumedPending 経由で処理する(external 失敗ページを scope へ誤登録しないため)。recursive はフラグ値(デフォルト true)が優先され、アーカイブ作成時の recursive 設定は継承しない(crawling(list, { recursive })で明示注入)。それ以外の設定(scope/excludes/userAgent 等)は archived 設定を流用し、明示指定したフラグのみ上書き。--resume/--append/--diff/--output/--list/--list-file/--singleとの同時指定は不可。Note: 全ページが失敗していた(reset 後に
scraped=1が 0 件になる)アーカイブでも取りこぼさないよう、Crawler.start()の resume 判定は#resumedScrapedだけでなく#resumedPendingも見る。#resumedScrapedのみを見ると「全ページ失敗 retry」や「1ページもスクレイプせず中断した resume」を fresh crawl と誤判定し、pending を全部捨ててしまう。
Stub mode viewer:
viewerは.nitpickerファイルだけでなく、crawlを強制停止した時に残る._nitpicker-*ディレクトリ("stub")も直接受け付ける。crawl --resumeと同じ mental model で同じパスを渡せる。stub オープンはArchive.connect(tmpDir)経由の read-only 接続で、Database.connect({readOnly: true})によりinitSchema/migrateInfoRootsは 走らない(user の tmpDir を絶対に書き換えない)。getHtmlOfPageも読み取り専用なら zip を tmpDir 内に展開しない(single-entry 抽出)。close 時にはdb.destroy()だけが呼ばれ、tar 化も tmpDir 削除も発生しないため、その後のcrawl --resumeが安全に走る。viewer footer は/api/info.crawlerPidに応じて "Live crawl in progress (PID xxx)" / "Interrupted crawl stub" のバッジを出し分ける(peekArchiveLockHolderで<tmpDir>.lock/pid.txtを probe)。Archive.releaseHandle()は writer の DB ハンドルと lock を解放するが tmpDir は残す書き戻し無しの exit hatch — fixture スクリプトと一部テストが使う。
Note:
-v/--versionはargv[0]の位置でのみ判定する。crawl -vのようにサブコマンドの後ろに置いた場合はそのコマンドのフラグとして解釈される(@d-zero/roarの仕様)。
エラー原因分類(
query error-kinds/ viewer Errors ビュー): クロール失敗を 8 種(dns / connection-refused / connection-reset / connection-timeout / tls / timeout / protocol / unknown)に分類する。kind は保存せず読み取り時にclassifyErrorKind(message)(@nitpicker/query)で導出するため、この機能より前のアーカイブもそのまま分類できる(分類ロジックが 1 箇所に集約され、capture 時と read 時で必ず一致する)。失敗は 2 系統: スクレイプ経路はpage_errors、crawler レベルのerrorチャネル(DNS/接続/TLS)は構造化テーブルcrawl_errors(Archive.addErrorがerror.logと両方へ記録、migrateCrawlErrorsで既存アーカイブに後付け)。getErrorKindsはcrawl_errorsに行があればそれを、無ければ(テーブル不在 または空)error.logをパースしてフォールバックする(空フォールバックが無いと、migration で空テーブルだけ作られた legacy アーカイブのエラーが読まれず消える)。crawl_errorsは read-only 接続ではmigrateCrawlErrorsが走らないため作られない(stub viewer は error.log フォールバックで分類)。
CrawlerOrchestrator.crawling(urls, options)
→ Archive.create()(SQLite DB を tmpDir に作成)
→ Crawler → deal()(@d-zero/dealer で並列制御)
→ 各 URL: HEAD プリフライト(fetchDestination, trackRedirects で最終到達先を解決)→ puppeteer.launch() → Scraper.scrapeStart(page, ...)
→ ScrapeResult を戻り値で返却
→ リダイレクト先が既に描画済み(#scrapedDestinations にキャッシュ)なら描画せず redirect-edge を返す(#73 多対一リダイレクトの再レンダリング抑止)
→ LinkList.done() + WriteQueue 経由で Archive にページデータ保存(redirect-edge は setRedirect で辺だけ記録)
→ 発見した新 URL を動的にキューに追加(HTML らしい URL は unshift で先頭へ優先、それ以外は push で末尾へ。判定は URL の拡張子ヒューリスティック isLikelyHtmlUrl)
→ crawlEnd 時に WriteQueue.drain() で未完了の書き込みを待機
→ CrawlerOrchestrator.write()(tmpDir を .nitpicker tar に圧縮)
Nitpicker.analyze(archivePath, plugins)
→ Archive.connect() → ArchiveAccessor
→ getPagesWithRefs() で全ページ取得
→ プラグインを順次処理。プラグインごとに専用 WorkerPool を生成
→ new WorkerPool({ size: plugin.concurrency ?? os.cpus().length })
→ 長寿命 Worker N 個をプール起動時にまとめて spawn
→ 各ページ: pool.run() でタスクをキュー投入 → 空き Worker が処理
→ Lanes(@d-zero/dealer)が進捗表示を担当(プラグイン内の console.log は不要)
→ プラグイン終了時に pool.terminate()
→ レポートファイル書き出し
report({ filePath, sheetUrl, dedupeResources, ... })
→ Archive.connect() → ArchiveAccessor
→ loadConfig() → analyze プラグインから生成された Report[] を読み込み
→ 対話プロンプト(または --all)で出力するシートを選択
→ createSheets() を 5 フェーズで実行
Phase 1: シート作成 + ヘッダー設定(全シート並列)
Phase 2: getPagesWithRefs() でページ反復
→ preEachPage で状態蓄積 → eachPage で行生成
→ sheet.appendRow(...) でストリーミング送信
Phase 3: getResources() でリソース反復
→ Phase 3 入り口で URL の自然順(Pool strnatcmp 移植の on-the-fly 比較、sortResourcesByUrl)にソート
→ eachResource で行生成 → sheet.appendRow(...)
→ finalizeResources hook(任意)を per-resource ループ完了後に 1 度呼び、
集約行を一括 emit(dedupe Resources シートが利用)
Phase 4: addRows でプラグインデータ送信(Phase 2/3 と並列実行)
Phase 5: updateSheet でフォーマット適用(凍結行・条件付き書式・列非表示)
--dedupe-resources: Resources シートを canonical URL(クエリ値を捨て、キーを sort + unique)で集約する。finalizeResourceshook を使って Phase 3 終端で集約行を一括 emit。広告/解析タグ(Google Ads conversion, Facebook Pixel, Yahoo, Bing UET, LINE Tag 等)で per-request unique なクエリ付き URL が爆発するサイトで、Google Sheets 1 ドキュメント 10,000,000 セル上限を回避するために使う。実例: 1.6M raw → 63K 行(96% 削減)。実装はpackages/@nitpicker/report-google-sheets/src/data/create-resources.ts、canonical 化規則はutils/canonicalize-url.ts。
dedupe モードの追加列: raw 6 列(URL / Status Code / Status Text / Content Type / Content Length / Referrers)に加え、末尾に Count(その canonical group に集約された raw レコード数)と Query Pattern(各クエリキーの観測ユニーク値数を
key=N形式で並べる、例:auid=27, capi=1, crd=25)が付く。Query Pattern はキーごとに最大MAX_PARAM_VALUE_SAMPLES(=100)まで sample set に保持し、cap 後の観測はoverflowedCountで数える。overflowedCount > 0の時のみkey=100+を付け、100 ジャストではkey=100のまま(cap=hit と overflow=発生を区別する)。値そのものは記録しない(プライバシー / メモリ)。実装はformatQueryPattern/recordParamValue。
新規シート実装時の Hook 選択:
- per-page / per-resource で逐次行を返す →
eachPage/eachResourceをそのまま使う(自然な実装)- per-resource を反復しつつ集約して 1 回だけ大量に emit したい →
eachResource内で Map に蓄積、finalizeResourcesで flush(dedupe 集約モードの基本パターン)。eachResource内で「最後の resource か」を判定して emit する書き方は脆いので禁止(Phase 3 が将来並列化されると壊れる)- プラグインデータなど反復不要・単発で送りたい →
addRows(Phase 2/3 と並列実行される)
Resources の出力順: Phase 3 入り口で
sortResourcesByUrl()(utils/sort-resources-by-url.ts)により URL の自然順にソートされる。実装は Martin Poolstrnatcmp.cの JS 移植版(Stuart Cheshire 1996 由来)で、charCodeAtベースの on-the-fly 比較。Pool のcompare_left/compare_rightの 2 path 分岐をそのまま踏襲(数値ランのどちらかが'0'で始まれば fractional 解釈で左から即決、それ以外は length-first で bias 同点解消)。派生文字列を一切生成しないためメモリ追加は O(1) per compare、O(N) の auxiliary(TimSort buffer)のみ。image-2.jpg < image-10.jpgの数値順、ASCII 大文字小文字は同値扱い、stable sort 保証。raw / dedupe 両モードで同じ並び順になる。以下の sort 実装は採用してはならない:
Intl.Collator.compareの per-compare 呼び出し: 1 比較あたり ICU の重みテーブル lookup が走り、N log N で累積して数分単位のブロックになる。長時間ブロック中に Google Sheets API への HTTPS keep-alive が server 側 timeout で切られ、後続 send がEPIPEになる。- 固定幅ゼロパディングで sort key を事前生成する Schwartzian: 1.6M 件 × URL 平均長 × 数値部 padding で key 配列だけで数百 MB のヒープを要し、
getResources()の 1〜1.5 GB と合わせて Node デフォルトヒープ 4 GB を突破する。- 比較関数内で
toLowerCase()/replaceAll()等の派生文字列を作る: V8 は 1 比較ごとに新規 SeqString を allocate するため、N log N 回の allocation が GC を圧迫する。sort 実装は 派生文字列を 1 つも作らない ことが鉄則。
charCodeAt/codePointAtの比較のみで完結させる。
以下の機能は @d-zero/shared から提供されており、独自実装は不要:
detectCompress/detectCDN— beholder から re-exportparseUrl—@d-zero/shared/parse-urldelay—@d-zero/shared/delayisError— beholder/is-error.ts に集約、crawler は re-export
- crawler:
deal()(@d-zero/dealer)による URL スクレイピングの並列制御 - core(analyze): プラグインごとの
WorkerPool(長寿命 Worker N 個 / プラグイン)。並列度はプラグインのconcurrency宣言、未指定時はos.cpus().length。Lanes(@d-zero/dealer)で進捗表示 - report-google-sheets: Phase 1 / 4 / 5 は
Promise.allで全シート並列。Phase 2 / 3 は per-resource ループは逐次(dedupe 集約とappendRowの順序保証のため)、ただしシート間は並列。Lanesでフェーズ進捗とシート別状況を表示
yarn test # ユニットテスト
yarn vitest run --config vitest.e2e.config.ts # E2E テスト(maxWorkers: 1)
yarn workspace @nitpicker/viewer test:e2e # Viewer の Playwright E2E(fixture 生成 → 実 CLI 起動 → ブラウザ検証)
yarn build # 全パッケージビルド
yarn lint # lint + cspell- E2E テストサーバー: Hono on port 8010(
test-server/src/__tests__/e2e/global-setup.ts) - 外部リンクのシミュレーション:
127.0.0.1(localhostと異なるホスト名で外部判定) - 1関数1ファイルにはユニットテスト必須: エクスポートされた関数ごとにユニットテストを必ず作成する
- 課題が明確な場合はテストファースト: バグ修正や仕様が明確な機能追加では、実装より先にテストを書く
- yarn のみ使用: npm 厳禁。すべてのコマンドは
yarn経由で実行する - パッケージディレクトリに cd しない: 個別パッケージディレクトリに移動してコマンドを実行しない。常にリポジトリルートから
yarn build等を実行する - ビルドは
yarn buildのみ:npx tsc,yarn tsc,npx nx,yarn dlx tsc等は禁止 - 対象を限定した操作: ビルド検証は
yarn buildで全パッケージ一括実行 - コマンドの連続実行禁止:
&&、;、改行によるコマンド連結をしない。1回の Bash 呼び出しで1コマンドのみ実行する。連結されたコマンドは settings.json の permissions allow/deny でパターンマッチできず、毎回ユーザーの手動承認が必要になり効率が大幅に低下する
- 1ファイル1エクスポート: エクスポートする関数/クラスは1つのみ。同居可能なのはファイルスコープに閉じた非エクスポートの内部関数のみ
- index.ts 禁止:
index.tsを作成しない。モジュールの公開はすべて package.json のexportsフィールドで行う - 型は types.ts に集約: ドメインごとに専用
types.tsを作成する
| ドキュメント | 内容 | 対象読者 |
|---|---|---|
README.md |
CLI の使い方・オプション・出力形式 | API ユーザー |
ARCHITECTURE.md |
パッケージ構成・データフロー・DB スキーマ・テスト構成 | コントリビューター |
ドキュメントと実装に矛盾がある場合は、実装が正とし、ドキュメントを修正すること。
.env、.env.*等の機密ファイルを読み取り・編集・コミットしない(機密ファイルの判断は.gitignoreを参考にすること)- コミット前に
git diff --stagedで機密情報(API キー、トークン、パスワード、企業名、顧客情報)が含まれていないか確認する - 環境変数やシークレットをコード内にハードコードしない
- yarn dlx は完全禁止: ローカルパッケージを使わずリモートから直接実行するため、サプライチェーン攻撃に脆弱
- npx は原則使わない: package.json の scripts で定義されたコマンドを
yarn <script>で実行すること - 新しい依存パッケージの追加は慎重に。既存の依存で解決できないか先に確認する
yarn addする前にパッケージの信頼性(ダウンロード数、メンテナンス状況、既知の脆弱性)を確認するyarn addする場合はバージョンを固定する(例:yarn add foo@1.2.3)- lockfile(yarn.lock)の手動編集は禁止
タスクに応じて .claude/skills/ 配下のスキルを参照すること。
| スキル | パス | 用途 |
|---|---|---|
| Product Manager | .claude/skills/product-manager/SKILL.md |
リポジトリ分析、ドキュメント生成・レビュー、アーキテクチャ評価、PR レビュー |
| QA Engineer | .claude/skills/qa-engineer/SKILL.md |
コードレビュー、テスト品質チェック、カバレッジ改善、リファクタリング提案 |
| Impl | .claude/skills/impl/SKILL.md |
合意済み計画の実装・検証・PR 作成までのオーケストレーション |
import { describe, it, expect } from 'vitest'を明示的に記述(Vitest 4 の要件)@d-zero/sharedはサブパスエクスポート(@d-zero/shared/delay形式)- analyze プラグインでは
console.logを使わない(Lanesが進捗表示を担当) - JSDoc 必須: すべての関数、クラス、クラスメンバー変数、クラスメンバー関数(private 含む)、interface、interface プロパティ、type、type オブジェクトリテラルプロパティ、関数型、トップレベル定数に JSDoc を記述する
- interface 優先:
typeはユニオン型・交差型・マップ型などtypeでしか定義できない場合のみ使用する - 公開 API はオブジェクトコンテキスト: パラメータ3つ以上の関数は名前付きオブジェクトにまとめる
- Options は Partial: オプショナル設定は
Partial<OptionsType>パターンを使用する - exports で公開 API を厳選: package.json の
exportsフィールドにサブパスを明示的に定義し、公開 API を限定する。モノレポ内パッケージ間でも exports 経由でのみアクセスする Promise.raceの負け側 timer は必ずキャンセル: タイムアウト実装でPromise.race([work, delay(N)])を書く場合、勝者が決まった後も負け側のsetTimeoutは発火するまで event loop を握り続け、CLI プロセス終了をブロックする。setTimeout/clearTimeoutを直接使い、.finally()で確実に clear すること(delay()は signal を取らないので race には使わない)。実例:packages/@nitpicker/crawler/src/crawler/fetch-destination.ts(HEAD 10s)、packages/@nitpicker/crawler/src/crawler/close-browser-safely.ts(ブラウザクローズ 30s + SIGKILL フォールバック)- CLI 末尾は明示的に
process.exit:packages/@nitpicker/cli/src/cli.tsの末尾でprocess.exit(process.exitCode ?? ExitCode.Success)を呼ぶ。外部依存(特に@d-zero/beholderのdom-evaluation.js#getProp)に同じ「Promise.race+setTimeoutの負け側 timer 残留」パターンがあり、自然終了をブロックするため。await 済み work 完了後の defensive measure であり、内部の cleanup は順番に await した上で呼ぶこと - 集約系シートは
finalizeResourcesを使う: Phase 3 で per-resource ループを反復しつつ集約結果を 1 度だけ送信したい場合(dedupe Resources のような実装)、eachResourceでnum/totalを見て「最後だから emit」する設計は禁止。CreateSheetSetting.finalizeResourceshook を使い、accumulate と emit を分離すること。Phase 3 の実装詳細(逐次 / 並列、num/total の意味)から切り離されるため、将来 Phase 3 が並列化されても集約ロジックが壊れない。実例:packages/@nitpicker/report-google-sheets/src/data/create-resources.tsの dedupe モード - 巨大集合のユニーク値カウントは「sample set + overflowedCount」パターン: トラッカー URL の per-request unique 値のように、1 グループ内で数十万件のユニーク値が出る可能性がある場合は、
Set<string>を上限 N(例MAX_PARAM_VALUE_SAMPLES = 100)で打ち切り、cap 到達後の観測は別カウンタoverflowedCountに増やす。出力時にoverflowedCount > 0の時のみ+等の overflow マークを付与し、cap ジャスト(=N unique、overflow なし)は マークなし で表示する。これにより「cap に達した」と「実際に観測を落とした」を区別でき、ユーザーにNジャストの精度を返せる。実例:ParamValueTracker/formatQueryPattern
- 修正前にスキャン: コード変更を行う前に、対象パッケージの構造・依存関係・exports を確認してから修正を開始する
- exports を壊さない: package.json の
exportsフィールドを変更する場合は差分のみ追記し、既存のエクスポートパスを削除しない - アーキテクチャガード: 変更後にディレクトリ・構造ルール(1ファイル1エクスポート、index.ts 禁止、型の集約)に違反していないかセルフチェックする