3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

VALTES Advent Calendar 2024

Day 8

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

Last updated at Posted at 2024-12-07

この記事に関して

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

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

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

実際に作成したアプリイメージ

image.png

動きの説明

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

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

  • 指示文(例):
Qbookのページにアクセスして、検索ボタン押下して「テスト自動化」で検索してスクショを取ってください。
https://www.qbook.jp/

image.png

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

image.png

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

image.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"}

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

image.png

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

image.png

image.png

実際のソース

ロジック概要

  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形式で出力するようにプロンプティングしています。

主なコード:

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)
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
  • 生成されるアクション(例):

    [
      {"action": "TYPE", "selector": "#search-box", "value": "Python"},
      {"action": "CLICK", "selector": "#search-button"}
    ]
    

2. チャンク処理(DOM要素の分割)

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

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

主なコード:

    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に通知

主なコード:

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. 履歴の保存:

    • 操作結果をテキストファイルに保存

主なコード:

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)を作ってみたいと思います
3
0
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
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?