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 レポーター
└── 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
└── @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["push() で発見した URL を動的にキューに追加"]
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/>snapshot を zip 圧縮 → 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: アーカイブの作成・再開・書き出しArchiveAccessor: 読み取り専用アクセサ(getPages,getPagesWithRefsなど)Page: ページデータラッパー
内部モジュール構造:
crawler/src/
├── utils/ # 型定義 + ユーティリティ
│ ├── types/ # ExURL, PageData, Link, CrawlerError 等
│ ├── array/ # eachSplitted
│ ├── object/ # cleanObject
│ └── error/ # DOMEvaluationError, ErrorEmitter
├── archive/ # SQLite アーカイブストレージ
│ ├── filesystem/ # 1関数1ファイル(16ファイル)+ tar, untar
│ ├── archive-lock.ts # tmpDir 単位の advisory lock(mkdir + pid.txt + stale 検出)
│ ├── migrate-info-roots.ts # info テーブルを現行スキーマに揃える冪等 migration(roots 追加・scope 削除)
│ ├── 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 の薄ラッパ)
│ ├── 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 を再実行しないlistPages: ページ一覧取得(ステータス・メタデータ欠損・URL パターンなどでフィルタ)getSummary: サイト全体の統計(ページ数、ステータス分布、メタデータ充足率)getPageDetail: 単一ページの詳細情報(メタデータ、アウトバウンド/インバウンドリンク、リダイレクト元)getPageHtml: HTML スナップショット取得(truncation サポート)listLinks: リンク分析(broken / external / orphaned)listResources: サブリソース一覧(CSS, JS, 画像、フォント)listImages: 画像一覧(alt 欠損、寸法欠損、オーバーサイズ検出)getViolations: 分析プラグインの違反データ取得findDuplicates: 重複タイトル・説明の検出findMismatches: メタデータ不一致の検出(canonical, og:title, og:description)getResourceReferrers: リソースを参照しているページの特定checkHeaders: セキュリティヘッダーチェック(CSP, X-Frame-Options, X-Content-Type-Options, HSTS)
依存: @nitpicker/crawler(Archive, ArchiveAccessor を使用)
Model Context Protocol サーバー。AI アシスタント(Claude 等)から .nitpicker アーカイブを直接クエリするための 14 ツールを提供。
構成:
mcp-server.ts:createServer()で MCP Server インスタンスを構築。低レベルServerAPI を使用(McpServer+ Zod スキーマの深い型インスタンス化問題を回避)tool-definitions.ts: 14 ツールの JSON Schema 定義
バイナリ: nitpicker-mcp(stdio トランスポート)
依存: @modelcontextprotocol/sdk, @nitpicker/query
@d-zero/roar ベースの統合 CLI。5つのサブコマンドを提供。全 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 から利用可能。12 のサブコマンド(summary,pages,page-detail,html,links,resources,images,violations,duplicates,mismatches,headers,resource-referrers)を提供
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. HEAD プリフライト → 到達不能なら error
5. metadataOnly / 非 HTML → ブラウザなしで結果返却
6. HTML → Puppeteer 起動(User-Agent 設定済み)→ スクレイプ
@d-zero/dealerのdeal()がスケジューリングと並列制御を担当intervalオプションでリクエスト間の待機時間を設定可能- スクレイピングはインプロセス(
@d-zero/beholder)で実行。各 URL ごとにブラウザを起動・終了 push()で発見した新 URL を動的にキューに追加onPushコールバックでwithoutHashAndAuthによる重複排除signalオプションでAbortSignalを渡し、中断時に新規ワーカーの起動を停止
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()(ゾンビ Chromium プロセスの終了)→process.exit()を実行
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 秒以上ブロックする。
自リポ内の同型パターンは crawler/fetch-destination.ts の HEAD タイムアウトのみ。ここは cancellable な setTimeout/clearTimeout に書き直し済み(Promise.race + delay() を使わないこと)。
検証は 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(): 除外キーワードチェック
| フィールド | セレクタ | プロパティ |
|---|---|---|
| title | title |
textContent |
| lang | html |
lang |
| description | meta[name="description"] |
content |
| keywords | meta[name="keywords"] |
content |
| noindex/nofollow/noarchive | meta[name="robots"] |
content をパース |
| canonical | link[rel="canonical"] |
href |
| alternate | link[rel="alternate"] |
href |
| og:type, og:title, etc. | meta[property="og:*"] |
content |
| twitter:card | meta[name="twitter:card"] |
content |
excludeKeywords の各文字列を strToRegex() で正規表現に変換し、HTML 全体に対して test() する。マッチしたら呼び出し元(scraper.ts)が ScrapeResult を type: 'ignoreAndSkip' で返却し、changePhase(name: 'ignoreAndSkip')を emit する。
| カラム | 型 | 説明 |
|---|---|---|
| id | INTEGER PK | 自動採番 |
| url | VARCHAR(8190) UNIQUE | URL 文字列 |
| redirectDestId | INTEGER FK → pages.id | リダイレクト先ページID |
| scraped | BOOLEAN | スクレイプ済みか |
| isTarget | BOOLEAN | ターゲットページか |
| isExternal | BOOLEAN | 外部ページか |
| status | INTEGER | HTTP ステータスコード |
| statusText | TEXT | |
| contentType | TEXT | |
| contentLength | INTEGER | |
| responseHeaders | TEXT (JSON) | |
| lang | TEXT | <html lang> |
| title | TEXT | <title> |
| description | TEXT | meta description |
| keywords | TEXT | meta keywords |
| noindex | BOOLEAN | robots noindex |
| nofollow | BOOLEAN | robots nofollow |
| noarchive | BOOLEAN | robots noarchive |
| canonical | TEXT | link canonical |
| alternate | TEXT | link alternate |
| og_type, og_title, og_site_name, og_description, og_url, og_image | TEXT | Open Graph |
| twitter_card | TEXT | Twitter Card |
| html | TEXT | HTML スナップショットの相対パス |
| isSkipped | BOOLEAN | スキップされたか |
| skipReason | TEXT | スキップ理由 |
| order | INTEGER | Natural URL Sort 順序 |
| カラム | 型 | 説明 |
|---|---|---|
| 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カラムは無い)
リダイレクトは独立テーブルではなく、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される場合の対策
| メソッド | リダイレクト | アンカー | リファラー |
|---|---|---|---|
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 |
| なし | 全件 |
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 を参照。
実装詳細は
crawler/utils/url/配下の各関数の JSDoc を参照。
スコープエントリは (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' で返却 |
| 画像抽出 | 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 を省略する。
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)
テスト用 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 の進捗表示に影響@d-zero/shared: サブパスエクスポートの追加・削除に注意。@d-zero/shared/parse-url形式でインポートすること