はじめに — セレクタが壊れた朝
ある朝、いつものように動かしていた自動化スクリプトが止まっていた。
原因は、対象サイトのUIリニューアル。CSSのクラス名が変わっただけ。たったそれだけで、数十行のSeleniumスクリプトが全滅した。
こういう経験、みなさんもあるんじゃないかなと。
#submit-btn が #post-button に変わった。 .comment-input が .cm-editor .cm-content に変わった。セレクタベースの自動化は、サイト側の都合で簡単に壊れる。これは構造的な問題で、頑張ってメンテし続ける以外に方法がなかった。
...少なくとも、去年までは。
今、僕は 毎日5サイト以上、AIエージェントにブラウザ操作を任せている 。X(Twitter)への画像付き投稿、note.comの記事下書き、Qiitaへの技術記事投稿、YouTube Studioでの動画アップロード、Discordへのメッセージ送信。全部、LLMが Playwright を操作して自動でやっている。
セレクタは1行も書いていない。
なんでそんなことができるのか。答えは アクセシビリティツリー にあった。
なぜアクセシビリティツリーなのか
LLMにブラウザを操作させるアプローチは、大きく3つある。
1. Vision型(スクリーンショット)
- 画面のスクリーンショットをLLMに渡して「このボタンをクリックして」と指示する
- メリット: 見たまま判断できる
- デメリット: 画像の送受信が重い、座標指定が不安定、マルチモーダルモデルが必須
2. DOM型(HTML解析)
- ページのHTMLをそのまま渡す
- メリット: 構造がそのまま見える
- デメリット: HTMLは巨大。広告やトラッキングコードまで含むと数万行になる。LLMのコンテキストを圧迫する
3. アクセシビリティツリー型
- ブラウザが内部で保持している UIの意味構造 をテキストとして取得する
- メリット: 軽量、セマンティック、安定
- デメリット: カスタムUIでariaラベルがない要素は見えないことがある
結論から言うと、3番目が圧倒的に実用的だった。
アクセシビリティツリーというのは、もともとスクリーンリーダー(視覚障害者向けの読み上げソフト)のためにブラウザが構築している構造体のこと。ボタンには「ボタン: 送信」、テキスト入力欄には「テキストボックス: メールアドレス」といった 意味 が付与されている。
これがLLMとの相性が抜群にいい。
なぜかというと、LLMはテキストを理解するのが得意だから。「button "送信"」と書いてあれば、それが送信ボタンだと一発でわかる。CSSクラス名が .btn-primary-v2-new から .submit-action-btn に変わっても、アクセシビリティツリー上では同じ button "送信" のまま。
UIの見た目が変わっても、意味が変わらなければ壊れない。
これが、セレクタベースの自動化との決定的な違いになる。
DevToolsで確認してみたい場合は、Chromeで F12 → Elements パネルの横にある Accessibility タブを開いてみてほしい。各要素に role と name が付与されているのが確認できるかと。
アーキテクチャ — snapshot → 判断 → act のループ
全体の流れはシンプルで、3ステップのループになっている。
┌─────────────────────────────────────────┐
│ 1. snapshot — 画面の状態を取得 │
│ 2. LLM判断 — 次に何をするか決める │
│ 3. act — 操作を実行する │
│ → 1に戻る(目的達成まで繰り返し) │
└─────────────────────────────────────────┘
snapshot — 画面の状態をテキスト化する
snapshot は、現在のページのアクセシビリティツリーをテキスト形式で取得する操作。出力はこんなイメージになる。
[ref=e1] navigation "メインメニュー"
[ref=e2] link "ホーム"
[ref=e3] link "記事一覧"
[ref=e4] main
[ref=e5] heading "新規記事を作成" level=1
[ref=e6] textbox "タイトル" value=""
[ref=e7] textbox "本文" value=""
[ref=e8] button "下書き保存"
[ref=e9] button "公開する"
各要素に ref (参照ID)が振られていて、これを使って操作対象を指定する。HTMLのクラス名やIDは一切出てこない。 「タイトル」というテキストボックスと「下書き保存」というボタンがある 、という意味構造だけが渡される。
LLM判断 — 何をするか決める
このsnapshot をLLMに渡すと、LLMは目的に応じて次のアクションを判断する。
目的: 「タイトルに"テスト記事"と入力して下書き保存する」
snapshot: (上記のテキスト)
→ LLMの判断:
1. ref=e6 のテキストボックスに "テスト記事" と入力
2. ref=e8 の「下書き保存」ボタンをクリック
これが、人間がブラウザを操作する時のプロセスとほぼ同じなのが面白い。画面を見て、目的の要素を見つけて、操作する。LLMも同じことをテキストベースでやっている。
act — 操作を実行する
act は、具体的なブラウザ操作を実行するステップ。主な操作は以下の通り。
// クリック
{ kind: "click", ref: "e8" }
// テキスト入力
{ kind: "type", ref: "e6", text: "テスト記事" }
// キー入力
{ kind: "press", ref: "e6", key: "Enter" }
// ファイルアップロード
{ kind: "upload", ref: "e10", paths: ["/path/to/image.png"] }
// JavaScript実行(フォールバック用)
{ kind: "evaluate", fn: "() => document.querySelector('.btn').click()" }
複数タブの管理
実運用では複数のサイトを同時に操作することがある。このとき重要なのが targetId という概念。
// タブを開くとtargetIdが返る
const tab1 = browser.open({ targetUrl: "https://qiita.com/drafts/new" })
// → targetId: "ABC123"
const tab2 = browser.open({ targetUrl: "https://note.com/notes/new" })
// → targetId: "DEF456"
// 以降の操作では必ずtargetIdを指定する
browser.snapshot({ targetId: "ABC123" }) // Qiitaタブのsnapshot
browser.act({ targetId: "DEF456", ... }) // noteタブで操作
targetId を省略すると、別のタブに対して操作してしまう。これは地味だけど、実運用で何度もハマったポイント。
実装 — 実際に動くコードを見てみる
Playwright MCP のセットアップ
Playwright MCP は、MicrosoftがPlaywrightの公式 MCP(Model Context Protocol = AIエージェントとツールを繋ぐ標準規格) サーバーとして提供しているもの。2025年3月にリリースされて以来、急速に普及している。
# インストール
npm install @playwright/mcp
# MCPサーバー起動
npx @playwright/mcp --browser chromium
Claude Desktop や VS Code Copilot からMCPサーバーとして接続して使うのが標準的な使い方になる。
設定ファイル( mcp.json )の例:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp", "--browser", "chromium"],
"env": {
"DISPLAY": ":0"
}
}
}
}
snapshot の取得
MCPツール browser_snapshot を呼ぶと、現在のページのアクセシビリティツリーが返ってくる。
// MCP経由でsnapshot取得
const result = await mcpClient.callTool("browser_snapshot", {});
console.log(result.content[0].text);
出力例:
- navigation "グローバルナビ"
- link "Qiita" [ref=s1]
- link "検索" [ref=s2]
- main
- textbox "タイトル" [ref=s3]
- group "タグ"
- textbox "タグを入力" [ref=s4]
- group "本文"
- textbox "本文" [ref=s5]
- button "下書き保存" [ref=s6]
この出力を見て、 何がどこにあるか をLLMが理解する。人間がDevToolsを見るのと同じことを、LLMがテキストで行っている。
テキスト入力の実装
// タイトル入力
await mcpClient.callTool("browser_click", { element: "タイトル入力欄", ref: "s3" });
await mcpClient.callTool("browser_type", {
element: "タイトル入力欄",
ref: "s3",
text: "Playwright × アクセシビリティツリーの話"
});
ファイルアップロード
画像付きの投稿をする場合、ファイルアップロードが必要になる。従来はファイル選択ダイアログを操作する必要があったが、Playwright では upload アクションで直接ファイルパスを渡せる。
// inputタグの file type に直接パスを渡す
await mcpClient.callTool("browser_upload", {
ref: "s10",
paths: ["/Users/me/images/thumbnail.png"]
});
ファイル選択ダイアログが開かない。これが地味にありがたい。
CodeMirror対応 — ClipboardEvent paste
Qiitaやnote.com のエディタは CodeMirror 6 を使っている。CodeMirrorは独自のDOM構造( div.cm-content )を持っていて、通常の type アクションだとうまく入力できないことがある。
そんな時のフォールバックが、ClipboardEventを使ったペースト。
await mcpClient.callTool("browser_evaluate", {
fn: `() => {
const cm = document.querySelector('.cm-content');
if (!cm) return 'not found';
cm.focus();
// 既存内容をクリア
document.execCommand('selectAll');
document.execCommand('delete');
// ClipboardEventでペースト
const dt = new DataTransfer();
dt.setData('text/plain', 'ここに本文のMarkdownが入る');
const evt = new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true
});
cm.dispatchEvent(evt);
return 'ok';
}`
});
これは execCommand と ClipboardEvent を組み合わせた方法で、CodeMirror 6 の内部的なイベントハンドラが正しくペーストを処理してくれる。 textarea.value を直接書き換える方法はCodeMirror 6では使えない(DOMの実体が div なので)。
ここに辿り着くまでに、正直3時間くらい溶かした。
失敗から学んだ5つのこと
毎日使っていると、当然ハマることもある。ここからは僕が実際にやらかした失敗と、その対処法を書いていく。
1. SPA遷移後のsnapshot取得タイミング
SPAは画面遷移してもURLが変わらなかったり、DOMの更新が非同期だったりする。 navigate した直後に snapshot を取ると、まだ前のページの状態が返ってくることがある。
対処法: navigate後に少し待つか、期待する要素が出現するまでsnaphotを繰り返す。
// 悪い例
await navigate("https://example.com/dashboard");
const snap = await snapshot(); // まだ前のページかも
// 良い例
await navigate("https://example.com/dashboard");
// 期待する要素(例: heading "ダッシュボード")が出るまで待つ
let snap;
for (let i = 0; i < 5; i++) {
snap = await snapshot();
if (snap.includes('ダッシュボード')) break;
await sleep(1000);
}
2. オートサジェスト / ドロップダウンの罠
Qiitaのタグ入力欄がいい例で、文字を入力するとオートサジェスト(候補一覧)が表示される。ここで Enter を押すと、 自分が入力した文字ではなく、サジェストの先頭候補 が確定されてしまう。
「JavaScript」と入力したのに「JavaScriptCore」が登録された...みたいなことが起きる。
対処法: Escape でサジェストを閉じてから Enter で確定する。
// タグ入力
await type({ ref: "tagInput", text: "JavaScript" });
await press({ ref: "tagInput", key: "Escape" }); // サジェストを閉じる
await press({ ref: "tagInput", key: "Enter" }); // 確定
この「Escape → Enter」のパターンは、サジェスト付き入力欄では鉄板になる。
3. ビューポート外ボタン
X(Twitter)の投稿画面で「ポストを予約」ボタンが画面の下の方にあり、ビューポート外に隠れていることがあった。通常の click アクションは、ビューポート内の要素しか操作できないことがある。
対処法: JavaScript の evaluate でスクロール + クリック。
await evaluate(`() => {
const btn = document.querySelector('[aria-label="ポストを予約"]');
if (btn) {
btn.scrollIntoView();
btn.click();
return 'clicked';
}
return 'not found';
}`);
scrollIntoView() で要素を画面内に持ってきてからクリックする。これはフォールバックとして覚えておくと便利。
4. Cookie / セッション管理
LLMにブラウザを渡すということは、そのブラウザにログイン済みのセッションも渡すということ。ここはセキュリティ上、かなり慎重に設計する必要がある。
僕の運用ルール:
- 専用のブラウザプロファイルを使う(普段使いのChromeとは分離)
- 操作対象のサイトだけにログインしておく
- 金融系・決済系のサイトには絶対にアクセスしない
- 操作ログは全て記録する
「便利だから」と何でもかんでも自動化するのはやめた方がいい。 操作範囲を明示的に制限する のが大事。
5. エラーリカバリの設計
自動化は必ず失敗する。ネットワークの問題、サイト側のメンテナンス、予期しないポップアップ...
対処法: 失敗したらスクリーンショットを撮って状態を記録し、リトライまたは人間にエスカレーションする。
try {
await act({ kind: "click", ref: "s6" }); // 下書き保存
} catch (e) {
// スクリーンショットで状態を記録
const screenshot = await browser.screenshot({ fullPage: true });
// 人間に通知(Discord等)
await notify(`ブラウザ操作失敗: ${e.message}`, screenshot);
// Markdownファイルは保存済みなので手動投稿可能
}
重要なのは、 途中の成果物をファイルに保存しておく こと。記事の本文がファイルにあれば、ブラウザ操作が失敗しても手動でコピペすればいい。「全部メモリ上にある」状態で失敗すると、何もかも失われる。
まとめ — 明日から試せる最小構成
ここまで読んで「ちょっと試してみたいかも」と思ってくれた方へ。最小構成はこれだけ。
# Playwright MCPサーバーを起動
npx @playwright/mcp --browser chromium
あとはClaude DesktopやVS Code Copilot等のMCP対応クライアントから接続するだけで、自然言語でブラウザ操作を試せる。
向いているユースケース
- 定型的なWebフォーム入力(管理画面操作、データ登録)
- 複数サイトへの同時投稿(SNS、ブログ等)
- Webアプリのスモークテスト
- スクレイピング(ページ構造が変わっても壊れにくい)
向いていないユースケース
- ミリ秒単位の高速操作が必要な場合(直接APIを叩くべき)
- CAPTCHAや2段階認証が頻繁に発生するサイト
- 金融取引や決済(セキュリティリスクが高すぎる)
利用規約の確認
自動操作を行う前に、 対象サイトの利用規約 を確認すること。自動化が明示的に禁止されているサービスもある。API が提供されている場合は、ブラウザ操作よりも API を優先した方がいい。
セキュリティ上の注意
- 専用ブラウザプロファイルを使い、普段使いと分離する
- 操作対象のサイトを明示的にホワイトリスト化する
- 操作ログを必ず記録する
- 金融・医療・個人情報を扱うサイトは対象外にする
セレクタが壊れるたびにメンテしていたあの日々を思い出すと、アクセシビリティツリーベースのアプローチは本当に世界が変わった感覚がある。
とはいえ、万能ではない。aria属性がちゃんと付いていないサイトでは苦労するし、複雑なドラッグ&ドロップ操作にはまだ対応しきれないこともある。
でも、 「毎日のブラウザ作業をAIに任せる」 という体験は、一度やると戻れなくなる。
最初の一歩は意外と小さい。まずは npx @playwright/mcp を起動して、何か1つ、普段やっている操作を自然言語で指示してみてほしい。きっと「え、これでいけるの?」ってなるかなと。