LLMでブラウザを完全自動操作！ClaudeやAzure OpenAI × Playwrightの実践ガイド

# この記事に関して

Claude新機能「Computer use」機能がリリースされて、AIがPCを自動操作できることに感動して同じようなWebAgentを作ってみました。

:::note warn
本アプリを実際に動かしたい場合は`ClaudeのAPIキー`、
または`Azure OpenAIのAPIキー`が必要です。
:::

- **主な技術**:
  - **言語**: Python3.13.0
  - **GUI**: Tkinter
  - **API**: claude-3-5-sonnet、Azure OpenAI GPT-4
  - **ブラウザ操作**: Playwright
  - **DOM解析**: BeautifulSoup



# 実際に作成したアプリイメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2742767/0808fa6a-62ec-1f38-bbc8-aecd50487cf2.png)

# 動きの説明
1.指示の入力欄に、実際に動いてほしい指示文を記載します

:::note warn
URLも含めないと動いてくれません
:::

- **指示文（例）**:
```
Qbookのページにアクセスして、検索ボタン押下して「テスト自動化」で検索してスクショを取ってください。
https://www.qbook.jp/
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2742767/cc9875a9-fa49-feef-bb96-ee1891d3bdae.png)

2.`チャンク処理を有効にする`のチェックを外して`Claudeで実行`をクリックする
※`チャンク処理を有効にする`の設定に関しては後ほどで解説します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2742767/ebce7627-235a-e721-2589-a070a125eea6.png)

3.指示文の内容から自動実行するコードを生成して、Playwrightで自動操作が始まります

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2742767/eaefe2a1-aa3f-857f-cc67-c4a79e125677.png)

1行目：対象のサイトにアクセスする
2行目：検索ボタンをクリックする
3行目：テスト自動化と入力して検索
4行目：スクリーンショットを取得する

    {"action": "NAVIGATE", "selector": "", "value": "https://www.qbook.jp/"},
    {"action": "CLICK", "selector": "#searchbutton", "value": ""},
    {"action": "TYPE", "selector": "#search", "value": "テスト自動化"},
    {"action": "SCREENSHOT", "selector": "", "value": "search_result"}

:::note info
`検索ボタン`と書いただけで虫眼鏡アイコンを認識して自動操作するのが凄いです！
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2742767/392cba4e-82bb-59e9-520b-83dee9542e25.png)

4.操作完了して、スクショを取得します。
※コード情報の１手順目だけ出てこない部分はご容赦を・・

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2742767/49d94c01-367e-7194-cd76-ed62c908eaf9.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2742767/086c0bbb-d062-3979-25f2-46763e9de469.png)


# 実際のソース
https://github.com/VAL-SAITO-TOSHIAKI/web_ai_agent_gui


# ロジック概要
1. **ユーザー入力**:
   - ユーザーがGUI（Tkinter）上で指示を入力
   - 必要に応じて、チャンク処理（DOM要素の分割）を有効または無効にできます

2. **モデル選択**:
   - Claude API または Azure OpenAI GPT-4 を選択
   - 選択したモデルに基づいて、ユーザーの指示からWeb操作のアクションを生成します

3. **DOM解析**:
   - 指定されたURLのHTMLを取得
   - BeautifulSoupを使ってDOMを解析し、必要に応じてチャンク分割します

4. **アクション実行**:
   - Playwrightを使用して、生成されたアクション（クリック、入力、ナビゲーションなど）を実行します

5. **GUIの更新**:
   - 各アクションの結果（成功/失敗）をGUIに反映します
   - 操作履歴を保存する機能も提供

# 特徴的なロジック
## 1. **選択したモデルに基づくWeb操作のアクション生成**

操作内容を "CLICK", "TYPE", "NAVIGATE", "SCREENSHOT" にして、JSON形式で出力するようにプロンプティングしています。

#### 主なコード:
```python
def generate_actions(self, model, user_instruction, dom_elements=None):
    if model == "Claude":
        return self.claude_client.generate_actions(user_instruction, dom_elements)
    elif model == "GPT4":
        return self.gpt4_client.generate_actions(user_instruction, dom_elements)
```

```python
def generate_actions(self, user_instruction: str, dom_elements: list = None) -> Tuple[List[dict], str]:
    dom_elements_str = json.dumps(dom_elements, ensure_ascii=False) if dom_elements else 'なし'

    prompt = f"""
    以下のユーザー指示に基づいて、必要なウェブ操作を純粋なJSON形式で出力してください。コードブロック（```json と ```）は使用しないでください。
    ユーザー指示: {user_instruction}
    DOM要素:
    {dom_elements_str}
    出力形式は直接オブジェクトのリストで、各オブジェクトは辞書型であることを保証してください。
    [
        {{"action": "ACTION_TYPE", "selector": "CSS_SELECTOR", "value": "VALUE"}}
    ]
    ACTION_TYPEは "CLICK", "TYPE", "NAVIGATE", "SCREENSHOT" のいずれかとします。
    """

    try:
        logging.debug("Claude APIへのリクエストを送信します。")
        response = self.client.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=5000,
            temperature=0.0,
            system="あなたはウェブ操作を生成するアシスタントです。",
            messages=[
                {
                    "role": "user",
                    "content": prompt
                }
            ]
        )
        =======コード省略======
        gpt_response = re.sub(r'```$', '', gpt_response).strip()
        actions = json.loads(gpt_response)
```

- **入力例**:
  - 指示: 「指定されたURLで検索ボックスに'Python'と入力し、検索ボタンをクリック」
  - URL: `https://example.com`

- **生成されるアクション（例）**:
  ```json
  [
    {"action": "TYPE", "selector": "#search-box", "value": "Python"},
    {"action": "CLICK", "selector": "#search-button"}
  ]
  ```

## 2. **チャンク処理（DOM要素の分割）**
URLの情報からDOM情報を取得する際に、トークン数が大きいとLLMのリクエストに失敗するためチャンク（分割）して送る処理にしています。

:::note warn
`claude-3-5-sonnet`はLong-Contextでも結構いけるのかチャンクしなくてもOKでした。
`Azure OpenAI GPT4o`はチャンクしないとだめでした。
:::

### 主なコード:
```python
    def chunk_dom_elements(self, dom_elements: list, max_chunk_size: int = 10000) -> list:
        chunks = []
        current_chunk = []
        current_size = 0

        for element in dom_elements:
            element_str = json.dumps(element, ensure_ascii=False)
            element_size = len(element_str)
            if current_size + element_size > max_chunk_size:
                if current_chunk:
                    chunks.append(current_chunk)
                    current_chunk = []
                    current_size = 0
            current_chunk.append(element)
            current_size += element_size

        if current_chunk:
            chunks.append(current_chunk)

        logging.info(f"dom_elementsを{len(chunks)}チャンクに分割しました。")
        return chunks
```

## 3. **Playwrightを使用したアクションの実行**

生成されたアクションをもとに、Playwrightを使用してブラウザ操作を実行します

### 流れ:
1. **ブラウザの起動**:
   - Chromiumブラウザを表示モード（またはヘッドレスモード）で起動

2. **アクションの処理**:
   - アクションリストを順に処理
   - `CLICK`, `TYPE`, `NAVIGATE`, `SCREENSHOT` など、種類に応じた操作を実行

3. **エラーハンドリング**:
   - タイムアウトや操作失敗時にエラーを記録
   - 成功/失敗をコールバックでGUIに通知

### 主なコード:
```python
def perform_actions(self, actions: List[dict], url: Optional[str] = None, callback: Optional[Callable] = None):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        page = browser.new_page()
        try:
            if url:
                logging.info(f"URLにアクセスします: {url}")
                page.goto(url, timeout=60000)
                page.wait_for_load_state('networkidle', timeout=60000)
                logging.info(f"URLにアクセスしました: {url}")
        
            for index, action in enumerate(actions, start=1):
                act = action.get("action")
                sel = action.get("selector")
                val = action.get("value")
                code_snippet = ""

                try:
                    if act == "NAVIGATE" and val:
                        logging.info(f"ナビゲートします: {val}")
                        page.goto(val, timeout=60000)
                        page.wait_for_load_state('networkidle', timeout=60000)
                        logging.info(f"ナビゲートしました: {val}")
                        time.sleep(2)
                        code_snippet = f'page.goto("{val}")'
                        if callback:
                            callback(index, act, sel, val, code_snippet, success=True)
                    elif act == "CLICK" and sel:
                        logging.info(f"クリックします: {sel}")
                        page.wait_for_selector(sel, timeout=60000)
                        page.click(sel, timeout=60000)
                        logging.info(f"クリックしました: {sel}")
                        time.sleep(1)
                        code_snippet = f'page.click("{sel}")'
                        if callback:
                            callback(index, act, sel, val, code_snippet, success=True)
                    elif act == "TYPE" and sel:
                        logging.info(f"タイプします: '{val}' into {sel}")
                        page.wait_for_selector(sel, timeout=60000)
                        page.fill(sel, val, timeout=60000)
                        logging.info(f"タイプしました: '{val}' into {sel}")
                        time.sleep(1)
                        code_snippet = f'page.fill("{sel}", "{val}")'
                        if callback:
                            callback(index, act, sel, val, code_snippet, success=True)
                    elif act == "SCREENSHOT":
                        path = val or "screenshot.png"
                        page.screenshot(path=path)
                        logging.info(f"スクリーンショットを保存しました: {path}")
                        code_snippet = f'page.screenshot(path="{path}")'
                        if callback:
                            callback(index, act, sel, val, code_snippet, success=True)
```

- **例**:
```
  1. 「#search-box」に「Python」を入力
  2. 「#search-button」をクリック
```

## 4. **GUIへの結果の反映**

各アクションの実行結果（成功/失敗）をリアルタイムでGUIに表示します。

### 流れ:
1. **キューを使用した非同期更新**:
   - アクションの実行結果をキューに送信
   - GUIスレッドがキューを監視し、結果を反映

2. **結果の色分け表示**:
   - 完了: 緑色で表示
   - 失敗: 赤色で表示し、エラーメッセージを追加

3. **履歴の保存**:
   - 操作結果をテキストファイルに保存

### 主なコード:
```python
def action_callback(self, index, action, selector, value, code_snippet='', success=True):
    if success:
        status = "完了"
        self.queue.put((index, status, code_snippet))
        logging.debug(f"アクション {index}: {action} - {status} - {code_snippet}")
    else:
        status = "失敗"
        self.queue.put((index, status, "不明なエラー"))
        logging.debug(f"アクション {index}: {action} - {status} - 不明なエラー") 


def process_queue(self):
    try:
        while True:
            message = self.queue.get_nowait()
            if isinstance(message, tuple):
                if message[0] == "add_actions":
                    actions = message[1]
                    self.add_actions_to_list(actions)
                elif len(message) == 3:
                    index, status, info = message
                    self.update_action_status(index, status, info)
```

- **表示例**:
  ```
  1. 完了: SELECTOR - #search-box に入力
  2. 失敗: SELECTOR - #nonexistent が見つかりません
  ```

# 最後に
- `claude-3-5-sonnet`はDOMの解析が優秀
    - `Azure OpenAI GPT-4`だと検索ボタンを見つけることできなかった
    - 存在しない属性名指定してきて、ハルシネーションしてた・・ 
- `claude-3-5-sonnet`のAPIは高い
    - 今回の操作だけでも20円ぐらい取られた
    - 自分がケチなだけ・・？（笑）
- チャンクのデータの詰め方を工夫すればもっと良くなるはず
- AIAgentのデータセット（WebSRC、MiniWob++）は結構あるぽいので、HTMLの構造解析するモデル（HTML-T5）を作ってみたいと思います
    - [LLM でブラウザを操作する WEB エージェントと周辺技術のざっくり紹介](https://tech.algomatic.jp/entry/survey/agent/web-navigation)
    - 頑張る。