TL;DR(要約)
- 課題: Playwright MCPでブラウザ操作すると、1操作で5,000〜10,000トークン消費
- 解決: HTTP API + CLIスクリプト方式で、トークン消費を90〜92%削減
- 結果: 複数回の操作を含むタスクで、トークン消費を10分の1に削減
-
アーキテクチャ:
Claude Code (Bash) → CLIスクリプト (curl) → HTTP APIサーバー → Playwright
対象読者
- Claude CodeでPlaywrightを使っている方
- Playwright MCPのトークン消費に悩んでいる方
- ブラウザ操作の自動化に取り組んでいる方
- HTTP APIベースの自動化に興味がある方
1. 背景:Playwright MCPの問題点
AIによるブラウザ自動化の流行
最近、AIにブラウザ操作を自動化させることが増えています。
なぜAIにブラウザ操作させるのか?
- テスト自動化: 「このページでログインしてエラーが出ないか確認して」と自然言語で指示できる
- スクレイピング: 画面構造が変わっても、AIが柔軟に対応できる
- RPA(業務自動化): 定型的なWebタスクを自動化
その代表的なツールがPlaywright MCPです。
Playwright MCPとは
Playwright MCPは、Claude CodeからPlaywrightブラウザ操作を直接呼び出せるMCP(Model Context Protocol)ツール群です。
主なツール:
-
mcp__playwright__browser_navigate- ページ遷移 -
mcp__playwright__browser_snapshot- アクセシビリティツリー取得(画面構造の詳細) -
mcp__playwright__browser_click- 要素クリック -
mcp__playwright__browser_type- テキスト入力 -
mcp__playwright__browser_take_screenshot- スクリーンショット撮影 -
mcp__playwright__browser_run_code- 任意のPlaywrightコード実行
直面した問題:大量トークン消費
しかし、この便利な仕組みには大きな問題があります。
トークンとは?
- LLM(大規模言語モデル)のAPI呼び出しで消費される単位
- 入力・出力の文字数に応じて消費される
- トークン消費 = コスト(例:Claude Sonnet 4.5は$3/100万トークン)
何が問題なのか?
AIがブラウザを操作する際、画面情報をすべてAIに送る必要があります。この情報量が膨大なため:
- トークンを大量に消費 → コストが爆増
- 繰り返し操作すると、さらにコストが積み上がる
- Claude Codeの会話が圧縮されやすくなる(文脈が失われる)
具体的に見てみましょう。
問題1:browser_snapshotの巨大な出力
browser_snapshotは、アクセシビリティツリー全体をMarkdown形式で返します。
実例(ログイン画面1回の出力):
heading "ログイン" [level=1]
textbox "メールアドレス"
textbox "パスワード"
checkbox "ログイン状態を保持する"
text "ログイン状態を保持する"
button "ログイン"
link "パスワードを忘れた方"
link "新規登録"
... (以下、ヘッダー、フッター、サイドバー等の全要素が続く)
問題点:
- 1回の出力で5,000〜10,000トークン消費
- 必要なのは入力欄とボタンだけなのに、ページ全体の情報が返ってくる
- Claude Codeの会話に全文が含まれるため、トークン消費が累積
問題2:何度も呼び出す必要がある
典型的なブラウザ操作フロー(ログイン → 2段階認証):
-
browser_navigate- ログイン画面にアクセス -
browser_snapshot- 画面構造を確認(5,000トークン) -
browser_type- メールアドレス入力 -
browser_type- パスワード入力 -
browser_click- ログインボタンクリック -
browser_snapshot- 2段階認証画面を確認(8,000トークン) -
browser_take_screenshot- スクリーンショット保存 - ... 続く
1つのタスクで20〜30回のMCPツール呼び出しが発生
各呼び出しの結果が会話履歴に残るため:
- 1タスクで50,000〜100,000トークン消費
- Claude Codeの会話が頻繁に圧縮される
- 途中で文脈が失われやすい
問題3:複数タスクでのスケーラビリティ
複数のブラウザ操作タスクを実行する場合:
必要な総トークン数:
- 10タスク × 80,000トークン = 800,000トークン
- Sonnet 4.5のコスト:$3/1Mトークン(入力) = $2.40
トークン消費以上に問題なのは:
- Claude Codeの会話が頻繁に圧縮される
- 途中で文脈が失われやすい
- エラー発生時の復旧が困難
なぜMCPは巨大な出力を返すのか
Playwright MCPの設計思想:
- 汎用性重視: あらゆるWebサイトに対応するため、ページ全体の情報を返す
- LLMの判断に委ねる: どの要素が重要かはLLMが判断する前提
- 詳細性優先: セレクタ取得のために全要素の情報が必要
これは対話的な操作には最適ですが、バッチ処理や繰り返し操作には不向きです。
求められた解決策
必要な要件:
- トークン消費を大幅に削減(目標:80%削減)
- Playwrightの操作能力は維持
- Claude Codeから呼び出せる
- 既存のワークフローと統合できる
→ HTTP API + CLIスクリプト方式の採用へ
2. CLIとMCPの比較:実測値
単一操作での比較
| 指標 | MCP版 | CLI版 | 削減率 |
|---|---|---|---|
| navigate + snapshot | 6,000〜11,000トークン | 200〜500トークン | 95%削減 |
| fill | 300〜500トークン | 50〜100トークン | 80%削減 |
| click | 500〜1,000トークン | 100〜200トークン | 80〜90%削減 |
典型的な操作フローでの比較
ログイン + 2段階認証の例:
| ステップ | MCP版 | CLI版 |
|---|---|---|
| 1. navigate + snapshot | 8,000トークン | 500トークン |
| 2. fill (email) | 500トークン | 100トークン |
| 3. fill (password) | 500トークン | 100トークン |
| 4. click + snapshot | 8,000トークン | 400トークン |
| 5. fill (2FA code) | 500トークン | 100トークン |
| 6. click + snapshot | 10,000トークン | 500トークン |
| 合計 | 27,500トークン | 1,700トークン |
| 削減率 | - | 93.8%削減 |
複数タスクでの比較
10タスク実行時の試算:
| 指標 | MCP版 | CLI版 | 削減効果 |
|---|---|---|---|
| 総トークン消費 | 500,000〜1,000,000 | 50,000〜100,000 | 90%削減 |
| コスト(Sonnet 4.5) | $1.50〜$3.00 | $0.15〜$0.30 | 90%削減 |
| 文脈維持 | 5〜10タスクでリセット必要 | 連続処理可能 | - |
削減の仕組み
MCP版(browser_snapshot):
- 出力: 5,000〜10,000トークン
- 内容: ページ全体のアクセシビリティツリー(すべての要素、階層構造、すべての属性)
CLI版(HTTP APIレスポンス):
{
"success": true,
"action": "navigate",
"url": "https://example.com/login",
"title": "ログイン",
"snapshot": "heading \"ログイン\" [level=1]\n textbox \"メールアドレス\"\n textbox \"パスワード\"\n button \"ログイン\"",
"tabs": { "count": 1, "current": 0, "newTabOpened": false }
}
- 出力: 200〜500トークン
- 削減率: 90〜95%
3. CLI版のアーキテクチャ設計
設計方針
MCP版の問題を解決するために、以下の方針で設計しました:
1. 出力を最小化
- 必要な情報だけを返す(MCPのような全要素情報ではなく、操作結果のみ)
- JSON形式で構造化(Claude Codeが解釈しやすい)
- トークン消費を10分の1以下に
2. ステートフルな設計
- ブラウザセッションを永続化(毎回起動せず、必要な期間だけ維持)
- 複数のセッションに対応(並行処理も可能)
- Context/Pageを保持(認証状態を維持)
3. シンプルなインターフェース
- CLIスクリプト経由で呼び出し(Claude CodeからBashツールで実行)
- HTTP API経由で操作(curlで簡単にアクセス)
- JSON入出力(解析が容易)
アーキテクチャ概要図
┌─────────────────────────────────────────────────┐
│ Claude Code │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Bash Tool │ │
│ │ │ │
│ │ bash screen-check.sh \ │ │
│ │ --session-id 1 \ │ │
│ │ --action navigate \ │ │
│ │ --url "https://..." │ │
│ └────────────┬────────────────────────────┘ │
└───────────────┼────────────────────────────────┘
│ シェルスクリプト呼び出し
▼
┌─────────────────────────────────────────────────┐
│ screen-check.sh (CLIシェルスクリプト) │
│ │
│ 1. 引数解析(--session-id, --action等) │
│ 2. JSONリクエスト構築 │
│ 3. curl でHTTP APIを呼び出し │
│ 4. JSONレスポンスを標準出力 │
└────────────┬────────────────────────────────────┘
│ HTTP POST request
│ Content-Type: application/json
▼
┌─────────────────────────────────────────────────┐
│ browser-server.ts (HTTP APIサーバー) │
│ Port: 8000 + sessionId │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ POST /action │ │
│ │ - action: "navigate|fill|click|..." │ │
│ │ - url, selector, value, ... │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Playwrightブラウザインスタンス │ │
│ │ - browser: Browser │ │
│ │ - context: BrowserContext │ │
│ │ - page: Page │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 1. リクエスト解析 │
│ 2. Playwright操作実行 │
│ 3. 結果をJSON形式で返す(最小限の情報) │
└────────────┬────────────────────────────────────┘
│ JSON Response
│ { "success": true, "url": "...", ... }
▼
Claude Codeに最小限の出力を返す
各コンポーネントの役割
1. browser-server.ts(HTTP APIサーバー)
役割:
- Playwrightブラウザインスタンスを保持
- HTTP APIエンドポイント(
POST /action)を公開 - 操作リクエストを受け取ってPlaywrightで実行
- 結果を最小限のJSONで返す
主要なエンドポイント:
GET /health - サーバー稼働確認
POST /action - Playwright操作実行
GET /shutdown - サーバー停止
2. CLIシェルスクリプト群
- start-browser.sh: ブラウザサーバー起動
- screen-check.sh: 画面操作(navigate, fill, click, snapshot)
- stop-browser.sh: ブラウザサーバー停止
CLI版とMCPの違い、そしてHTTP APIによる解決
MCPツールの特徴:完全対話型
MCP版では、Claude CodeがPlaywrightツールを対話的に呼び出して操作できます:
ユーザー: 「ログインページに移動してください」
Claude Code: mcp__playwright__browser_navigate({ url: "..." })
Claude Code: mcp__playwright__browser_snapshot() ← 画面構造を取得
Claude Code: 「ログインページが表示されました。次は何をしますか?」
ユーザー: 「ユーザーIDを入力してください」
Claude Code: mcp__playwright__browser_type({ ref: "textbox[name='ユーザーID']", text: "..." })
AIが画面を見て判断しながら操作を進められます。
従来のCLIツールの問題
一方、通常のCLIツール(Bashツール)では、すべての操作を事前に決める必要がありました:
# すべての手順を事前に決めておく必要がある
bash some-script.sh step1
bash some-script.sh step2
bash some-script.sh step3
画面の状態を確認せずに、盲目的に実行するしかありません。
HTTP APIによる解決:画面確認しながら処理
しかし、HTTP APIを工夫することで、この問題を解決できます:
# 1. ページ遷移 → レスポンスに画面構造(snapshot)を含める
bash screen-check.sh --session-id 1 --action navigate --url "https://..."
# → { "success": true, "snapshot": "heading \"ログイン\" [level=1]\n textbox \"ユーザーID\"...", ... }
# Claude Codeがsnapshotを見て「textboxがある」と判断
# 2. 入力 → 再びレスポンスに画面構造を含める
bash screen-check.sh --session-id 1 --action fill --selector 'textbox[name="ユーザーID"]' --value "..."
# → { "success": true, "snapshot": "...", ... }
# Claude Codeが画面を確認しながら次の操作を決める
ポイント:
- 各操作のレスポンスに画面構造(snapshot)を含める
- Claude Codeがそれを見て次の操作を判断できる
- 結果として、1ステップずつ画面を確認しながら処理できる
MCPほど対話的ではないものの、トークン消費を抑えつつ、画面確認しながら処理することが可能になりました。
4. 実装:HTTP API Server(browser-server.ts)
グローバルステート管理
let browser: Browser | null = null;
let context: BrowserContext | null = null;
let page: Page | null = null;
なぜグローバル変数を使うのか:
- ステートフル: ブラウザセッションを維持(認証状態、Cookie、履歴など)
- 高速: 毎回ブラウザを起動せず、既存インスタンスを再利用
- HTTP APIの制約: HTTP APIはステートレスなので、サーバー側でステートを保持する必要がある
主要な操作ハンドラー
navigate(ページ遷移)
if (req.action === 'navigate') {
context = await browser.newContext({
userAgent: 'Mozilla/5.0 ...',
viewport: { width: 1920, height: 1080 },
locale: 'ja-JP',
timezoneId: 'Asia/Tokyo'
});
page = await context.newPage();
// webdriverフラグを隠す(CAPTCHA対策)
await page.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined,
});
});
await page.goto(req.url, { waitUntil: 'networkidle' });
}
ポイント:
- 新しいContextを作成してクリーンな状態から開始
- webdriverフラグを隠して自動化検出を回避
fill(テキスト入力)
if (req.action === 'fill') {
// セレクタ解析: "textbox[name='メールアドレス']" → role="textbox", name="メールアドレス"
const match = req.selector.match(/^(\w+)\[name=['"](.+)['"]\]$/);
const [, role, name] = match;
const element = page.getByRole(role as any, { name });
await element.fill(req.value);
}
ポイント:
- role+name形式のセレクタ(Playwright推奨方式)
- 簡易的な正規表現パーサーで解析
click(要素クリック + 新規タブ検出)
if (req.action === 'click') {
const element = page.getByRole(role as any, { name });
// クリックと新規タブ検出を並行実行
const [newPage] = await Promise.all([
context.waitForEvent('page', { timeout: 3000 }).catch(() => null),
element.click()
]);
if (newPage) {
// 新規タブが開いた場合、そちらに切り替え
await newPage.waitForLoadState('networkidle');
page = newPage;
} else {
// 同じタブ内での画面遷移
if (req.waitForText) {
await page.getByText(req.waitForText).waitFor({ timeout: 10000 });
}
}
}
ポイント:
- 新規タブの自動検出と切り替え
-
waitForTextで特定のテキストが表示されるまで待機
snapshot(画面構造取得)
// 各アクション実行後に自動的にスナップショット取得
const snapshot = await page.locator('body').ariaSnapshot();
const currentUrl = page.url();
const title = await page.title();
const result: ActionResult = {
success: true,
action: req.action,
url: currentUrl,
title: title,
snapshot: snapshot,
tabs: {
count: pages.length,
current: currentIndex,
newTabOpened: false
}
};
ポイント:
-
ariaSnapshot()を使用(MCPと同じAPI) - 最小限の情報のみ返す(snapshotは常に取得するが、詳細は必要な時だけ)
ポート番号管理
const BASE_PORT = 8000;
const port = BASE_PORT + parseInt(sessionId, 10);
例:
-
--session-id 1→ Port 8001 -
--session-id 2→ Port 8002
メリット:
- 複数セッションの並行処理が可能
- ポート番号の衝突回避
5. 実装:CLIシェルスクリプト
start-browser.sh(ブラウザサーバー起動)
#!/bin/bash
set -e
# バックグラウンドでサーバー起動
nohup ./node_modules/.bin/tsx scripts/browser-server.ts start --session-id "$SESSION_ID" \
> "$TEMP_DIR/server-${SESSION_ID}.log" 2>&1 &
# ポート番号ファイルが作成されるまで待機(最大15秒)
for i in {1..30}; do
if [ -f "$PORT_FILE" ]; then
PORT=$(cat "$PORT_FILE")
if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
echo "{\"success\": true, \"sessionId\": \"$SESSION_ID\", \"port\": $PORT}"
exit 0
fi
fi
sleep 0.5
done
ポイント:
- べき等性(既に起動している場合は何もしない)
- ヘルスチェック待機(実際にHTTP APIが応答するまで待つ)
screen-check.sh(画面操作)
# JSONリクエスト構築
build_json() {
local json="{\"action\": \"$ACTION\""
if [ -n "$URL" ]; then
local escaped_url=$(printf '%s' "$URL" | sed 's/\\/\\\\/g; s/"/\\"/g')
json="$json, \"url\": \"$escaped_url\""
fi
# ... その他のフィールド
json="$json}"
echo "$json"
}
# HTTP POST リクエスト送信
RESPONSE=$(curl -s -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-d "$JSON_BODY" \
--max-time 60)
実行例:
# ページ遷移
bash screen-check.sh --session-id 1 --action navigate --url "https://example.com"
# テキスト入力
bash screen-check.sh --session-id 1 --action fill \
--selector 'textbox[name="メールアドレス"]' \
--value "user@example.com"
# ボタンクリック
bash screen-check.sh --session-id 1 --action click \
--selector 'button[name="ログイン"]' \
--wait-for-text "認証コード"
6. MCPでできてCLIでできないこと
主な制約と回避策
| 制約 | 影響度 | 回避策 |
|---|---|---|
| 自由なセレクタ取得 | 中 | よく使うパターンをサーバーに追加実装 |
| 任意のコード実行 | 中 | 必要な操作をアクションとして追加 |
| 完全対話型ではない | 低 | snapshotで1ステップずつ画面確認可能 |
| 複雑なUI操作 | 低 | 必要になったらサーバーに追加実装 |
| エラーメッセージ詳細度 | 低 | サーバーログを確認 |
1. 自由なセレクタ取得
MCP版: あらゆるセレクタ(CSS、XPath、nth()、チェーンセレクタ)を使用可能
CLI版: role[name="..."] 形式のみサポート
実際の影響:
- Playwrightの推奨方式は
getByRole(role, { name }) - 98%以上のケースで
role[name="..."]形式で対応可能 - 例外的なケースは、サーバー側に新しいアクションを追加
2. 任意のPlaywrightコード実行
MCP版: browser_run_codeで任意のコード実行可能
CLI版: 事前定義されたアクションのみ
回避策:
// browser-server.tsに新しいアクションを追加
if (req.action === 'clear_storage') {
await page.context().clearCookies();
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
}
3. 完全対話型ではない
MCP版: ユーザーと対話しながらリアルタイムにデバッグ可能
CLI版: snapshotで画面確認しながら処理できるが、MCPほど対話的ではない
実際の影響:
- HTTP APIがsnapshotを返すため、1ステップずつ画面を確認しながら処理可能
- ただし、MCPのように「ユーザーが途中で指示を変える」ような完全対話型ではない
- トークン消費を抑えつつ画面確認できるため、実用上は問題なし
重要な判断基準
- 完全対話型が必要: MCP版が最適(ユーザーが途中で指示を変える)
- トークン消費を抑えたい: CLI版が最適(snapshotで画面確認しながら処理)
- どちらも画面確認可能: HTTP APIのsnapshotレスポンスで状態を把握できる
7. まとめ
CLI版を選ぶべき状況
- 同じ操作を何度も実行する(繰り返し操作)
- 複数のタスクを連続実行する(バッチ処理)
- トークン消費を抑えたい(コスト削減)
- 文脈を保持したい(会話圧縮を避ける)
MCP版を選ぶべき状況
- 完全対話型が必要(ユーザーが途中で指示を変える)
- 複雑なデバッグ(対話的に試行錯誤が必要)
- 1回限りの操作(繰り返しなし)
- 複雑なセレクタが必要(role+name形式で対応できない)
最終的な判断
MCP版とCLI版は対立するものではなく、補完関係にある
- 完全対話型が必要: MCP版(ユーザーが途中で指示を変える)
- トークン消費を抑えたい: CLI版(snapshotで画面確認しながら処理)
どちらも画面を確認しながら処理できるが、トークン消費の違いで使い分けることで、柔軟性と低コストを両立できます。
学んだこと
1. トークン消費の最適化は重要
- MCPツールは便利だが、大量のトークンを消費する
- 繰り返し操作では、必要最小限の情報だけを返すAPIを設計すべき
- トークン消費を意識した設計が、効率的な自動化の鍵
2. ツールの適材適所
- 完全対話型が必要: MCP版が最適
- トークン消費を抑えたい: CLI版が最適
- どちらも画面確認しながら処理できる: snapshotで状態を把握可能
3. HTTP APIの汎用性
- HTTP APIは様々なツールから呼び出せる
- Claude Code以外にも応用可能
- 拡張性とメンテナンス性が高い
この記事が、Playwright MCPのトークン消費に悩んでいる方の参考になれば幸いです!
参考資料
- Playwright公式ドキュメント: https://playwright.dev/
- Claude Code公式ドキュメント: https://docs.anthropic.com/claude/docs/claude-code
- Model Context Protocol (MCP): https://modelcontextprotocol.io/