1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Playwright × アクセシビリティツリーでAIにブラウザ操作を任せる設計と実装

1
Posted at

はじめに — セレクタが壊れた朝

ある朝、いつものように動かしていた自動化スクリプトが止まっていた。

原因は、対象サイトの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つ、普段やっている操作を自然言語で指示してみてほしい。きっと「え、これでいけるの?」ってなるかなと。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?