Lerna + Yarn Workspaces によるモノレポ。
packages/
├── @nitpicker/
│ ├── cli # 統合 CLI(crawl / analyze / report コマンド)
│ ├── crawler # オーケストレーター + 型定義 + ユーティリティ + アーカイブ
│ ├── core # Nitpicker プラグインシステム
│ ├── types # 共有型定義
│ ├── query # アーカイブクエリ API(SQL レベルのフィルタ・集計)
│ ├── mcp-server # MCP サーバー(AI アシスタントからのアーカイブクエリ)
│ ├── analyze-* # 各種 analyze プラグイン
│ ├── report-google-sheets # Google Sheets レポーター
│ └── viewer # ローカルブラウザビューア(Hono API + React SPA)
└── test-server/ # E2Eテスト用 Hono サーバー
@d-zero/beholder(外部)
↑
└── crawler ── @nitpicker/cli ← @d-zero/roar(外部)
↑ ↑ ↑ ↑ ↑
│ │ core │ report-google-sheets ← @d-zero/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:
@d-zero/dealerは上図では crawler と report-google-sheets への接続のみ表示しているが、cli と core もLanes型のインポートのために依存している。
flowchart TD
User["ユーザー(CLI / API)"] --> Crawling["CrawlerOrchestrator.crawling(urls, options)"]
Crawling --> Archive["Archive.create()<br/>SQLite DB を tmpDir に作成"]
Crawling --> Crawler["Crawler(options)"]
Crawler --> Scope["scope 解析(Map<hostname, URL[]>)"]
Crawler --> LinkList["LinkList に開始 URL を追加"]
Crawler --> Deal["deal()(@d-zero/dealer)"]
Deal --> RobotsCheck["robots.txt チェック(RobotsChecker)"]
RobotsCheck --> Checks["除外チェック / fetchExternal チェック"]
Deal --> Push["発見した URL を動的にキューに追加<br/>(HTML らしい URL は unshift で先頭へ優先 / それ以外は push で末尾へ)"]
Deal --> Beholder["Scraper(@d-zero/beholder)<br/>インプロセス実行"]
Beholder --> Head["HEAD リクエスト(User-Agent 付き)"]
Beholder --> Puppeteer["Puppeteer でページ取得<br/>(ブラウザは Crawler が管理)"]
Beholder --> DOM["DOM からアンカー・メタ・画像を抽出"]
Beholder --> Keyword["キーワード除外チェック"]
Beholder --> Result["ScrapeResult を返却(戻り値)"]
Result --> Done["LinkList.done() でリンク完了処理"]
Result --> Save["Archive にページデータ保存"]
Crawling --> Write["CrawlerOrchestrator.write()"]
Write --> ArchiveWrite["Archive.write()<br/>WAL checkpoint → tmpDir を .nitpicker ファイルに tar"]
Puppeteer ベースのスクレイパー。インプロセスで実行され、戻り値ベースの API を提供。
自己完結型で、型定義・ユーティリティ関数を内部に持ち、@d-zero/shared に直接依存。
主要クラス:
Scraper: スクレイピングロジック(scrapeStart()がScrapeResultを返す)
API の特徴:
scrapeStart()はScrapeResultを直接返す(イベント経由ではない)- ストリーミングイベント(
changePhase,resourceResponse)のみ emit - Page オブジェクトは外部から注入(ブラウザ管理は呼び出し元が担当)
スクレイピングフェーズ:
scrapeStart → openPage → loadDOMContent → getHTML → waitNetworkIdle
→ getAnchors → getMeta
→ extractImages → [setViewport → waitImageLoad → getImages](デバイスプリセットごとにループ)
→ scrapeEnd
オーケストレーター + 型定義 + ユーティリティ + アーカイブストレージ。
主要クラス:
CrawlerOrchestrator: エントリポイント。CrawlerOrchestrator.crawling()(複数 URL で multi-root),CrawlerOrchestrator.resume()(中断再開),CrawlerOrchestrator.append()(既存アーカイブへの追加クロール)Crawler: リンク管理・スクレイプスケジューリングLinkList: URL キュー管理(pending → progress → done)Archive: アーカイブの作成・再開・書き出し。Archive.close()は冪等(#closeOnceで破壊的プロローグも含めて1回だけ実行)、Archive.releaseHandle()は writer の DB ハンドルと lock だけを解放し tmpDir /.nitpickerには触れない代替 exit。Archive.connect(tmpDir)は read-only モードでアクセサを返し、Database.connect({readOnly: true})経由でinitSchema/migrateInfoRootsを 走らせない(user の tmpDir を絶対に変更しない)。HTML スナップショットは SQLite BLOB なのでgetHtmlOfPageは read-only でも単純 SELECT で済むArchiveAccessor: 読み取り専用アクセサ(getPages,getPagesWithRefsなど)。close()は冪等 + 5 秒のdb.destroy()timeout 付き(viewer Ctrl-C が live crawler の write lock で 10 分ハングするのを防ぐ)。HTML スナップショットの読み取りはarchive-accessor.ts/database.tsの JSDoc を参照peekArchiveLockHolder(tmpDir):<tmpDir>.lock/pid.txtを probe する read-only ヘルパ(lock を取りに行かない)。viewer footer の "Live crawl in progress" / "Interrupted crawl stub" バッジ判定に使用。crawler 側archive-lock.tsと同じ alive 判定ロジックを共有Page: ページデータラッパー
内部モジュール構造:
crawler/src/
├── utils/ # 型定義 + ユーティリティ
│ ├── types/ # ExURL, PageData, Link, CrawlerError 等
│ ├── array/ # eachSplitted
│ ├── object/ # cleanObject
│ └── error/ # DOMEvaluationError, emitError / emitErrorAndRetry (HOF)
├── archive/ # SQLite アーカイブストレージ
│ ├── filesystem/ # 1関数1ファイル(16ファイル)+ tar, untar
│ ├── archive-lock.ts # tmpDir 単位の advisory lock(mkdir + pid.txt + stale 検出)
│ ├── peek-archive-lock.ts # lock を取らずに pid.txt を probe する read-only ヘルパ(viewer / MCP 用)
│ ├── migrate-info-roots.ts # info テーブルを現行スキーマに揃える冪等 migration(writer-only — Database.connect({readOnly:true}) では走らない)
│ ├── libsql-dialect.ts # better-sqlite3 dialect の libsql 上書き
│ └── ... # archive, archive-accessor, database, init-schema, limited-page-ids, redirect-table, get-json, page, resource, safe-path, types
├── crawler/ # Crawler エンジン
│ ├── crawler.ts # Crawler クラス
│ ├── link-list.ts # URL キュー管理
│ ├── types.ts # CrawlerOptions, CrawlerEventTypes, PaginationPattern
│ ├── should-skip-url.ts # URL 除外判定
│ ├── find-scope-entry.ts # スコープ判定の単一エントリポイント(hostname+port+path で最深一致を返す or null)
│ ├── is-external-url.ts # 外部 URL 判定(findScopeEntry の薄ラッパ)
│ ├── is-likely-html-url.ts # URL 拡張子から HTML らしさを判定(キュー優先度用、HEAD 前)
│ ├── partition-urls-by-html.ts # URL 群を HTML / 非 HTML に分割(unshift / push 振り分け)
│ ├── inject-scope-auth.ts # スコープ認証注入(matchedScope を直接受け取る)
│ ├── handle-scrape-end.ts # スクレイプ成功ハンドラ
│ ├── handle-ignore-and-skip.ts # スキップハンドラ
│ ├── handle-resource-response.ts # リソースレスポンスハンドラ
│ ├── handle-scrape-error.ts # スクレイプエラーハンドラ
│ ├── detect-pagination-pattern.ts # ページネーション検出
│ ├── generate-predicted-urls.ts # 予測 URL 生成
│ ├── should-discard-predicted.ts # 予測結果破棄判定
│ ├── decompose-url.ts # URL トークン分解
│ ├── reconstruct-url.ts # URL 再構築
│ ├── fetch-destination.ts # HTTP HEAD/GET リクエスト
│ ├── clear-destination-cache.ts # キャッシュクリア
│ ├── destination-cache.ts # リクエストキャッシュ
│ ├── fetch-robots-txt.ts # robots.txt 取得・パース
│ ├── robots-checker.ts # robots.txt 準拠チェッカー(origin 別キャッシュ)
│ ├── format-crawl-progress.ts # deal() 進捗表示のフォーマッタ
│ └── ... # link-to-page-data, protocol-agnostic-key, net-timeout-error
├── crawler.ts # バレルエクスポート(パッケージ公開 API)
├── crawler-orchestrator.ts # CrawlerOrchestrator
├── debug.ts # デバッグログユーティリティ
├── resolve-output-path.ts # 出力パス解決・検証
├── types.ts # CrawlEvent インターフェース
└── write-queue.ts # Archive 書き込み直列化キュー
.nitpicker アーカイブファイルに対する SQL レベルのクエリ API。大規模データセット(10,000+ ページ、500,000+ レコード)向けに最適化。
主要クラス・関数:
ArchiveManager: アーカイブのライフサイクル管理(open / get / close / closeAll)。同一ファイルの重複オープンは参照カウントで管理し、untar を再実行しない。open()は.nitpickerファイルだけでなく stub ディレクトリ (<dir>/db.sqliteを含むディレクトリ)も受け付け、ファイル/ディレクトリはfs.statSyncで自動判定。stub オープンはArchive.connect経由の read-only モードで lock を取らず、close 時には DB ハンドルだけ解放(tmpDir は user の crawl 状態として残す)。.nitpickerファイルの open は default でArchive.openCached(タールキャッシュ経由) に振られる: OS-temp スコープの<os.tmpdir>/nitpicker/cache/<key>-<basename>/に展開して keep、2回目以降の open は untar をスキップする。キャッシュ寿命は誰も evict しない — OS の temp cleanup (macOS reboot / Linuxsystemd-tmpfiles/ Windows Disk Cleanup) に委ねる。同一プロセス内で同 path への concurrentopen()は#openInflightMap で dedup し、loser のアクセサ leak を防ぐ。close と open はentry.closingPromise を介して serialise されるため、teardown 中の同一パスへの concurrent open は close 完了まで待ってから新規 open に進む(ArchiveLockError 競合を防ぐ)。NITPICKER_DISABLE_TAR_CACHE=1(ortrue/yes/on) を立てると旧 writer 経路 (Archive.openで<cwd>/._nitpicker-*に展開、close 時に tmpDir 削除 + 再 tar 化) にフォールバックする — クロール経路 (crawl --append/--retry-failed) は env と無関係に常に writer 経路を直接呼ぶ。new ArchiveManager({onWarn})で警告 sink を差し替え可能(既定はconsole.warn、MCP server はprocess.stderr.write専用 sink を渡して JSON-RPC stdio framing を守る)。open()の戻り値にはmode: 'archive' | 'stub'とcrawlerLockHolder: ArchiveLockHolder | null(live PID 検出用、viewer footer が消費)と writer 経路時のみarchive: Archive(最初の opener が finalisation owner、refcount 共有される 2 回目以降はundefined)を含むlistPages: ページ一覧取得(ステータス・メタデータ欠損・URL パターンなどでフィルタ)getSummary: サイト全体の統計(内部/外部のページ数とコンテンツ数、ステータス分布、Content-Type 分布、メタデータ充足率)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をそのまま再利用する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 欠損、寸法欠損、オーバーサイズ検出)getViolations: 分析プラグインの違反データ取得findDuplicates: 重複タイトル・説明の検出findMismatches: メタデータ不一致の検出(canonical, og:title, og:description)getResourceReferrers: リソースを参照しているページの特定checkHeaders: セキュリティヘッダーチェック(CSP, X-Frame-Options, X-Content-Type-Options, HSTS)classifyContentType: 生 MIME を 18 個のContentTypeCategory(html/pdf/csv/word/excel/powerpoint/image/css/javascript/json/xml/font/audio/video/archive/text/other/unknown)に正規化する純関数。getSummaryのcontentTypeDistribution集計とlistPagesのcontentTypeCategoryフィルタが同じカテゴリ境界を共有する単一の源泉。カテゴリ統合:csvは CSV + TSV、wordは .doc + .docx、excelは .xls + .xlsx、powerpointは .ppt + .pptx、jsonは JSON + YAML、textは .txt + .md を 1 カテゴリにまとめる(拡張子別の分散を避け、ユーザーが「文書ファイル」「データファイル」をまとめて評価できるようにする設計判断)applyCategoryFilter:ContentTypeCategoryを Knex query に適用する SQL マッチャ。後述の「Content-Type ルール表」から派生し、JS classifier の優先順位を SQL に正確に投影する- 上記以外にも
classifyErrorKind/getErrorKinds/getLinkGraph/computeIsolatedClusters/listUnusedResources/listInventoryRuns/listPagesByTag/countPagesByTag/listPagesByJsonLdType/countPagesByJsonLdType/getTagInventory/getPageTags/getPageJsonLd/getPageJsonLdOverview/prepareUrlSortTempTable等の関数がある。全関数の正はpackages/@nitpicker/query/src/query.ts(バレルファイル)の re-export 一覧であり、上記は代表例に留める(機能追加のたびここを手動更新すると陳腐化するため網羅列挙はしない)
サブエクスポート:
@nitpicker/query/categories—ContentTypeCategory型、CONTENT_TYPE_CATEGORIES値、classifyContentType関数、ContentTypeCount型を再エクスポートする browser-safe な leaf モジュール。knex等の Node-only ランタイムを含まないため、viewer の React 側が Vite バンドルから安全にインポートできる
依存: @nitpicker/crawler(Archive, ArchiveAccessor を使用)
Content-Type カテゴリ判定は packages/@nitpicker/query/src/content-type-rules.ts の CONTENT_TYPE_RULES 配列が唯一の源泉になる。配列の順序が判定の優先順位を表し、上から順に最初にマッチしたルールがそのカテゴリを返す。classifyContentType(JS classifier)と applyCategoryFilter(SQL マッチャ)の両方がこの 1 表から派生するため、Summary チャートが「PDF: 3 件」を出すならば Pages を contentTypeCategory='pdf' で絞り込んだ結果も同じ 3 行になる、という整合性が構造的に保証される。
優先順位による棲み分けの例:
image/svg+xml—image/プレフィックスのルールが先にマッチするため'image'(+xmlサフィックスのルールには到達しない)application/xhtml+xml—text/htmlルールの完全一致リストに含まれるため'html'text/calendar+xml—+xmlサフィックスのルールがtext/プレフィックスより先にあるため'xml'text/x-foo+json— 同様に+jsonサフィックスが先にあるため'json'
SQL マッチャ側はこの優先順位を「目的カテゴリの positive 節 AND それより前にある全ルールの negative 節」として自動生成する。applyCategoryFilter(qb, 'xml') は (='application/xml' OR ='text/xml' OR LIKE '%+xml') AND NOT('text/html' or 'application/xhtml+xml') AND NOT LIKE 'image/%' AND ... を出力する。
新カテゴリ追加手順:
packages/@nitpicker/query/src/types.tsのContentTypeCategoryユニオン型に新しいリテラルを追加(TypeScript が全 switch 文の網羅性検査で残り箇所を教えてくれる)packages/@nitpicker/query/src/content-type-rules.tsのCONTENT_TYPE_RULES配列に新ルールを挿入(順序が優先順位)packages/@nitpicker/viewer/web/i18n/translations.tsのviews.contentTypeに新カテゴリの en / ja ラベルを追加packages/@nitpicker/viewer/web/styles.cssに.bar-segment-<新カテゴリ>ルールを追加(--ct-colorの値 + 必要ならbackground-imageパターン)。Summary 画面の Stacked bar 用packages/@nitpicker/cli/src/commands/query.tsの--contentTypeCategoryのdescと、packages/@nitpicker/mcp-server/src/tool-definitions.tsのenumリストを更新
content-type-rules.spec.ts の property-based テスト(JS classifier と SQL マッチャの一致)、content-type-class.spec.ts の CSS 網羅テスト(.bar-segment-<cat> ルール存在と --ct-color 一意性)、translations.spec.ts の i18n 網羅テスト(全カテゴリの en/ja ラベル存在)の三層で抜けが CI で必ず落ちる。
Model Context Protocol サーバー。AI アシスタント(Claude 等)から .nitpicker アーカイブを直接クエリするためのツールを提供(ツール数・一覧は tool-definitions.ts を正とする。増減が頻繁なためここでは数値を固定しない)。
構成:
mcp-server.ts:createServer()で MCP Server インスタンスを構築。低レベルServerAPI を使用(McpServer+ Zod スキーマの深い型インスタンス化問題を回避)tool-definitions.ts: 全ツールの JSON Schema 定義(数・一覧の正)
バイナリ: nitpicker-mcp(stdio トランスポート)
依存: @modelcontextprotocol/sdk, @nitpicker/query
.nitpicker アーカイブをローカルブラウザで閲覧する Web ビューア。mcp-server の HTTP/REST 版に相当し、@nitpicker/query の関数群をそのまま再利用する。
構成(単一パッケージ、backend + frontend 同居):
- backend(
src/→tsc→lib/): Hono アプリ。start-viewer.ts(サーバ起動 + ブラウザオープン + SIGINT graceful shutdown)、create-app.ts(全ルート登録 +serveStatic+ エラーハンドラ)、archive-context.ts(ArchiveManagerで 1 アーカイブを常駐保持)、routes/register-*-route.ts(@nitpicker/queryの関数 1:1 のルート群、数・一覧はcreate-app.tsのルート登録箇所を正とする) - frontend(
web/→vite build→lib/public/): React 19 SPA。@tanstack/react-query+@tanstack/react-tableをベースに、ページネーションモードを TopBar から切替できる(PagedTable= MPA、VirtualTable=@tanstack/react-virtual経由の仮想スクロール、DataTableがモードで dispatch)。BrowserRouter(History API、未マッチ GET は Hono が index.html を返す SPA フォールバック)ルーティング、@nitpicker/queryの型を DTO として再利用。ダーク/ライトテーマ切替(data-theme+ localStorage、web/theme/)、i18n(en/ja、web/i18n/の自前辞書 + Context)、列リサイズ(マウス + 矢印キー)、ローディングスケルトン +aria-busy+ グローバル進捗バー、画像サムネイルプレビュー、Mismatches の赤緑文字差分(web/utils/diff-text.ts)を備える。アクセシビリティ(WCAG 2.1 AA)対応として、PagedTable/VirtualTable両方への明示的 ARIA グリッドロール、スキップリンク(web/components/skip-link.tsx)、フォームコントロールのアクセシブルネーム、ライブリージョン、prefers-reduced-motion、AA コントラストを実装(README「アクセシビリティ」節 + 下記「設計注意」参照)
データフロー:
nitpicker viewer <file>
→ startViewer() → createArchiveContext()(ArchiveManager.open で 1 アーカイブ常駐)
→ createApp()(Hono: REST ルート + lib/public 静的配信)
→ serve()(@hono/node-server)+ openBrowser()
→ ブラウザ: SPA が /api/* を fetch → query 関数の結果を表示
→ 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/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 サブコマンド経由で起動)
依存: @nitpicker/query, hono, @hono/node-server, react, react-dom, @tanstack/react-query, @tanstack/react-table, @tanstack/react-virtual, react-router
設計注意(CLI 常駐コマンド): 他の 5 コマンドはバッチ型(実行→完了→
cli.ts末尾のprocess.exit)だが、viewerだけは常駐サーバ。startViewerは SIGINT/SIGTERM を受けるまで resolve せず、process.exitに到達しない。将来cli.tsを変更する際は、この例外を壊さないこと(viewer のシャットダウン経路でArchiveManager.closeAll()を必ず通すこと)。
設計注意(タールキャッシュは誰も evict しない): viewer / MCP /
queryCLI が.nitpickerを開くとき、ArchiveManager.open()はArchive.openCachedを呼び<os.tmpdir>/nitpicker/cache/<key>-<basename>/に untar 結果を keep する(2 回目以降の open で untar をスキップ)。キャッシュの evict は完全に OS の temp cleanup に委ねている — macOS は再起動時、Linux はsystemd-tmpfilesのデフォルト (/tmp10 日、/var/tmp30 日)、Windows は Disk Cleanup。運用上のリスク: 短期で大量に異なる.nitpickerを開くと cache が GB 単位で溜まる。手動で消すならrm -rf <os.tmpdir>/nitpicker/cache/で安全(次回 open は cold untar に戻るだけ)。検索キーワード: 「nitpicker cache eviction」「キャッシュ 削除」「ディスク 埋まる」「NITPICKER_TAR_CACHE_DIR」。キャッシュキーは
size + mtimeNs + ctimeNs + 先頭 64 KiB sha256 + 末尾 64 KiB sha256。mtime + ctimeだけだと FAT/exFAT/NFSv3 の低解像 timestamp で in-place rewrite が誤って hit する事故が起きるので head/tail sha を追加した(cold cost ~1-5 ms / archive)。READY marker は.nitpicker-cache-ready(内部名)、これが無い cacheDir は次回 open 時に.corrupt.<pid>.<n>にリネームして避ける(rm-in-place しない理由は他リーダーが fd を握っている可能性、reader refcount を持っていないため)。TOCTOU 対策: untar 完了後に再 stat → key 不一致なら同時 writer 検出として abort + cacheDir 削除(古いキーで新コンテンツを landing させない)。
Archive.openCachedの対称: writer 経路はキャッシュを通らない。crawl --append/--retry-failedはArchive.openを直接呼んで<cwd>/._nitpicker-*に展開、close 時に再 tar 化 + tmpDir 削除する(既存挙動)。NITPICKER_DISABLE_TAR_CACHE=1で reader 経路もこの旧挙動にフォールバックできる(debug 用 / cache を完全に切りたい場合)。NITPICKER_TAR_CACHE_DIRで配置 override 可能だが、0/false/nullなどの sentinel 風ワードは reject して default に倒す(NITPICKER_DISABLE_*の感覚で空に近い値を入れた user が$PWD/0/を汚染する事故防止)。libsql 0.5.x の read-only 強制は no-op:
Database.connect({ readOnly: true })は#initで migration をスキップする +ArchiveAccessor.setDataの namespace ガード +getKnex()経由の writes を内部 review で防ぐ — の 3 層で担保している。libsql 自体のreadonly: truedriver オプションは flag として accept されるが SQL 層では強制されない(known libsql limitation、将来のバンプで改善する可能性あり)。accessor.getKnex().raw('INSERT ...')のような ad-hoc 書き込みは現状 cacheDir を変更できてしまうので、新規開発時の自衛が必要。
設計注意(viewer プロセス側 precompute cache): 10 GB scale archive で isolated-* 系 (20-30 s) の wall-clock を SQL 単独で詰めるのは denorm 列(
canonicalId/isolated_root)導入が必須、しかし schema 変更は consistency / migration / archive 互換性リスクが大きく本 PR では避ける判断。代替として viewer プロセス側で per-archive の memoised computation を持つ:
packages/@nitpicker/viewer/src/isolated-clusters-cache.ts(getCachedIsolatedClusters):computeIsolatedClusters結果 (IsolatedComponent[]) を archive 単位で memoise。/api/isolated-pages//api/isolated-clusters//api/isolated-clusters/:repの 3 endpoint が共有。10 GB archive 実 HTTP 計測: 初回 25 s (union-find 自体は速くなっていない、cache miss コスト) → 2 回目以降 1-7 ms (Map から返るので確実な改善、~3000-24000x)。この PR の最も大きな実効果LRU helper
packages/@nitpicker/viewer/src/promise-lru.ts(createPromiseLru) を使う。Promise 単位で cache (concurrent 初回 request 1 回 computation で済む)、read promote-on-read で真の LRU (Map.setの existing key は insertion order を更新しないのでdelete+ 再set)、rejected promise は cache から落として retry 可能、max 4 entry。stub mode (live crawl) bypass:
context.mode === 'stub'の archive は writer が in-place で anchors / pages を書き続けるため、cache snapshot を返すと永久 stale になる (/api/linksと数字が食い違う等)。getCachedIsolatedClustersは stub mode で cache を経由せず毎回 union-find を再計算する (slow but live)。Query API 側は
listIsolatedPages/listIsolatedClusters/getIsolatedClusterにprecomputedComponents?option を追加。viewer route が cache から取って渡す。CLI / MCP は option を渡さないので従来の SQL 経路を踏む (一回呼び切りなので precompute コストを payback できないため、これは意図通り)。get-isolated-clusterの 404 は singleton(/api/isolated-pagesを使うべき URL)と collapsed cluster で別メッセージに分けて、deep-link / 共有 URL の混在で「実は singleton でした」を判別可能にしている。scope 外:
/api/links?type=broken19 s //api/summary3.6 s //api/duplicates3.5 s //api/images2.6 s は本 PR で改善せず accept。listLinksは anchor-scan bound (SCAN anchors → rowid seek source / dest / canonical)で、denorm 列canonicalIdを入れても JOIN 経路の dominant cost である anchor scan が残るため意味が薄い。残り 3 endpoint は covering index 追加で詰める余地ありだが、PR #96 教訓(status + anchor の 7 個 bulk 追加が listLinks/Graph を 30-50x 回帰させた)に従って index 追加は最大 2 個までと自制したい — 本 PR では加えず別 issue 候補。検索キーワード: 「viewer endpoint 遅い」「isolated 28s」「precomputed cache viewer」「stub mode stale」。**旧
referrer-count-cache.ts//api/page-links/listPageLinks(本節が元々説明していたもう一方の cache)は「ページリンク」ビューの廃止に伴い削除済み。**per-page の被リンク数・リダイレクト元数は Page Detail ビュー(/api/pages/detail)の inbound/outbound/redirectFrom で個別ページ単位に確認する。
設計注意(ポート探索は serve と同じ host を probe する):
findFreePort(preferred, host)はserve()がバインドするのと同じhostで空きを確認しなければならない。localhostは::1(IPv6)に解決される一方、host 未指定の bind は0.0.0.0/::を使うため、別インターフェースを probe すると「空き」と誤判定し、フォールバックが効かず banner 表示後にEADDRINUSEでクラッシュする。start-viewer.tsは必ずhostを渡すこと。回帰テストはfind-free-port.spec.ts(net.createServerをスパイしlistenへの host 転送を検証)。
設計注意(テーブルの ARIA ロールは必須):
web/components/virtual-table.tsx(仮想スクロール)とweb/components/paged-table.tsx(MPA)の両方が CSS で table 要素をdisplay: flex/blockにレイアウトしており、これがネイティブ table セマンティクスをアクセシビリティツリーから剥がす。明示的な ARIA ロール(table/rowgroup/row/columnheader/cell)+aria-row/colcount/indexで復元しているため、これらを削除すると画面読み上げが「無構造なテキストの羅列」に退行する。列ヘッダーのアクセシブルネームはリサイザーのラベル混入を避けるためto-accessible-header-label.tsで固定(両モード共有)。E2E(e2e/viewer.spec.tsの「アクセシビリティ」群)は両モードで回帰を検知する。
設計注意(ページネーションは MPA をデフォルト・仮想スクロールを opt-in にする): リスト系ビュー(pages / resources / images / broken-links / external-links / violations / isolated-pages / isolated-clusters)はすべて
DataTable経由でusePaginationMode()を読み、MPA(PagedTable+?page=/?pageSize=URL クエリ)と virtual scroll(VirtualTable+useInfiniteQuery)を切り替える。MPA をデフォルトにしている理由は (a) deep-link / URL 共有 / ブラウザ戻る・進むが効くこと、(b) クライアント / ディレクター用途では特定ページに直行できる UX が好まれること。virtual scroll は 10 万行規模の探索性が必要なときの opt-in。両モードとも backend は同じlimit/offsetAPI で、view 側でusePagedQuery/use-*-infiniteのどちらかをenabledフラグで起動する。
設計注意(page と pageSize は両方が URL クエリの正、localStorage は hint):
?page=だけが URL に乗って?pageSize=が無いと、共有された?page=5は受け手の localStorage の窓サイズ次第で別の行を指す ─ deep-link / 共有の意義が崩れる。そのためusePageSizeもuseSearchParams経由で URL を読み書きする(?pageSize=N、デフォルト値 100 は URL から省略してクリーンに保つ)。localStorage(nitpicker-page-size)は「URL 未指定で新規タブ訪問したときの hint」にしか使わない。フィルタ変更時の?page=リセットはuseUrlFilterの副作用に集約、?pageSize=変更時の?page=リセットはusePageSize.setPageSizeの updater 内で同じsetSearchParams一発で実行する(2 つに分けると一時的に矛盾状態を経由してフェッチが 2 回走る)。モード(mpa / virtual)は URL に乗せず localStorage のみ(nitpicker-pagination-mode、useSyncExternalStoreで全 view に即時伝播)— モードは「閲覧スタイル」であって個別 URL の状態ではないため。
設計注意(Summary カードの "ページ" vs "コンテンツ"): Summary 画面の上部カードは 3 つで、それぞれ意味する分母が異なる。
- 総コンテンツ数(内部) =
internalContents— 内部の全行(HTML + PDF + CSV + ZIP + Office docs + ...)。MIME フィルタなし。SummaryResult.contentTypeDistributionの internal 列の合計と等しい- 総ページ数(内部) =
internalPages— 内部の HTML 行のみ(contentType IS NULL OR text/html)。getSummaryが「HTML ページ」と呼ぶ歴史的な数値- 外部リンク(外部コンテンツ) =
externalContents— 外部の全行(MIME 不問)
internalPages≤internalContentsが常に成り立つ。同じ archive に対して両方を提示することで「内部にはページが N 個ある、ただしリソース全体は M 個」をユーザーが一目で把握できる。totalPages/externalPagesも API には残っている(CLI / MCP の後方互換)が viewer 上部カードでは使わない。「起点」カードは廃止(root URL リストが上のテキスト行で既出のため)。
設計注意(Summary バーの精度契約): Summary 画面のバーは全グループ(Status / Content-Type / Metadata)が「全体に対する割合」を表示する。
- % 表示の単一窓口は
web/utils/format-percent.ts: Status 行も Metadata 行も Stacked bar のツールチップ・凡例もformatPercent(ratio)を通る。<0.1%表記(非ゼロだが小さい値)はここで決まっており、各呼び出し側でtoFixedを散らさないこと(散らすと「非ゼロ件数 (0.0%)」のような矛盾表記が再発する)。- Stacked bar の
min-inline-sizeは CSS 側に置く:web/components/compute-stacked-bar-widths.tsはtotal / grandTotal × 100を 再正規化せずに 返し、サブピクセル分は.bar-segment { min-inline-size: 4px }で底上げする。再正規化を JS でやると floor 後の rescale で lifted セグメントが再び floor を下回り、凡例 % とバー幅が乖離する(旧実装のバグ)。JS は raw proportional、CSS は final-width guarantee の分担を逆転させないこと。- カテゴリ同期はテストが強制する 3 箇所 + 手動で同期する 2 箇所: 新カテゴリ追加時は上記「新カテゴリ追加手順」の 5 ステップが必要だが、そのうち CI が自動検出するのは ①
content-type-rules.tsの rule 追加(content-type-rules.spec.tsが SQL/JS パリティを検証)、②web/styles.cssの.bar-segment-<新カテゴリ>ルール追加(content-type-class.spec.tsが CSS 網羅 +--ct-color一意性を検証)、③web/i18n/translations.tsの en/ja ラベル追加(translations.spec.tsが両ロケール網羅を検証)の 3 点同期のみ。types.tsのユニオン型追加と、cli/src/commands/query.tsのdesc/mcp-server/src/tool-definitions.tsのenum更新は TypeScript の網羅性検査と手動レビューで検出する(spec ではガードしていない)。
@d-zero/roar ベースの統合 CLI。6つのサブコマンドを提供。全 analyze プラグインを dependencies に含んでおり、npx 実行時に @nitpicker/core の動的 import() がプラグインモジュールを解決できるようにしている。
npx @nitpicker/cli crawl <URL>: Webサイトをクロールして.nitpickerファイルを生成npx @nitpicker/cli analyze <file>:.nitpickerファイルに対して analyze プラグインを実行。--search-keywords,--axe-lang等のフラグで設定ファイルのプラグイン設定を上書き可能(buildPluginOverrides()→Nitpicker.setPluginOverrides()経由)npx @nitpicker/cli report <file>:.nitpickerファイルから Google Sheets レポートを生成npx @nitpicker/cli pipeline <URL>: crawl → analyze → report を直列実行。startCrawl()でアーカイブパスを取得し、そのパスをanalyze()とreport()に引き渡す。--sheet指定時のみ report ステップを実行npx @nitpicker/cli query <file> <sub-command>:.nitpickerファイルに対してクエリを実行し、結果を JSON で出力。@nitpicker/queryの全関数を CLI から利用可能。サブコマンド一覧は README または--helpを正とする(@nitpicker/queryに関数が増えるたびここを手動更新するのは陳腐化しやすいため、列挙はしない)npx @nitpicker/cli viewer <file>:.nitpickerファイルをローカルブラウザで閲覧する Web ビューアを起動(@nitpicker/viewer)。常駐サーバなのでCtrl-Cまで動き続ける(詳細は@nitpicker/viewerセクション)
URL 発見 → add(url) → pending セット
deal() で選択 → progress(url) → progress セット
スクレイプ完了 → done(url) → done セット + Link オブジェクト生成
LinkList.done() の処理:
isExternal判定:findScopeEntry(url, scope, options) === null。スコープエントリは(hostname, port, path)のトリプルで、いずれかのスコープエントリの下層に入れば internal、入らなければ externalisLowerLayer判定: 同じスコープエントリ群に対する path 配列先頭一致isPage判定:!isExternal && isLowerLayer && isHTTP && hasResponse && isHTML && !isErrorisPage = true→completePagesカウント増加
終了判定: deal() が全アイテムの処理完了で resolve → crawlEnd イベント emit
Archive 書き込みの直列化: CrawlerOrchestrator は複数のイベントハンドラ(page, externalPage, skip, response, responseReferrers, error)から Archive に非同期書き込みを行う。高並列度で SQLite の書き込みロック競合を防ぐため、すべての書き込みは WriteQueue(Promise チェーンベースの FIFO キュー)で直列化される。crawlEnd 時には WriteQueue.drain() で未完了の書き込みを全て待機してからクロール完了とする。
発見したアンカーについて、findScopeEntry() を 1 回だけ評価し、matchedScope を再利用する:
├── matchedScope !== null(スコープエントリの下層):
│ ├── matchedScope の auth を anchor.href に注入(既に auth がない場合のみ)
│ └── recursive=true → LinkList.add(url) # フルスクレイプ
│ recursive=false → add(url, { metadataOnly: true })
│
└── matchedScope === null(外部 URL):
├── recursive=true:
│ └── fetchExternal=true → add(url, { metadataOnly: true })
│ fetchExternal=false → 何もしない
└── recursive=false → add(url, { metadataOnly: true })
URL を deal() で受け取り:
1. robots.txt チェック → 拒否なら skip イベント emit + return
2. shouldSkipUrl(excludes / excludeUrls)→ マッチなら skip
3. fetchExternal チェック → 外部 URL で無効なら externalPage emit + return
4. キャプチャ済みリソースの再利用チェック → ヒットすればフェッチなしで結果返却
5. HEAD プリフライト → 到達不能なら error
6. metadataOnly / 非 HTML → ブラウザなしで結果返却
7. HTML → Puppeteer 起動(User-Agent 設定済み)→ スクレイプ
ページレンダリング中にサブリソース(<img src> 等)として既にキャプチャ済みの URL が
直リンク(<a href>)としてキューに積まれた場合、HEAD プリフライトを省略して
resources テーブルの記録(status / contentType / contentLength / responseHeaders)から
PageData を合成する。
#scrapePage(url)
├── #resources Set(メモリ)で一次フィルタ — ミス時のコストはゼロ
├── ヒット時のみ lookupResource()(orchestrator がコンストラクタで注入するコールバック)
│ ├── まず SQLite を直接点引き(hit ならブロックなしで即返却)
│ └── miss の場合のみ WriteQueue.enqueue 経由で再読
│ (未 flush の resource insert の後ろに直列化され hit/miss が決定的になる。
│ 直列化待ちは miss パスに限定されるため dealer レーンをブロックしない)
├── status 2xx かつ contentType 非 HTML → PageData 合成して即返却
└── それ以外(行なし / 非 2xx / HTML / null / lookup 失敗)→ HEAD プリフライトにフォールバック
- 2xx 限定の理由: Puppeteer はリダイレクトの各ホップごとに response イベントを発火する
ため、リダイレクトする URL は必ず 3xx 行として記録される。非 2xx をフォールバックさせる
ことで、リダイレクト元 URL は従来どおり HEAD(follow-redirects)が完全な
redirectPathsを取得し、再利用時のredirectPaths: []は常に正確な値になる - 非 HTML 限定の理由: HTML はアンカー抽出のため Puppeteer レンダリングが必須。
MIME タイプ比較は
isHtmlContentType()(大文字小文字非依存)で行う — Puppeteer 由来の値はサーバーの大文字小文字をそのまま保持するため - lookup 失敗は HEAD にフォールバック:
getResourceByUrlは意図的にemitErrorを 付けずretryCall直呼びのみ(DBerrorイベント → orchestrator の abort listener はクロール全体を中断するため)。 crawler 側フックも try/catch で握り、読み取り失敗は「最適化なし」と同じ挙動に縮退する - 注入はコンストラクタ DI:
CrawlerOptions.lookupResourceとしてnew Crawler()時に 渡す(セッターの呼び順依存を排除)。nullなら最適化は無効 - 検証:
packages/test-server/src/__tests__/e2e/resource-reuse.e2e.ts(画像エンドポイントへの リクエスト回数・メソッドを test-server 側で記録して検証)とcrawler.spec.tsのresource reuse via the lookupResource option(ネットワーク不使用・ lookup 失敗時フォールバックのユニットテスト)
deal() の header コールバックでクロール中に表示される 1 行サマリ。Crawler.#runDeal が formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, pagesScraped, limit }) を返す。
完成形の例:
Crawling: 130(85) done / 250 found URLs (+12/20 ext) (56%) [108 remaining] [10 parallel]
各フィールドの意味:
| 表記 | 意味 |
|---|---|
Crawling: X(Y) done |
X = internal の処理済 URL 数(HEAD のみ・skip 含む通算)。Y = X のうち ブラウザで HTML をレンダーしてアーカイブに保存した件数 |
/ Z found URLs |
Z = internal で発見済の総 URL 数(クロール進行中も増える) |
(+a/b ext) |
external URL の進捗。a = 完了、b = 発見済 |
(N%) |
(allDone) / (allTotal) のパーセント。total=0 のときは 0% |
[R remaining] |
internal 残り + external 残り |
[L parallel] |
並列ワーカー数(--parallels または MAX_PROCESS_LENGTH) |
Y が X より小さい状態は正常:
Xは CSS / image / フォントなど HEAD だけで完了する非HTMLリソースも含む通算カウントYは#scrapePageで#launchBrowserAndScrapeがtype: 'success'で resolve した件のみ加算(predicted-discard・launch エラー・scraper 内部 error result は除外)- 非HTMLリソースが多いサイトでは
YはXより大幅に小さくなる
--resume / --append 時の挙動:
X(internalDone)とZ(internalTotal)にはresumeOffset = #resumedScraped.lengthが加算されるY(pagesScraped)にはArchive.getScrapedHtmlPageCount()の戻り値(pagesテーブルのisTarget=1 AND scraped=1 AND contentType='text/html'件数)が初期値として加算される。contentType='text/html'で絞るのは、in-scope な非HTMLリソース(PDF 等はisTarget=1)を「描画済みHTMLページ」として数えないため(→「ページ性の判定」参照)- そのため通算値で表示され、resume 跨ぎでも
YとXの意味的整合が保たれる
更新手順(フォーマット変更): 表示文字列を変える場合は
format-crawl-progress.tsの return 文を編集し、format-crawl-progress.spec.tsの「完成形のフォーマット文字列を 1 文字ズレなく組み立てる」テスト(expect(result).toBe(...)のリテラル)を併せて更新する。pagesScrapedのセマンティクスを変える場合(例: launch エラーを含める)はcrawler.ts:#scrapePageのmarkBrowserScrape()呼び出し位置と worker 側のrenderedInBrowser判定の両方を編集し、should-discard-predicted.spec.tsと新規 worker レベルテストの追加を検討する。検索キーワード: 進捗行が想定外の値を表示する場合は
formatCrawlProgress/pagesScraped/getScrapedHtmlPageCountでコードを grep し、DEBUG=Nitpicker:Crawlerを有効化すると#scrapePageの各分岐がログに出る。
@d-zero/dealerのdeal()がスケジューリングと並列制御を担当intervalオプションでリクエスト間の待機時間を設定可能- スクレイピングはインプロセス(
@d-zero/beholder)で実行。各 URL ごとにブラウザを起動・終了(クローズのハング対策は下記「ブラウザクローズの安全策」参照) - 発見した新 URL を動的にキューに追加する際、HTML らしい URL は
unshift()でキュー先頭へ優先投入し、それ以外(画像・PDF・CSS/JS 等)はpush()で末尾へ追加する。これにより HTML ページのクロールがアセット/ドキュメント取得より先に進む。バッチ(予測ページネーション等)はpartitionUrlsByHtml()(crawler/partition-urls-by-html.ts)で HTML 群と非 HTML 群に分割し、HTML 群を 1 回のunshift(...html)で投入することで昇順を維持する(1 件ずつ unshift すると逆順になるため) - HTML 判定は HEAD/GET 前の URL のみで行うため、実
Content-TypeではなくisLikelyHtmlUrl()(crawler/is-likely-html-url.ts)の拡張子ヒューリスティックを使う: 拡張子なし・ディレクトリ型 URL(/,/about/)と末尾ドット URL、.html/.htm/.php/.aspx/.jsp/.ashx等を HTML 扱い、非 HTTP(mailto:等)と.jpg/.pdf/.css/.js等を非 HTML 扱い。誤判定しても fetch 順が変わるだけで網羅性・正確性には影響しない onPushコールバックでwithoutHashAndAuthによる重複排除(push()/unshift()どちらも通る)signalオプションでAbortSignalを渡し、中断時に新規ワーカーの起動を停止
更新手順(HTML 優先判定の拡張): HTML を返す拡張子が取りこぼされている場合は
crawler/is-likely-html-url.tsのHTML_EXTENSIONSセット(ドット付きキー)に追記し、cspell が未知語を弾く拡張子はcspell.jsonの「HTML page file extensions」ブロックにも追加する。判定ロジックの単体テストはis-likely-html-url.spec.ts、優先投入の配線テストはcrawler.spec.tsの「discovered-URL queue prioritisation」、分割の単体テストはpartition-urls-by-html.spec.ts。
unshiftAPI の所在: キュー先頭への優先投入は@d-zero/dealerのdeal()setup コールバック第 6 引数unshiftに依存する(1.9.0 で追加)。優先制御の挙動を変える場合は dealer 側(d-zero-dev/toolsのpackages/@d-zero/dealer、Dealer.unshift/deal.js)を参照すること。
CLI シグナルハンドラ(SIGINT / SIGHUP 等)
→ CrawlerOrchestrator.abort()
→ Crawler.abort()
→ AbortController.abort()
→ deal() の signal オプション経由で新規ワーカー起動を停止
→ 実行中のワーカーは正常完了まで継続
→ 全ワーカー完了後 deal() が resolve → crawlEnd イベント emit
Crawlerは内部にAbortControllerを保持し、signalgetter でAbortSignalを公開CrawlerOrchestratorのコンストラクタでarchiveのerrorイベントを監視し、アーカイブエラー発生時にもCrawler.abort()を呼び出す- CLI の
killed()ハンドラではabort()後にgarbageCollect()→process.exit()を実行。ただしCrawler.getUndeadPid()は現アーキテクチャでは常に空配列を返すためgarbageCollect()自体は no-op。Chromium プロセスの強制終了は per-URL のcloseBrowserSafely()(下記「ブラウザクローズの安全策」)で完結している
各 URL のスクレイプ後、Crawler.#launchBrowserAndScrape の finally で handleBrowserClose(browser, url.href, crawlerLog) を呼び、内部で closeBrowserSafely(browser) がグレースフル close を 30 秒タイムアウトで実行する。
closeBrowserSafely(browser, timeoutMs = 30_000)
├── browser.process() を upfront capture(close 成功後は null になるため)
├── Promise.race([
│ browser.close().then(()=>false).catch(()=>false),
│ new Promise(resolve => setTimeout(resolve(true), timeoutMs))
│ ]).finally(clearTimeout) // 負け側 timer を必ず clear
└── timedOut かつ未 kill なら childProcess.kill('SIGKILL')
なぜ必要か: viewport 切替(desktop-compact → mobile-small)でページ側の execution context が破壊されると、Chromium のセッションが detached 状態になり、browser.close() が CDP ハンドシェイクの応答を待ち続けて永久に settle しないケースがある。タイムアウトを設けず単に await browser.close().catch(() => {}) だと、deal() ワーカーが完了せずクロール全体がハングする(症状: 📷 mobile-small: skipped — Attempted to use detached Frame ログの後、CLI が終了しない)。
子プロセスツリーの kill: Chromium は親プロセスから renderer / network / zygote / GPU などの子プロセスを fork するため、親プロセスのみへの SIGKILL では子が orphan として残ることがある(puppeteer は detached: false で spawn するため process group kill 不可。負の PID kill は Node 自身を巻き込む)。closeBrowserSafely は childProcess.kill('SIGKILL') で Node 側のハンドルを kill した直後に killProcessTree(pid, 'SIGKILL')(crawler/kill-process-tree.ts)を呼び、POSIX では ps -A -o pid=,ppid= を spawn して PPID マップを構築 → BFS で全子孫を列挙 → 葉から順に signal、Windows では taskkill /T /F /PID <pid> を spawn して OS レベルで再帰 kill する。ps/taskkill の呼び出し失敗・ESRCH(既に消滅した PID)は best-effort としてすべて握りつぶし、関数は必ず resolve する。
観測性: タイムアウト発火時は crawlerLog('Force-killed wedged Chromium browser for %s ...') を出す。さらに killProcessTree 内の best-effort 失敗(process.kill の ESRCH/EPERM / ps 起動失敗・非 0 exit / taskkill 起動失敗・非 0 exit)も DI された logger 経由でログされる。closeBrowserSafely は crawlerLog を渡すので DEBUG=Nitpicker:Crawler を有効にすると本番の tree-kill 失敗が観測可能。closeBrowserSafely 自体が throw した場合(browser.process() が予期せず throw 等)も handleBrowserClose が握りつぶしてログするため、finally から例外が伝播することはない。
更新手順(タイムアウト値の変更): 30 秒という値は重いページや低速環境での余裕を取った設定。変更する場合は
close-browser-safely.tsのDEFAULT_CLOSE_TIMEOUT_MSを編集し、close-browser-safely.spec.tsの「defaults to a 30-second timeout」テストを併せて更新。Promise.race の負け側 timer は 必ずclearTimeoutで clear すること(fetch-destination.tsと同じ規律。詳細は下記「CLI プロセス終了とリソース解放」)。検証:
close-browser-safely.spec.ts(13 ケース: 正常 / hang / reject / null process / 既 killed / デフォルト timeout / 遅延 rejection / timeoutMs=0 / kill が false / 冪等性 / tree-kill / pid 無し / close 成功時の no-op)、handle-browser-close.spec.ts(4 ケース)、kill-process-tree.spec.ts(15 ケース: POSIX BFS 順 / signal 透過 / 列挙 0 件 / killer throw 伝播 / Windows 委譲 / runTreeKill 失敗 / プラットフォーム既定 / Spawner 注入で Windows taskkill 引数検証 / POSIX ps 引数検証 / 4 つの失敗パス(ps 起動失敗 / ps 非 0 exit / taskkill 起動失敗 / taskkill 非 0 exit)の log 検証 / process.kill ESRCH の log 検証)。puppeteer Browser 型との整合性はclose-browser-safely.spec.ts冒頭の compile-time assertion で担保。
#fetchImages で viewport 切替が失敗するなど、ページ自体は取れたが二次的なスクレイプ手順が失敗するケースは、beholder の @retryable が最終的に changePhase イベントを name='retryExhausted' で emit する。これを stdout に流して捨てるのではなく、archive (SQLite) の page_errors テーブルにレコードとして残す。
page_errors:
id INTEGER PK
pageId INTEGER FK → pages.id
phase VARCHAR (e.g. 'retryExhausted')
message TEXT (e.g. '📷 mobile-small: skipped — Attempted to use detached Frame')
createdAt INTEGER (unix ms)
INDEX (pageId)
イベントフロー(関連ファイルは crawler/ 配下に集約):
beholder.Scraper#fetchImages catch
→ emit changePhase { name: 'retryExhausted', message, url: null, ... }
createChangePhaseHandler (create-change-phase-handler.ts)
→ forward as 'changePhase' event
→ name === 'retryExhausted' なら #pendingPhaseErrors Map に url.href キーで buffer
Crawler #runDeal の worker
→ scrapePage 完了 → #handleResult が 'page' / 'externalPage' を emit
→ drainPhaseErrors (drain-phase-errors.ts) で buffer を flush し 'pageError' を emit
→ 失敗 path(catch)でも同様に drain
→ 最後に logUndrainedPhaseErrors (log-undrained-phase-errors.ts) で
finally の取りこぼし(predicted-discard 等)を `DEBUG=Nitpicker:Crawler`
にログしつつ Map から delete(leak 防止)
CrawlerOrchestrator
→ on('pageError') で writeQueue 経由で archive.addPageError(url, phase, message, isExternal)
→ 'page' が先に enqueue されているため WriteQueue 上で setPage → insertPageError の順序保証
Database.#getIdByUrl は URL から pages.id を upsert で解決するため、insertPageError は setPage より先に走っても FK が満たされ、エラーがロストすることはない。
関連ヘルパー(純粋関数として extract、各々独立して単体テスト可能):
| ファイル | 役割 |
|---|---|
create-change-phase-handler.ts |
scraper.on('changePhase') 用の listener factory。emit/update/formatLog/buffer/urlHref を DI |
drain-phase-errors.ts |
buffer の per-URL flush。emit + delete を idempotent に実行 |
log-undrained-phase-errors.ts |
finally で残った buffer エントリをログして削除(predicted-discard 等の取りこぼし観測) |
既存 archive の自動マイグレーション: Database.#init で migratePageErrors(archive/migrate-page-errors.ts)を呼び、page_errors テーブルが無ければ作成する(idempotent)。pages テーブルすら無い空 archive は initSchema 経路に任せる。マイグレーションが実走した場合のみ stderr に [migrate] page_errors table created を出す。
downstream の未実装: 本 PR の射程は page_errors への記録まで。query / report-google-sheets / mcp-server から表示する仕組みは未実装で、現状は sqlite3 file.nitpicker 'SELECT * FROM page_errors' で直接見るしかない。次の PR で expose 予定。
更新手順(新しい phase を記録する):
retryExhausted以外の phase(例:setViewport、getImagesなど)を保存対象に増やす場合は、crawler/create-change-phase-handler.tsのif (event.name === 'retryExhausted')判定を拡張する。phase 名は文字列のままpage_errors.phase列に入るので、新しい phase 種別をクエリで区別する場合は値を decide してから入れること。検証:
migrate-page-errors.spec.ts(3 ケース: 作成 / 冪等性 / 空 archive スキップ)、database.spec.ts > insertPageError(3 ケース)、archive.spec.ts > addPageError(separate Database 接続で row 検証)、crawler-orchestrator.spec.ts > pageError ハンドラ(archive.addPageError の呼び出し + 失敗時の reject 伝播)、drain-phase-errors.spec.ts(6 ケース)、create-change-phase-handler.spec.ts(9 ケース)、log-undrained-phase-errors.spec.ts(5 ケース)。worker → drain wiring の直接 e2e テストは puppeteer mock コストの兼ね合いで意図的に省略(Crawler.#drainPhaseErrorsの JSDoc に known gap として明記)。
crawl --inventory <urls.txt> の 成功 path で inventory_runs テーブルに 1 行追加する監査ログ。クライアント・ディレクター対応で頻発する「先月もらった list 反映しました?」「同じ list 2 度 apply してないですよね」を archive 単体で答えるための durable な記録。.bak は成功時に消えるので、それ以外に provenance が残らない問題への対策。
inventory_runs:
id INTEGER PK AUTOINCREMENT
ran_at TEXT NOT NULL ISO 8601 timestamp (UTC または local TZ 文字列)
list_label TEXT 人間可読 ID。未指定なら `inventory-${ran_at}` 自動命名
source_file_sha256 TEXT stream hash で算出 (O(1) メモリ)、失敗時は NULL
total_lines INTEGER 入力 URL 総数
new_pages INTEGER HTML seed として新規 page 化された件数
new_resources INTEGER 非 HTML として新規 resources 行になった件数
scope_skipped INTEGER scope 外で skip した件数
notes TEXT 自由記述 (将来用予約、Phase 1 では空)
INDEX (ran_at)
source_file_path は永続化しない: absolute path に user-home / OS 構造が混入し、archive 共有時に環境が漏れる。同一ファイル 2 度反映の検出は source_file_sha256 (content identity) で完結する。CLI / orchestrator は依然 path を受け取り computeFileSha256 の入力に使うが、DB の行には書かない。
イベントフロー:
CLI inventoryCrawl
→ resolveListFile (absolute path, sha256 計算用)
→ readList (parse txt → URL[])
→ CrawlerOrchestrator.inventory(archivePath, urls, options, callback, sourceFileSha256)
--- ingestion フェーズ (.bak 保護下) ---
→ archive.insertInventoryResources(nonHtmlSeeds) (chunked bulk insert)
→ archive.insertInventorySeeds(htmlSeeds) (chunked bulk insert, scraped=0)
→ #writeInventoryRunRow(archive, aggregates)
→ archive.recordInventoryRun(meta) (path は meta に含めない)
→ Database.recordInventoryRun → INSERT INTO inventory_runs
→ ingestionComplete = true (.bak restore は以降ガードされる)
→ unlinkFile(<archive>.bak) (ingestion フェーズ終了)
--- scrape フェーズ (.bak 保護なし、Ctrl+C は --resume で復活) ---
→ getCrawlingState() を再取得 (pre-inserted seeds が pending に乗る)
→ crawler.resume(pendingAfter, scrapedAfter, ...)
→ orchestrator.crawling([], { recursive: true }) (seed list 空、pending から起動)
→ archive.setUrlOrder()
→ return orchestrator
ストレージ契約:
- append-only: UPDATE 経路なし、
source_file_sha256に UNIQUE 制約なし。同じ list を 2 度 apply すれば 2 行。重複検知は Phase 3 (--refresh) でsource_file_sha256を pre-flight key として使う領域 - ran_at だけ NOT NULL: 残り 7 列はすべて NULL 可。post-merge backfill (Phase 1 deploy 前の initial inventory pass を後付け記録するための1回限り raw SQL INSERT) で集計値を欠損させたままでも記録できる
- Strategy A (orphan column 容認): pre-update archive (=
source_file_path列を持つ Phase 1 直後の archive) は当該列を残置したまま新コードで開ける。READ / WRITE 経路は SELECT / INSERT に列を含めないので JSON 出力には現れず、orphan として残るだけ。ALTER TABLE ... DROP COLUMNmigration は打たない (SQLite version 制約回避 + 実書き込み 0 件)。物理削除したい運用者は手動sqlite3 db.sqlite "ALTER TABLE inventory_runs DROP COLUMN source_file_path"を打てる - ingestion フェーズ内で INSERT (issue #121): pre-insert + audit 行 +
.bak削除を1セットの ingestion として扱い、いずれかが失敗すれば.bak復元で全て巻き戻る (transaction atomicity ではなく.bakrevert semantics)。scrape フェーズの失敗 (Ctrl+C / puppeteer crash / dealer error) は ingestion 完了後 なので.bak復元しない —crawler-orchestrator.tsのingestionCompleteフラグが境界を表す。scrape 失敗時は orchestrator がarchive.write()で tmpDir を.nitpickerに persist し、operator はcrawl --resume <archive>で残りを scrape できる - audit-write 失敗の扱い: 旧実装は audit-row INSERT 失敗を swallow していた (audit が scrape の末尾だったため、巻き戻すと完走済み crawl まで失われる懸念があった)。新実装は audit を ingestion フェーズに lift したので失敗→
.bak復元→operator 再実行が "ingestion 全体が乗るか乗らないか" の原子性を担保する設計に。#writeInventoryRunRowの swallow 削除はこの境界の置き換えに対応する - noop early-return path (
novelUrls.length === 0) は run 行を書かない: 現実装では novel = 0 で.bakを作らず即 return するため、ここで DB write すると tar 書き戻し中断時の archive 破損リスクが出る。全 URL が既存だった run の audit は console log[inventory] N already in archive, 0 newでしか残らない (Phase 2 候補:.bak取得拡張と合わせて noop 記録対応) - list_label 自動命名:
--labelCLI フラグは Phase 1 で実装しない。orchestrator がinventory-${ran_at}を自動付与
読み出し:
nitpicker query <archive> inventory-runs [--limit N] [--offset M]でran_at DESC順- 関数:
@nitpicker/queryのlistInventoryRuns(accessor, { limit?, offset? }) - ArchiveAccessor 経由なので read-only / stub mode でも動く が、migration が走らない read-only 接続では
inventory_runsテーブルが不在 →hasTableフォールバックで空配列を返す (get-error-kinds.tsの error.log フォールバックと同型)
既存 archive の自動マイグレーション: Database.#init で migrateInventoryRuns を呼び、テーブルがなければ作成する (idempotent)。Phase 1 deploy 前の archive を新 CLI で開くと、最初の writer 接続 (= --inventory / --retry-failed / --resume / --append のいずれか) で migration が走る。pages テーブルすら無い空 archive は initSchema 経路に任せる。
Phase 1 で意図的に未実装:
--register-runCLI: 1 回限りの backfill には raw SQL INSERT で足りるので CLI 追加しない。再帰的な手動登録ユースケースが Phase 2/3 で見えてきたら実装- MCP tool / viewer UI: CLI で run 一覧が見えれば Phase 1 のユースケースは満たすので、AI/UI 露出は実運用フィードバック待ち
inventory_memberships(run × page/resource の M:N): Phase 2 で diff 機能の基盤として導入--diff <new.txt>/--apply <new.txt>/--refresh <new.txt>: 旧 list との差分適用は Phase 3
更新手順(カラム追加): 新規列を加える場合、(1)
init-schema.tsのinventory_runscreateTable に追加、(2)migrate-inventory-runs.tsには追加列のhasColumnチェックとalterTableを追加 (新規 archive と既存 archive の両方をカバーするため)、(3)InventoryRunMeta/InventoryRunEntryinterface に追加、(4)Database.recordInventoryRunの INSERT object に追加、(5)listInventoryRunsの SELECT columns に追加。検証:
migrate-inventory-runs.spec.ts(3 ケース: 作成 / 冪等性 / 空 archive スキップ)、compute-file-sha256.spec.ts(4 ケース: 空ファイル / 既知バイト列 / >1MB streaming / 存在しないファイルnull返し)、database.spec.ts > inventory run audit log(5 ケース: 全フィールド / NULL省略 / 自動採番 / ran_at DESC ソート / 同 sha256 で 2 行)、list-inventory-runs.spec.ts(5 ケース: ソート / pagination / 全列 / テーブル不在フォールバック / 空テーブル)、inventory.e2e.tsの追加 describe(成功 path で run 行が書かれる + sha256 が 64 文字 hex + noop run では書かれない)。
commands/crawl.ts の startCrawl / resumeCrawl では try { write } finally { close + garbageCollect } 構造で SQLite コネクションプール(Knex の acquireTimeoutMillis: 600_000)を archive.close() → db.destroy() で確実に解放する。これをサボると .nitpicker ファイルは生成されるがプロセスが終了しない(pool 内部の reaper timer が event loop を握る)。
さらに cli.ts 末尾で process.exit(process.exitCode ?? ExitCode.Success) を明示的に呼ぶ。理由は外部依存の timer leak で、特に @d-zero/beholder の dom-evaluation.js#getProp が Promise.race(_getProp, setTimeout(fallback, 10_000)) の負け側 timer を clear しないため、getMeta 1 回あたり最大 ~13 個の 10 秒 timer が積み上がり、自然終了を 10 秒以上ブロックする。
自リポ内の同型パターン(Promise.race + cancellable setTimeout/clearTimeout)は 2 箇所:
crawler/fetch-destination.ts: HEAD/GET の 10 秒タイムアウトcrawler/close-browser-safely.ts: ブラウザクローズの 30 秒タイムアウト(上記「ブラウザクローズの安全策」参照)
どちらも .finally() で clearTimeout を呼び、delay() を race に使わない(delay() は signal を取らないため負け側 timer が clear できない)。
検証は packages/test-server/src/__tests__/e2e/cli-process-exit.e2e.ts が CLI を spawn して 60 秒以内に exit するかを継続的に保証する。
| 定数 | 値 | 説明 |
|---|---|---|
MAX_PROCESS_LENGTH |
10 | 最大並列プロセス数 |
fetchDestination({ url, isExternal, userAgent?, method?, options? })
├── キャッシュ確認(cacheMap)
├── 10秒タイムアウト
└── follow-redirects で HTTP リクエスト
├── hostname + port を分離して指定
├── User-Agent ヘッダー付与(設定時のみ)
├── 405/501/503 → GET にフォールバック
└── redirectPaths を記録
scrapeStart(url, page, options)
├── #fetchData(url, page):
│ ├── page.goto(url)
│ ├── リダイレクトチェーン追跡(Puppeteer redirectChain)
│ ├── contentType チェック → 非HTML なら早期リターン
│ ├── waitForNavigation('domcontentloaded', 5s)
│ ├── HTML + title 取得
│ ├── metadataOnly=true → ここでリターン(アンカー・画像なし)
│ ├── waitForNavigation('networkidle0', 5s)
│ ├── getAnchorList(): <a>, <area> から href 抽出
│ ├── getMeta(): メタ情報抽出
│ └── #fetchImages()(オプション、@retryable fallback:[]):
│ └── デバイスプリセットごとにループ(desktop-compact, mobile-small):
│ ├── try-catch で各プリセットを独立実行(部分結果を許容)
│ ├── beforePageScan(): viewport 変更 + リロード + スクロール
│ ├── waitForFunction(): lazy 画像ロード完了待ち
│ └── getImageList(): 画像データ取得
└── keywordCheck(): 除外キーワードチェック
beholder 3.0.0 で Meta は flat key ('og:type', noindex, canonical) から frontmatter-keys.md ベースのネスト構造 (meta.og.type, meta.robots.noindex, meta.link.canonical) に再構築された。<head> 内の各要素を collectHead() でシリアライズし、Node 側で classify() が typed Meta に組み立てる流れ。
nitpicker は crawler/src/archive/meta/derive-flat-from-meta.ts で nested Meta → pages テーブル ~47 flat カラム に展開する。URL 系列 (canonical, og_url, og_image, amphtml, manifest, icon_href, appleTouchIcon_href, twitter_image) は <base href> + ページ URL を基準に絶対化される(beholder 自体は属性値生のまま返すので nitpicker 側の責務)。
JSON-LD / SpeculationRules は page_jsonld テーブル、Wappalyzer 検出は page_tags テーブルに分離。残った nested 構造(meta.referrer / meta.viewport 等)は pages.meta_extras JSON にダンプ。詳細フィールド一覧は archive/meta/types.ts の FlatPageMetaColumns を参照。
excludeKeywords の各文字列を strToRegex() で正規表現に変換し、HTML 全体に対して test() する。マッチしたら呼び出し元(scraper.ts)が ScrapeResult を type: 'ignoreAndSkip' で返却し、changePhase(name: 'ignoreAndSkip')を emit する。
beholder 3.0.0 アップグレードで pages のメタカラムは ~47 列の flat 化された beholder Meta フィールド + meta_extras JSON 1 列に再構築された。代表的なカラムを抜粋(網羅的な定義は crawler/src/archive/init-schema.ts と crawler/src/archive/meta/types.ts の FlatPageMetaColumns を正とする):
| カラム群 | 型 | 説明 |
|---|---|---|
| id / url / redirectDestId / scraped / isTarget / isExternal | … | 基本属性(pre-0.10 から踏襲) |
| status / statusText / contentType / contentLength / responseHeaders | … | HTTP 応答メタ |
| lang / dir / charset / baseHref / viewport_raw / themeColor | TEXT | Document basics |
| applicationName / author / generator / publisher | TEXT | 編集者情報 |
| title / description / keywords | TEXT | top-level Meta |
| robots_raw / robots_noindex / robots_nofollow / robots_noarchive / robots_noimageindex / googlebot | TEXT/INT | meta[name="robots"] の raw + 各フラグ |
| canonical / amphtml / manifest / icon_href / appleTouchIcon_href | TEXT (URL absolutised) | <link rel="*"> の 1:1 マッピング(URL 系は絶対化済み) |
| og_type / og_title / og_url / og_site_name / og_description / og_image / og_image_alt / og_image_width / og_image_height / og_locale / og_article_published_time / og_article_modified_time | TEXT | Open Graph 系 |
| twitter_card / twitter_site / twitter_creator / twitter_title / twitter_description / twitter_image | TEXT | Twitter Card 系 |
| fb_app_id / verification_google / formatDetection_telephone | TEXT/INT | 単発フィールド |
| firstCrawledAt / lastCrawledAt | INTEGER (UNIX ms) | within-archive 観測軸。--append / --retry-failed で書き換えるとき firstCrawledAt は保護される |
| tag_count / jsonld_count / tags_providers_csv | INTEGER / TEXT | denormalised aggregates(書き込み時に集計、Sheets / page-detail 読み出しで GROUP BY 不要) |
| meta_extras | JSON | flat 化されない nested Meta サブオブジェクト(referrer / viewport(parsed) / apple / msapplication / verification.{bing|yandex|...} / geo / citation / link.alternateHreflang[] / others.* / originTrial / 等) |
| isSkipped / skipReason / order | … | クロール状態 |
追加 INDEX: pages(robots_noindex), pages(og_type)。lang はモノリンガルサイトで cardinality 低く無効なので skip。
追加 INDEX 群 (viewer 高速化): init-schema.ts 末尾で 3 つの perf index を raw SQL で追加。428k 行 / フィルタ後 168k 行の実 archive 計測:
| Index | 対象 query | Before | After |
|---|---|---|---|
idx_pages_listfilter — pages(isExternal, scraped, redirectDestId, url, contentType) (PR #96 では 4 列、本 PR で先頭に isExternal を追加。詳細下記) |
listPages |
15s | 45ms (368x) |
idx_resources_internal_url — resources(isExternal, url) covering |
listUnusedResources |
66s | 7.5s (8.8x) |
idx_images_covering — images(pageId, src, alt, width, height, naturalWidth, naturalHeight, isLazy) covering |
listImages |
32s | 16s (2.0x) |
idx_pages_summary_contenttype — pages(scraped, redirectDestId, contentType, isExternal, isSkipped) covering |
getSummary Q2/Q3 |
— | 38% 改善 |
idx_pages_summary_failed — pages(scraped, status, redirectDestId) |
getSummary Q4 |
5113ms | 14ms (365x) |
加えて find-duplicates を N+1 SQL (代表値ごとに別 SELECT url ループ) から GROUP_CONCAT(url, X'1F') の単発 query に書換 (414s → 8s, 49.6x、scripts/bench-find-duplicates.mjs)。get-link-graph の pageRows + edgeRows を Promise.all に統合 (sequential 38s → parallel 30s ish、JS aggregation はそのまま — SQL push-down を試した結果 10x 悪化したため不採用、根拠は get-link-graph.ts の JSDoc)。
設計注意(
idx_pages_listfilterの column 順はisExternal先頭): PR #96 では 4 列(scraped, redirectDestId, url, contentType)だったが、本 PR で先頭にisExternalを追加した。Pages view のデフォルト「Include external 無し」フィルタは WHERE にisExternal=0を入れ、これは paginate-query の COUNT と SELECT の両方に効く。SELECT はORDER BY urlのおかげで listfilter index が選ばれていたが、COUNT には ORDER BY が無いので planner が単一列pages_isexternal_indexを選んで scan + per-row filter に倒れ、165k internal page archive で COUNT だけで ~8.7 秒消費していた。isExternalを先頭に置くと COUNT も SELECT も同じ covering 構成で完結し、COUNT は ~33ms に短縮 (264x)。確認に使ったベンチマークスクリプトは調査後に削除済み(再現する場合はEXPLAIN QUERY PLANを該当 SQL に対して直接実行すればよい)。Include external を ON にした場合は依然 8s 級 (pages_scraped_indexへの fallback、partial index で別途解決可能)。既存 archive 側はscripts/add-perf-indexes.mjsがDROP INDEX IF EXISTS経由で 4 列版を 5 列版に張り替える。
設計注意(.nitpicker archive に
ANALYZEを絶対に走らせない):idx_pages_listfilterは SQLite の planner heuristics に依存して動作する。ANALYZEで per-index 統計が生成されると、planner は同 index をlistLinks/getLinkGraphの JOIN paths でも source/dest seek に流用しはじめ、これらが ~15s → ~500s (33x worse) に回帰する。実証:scripts/bench-partial-listfilter.mjsの no-ANALYZE / +ANALYZE pass 比較。crawler / viewer / MCP / migration のいかなる経路でもANALYZE/PRAGMA optimizeを実行しないこと。既存 archive への手動適用はscripts/add-perf-indexes.mjsで行う (このスクリプトも ANALYZE しない、3 index 一括追加)。
受容済みの遅い query:
listLinks13-16s (anchor SCAN + 3-way JOIN + COALESCE、SQL-first push-down 不能、canonicalId列の denormalisation 待ち),computeIsolatedClusters17s (66k inventory pages + 5M anchors、SQL-side filter 試したが 11.6s で改善なし、isolated_root列の denormalisation 待ち)。getSummaryはidx_pages_summary_contenttype/idx_pages_summary_failed(後述の「追加 INDEX」参照)の追加で cold 45s → 14s まで改善済み — 残る cold-path コストは SQLite の I/O bound(詳細はpackages/@nitpicker/viewer/src/summary-cache.tsの JSDoc)。各 query.ts の JSDoc に「push-down 不能の根拠」と「次のステップ (schema 変更)」を記載。
pre-0.10 互換性: clean-break。archive/meta/assert-compatible-version.ts が info.version を読んで REQUIRED_FORMAT_VERSION = "0.10.0" と semver 比較し、古い archive を Database.connect で開いた時点で IncompatibleArchiveError を throw する。v0.x 系の breaking 容認方針(MEMORY: v0-x-breaking-changes)に基づく。移行は scripts/migrate-to-0.10.mjs。
Wappalyzer 検出を構造化。1 行 = 1 (provider × external-id) タプル × 1 ページ。compound INDEX (provider, externalId) / (provider, pageId) を Phase 1 で先取り済み — 「同一 ID 別ページ検出」「provider 絞り込みリスト」が SQL レベルで stream 化される。
| カラム | 型 | 説明 |
|---|---|---|
| id | INTEGER PK | |
| pageId | FK → pages.id (CASCADE) | |
| provider | TEXT NOT NULL | Wappalyzer provider 名 (例: Google Tag Manager) |
| category | TEXT | categories[0] の便宜的射影 |
| externalId | TEXT | GTM-XXXX / G-XXXX / null |
| version / confidence | TEXT / INTEGER | Wappalyzer 由来 |
| categories / sources | JSON | full list & detection sources (script-src / inline / iframe-src / 等) |
<script type="application/ld+json"> および <script type="speculationrules"> を 1 行 1 エントリで保存。compound INDEX (type, pageId) を Phase 1 で先取り。
| カラム | 型 | 説明 |
|---|---|---|
| id | INTEGER PK | |
| pageId | FK → pages.id (CASCADE) | |
| kind | TEXT NOT NULL | 'ld+json' または 'speculationrules' |
| type | TEXT | top-level @type を classifyJsonLdType で正規化 |
| raw | TEXT NOT NULL | 非圧縮(SQLite overflow page に任せる) |
| parsed | JSON | JSON.parse(raw) 結果(parseError 時は null) |
| parseError | TEXT | beholder が記録した parse error メッセージ |
| カラム | 型 | 説明 |
|---|---|---|
| id | INTEGER PK | |
| pageId | FK → pages.id | アンカーが存在するページ |
| hrefId | FK → pages.id | リンク先ページ |
| hash | TEXT | フラグメント |
| textContent | TEXT | アンカーテキスト |
- images: pageId, src, currentSrc, alt, width/height, naturalWidth/naturalHeight, isLazy, viewportWidth, sourceCode
- resources: url, isExternal, status, statusText, contentType, contentLength, compress, cdn, responseHeaders
- resources-referrers: resourceId → resources.id, pageId → pages.id
- info: 設定情報(単一レコード、
Config型のフィールドを JSON で保存)。baseUrl(先頭起点 URL、roots[0]と同値)とroots(位置引数で渡された全起点 URL の JSON 配列)を含む。スコープエントリはroots1 本で表現する(独立したscopeカラムは無い) - page_html_blobs / page_html_ref: HTML スナップショットを SHA-256 hash PK の content-addressable BLOB として持つ。詳細スキーマと WHY は
init-schema.tsの JSDoc を参照(同期は実装側を正とする)
リダイレクトは独立テーブルではなく、pages.redirectDestId で表現:
updatePage(pageData) の処理:
redirectPaths = [...pageData.redirectPaths]
destUrl = redirectPaths.pop() # 最後の要素 = 最終宛先
redirectPaths.unshift(pageData.url) # 元URL を先頭に追加
# destUrl のページをINSERT/UPDATE(スクレイプ結果を保存)
# redirectPaths の各URL に redirectDestId = destPageId を設定
# ただし redirect === destUrl(自己リダイレクト)はスキップ
# → Basic認証チャレンジ等で同一URLへ302される場合の対策
# リダイレクト元になった各URLの旧 anchors / images は削除(本文を持たないため)
# destPageId の anchors / images は「置き換え」で保存(→「再スクレイプ時の…」参照)
被リンク(incoming links / referrers)は 読み取り時に redirect を透過解決する。アンカーがリダイレクト元(例: http://x が https://x に 301)を指していても、そのリンクは最終宛先(canonical ページ)の被リンクとして集約される。これにより http/https の別や、同一ページへ至る複数のリダイレクト経路があっても、被リンクが正規ページに合算され分裂しない。
解決規則: redirectDestId は #linkRedirectSources が常に最終宛先まで pre-flatten する(A → B → X のとき A も B も redirectDestId = X)。そのため再帰的なチェーン走査は不要で、COALESCE(target.redirectDestId, target.id) の 1 ホップで最終宛先が求まる。これは redirectTable()(A.redirectDestId = B.id UNION identity)と同一セマンティクス。
referrer 単位への集約: getPageDetail.inboundLinks は anchors.pageId(referrer)で GROUP BY し、1 referrer につき 1 行を返す。下記「なぜ tuple 重複削除 / UNIQUE 制約を使わないか」の通り、同一ページが header/footer 等で同じ宛先に複数アンカーを持つのは storage 層では正当な重複だが、被リンク一覧としては「何ページから参照されているか」が知りたいので referrer 単位に畳む。listExternalLinks の referrerCount(COUNT(DISTINCT source.id))も同じ理由で同じ粒度に揃えてある — 両者が食い違うと外部リンク一覧の参照元数と Page Detail の被リンク件数が一致しなくなる。
読み取り経路間の一貫性: 以下はすべて同じ規則で解決する。
| 関数 | パッケージ | 用途 |
|---|---|---|
ArchiveAccessor.getPagesWithRefs(内部で #getPagesWithRels を使用、redirect.from/fromId = 経由元) |
crawler | report(Google Sheets) |
getReferrersOfPage(through/throughId = 経由元) |
crawler | Page.getReferrers/getRequests フォールバック |
getPageDetail.inboundLinks |
query | viewer / mcp / cli |
through / throughId は「アンカーが実際に指した URL(= リダイレクト元)」で、report の [REDIRECTED FROM] 注記に使う。
意図的な非対称性(発リンクは解決しない): **inbound(被リンク)**は redirect 透過で canonical に集約する一方、**outbound(発リンク)**は getPageDetail.outboundLinks がアンカーの raw な指し先(例: http://x)をそのまま返す。これは「このページは古い/リダイレクトする URL にリンクしている」という監査シグナルを保持するための設計。この非対称性を「統一」しようとしないこと(発リンク側を解決すると監査情報が失われる)。
同一ページは 1 クロール内でも複数回 updatePage されうる。最も多いのは 多対一リダイレクト: 多数の旧 URL が 301 で 1 つの宛先ページ D に集約されると、クローラはリダイレクト元 URL を 1 つずつスクレイプし、そのたびに D を再取得して D の anchors / images を保存する(crawl --resume で実行をまたいでも同様)。
そのため updatePage(database.ts)は anchors / images を 追記ではなく置き換えで書く:
- 新しい
anchorList/imageListが 非空のときだけ、そのpageIdの既存行を DELETE してから INSERT する(=最新スクレイプ 1 回分で置換)。挿入経路はここ 1 箇所なので、何回updatePageされても・実行をまたいでも、最後の 1 回分だけが残る。 - 空のとき(タイムアウト / 部分描画の劣化スクレイプ)は 据え置く(旧データを消さない)。劣化と「正当に空になった」を区別できないための保守的挙動。代償として、正当に全リンクを失ったページは次の非空スクレイプまで stale 行が残る。
- ページが リダイレクト元になった場合は、その旧 anchors / images を無条件に削除する(リダイレクトは本文を持たない)。
なぜ tuple 重複削除 / UNIQUE 制約を使わないか: (pageId, hrefId, hash, textContent) は一意ではない。同じリンクが header と footer の両方にある等、1 ページ内に同一 tuple が正当に複数存在しうる。行単位では「正当な重複」と「再保存による冗長コピー」を区別できないため、行の dedup は不可。スクレイプ 1 回分を丸ごと置き換えることで、正当な重複を保ったまま冗長コピーを防ぐ。
既知の制約: 本修正より前にクロールされたアーカイブは、リダイレクト先ページの anchors / images が「リダイレクト元の数 + 1」倍に膨らんでいることがある(pages テーブルは重複しない。発リンク数・被リンク数の表示にのみ影響)。in-place の行削除では直せない(正当な重複まで消えるため)。再クロールで解消する。
関連: GitHub issue #70(置き換え修正・storage 層の対症療法)/ #73(クローラ層の根本対応・下記)
上記の置き換えは storage 層の対症療法。根本原因は クローラがリダイレクト元 URL ごとに宛先 D をフルレンダリングしていたこと(多対一集約で D が「元 URL 数 + 1」回描画される)。#73 でこれを抑止する。
フロー:
- HEAD プリフライトで最終到達先を解決する。
fetch-destination.tsの HEAD リクエストにtrackRedirects: trueを渡し、follow-redirects がres.redirectsを埋めるようにした。これで puppeteer を起動する前に最終到達先 D が分かる。 #scrapePage(crawler.ts)が描画の前に判定する。最終到達先のキー(redirectDestKey=protocolAgnosticKey(末尾ホップ or リダイレクト無しなら URL 自身))がCrawler#scrapedDestinationsに既にあれば、ブラウザを起動せずtype: 'redirect-edge'を返す。この CHECK は metadata-only / 非 HTML ブランチより上に置く(後述)。- worker が
redirectイベントを発火 → orchestrator がArchive.setRedirect(=Database.recordRedirect)でリダイレクト辺だけを記録(宛先の本文は触らない)。
redirectPaths の契約(最重要・破壊厳禁):
redirectPaths は「リダイレクト無し=空配列/リダイレクト有り=[...中間ホップ, 最終D](元 URL は含めない)」という契約。HEAD 経路(fetch-destination.ts)と browser 経路(@d-zero/beholder のスクレイプ結果)の 両方が同じ形状を返す前提で resolveRedirectChain / updatePage / redirectDestKey が動く。
- follow-redirects の
trackRedirectsはres.redirectsの 先頭に必ず元の要求 URL を積む(follow-redirects README 参照)。さらに HEAD はpath: url.pathnameで送るため、その先頭要素は クエリが落ちている。そのまま使うと (a) リダイレクト無しのページまでredirectPathsが非空になり自己リダイレクト扱い、(b)?id=1と?id=2のようなクエリ違いの別ページがredirectDestKeyで同一視され 2 件目が描画されず消える。 - そこで
fetch-destination.tsはres.redirects.map(r => r.url).slice(1)で先頭(クエリ落ちの元 URL)を捨て、上記契約に戻す。リダイレクト先は Location ヘッダ由来なのでクエリは保持される。 - この
slice(1)を外す/変えると、クエリ別ページ消失バグが即再発する。 ガード:test-serverの/query-distinct/E2E(?kind=alphaと?kind=betaが両方記録されること)。検索キーワード: 「follow-redirects trackRedirects redirects[0]」「redirectPaths slice」。
CHECK を metadata-only / 非 HTML ブランチより上に置く理由(#73 #2):
metadata-only(title のみ)と非 HTML は headCheckResult を updatePage に流す。updatePage は宛先行を #insertPage で UPDATE するため、もし CHECK がこれらの下にあると、既に描画済みの D を、リダイレクト元の薄い HEAD/title 結果(isExternal / isTarget / 空 meta)で上書きしてしまう(例: サイト内ページへ 301 する外部リンク)。CHECK を上に置けば edge-only に振り分けられ、D は無傷。ガード: /clobber/ E2E。
claim は「実際に描画した宛先」で行う(#73 #5):
#scrapedDestinations.add(...) のキーは HEAD の推測ではなく browser の描画結果の redirectPaths から算出する(renderedKey)。HEAD と GET で最終到達先が食い違う場合(method 条件付き / JS / meta-refresh リダイレクト)に、HEAD 推測キーで claim すると、後続ソースが 一度も描画されない幽霊行への辺になってしまうため。claim は描画成功後のみ。
- 既知の制約: HEAD と GET が食い違う稀なケースでは重複排除が空振りして再描画するだけ(データは正しい)。幽霊行は作らない。ガード:
/diverge/E2E。 - 並行: 同一 D への in-flight ソース(並列度が上限)は両方描画しうる。anchors/images は #70 の置き換えで正しく収束。sub-resource は一時的に重複しうるが、#73 前の「ソース数ぶん描画」より遥かに少ない。
辺記録は本文を壊さない:
recordRedirect は updatePage と #linkRedirectSources / resolveRedirectChain を共有するが、#insertPage(宛先行の本文 UPDATE)を通さない。空チェーン(sources 空=実際にはリダイレクトしていない)と解析不能な宛先 URL は、辺もスタブ行も作らず早期 return する(後者は throw すると WriteQueue 経由でクロール全体が abort するため)。ガード: database.spec.ts の recordRedirect 群。
直接リンクとリダイレクトの両方で到達される宛先も同じキーで claim されるため、どちらの経路で先に到達しても D は 1 回だけ描画される。
予測ページネーション: 投機的に生成された URL がリダイレクトした場合は実在 URL(サーバが 3xx 応答)なので辺を記録する(描画経路と同じ扱い。404/エラーの投機 URL だけ
shouldDiscardPredictedで破棄)。
| メソッド | リダイレクト | アンカー | リファラー |
|---|---|---|---|
getPages(filter?) |
ロードする | ロードしない | ロードしない |
getPagesWithRefs() |
ロードする | ロードする | ロードする |
getPages() は getRedirectsForPages() で redirectFrom を一括ロードする。getAnchors() は DB に都度クエリする(遅い)。
| フィルタ | 条件 |
|---|---|
'page' |
contentType='text/html' AND isTarget=1 |
'page-included-no-target' |
contentType='text/html' |
'internal-page' |
contentType='text/html' AND isExternal=0 |
'external-page' |
contentType='text/html' AND isExternal=1 |
'no-page' |
contentType IS NULL OR contentType != 'text/html' |
'internal-no-page' |
(contentType IS NULL OR != 'text/html') AND isExternal=0 |
'external-no-page' |
(contentType IS NULL OR != 'text/html') AND isExternal=1 |
| なし | 全件 |
isTarget は「ページか」ではなく「in-scope なクロール対象か」を表す。 fetch-destination が isTarget = !isExternal で設定するため、in-scope な 非HTMLリソース(PDF / zip / 画像)も isTarget = 1 になる。したがって「これはページか」は content-type で判定する(isTarget で判定してはいけない)。
- 書き込み時に content-type を正規化:
Database.#insertPage(pages)とDatabase.insertResource(resources)がnormalizeContentType(trim + 小文字化、空→null)を通して保存する。レスポンスはheader.split(';')[0]で verbatim に記録されるためText/HTML/text/htmlが来うるが、正規化により SQL の完全一致述語(WHERE contentType = 'text/html')と コード側のisHtmlContentType()(trim+小文字)が一致し、pages / resources 両テーブルの content-type 表現も揃う。 - 読み出し時のページ性述語は 2 種類:
- strict(
= 'text/html'): 「描画済みHTMLページ」を数える/見る所。getPages('page')、getScrapedHtmlPageCount(resume カウンタ=ライブの描画カウンタと一致させる)、getSummaryの metadata 充足率の分母(非HTML/エラー行はメタを持てず率を希釈するため)。 - loose(
contentType IS NULL OR = 'text/html'): ユーザー向けのページ一覧/件数。listPages、getSummaryの total/internal/external/statusDistribution。エラー/到達不能ページ(contentType = null・scraped = 1)を残すため(壊れたページは監査で見えるべき)。除外されるのは 既知の非HTMLリソースだけで、それらは Resources ビューに出る。
- strict(
- スナップショット:
updatePageはpage.html.length > 0のときだけ BLOB を書く。劣化スクレイプ vs HTML→非HTML 移行の扱いなど、書き込み判定の WHY はdatabase.tsのupdatePageJSDoc を参照(実装が正)。
既知の制約(将来の保守者向け):
- 正規化は書き込み時のみ・backfill 無し: 上記の正規化は新規 write にだけ適用される。本修正より前に作られたアーカイブの mixed-case content-type(
Text/HTML等)は残るため、完全一致述語が拾えないことがある。#initのマイグレーションは pages を backfill しない(v0.x、再クロールで解消)。 - 非正規 casing のページは snapshot 無しになりうる:
@d-zero/beholder(外部)は描画判定を exactcontentType === 'text/html'で行う。サーバがText/HTMLを返すと beholder は描画せずhtml=''を返す。#insertPageで content-type はtext/htmlに正規化されるためページとして計上されるが本文(snapshot)は無いという行になる。nitpicker 側では根治できず(beholder の判定を case-insensitive にする必要がある)、別途 beholder 側の課題。
検索キーワード: 「isTarget 意味」「ページ 非HTML 除外」「normalizeContentType」「listPages contentType」。 更新責任: ページ性の定義(strict/loose の使い分け)を変える場合、
list-pages.ts/get-summary.ts(query)とgetScrapedHtmlPageCount/#insertPage(crawler)を同時に見直し、各 spec の「PDF 除外 / エラーページ保持 / メタ分母」テストを更新する。content-type は exact 文字列で多数の SQL に inline されている(共有述語は未導入)ため、HTML 判定規則を変える際は全 inline 箇所を確認すること。
sequenceDiagram
participant CLI as npx @nitpicker/cli analyze
participant NP as Nitpicker(@nitpicker/core)
participant Archive as Archive
participant Pool as WorkerPool(プラグインごと)
participant Worker as 長寿命 Worker Thread
CLI->>NP: Nitpicker.open(filePath)
NP->>Archive: Archive.open({ openPluginData: true })
Archive-->>NP: Archive インスタンス
CLI->>NP: setPluginOverrides(overrides)
CLI->>CLI: selectPlugins()(--all / --plugin / TTY プロンプト / 全選択)
CLI->>NP: analyze(filter?)
NP->>NP: loadPluginSettings({}, pluginOverrides)(cosmiconfig)
NP->>NP: importModules(plugins)
NP->>Archive: getPagesWithRefs(100_000, callback)
loop ページバッチごと
par eachPage トラック(プラグインごとに専用プール)
loop 各プラグイン(順次)
NP->>Pool: new WorkerPool({ size: plugin.concurrency ?? cpus().length })
Note over Pool,Worker: N 個の Worker をプール起動時に 1 回だけ spawn
loop 各ページ(プールがキュー管理)
Pool->>Worker: postMessage({ type: 'task', taskId, data })
Note over Worker: JSDOM パース + プラグイン実行
Worker-->>Pool: postMessage({ type: 'result', taskId, ... })
end
NP->>Pool: pool.terminate() → 全 Worker shutdown
end
and eachUrl トラック(メインスレッド)
loop 各ページ × 各プラグイン
NP->>NP: mod.eachUrl({ url, isExternal })
end
end
end
NP->>Archive: setData("analysis/report", report)
NP->>Archive: setData("analysis/table", table)
NP->>Archive: setData("analysis/violations", violations)
CLI->>NP: write()
NP->>Archive: Archive.write()(tar 圧縮)
- Worker プール per プラグイン: プラグインごとに
WorkerPoolを 1 つ生成し、N 個の長寿命 Worker をプール起動時にまとめて spawn。各 Worker はメッセージループでタスクを次々受け取り、プラグイン実行が終わるまで再利用される。プラグイン切替時にプールを破棄して次プラグイン用に作り直す - プラグインごとの並列度宣言:
AnalyzePlugin.concurrencyで並列度を宣言できる(省略時はos.cpus().length)。Chrome 起動など重いプラグインは小さく設定(例:analyze-lighthouseは 2) - HTML 蓄積防止: メインスレッドの IIFE 並列度を
concurrency × 2で bound し、ロードした HTML 文字列がプール待ちで積み上がらないようにする - Cache: URL 単位で結果をキャッシュ。部分失敗後の再実行時にスキップ可能
設計判断の経緯: 旧実装は 1 ページにつき 1 Worker を spawn する固定 50 並列の bounded Promise pool だった。750 ページ規模で同時 50 Worker boot による「boot wave」が繰り返し発生し、ピークメモリが 20GB 級まで膨らむ事故が発生したため、長寿命プールに置き換えた。詳細は
@nitpicker/core/src/worker/worker-pool.tsの JSDoc を参照。
実装詳細は
@nitpicker/coreの JSDoc を参照(Nitpicker.analyze(),WorkerPool,worker.ts,page-analysis-worker.ts)。
sequenceDiagram
participant CLI as npx @nitpicker/cli report
participant GS as @nitpicker/report-google-sheets
participant Archive as Archive
participant Sheet as @d-zero/google-sheets Sheet
participant API as Google Sheets API
CLI->>GS: report(filePath, sheetUrl, credentials, config, limit, all?, silent?)
GS->>GS: authentication(credentials)(OAuth2)
GS->>Archive: getArchive(filePath) → { archive, removeSignalHandlers }
Note over GS: try/finally で cleanup を保証
GS->>GS: loadConfig(configPath)
GS->>Archive: getPluginReports(archive)
alt all=true(--all 指定 or 非TTY環境)
GS->>GS: 全シートを自動選択
else all=false
GS->>GS: enquirer プロンプト(シート選択)
end
GS->>Archive: getPagesWithRefs(limit, callback)
loop ページ/リソース反復(Phase 2 / 3)
GS->>GS: eachPage / eachResource で行を生成
GS->>Sheet: appendRow(...rows)
Note over Sheet: バッファに積む。2500 行に達したら<br/>自動 flush(lazy セル検出時は保留)
opt buffer >= 2500 かつ lazy なし
Sheet->>API: batchUpdate(updateCells)
end
end
GS->>Sheet: flush()
Sheet->>API: 残余 batchUpdate(updateCells)
Note over GS,API: silent=false 時: Lanes で進捗表示 + レート制限カウントダウン
GS->>GS: removeSignalHandlers()
GS->>Archive: archive.close()
| シート名 | 内容 |
|---|---|
| Page List | 全ページのメタデータ一覧 |
| Links | 全ページの HTTP ステータス・リンク情報・備考一覧 |
| Resources | ネットワークリソース一覧(raw / dedupe 切替可) |
| Images | 画像一覧(サイズ・alt・lazy 等) |
| Violations | analyze プラグインが検出した違反一覧 |
| Discrepancies | analyze プラグインの比較データ |
| Summary | サマリー |
| Referrers Relational Table | ページ → リファラーの関係テーブル |
| Resources Relational Table | ページ → リソースの関係テーブル |
createSheets() は Phase 2(eachPage)と Phase 3(eachResource)でページ/
リソースを反復しながら行を生成し、sheet.appendRow(...rows) でストリーミング
送信する。バッチ終端で sheet.flush() を呼んで残余を排出する。Phase 4
(addRows)も同じ appendRow + flush で送信する。
Phase 3 には逐次ループ終端の finalizeResources フックも用意されている。
eachResource 内で状態を蓄積したい factory(典型的には Resources シートの
dedupe 集約モード)が、ループ完了後にまとめて行を emit するために使う。
hook が登録されていれば createSheets() は Phase 3 の per-resource ループ
完了後・sheet.flush() 直前に 1 度だけ呼び、返ってきた行を appendRow で
送信する。Phase 3 の実装詳細(逐次 / 並列、num / total 等)に依存しない
ので、eachResource の呼ばれ方が将来変わっても集約ロジックは壊れない。
Resources シートは --dedupe-resources で raw / dedupe の 2 モードを
切り替えられる。raw モードは 6 列(URL / Status Code / Status Text /
Content Type / Content Length / Referrers)で 1 raw resource = 1 行。
dedupe モードは (canonical URL, status, contentType) で集約し、末尾に
Count(その canonical group の raw レコード数)と Query Pattern
(クエリキーごとのユニーク値数を key=N で並べる、例 auid=27, capi=1)
を加えた 8 列構成。Query Pattern は per-key の sample set(上限
MAX_PARAM_VALUE_SAMPLES = 100)と overflowedCount の 2 値で値の分布を
要約する:cap ジャスト(100 unique、overflow なし)は key=100、cap 後に
追加観測が来た場合は key=100+。値そのものは保存しない(プライバシーと
メモリの両方の観点で)。実装は data/create-resources.ts。
Phase 3 入り口では getResources() の結果を sortResourcesByUrl() で
URL の自然順に並び替える。実装は Martin Pool strnatcmp.c(Stuart
Cheshire, 1996 由来)の JS 移植で、Array.prototype.toSorted に
on-the-fly 比較関数を渡す。比較は Pool 由来の 2 path 構造を踏襲し、
両側に数値ランがある時に どちらかが '0' で始まる場合は
compare_left(fractional 解釈、左から digit-by-digit で即決)、
そうでなければ compare_right(length-first、bias で同点解消)
を呼び分ける。それ以外の文字は ASCII whitespace を skip し、ASCII
大文字を小文字に fold した UTF-16 code unit 比較を行う。派生文字列
を一切生成せず、charCodeAt と整数演算のみで完結するため、追加
メモリは O(1) per compare、V8 TimSort の auxiliary(N ポインタ分、
1.6M で約 13 MB)のみが上乗せされる。Lanes header に
Sorting resources by URL を一時表示する。実装は
utils/sort-resources-by-url.ts に集約されており、Pool 互換性
(Pool ドキュメントのリファレンスシーケンスと compare_left /
compare_right の path 分岐)、stable sort、ASCII case-insensitive
挙動、surrogate pair 含む URL の決定的比較、100K 件 sort の heap
増分 100 MB 未満であることは単体テストで固定。
参照: Martin Pool, "Natural Order String Comparison", sourcefrog.net/projects/natsort/。 オリジナル C ソース: github.com/sourcefrog/natsort/blob/master/strnatcmp.c。
ストリーミング・チャンク化のロジックは @d-zero/google-sheets の Sheet
クラスに集約されている。appendRow() は内部バッファに行を積み、2500 行
ごとに自動的に addRowData() を呼んでフラッシュする。これにより、巨大な
レポートでも呼び出し元側のメモリ滞留はチャンクサイズ分に抑えられる。
finalizeResources で集約された結果(dedupe Resources で典型的に
63K 行クラス)を appendRow(...finalRows) に一括で渡すと、内部の
chunk flush が逐次進む間、呼び出し元から見ると単一の await が
ブロックしているように見えるため、Lanes の進捗が止まったように
映る。Sheet は chunk flush ごとに onProgress(sent, remaining)
を発火するので、createSheets() はこれを購読して
Sending ${sent}/${total} aggregated rows を Lanes に反映する。
購読の設定とリセットは sheets/run-finalize-resources.ts に切り
出されており、appendRow が throw した場合でも finally で
sheet.onProgress = undefined がクリアされるため、ハンドラが
別シートの lane に漏れ込むことはない。
手動検証手順: 5 万行以上の resources を持つ archive を用意し、
npx @nitpicker/cli report <archive>.nitpicker --dedupe-resources --sheet <url>
を実行する。Phase 3 で Resources: Sending N/M aggregated rows の
N が 0 → 中間値 → M と刻々と更新されることを目視確認する
(chunk サイズ 2500 行刻みで遷移する)。
既知の制約 (V8 引数制限): 集約後の finalRows は appendRow(...finalRows)
にスプレッドで渡されるため、配列長が V8 の関数引数上限(実用上 6.5 万件付近)
を超えると RangeError: Maximum call stack size exceeded で破綻する。1.6M
raw resources → 63K 集約までは実機で動作確認済みだが、将来サイト規模が
さらに大きくなり aggregate 後でも 6 万件を超えそうな場合は、appendRow を
chunk 単位で複数回呼ぶ実装(例: 1 万件ずつループ)に切り替える必要がある。
Sheet.appendRow の内部 2500 行バッファは呼び出し回数に依存しないので、
外側で分割しても順序保証と総送信回数は変わらない。テスト側 (run-finalize- resources.spec.ts) では V8 制限を避けるため 100 件で挙動を固定している。
createCellData(() => ...) で生成された遅延セル(thunk)は provide()
評価時の共有状態を参照するため、評価タイミングが重要になる。appendRow()
は受け取った行が遅延セルを含むことを検出すると、自動 flush を停止して
明示的な flush() 呼び出しまでバッファ全件を保留する(FIFO 順保証)。
Page List の「Internal Referrers」列がこの仕組みに乗っており、バッチ内の
インデックスページが順次 parentRefs を mutate していくため、appendRow
は遅延セルを検出した時点でバッファリングモードに切り替わる。バッチ終端の
flush() で初めて thunk が評価されるので、参照元数が正しく計算される。
新規 createX.ts の実装者は通常この仕組みを意識する必要はない。各
create-*.spec.ts には「eachPage/eachResource が返すセルが
Cell.prototype.provide に揃っているか」のアサーションがあり、誤って
遅延セルを混入させると spec が落ちる。Page List の spec は逆向きで、
少なくとも 1 つの遅延セルが含まれることをアサートしている。
実装詳細は
@nitpicker/report-google-sheetsと@d-zero/google-sheetsの JSDoc を参照(report(),createSheets(),Sheet.appendRow,Sheet.flush, 各create-*.ts)。
連番 URL(例: /page/1, /page/2, ...)を検出し、先読みで予測的にキューへ追加する仕組み。
flowchart TD
A["新 URL を push()"] --> B{"前回 push した URL と比較"}
B -->|パターン検出| C["detectPaginationPattern()"]
B -->|パターンなし| D["通常のキュー追加"]
C --> E{"単一トークンの数値差分?"}
E -->|Yes| F["PaginationPattern を返却"]
E -->|No| D
F --> G["generatePredictedUrls(pattern, url, count)"]
G --> H["予測 URL をキューに追加"]
H --> I["deal() でスクレイプ実行"]
I --> J{"shouldDiscardPredicted(result)"}
J -->|4xx/5xx/error| K["結果を破棄"]
J -->|2xx/3xx| L["Archive に保存"]
- パターン検出: URL をトークン(パスセグメント + クエリ値)に分解し、前回 URL と比較。差分が単一トークンかつ整数の場合のみ検出
- URL 生成: 検出したステップ(差分値)を元に、並列数分の未来ページ URL を生成
- 結果フィルタ: 予測 URL のスクレイプ結果が 4xx/5xx/error/skip なら破棄
- cascade 防止:
paginationCtxで予測 URL から更なる予測生成を抑制
実装詳細は
crawler/detect-pagination-pattern.ts,crawler/generate-predicted-urls.ts,crawler/should-discard-predicted.tsの JSDoc を参照。
実装詳細は各関数自身の JSDoc を参照(
parseUrlは@d-zero/shared/parse-urlからのエイリアス import、pathMatchはcrawler/should-skip-url.ts、isLowerLayerはcrawler/find-scope-entry.ts、normalizeToArrayはnormalize-to-array.tsに実装がある)。
スコープエントリは (hostname, port, path) のトリプル。findScopeEntry(url, scope, options) は対象 URL が含まれる 最深一致のスコープエントリ を返し、どのエントリにも入らなければ null を返す。
判定条件:
scope.get(url.hostname)で同一ホスト名のエントリ群を取り出す(hostname 不一致なら即 null)- 各エントリについて
entry.port !== url.portで ポート一致を要求(localhost:3000とlocalhost:8080は別 scope。WHATWG URL のデフォルトポート正規化で:80/:443は空文字に折り畳まれるため、明示・省略は同一視される) isLowerLayer(url.href, entry.href, options)で path 階層先頭一致- 全ての条件を満たすエントリの中から
entry.depthが最も深いものを返す
ドメインスコープとサブディレクトリスコープは別概念ではない。https://example.com/(path=/)は「ホスト全体」を意味する特殊ケース、https://example.com/blog/ は「/blog/ 配下のみ」を意味する一般ケース。両者を Map<hostname, ExURL[]> で同列に保持する。
paths = URL の pathname を "/" で split した文字列配列
例:
/meta/ → paths: ['meta', ''] (末尾スラッシュ)
/meta/full → paths: ['meta', 'full']
isLowerLayer('/meta/full', '/meta/') → true (meta が一致, full は追加)
isLowerLayer('/meta/robots-noindex', '/meta/full') → false (full ≠ robots-noindex)
isLowerLayer('/meta/robots-noindex', '/meta/') → true (meta が一致)
重要: 再帰クロールで子ページを発見するには、開始 URL をディレクトリパス(末尾
/)にする必要がある。ファイルパス(例:/meta/full)を開始 URL にすると、同階層の他ページはisLowerLayer=falseとなりスクレイプされない。
CrawlerOrchestrator.crawling(urls, options) に位置引数 URL を複数渡すと、それぞれが「再帰クロールの起点」かつ「スコープエントリ」として扱われる。info.roots に元の位置引数リストがそのまま記録され、同じ配列が Crawler 構築時にも渡されるため、メモリ上の scope map と DB に保存される roots は常に同期する。スコープと起点は別概念ではなく、info.roots 1 本で表現される。
CrawlerOrchestrator.append(archivePath, newUrls, options, cb) は既存 .nitpicker を開き、newUrls を追加の起点として再帰クロールを継続する。フロー:
Archive.open(archivePath) # tar 展開 + advisory lock 取得
archived = archive.getConfig()
archived.fromList === true → エラー(list-mode archive は append 不可)
copyFile(archivePath, archivePath + '.bak') # 失敗時の復元用バックアップ
mergedRoots = unique(archived.roots, newUrls.withoutHash)
archive.updateConfig({ roots, fromList:false, recursive:true, baseUrl:roots[0] })
archive.repromoteExternalPages(scopeMap) # 旧 external のうち新 scope 下層を pending に戻す
crawler.resume(pending, scraped, resources) # 既存状態を crawler に流す
orchestrator.crawling(newParsed) # 新 root + repromote 対象を再クロール
archive.setUrlOrder()
unlink(archivePath + '.bak') # 成功 → .bak 削除
例外時:
copyFile(archivePath + '.bak', archivePath) # 原本を復元
unlink(archivePath + '.bak')
restore 自体が失敗した場合は AggregateError([appendError, restoreError]) を投げ、
.bak を残してオペレータが手動復旧できる状態にする
repromoteExternalPages は対象 page の pages 行を scraped=0, isExternal=0, contentType=null, status=null, html=null, redirectDestId=null などにクリアし、関連する anchors / images / resources-referrers 行を chunk (500件単位) で DELETE する。page id は維持されるため、他ページの anchors.hrefId 参照は壊れない。
Archive.create / Archive.open / Archive.resume は冒頭で fs.mkdir(<tmpDir>.lock, { recursive: false }) の atomic 性を使って tmpDir 単位のロックを取得し、その中に pid.txt(プロセス ID)を書き込む。Archive.close() / Archive.write() の finally でロックを解放する。Archive.connect(read-only アクセサ)はロックを取らない。
別プロセスが同じ archive を開こうとした場合:
- ロックが存在 +
pid.txtの PID がprocess.kill(pid, 0)で生存 →ArchiveLockErrorを投げる - ロックが存在 + PID が死んでいる(stale lock)→ ロックを削除して 1 回だけ再取得を試みる
migrate-info-roots.ts は Database.connect 直後に毎回呼ばれる冪等な migration。info テーブルが現行スキーマでない場合、(1) roots カラムを追加して UPDATE info SET roots = json_array(baseUrl) で seed し、(2) 不要になった scope カラムを ALTER TABLE info DROP COLUMN scope で削除する。baseUrl が NULL の場合は roots = [] で初期化。実行時のみ stderr に 1 行 [migrate] info table upgraded (roots seeded, scope dropped) を出力する。
disableQueries=true→ クエリ文字列を完全削除PHPSESSIDパラメータは自動削除- 複数スラッシュ(
//)は単一に正規化 withoutHashAndAuth: DB 保存用(認証情報・ハッシュなし)withoutHash: クローラー内部用(認証情報あり、ハッシュなし)
excludeUrls は URL プレフィックスのリストで、url.href.startsWith(prefix) による先頭マッチで判定する。
デフォルトでソーシャルメディアの共有エンドポイント等が含まれ、--exclude-url で追加可能。
パスの glob パターンを使う excludes とは異なり、スキーム・ホスト名を含むフル URL に対してマッチする。
micromatch による glob マッチ。URL の pathname に対して適用。
pathMatch('/blog/2020/01', '/blog/*') → true
pathMatch('/blog/2020/01', '/blog/**/*') → true
pathMatch('/about', '/blog/*') → false
--exclude 等の CLI フラグはカンマ区切りで複数パターンを指定可能。
normalizeToArray() がブレース展開({html,php})内のカンマを保持しつつ、トップレベルのカンマで分割する。
normalizeToArray('/blog/**/*,/facility/**/*')
→ ['/blog/**/*', '/facility/**/*']
normalizeToArray('/blog/*.{html,php},/admin/*')
→ ['/blog/*.{html,php}', '/admin/*']
| フェーズ | エラー | 処理 |
|---|---|---|
| HEAD リクエスト | タイムアウト(10s), ECONNREFUSED 等 | ScrapeResult.type='error'(shutdown=false) |
| ブラウザ起動 | Puppeteer 起動失敗 | ScrapeResult.type='error'(shutdown=true) |
| page.goto() | タイムアウト, ERR_NAME_NOT_RESOLVED | @retryable でリトライ後 type='error' で返却 |
| page.goto() = null (JS redirect) | window.location.replace() / <meta refresh> で navigation 上書き |
Crawler.#scrapePage の rescue が page.url() を救出し redirect-edge として記録(buildJsRedirectEdge / derive-js-redirect-target.ts / is-js-redirect-error-shape.ts の JSDoc が正) |
| 画像抽出 | context 破壊, タイムアウト | デバイスプリセット単位で try-catch、部分結果を返却。全失敗時は fallback:[] |
| DOM 解析 | evaluate 失敗 | catch でフォールバック値 |
crawl コマンドと pipeline コマンドはエラーの種類に応じて異なる終了コードを返す:
| コード | 定数 (exit-code.ts) |
意味 |
|---|---|---|
0 |
ExitCode.Success |
成功 |
1 |
ExitCode.Fatal |
致命的エラー(引数不足、内部エラー、スコープ内ページのエラー等) |
2 |
ExitCode.Warning |
警告 — 外部リンクエラーのみ発生(クロール自体は成功) |
CrawlerError.isExternal
├── true → 外部エラー(DNS 失敗、証明書エラー等)
└── false → 内部エラー(スコープ内ページの失敗)
CrawlAggregateError
├── hasOnlyExternalErrors = true → exit 2(--strict 時は exit 1)
└── hasOnlyExternalErrors = false → exit 1
--strict フラグを指定すると、外部リンクエラーのみの場合でも exit 1(致命的)として扱う。CI/CD パイプラインで外部リンクの一時的な障害を許容したい場合は --strict を省略する。
「死んだホスト」(DNS で引けないドメイン)の HEAD pre-flight を 1 セッションを越えて省略するための in-memory + crawl_errors-backed キャッシュ。getaddrinfo ENOTFOUND のような確定的失敗で、同 host 配下の URL が retry 3 回 × interval を独立に消費するのを止める。
真理ソース: classifyErrorKind(@nitpicker/crawler)。crawler パッケージへ物理移動し、@nitpicker/query は re-export 専用。crawler 自身がキャッシュの mark / preload 判定で必要としており、query → crawler の依存方向はあっても crawler → query は禁止だから(query が ArchiveAccessor 等を import する既存方向と矛盾しない)。
Session learning(同一セッション内の学習): Crawler.#sendHeadRequest の onGiveUp callback で shouldBurnHost(packages/@nitpicker/crawler/src/crawler/should-burn-host.ts)が true を返す時のみ host を dnsBurnedHostCache: Map<string, ErrorKind> に投入する。onWait(retry 待機開始)ではなく onGiveUp(全 retry 使い果たし)で mark する設計は、一過性のエラーを巻き込んで host を不当に burn しないため。最初の URL は retry を消化するが、その後のキュー pull は短絡される。
WHY: session-success guard(cascade 防止):
shouldBurnHostはerrorKind === 'dns'に加えて 「同 host で本セッション中に 1 件でも HTTP 応答があった(=#successfulHosts: Set<string>)」を否定条件として満たす場合のみ burn を許可する。理由: ローカル resolver が crawl の途中で flip する事故(WiFi → tether / VPN flip / ISP DNS hiccup)が起きると、dealer の並列 in-flight 全てが同時にgetaddrinfo ENOTFOUNDを返し、最初の onGiveUp が host を burn → 残りの URL がPreloadShortCircuitErrorで短絡 → dealer の queue が秒〜分で枯渇 →crawlEndが「成功」発火 → tar 化、で 健全な host を含むセッション丸ごとが degenerate completion に陥る。session-success guard を入れることで、過去に同 host で 1 件でも HTTP 応答を得た事実があれば burn しない(= 短絡しない= retry layer に任せる)。dead-domain の fast-fail 挙動は session-success の無い host については従来通り保たれる。実装の正はshould-burn-host.tsの JSDoc。#successfulHostsの populate はCrawler.#sendHeadRequest内のretryCallcallable でfetchDestinationが resolve した瞬間(外側awaitの前)と puppeteer fallback の success / skipped 経路で行う。 Known limitation(実装 JSDoc にも記載): (1) 同セッション初回 URL が直接ネットワーク blip に当たった host は guard が空のため burn される(pause-dealer-on-outage で塞ぐ別 issue #91)。(2) preload で seeded された host は#sendHeadRequest冒頭でPreloadShortCircuitErrorを即 throw するので#successfulHostsに到達せず、session-success による un-burn は起こらない(= 仕様)。
WHY:
EAI_AGAINはdnsではなくdns-transientに分類:getaddrinfo EAI_AGAIN <host>はローカル DNS resolver の一時的失敗(WiFi 切替、resolver 過負荷)であり、本物の NXDOMAIN とは性質が違う。dnsバケットに入れたままだと、ローカル resolver が一時不調なときに 3 retry 後の onGiveUp で 無実の host を全 burn する 穴になる。MATCHERSの first-match-wins 順序でdns-transientをdnsより前に評価することで、getaddrinfoトークンを共有しても EAI_AGAIN は transient ラベル側に倒す。Database.listDnsBurnedHostCandidatesの SQL LIKE フィルタも%EAI_AGAIN%を意図的に外しており、JS 側classifyErrorKindの確定判定と二段で守る。
WHY: HEAD pre-flight の timeout は attempt ごとに
10s → 30s → 60sに escalate: 一律 10s race だと、政府系/重負荷サーバの遅延(応答に 20-40s かかる)を全 retry で取りこぼす。最初の attempt を短めにして健常 URL のスループットを保ち、後の retry を長くして「遅いだけのサーバ」を救う。本当に死んでるサーバは 3 attempt 目で 60s 消費するが、一律 10s で諦めて recoverable な URL を取りこぼすより総量で得。実装はCrawler.#sendHeadRequestの retryCall fn クロージャ内でlet attemptを increment し、fetchDestination({ ..., timeout: HEAD_TIMEOUT_ESCALATION_MS[attempt] })で呼ぶ。
WHY: HEAD が unusable な時の GET fallback と puppeteer fallback の二段救済: WAF / 中間箱は HEAD を平気で握りつぶす(parse-error / TCP reset / silent timeout)一方、GET や本物のブラウザ navigation は通すことがある。HEAD/GET 両層で見逃すと、生きたページが status=-1 で永久に保存される。3 層構造で救済する: (1)
fetchDestination内部で HEAD がNetTimeoutError/connection-reset/parse-errorを返したら同じ URL を GET で 1 回再試行し、200/3xx/4xx の確定応答を得る(4xx/5xx は GET でも HEAD と一致するため preserve)。(2) GET も同上の失敗で死んで、(3a) URL が HTML 形(isLikelyHtmlUrl)かつ (3b) エラー kind がtimeout/connection-reset/parse-error(isPuppeteerFallbackCandidate=PUPPETEER_FALLBACK_KINDSに属する)の時のみ、Crawler.#scrapePageの HEAD catch ブロックで puppeteer fallback を 1 回だけ起動する。fallback で 'success' なら通常の scrape 経路と同じ後処理(markBrowserScrape/#scrapedDestinations登録)、'skipped' なら excludeKeywords ヒットとして skipped を尊重、それ以外はUnreachable (fallback failed)を lane 表示して HEAD のエラーメッセージをcrawl_errorsに記録する(puppeteer の noisier wrapper メッセージではなく root cause を残す)。dns/tls/client-blocked/connection-refused/connection-timeout/local-network/unknownは fallback 対象外(puppeteer でも同じ結果しか出ない or コストに見合わない)。PreloadShortCircuitErrorはdns分類になるので自動的に除外される。
WHY:
--retry-failedの収束にはPERMANENT_ERROR_KINDS除外が要る: 旧resetFailedPagesは SQL の粗いフィルタ(status=-1 / NULL / contentType NULL / 5xx)だけで候補を取り、reset → re-crawl → 同じ NXDOMAIN / 期限切れ証明書 /ERR_BLOCKED_BY_CLIENT/ HTTP パースエラー / ECONNREFUSED で再失敗 → 次の--retry-failedでもまた同じ候補が並ぶ、で収束しなかった。PERMANENT_ERROR_KINDS(packages/@nitpicker/crawler/src/permanent-error-kinds.ts) に登録された 5 種は 何度試しても結果が変わらない ことが分類上確定しているので、resetFailedPagesは候補 SELECT 後にgetFailedPageMessagesで page_errors → crawl_errors の順に最新メッセージを取り、classifyErrorKind結果が永続 kind なら reset せずスキップする。これにより--retry-failedを繰り返すほどリトライ対象が縮む。Known limitation: 失敗がerror.logだけに残っている pre-crawl_errorsarchive はgetFailedPageMessagesが message を解決できず(writer 側は依存方向の都合で error.log を読まない)、永続失敗でもunknownとして再 reset される。書き換え経路の依存サーフェスを最小に保つトレードオフで、必要なら一度通常クロールを走らせて構造化テーブルを populate すれば次回以降は収束する。
Session preload(既存 archive からの seeding): append / inventory / retryFailed / resume で archive を再オープンする 4 経路で Archive.listDnsBurnedHostCandidates() を呼び、crawl_errors 履歴のうち DNS only でかつ復活シグナル(pages / resources の 2xx-3xx、pages.lastCrawledAt > crawl_errors.createdAt)が無い host を cache に投入する。これにより --retry-failed の 2 回目以降は 1 URL の retry すら消費せず即 skip。
Short-circuit と自己増殖の遮断: cache hit 時は PreloadShortCircuitError を throw する。orchestrator の crawler.on('error', …) ハンドラが error.error instanceof PreloadShortCircuitError を見て addError を skip するので、crawl_errors への同一行重複挿入が起こらない(さもなくば --retry-failed を回すたびに DNS 行が膨らみ、次回 preload の候補が雪だるま式に増えてしまう)。pages.status = -1 は通常の scrape-error 経路で set されるので、見た目は現状維持。
生存期間: 1 crawl セッション。既存の clearDestinationCache() と同じ 4 箇所(orchestrator の crawl/append/inventory/retryFailed 終了直後)で clearDnsBurnedHostCache() を呼び、Map と short-circuit カウンタを両方リセットする。crawl 完了時には [preload] Short-circuited N URL(s) on DNS-burned hosts を stderr に出力。
復活時のリセット手段: 専用 CLI は設けない。DNS が復活した host を強制的に再評価したいときは crawl_errors から該当 message を持つ行を SQL で削除すれば、次回 preload で除外される。migration を伴わないので legacy archive は空配列を返し、何も壊れない。
viewer の Summary 画面では、pages.status = -1(ハード失敗 sentinel)の bar 行直下に DNS / connection-timeout / unknown 等の errorKind 別 sub-rows を入れ子描画する。WHY:
-1のままだと「何が原因で取得できなかったか」が読み取れない。Errors view を開けば分かるが、Summary で原因の分布が見えると最初のスクリーンで判断材料が揃う。- 分類は 読み取り時 に行う(
classifyErrorKind)。既存 archive を再 crawl せずに細分化が表示されるので、StatusCount.errorKindBreakdownは optional フィールドで additive な拡張。
実装上の核:
getSummaryのPromise.allに「pages WHERE status=-1の id 取得 →resolveFailedPageMessagesで page_errors → crawl_errors → error.log の 3 段 fallback でメッセージ解決 →classifyErrorKindで kind 別 count」を 並列クエリの一段 として追加(メタ / コンテンツタイプ集計と同時実行で waterfall を作らない)。- 不変条件:
sum(errorKindBreakdown[*].count) === parent count。メッセージ解決失敗の pageId は'unknown'に倒すことでこれを保つ。 - viewer 側は
summary-view.tsxの-1行直下にul要素を入れ子描画。bar 幅の分母は 親-1の count(全体ではない)—-1の内訳構成比として読ませるため。 - kind ラベルは
views.errorKind.{kind}の i18n key をgetErrorKindLabel経由で参照。Errors view 側も同じ helper を使うので、両ビューで kind 表記がブレない。
packages/test-server/
├── src/
│ ├── server.ts # createApp(), startServer()
│ ├── routes/
│ │ ├── basic.ts # /, /about
│ │ ├── recursive.ts # /recursive/**
│ │ ├── redirect.ts # /redirect/**(301→302→200チェーン)
│ │ ├── meta.ts # /meta/**(16メタフィールド)
│ │ ├── exclude.ts # /exclude/**(パス・キーワード・URLプレフィックス除外)
│ │ ├── options.ts # /options/**(fetchExternal, disableQueries)
│ │ ├── error-status.ts # /error-status/**(4xx/5xxステータス)
│ │ ├── scope.ts # /scope/**(スコープ判定)
│ │ ├── pagination.ts # /pagination/**(ページネーション検出)
│ │ └── scroll-jack.ts # /scroll-jack/**(viewport依存リダイレクト)
│ └── __tests__/e2e/
│ ├── global-setup.ts # Hono サーバー起動/停止(port 8010)
│ ├── helpers.ts # crawl(), cleanup() ヘルパー
│ ├── await-event-emitter-shim.ts # CJS/ESM interop shim
│ ├── single-page.e2e.ts
│ ├── recursive.e2e.ts
│ ├── redirect.e2e.ts
│ ├── meta.e2e.ts
│ ├── exclude.e2e.ts
│ ├── options.e2e.ts
│ ├── archive-pipeline.e2e.ts
│ ├── cli-process-exit.e2e.ts # CLI を spawn してプロセス終了を保証
│ ├── config-persistence.e2e.ts
│ ├── error-status.e2e.ts
│ ├── scope.e2e.ts
│ ├── parallel-and-interval.e2e.ts
│ ├── snapshot.e2e.ts
│ ├── output-path.e2e.ts
│ ├── pagination.e2e.ts
│ └── scroll-jack.e2e.ts
テスト実行: yarn vitest run --config vitest.e2e.config.ts(maxWorkers: 1)
viewer の E2E は別系統(Playwright): 上記は crawler 中心の Vitest E2E。
@nitpicker/viewerの E2E は Playwright で、packages/@nitpicker/viewer/e2e/(generate-fixture.mjsで fixture 生成 → 実 CLI で viewer 起動 → ブラウザ検証、a11y テスト群を含む)にある。実行はyarn workspace @nitpicker/viewer test:e2e。Playwright spec はtest.describe等が Vitest と非互換なため、ルートvitest.config.tsのexcludeで**/@nitpicker/viewer/e2e/**を除外している(これを外すとyarn testが「Playwright Test did not expect test.describe()」で落ちる)。
テスト用 crawl ヘルパーのデフォルトオプション:
interval: 0 # 待機なし
parallels: 1 # 直列実行
image: false # 画像取得なし
Nitpicker は D-ZERO が公開する以下の外部パッケージに依存している。
仕様変更やバグ調査時はこれらのパッケージを参照すること。バージョンは各パッケージの package.json を参照。
| パッケージ | 用途 | 検索キーワード |
|---|---|---|
@d-zero/beholder |
Puppeteer ベースのスクレイパーエンジン。ScrapeResult を返す |
"@d-zero/beholder" changelog |
@d-zero/dealer |
並列処理・スケジューリング。deal() 関数と Lanes 進捗表示を提供 |
"@d-zero/dealer" deal concurrent |
@d-zero/shared |
共有ユーティリティ(サブパスエクスポート形式: @d-zero/shared/parse-url 等) |
"@d-zero/shared" subpath exports |
@d-zero/roar |
CLI フレームワーク | "@d-zero/roar" command |
@d-zero/google-auth |
OAuth2 認証(credentials.json → token.json) |
"@d-zero/google-auth" oauth2 |
@d-zero/google-sheets |
Google Sheets API クライアント | "@d-zero/google-sheets" spreadsheet |
@d-zero/fs |
ファイルシステムユーティリティ | "@d-zero/fs" |
@d-zero/readtext |
テキスト読み取りユーティリティ | "@d-zero/readtext" |
@d-zero/beholder → crawler(Scraper, ScrapeResult)
@d-zero/dealer → crawler(deal() 並列制御), core・cli・report-google-sheets(Lanes 進捗表示)
@d-zero/shared → 全パッケージ(parseUrl, delay, isError, detectCompress, detectCDN)
@d-zero/roar → cli(CLI コマンド定義)
@d-zero/google-auth → report-google-sheets(OAuth2 認証)
@d-zero/google-sheets → report-google-sheets(Sheets API)
@d-zero/fs → crawler(ファイルシステムユーティリティ)
@d-zero/readtext → cli(リストファイル読み込み)
@d-zero/beholder:ScrapeResultの型が変わると crawler 全体に影響@d-zero/dealer:deal()の API が変わると crawler の並列処理に影響。Lanesの型が変わると core・cli・report-google-sheets の進捗表示に影響。crawler は 1.9.0 で追加されたdeal()setup コールバックの第 6 引数unshift(キュー先頭への優先投入)に依存するため、1.9.0 未満へのダウングレード不可。ソースはd-zero-dev/toolsのpackages/@d-zero/dealer@d-zero/shared: サブパスエクスポートの追加・削除に注意。@d-zero/shared/parse-url形式でインポートすること