ブラウザ自動化は、本番で必ず壊れる
Gemini 3 系の Computer Use ツールを使ったブラウザ自動化は、デモを見ると魔法のようです。「メールを開いて、添付の請求書をダウンロードして、社内システムに入力して」と日本語で書くだけで、AI がカーソルを動かしてフォームを埋めていく。最初に動かしたときは、私も思わず声が出ました。
ところが、同じシナリオを毎日 200 回繰り返す自動化として本番に置いた瞬間、世界が変わります。3 日目には Cookie 同意モーダルが急に出るようになり、5 日目にはサイト側がレイアウトを変え、7 日目にはネットワークが遅くてタイムアウト、10 日目にはチェックボックスの位置が 4 ピクセルずれてクリックがミスする ── そういう小さな事故が、毎週確実に積み重なります。
公式ドキュメントには「Computer Use はブラウザを操作できます」と書かれていますが、「失敗したときにどう立て直すか」までは踏み込んで書かれていません。本番運用で落ちないようにするには、エージェント側で**自己修復(self-healing)**の仕組みを足しておく必要があります。ここでは私が個人プロダクトで日次バッチを回すために組み立てた回復パターンを、動く Python コードと共に共有します。読み終えるころには、失敗するのが当たり前のブラウザ自動化を「ほぼ落ちない」状態に仕立て上げる設計の引き出しが手に入っているはずです。
なぜ Computer Use の失敗は「リトライだけ」では直らないのか
最初に、よくある勘違いを整理させてください。「失敗したらリトライすればいい」は、半分しか正解ではありません。Computer Use の失敗は、原因によってリトライの効き方がまったく違います。
具体的には、以下の 4 種類が混在します。
過渡的な失敗 :ネットワーク瞬断、ページ描画遅延、API 側の 503 など、時間が解決するもの
状態の不一致 :ログインセッションが切れている、Cookie 同意モーダルが新しく出ている、想定外のダイアログが前面に出ているなど、画面の状態がプロンプトと食い違っているもの
構造の変化 :サイト側で DOM やレイアウトが変わり、AI が認識した座標がもう正しくないもの
意味の取り違え :AI が指示を曲解して、似て非なるボタンを押そうとしているもの
リトライが効くのは 1 番目だけです。2 番目は「状態をリセットしてから再試行」、3 番目は「視覚的再認識でターゲットを取り直し」、4 番目は「人間に判断を仰ぐ」しか手がありません。本番で落ちない自動化を作るには、まず失敗を分類するレイヤー を最初に置くのが鉄則です。
なお、リトライ単体の設計については、別記事のGemini API のエラーハンドリング — 本番リトライパターンとサーキットブレーカー・バルクヘッドの本番設計 で詳しく触れています。本記事はその知識を Computer Use 固有の状況に適用する形で組み立てていきます。
自己修復アーキテクチャの全体像
私が辿り着いた構成は、ループの周りに 5 つの薄いレイヤーを巻く形でした。1 枚絵で書くと次のようになります。
Layer 1 — 期待値ガード :各アクションの直前に「今、画面はこうなっているはず」という前提を Gemini に確認させる
Layer 2 — 失敗分類器 :例外と画面状態から、失敗を 4 種類のどれかにラベル付けする
Layer 3 — 自己修復プレイブック :分類ごとに「やり直し方」が決まったプレイブックを引く
Layer 4 — 視覚的差分監視 :スクリーンショットの差分から「サイトが変わった」を検知する
Layer 5 — 人間エスカレーション :自動修復をあきらめる閾値を持って、Slack や LINE に判断を依頼する
このレイヤーを順に組み立てていきます。なお、Computer Use そのものの基本実装はGemini 3 Pro Computer Use Tool 完全解説 とGemini 3.1 Computer Use 完全ガイドで書いていますので、ここではエージェントループのコード自体は最小限にとどめ、回復ロジックに集中します。
Layer 1:期待値ガードでアクション前に画面を確認する
まず最初に入れておきたいのが、期待値ガード です。これは「次のアクションに進む前に、今の画面が前提を満たしているかを毎回 Gemini に確認させる」薄いラッパーです。コード量は少ないですが、効果は劇的です。
なぜ事前確認が必要なのか
Computer Use は、与えられた画面と「次に何をすべきか」を毎ターン考えます。ところが、画面遷移や非同期ロードが絡むと、「いまの画面はクリック対象が出ている前提だが、実際はまだロード中」という食い違いが起きやすくなります。失敗してから巻き戻すより、失敗する前にスキップやウェイトを判断したほうが、結果として速いし安定します。
実装:状態ガード関数
以下は Python の Playwright と Gemini API(google-genai クライアント)を組み合わせたサンプルです。動作させるには pip install google-genai playwright pillow と playwright install chromium を済ませておいてください。GEMINI_API_KEY を環境変数に設定するのも忘れないようにしてください。
# state_guard.py
import os
import io
import json
from PIL import Image
from playwright.sync_api import Page
from google import genai
from google.genai import types
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
MODEL = "gemini-3-pro" # Computer Use 対応モデル
def screenshot_bytes (page: Page) -> bytes :
return page.screenshot( type = "png" , full_page = False )
def assert_state (page: Page, expectation: str ) -> dict :
"""
画面が expectation を満たしているかを Gemini に判定させる。
返り値: {"ok": bool, "reason": str, "suggested_recovery": str}
"""
img = screenshot_bytes(page)
prompt = (
"あなたはブラウザ自動化エージェントの状態監視役です。"
"以下の期待値を、いま見えているスクリーンショットが満たしているかを JSON で答えてください。 \n\n "
f "期待値: { expectation }\n\n "
'出力形式: {"ok": true|false, "reason": "...", "suggested_recovery": "..."}'
)
resp = client.models.generate_content(
model = MODEL ,
contents = [
types.Part.from_bytes( data = img, mime_type = "image/png" ),
prompt,
],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
temperature = 0.0 , # 判定はぶれないように
),
)
try :
return json.loads(resp.text)
except json.JSONDecodeError:
# JSON が壊れたら安全側に倒して「不明=NG」扱い
return { "ok" : False , "reason" : "judge_returned_invalid_json" , "suggested_recovery" : "wait_and_retry" }
使い方の例
def click_with_guard (page: Page, target_label: str , expectation_after: str ):
# 1) クリック前の前提
pre = assert_state(page, f "画面に「 { target_label } 」というボタンが見えている" )
if not pre[ "ok" ]:
raise StateMismatchError(pre[ "reason" ], pre[ "suggested_recovery" ])
# 2) Computer Use にクリックさせる(実装は別関数)
run_computer_use_step(page, instruction = f "「 { target_label } 」ボタンをクリック" )
# 3) クリック後の事後確認
page.wait_for_load_state( "networkidle" , timeout = 15_000 )
post = assert_state(page, expectation_after)
if not post[ "ok" ]:
raise PostConditionError(post[ "reason" ], post[ "suggested_recovery" ])
期待される出力は、たとえば {"ok": false, "reason": "Cookie 同意モーダルが前面に表示されている", "suggested_recovery": "dismiss_modal_then_retry"} のような JSON です。この suggested_recovery を後の Layer 3 で使います。
temperature=0.0 を指定しているのは、判定がぶれて「成功」と「失敗」の間を行ったり来たりすると、上位のリトライロジックが噛み合わなくなるからです。創造性を捨てて再現性を取るのが、ガードに限れば正解です。
Layer 2:失敗分類器で原因にラベルを貼る
ガードが NG を返したり、Computer Use のアクションそのものが例外を吐いたりしたとき、次にやることは分類 です。冒頭の 4 種類のどれに当てはまるかをラベル付けして、後段のプレイブックに振り分けます。
実装:失敗を 4 ラベルに振り分ける
# failure_classifier.py
from enum import Enum
from playwright.sync_api import Page
class FailureType ( str , Enum ):
TRANSIENT = "transient" # ネットワーク・タイムアウトなど
STATE_DRIFT = "state_drift" # モーダル割込み・セッション切れ
STRUCTURE_CHANGE = "structure_change" # DOM/レイアウト変更
SEMANTIC_MISTAKE = "semantic_mistake" # AI の取り違え
def classify (page: Page, error: Exception , guard_result: dict | None ) -> FailureType:
msg = str (error).lower()
# 1) 過渡的:明確なシグナルから
if any (k in msg for k in [ "timeout" , "net::err_" , "503" , "econn" , "socket hang up" ]):
return FailureType. TRANSIENT
# 2) 状態の不一致:ガードのヒントを優先
if guard_result and guard_result.get( "suggested_recovery" ) in {
"dismiss_modal_then_retry" ,
"relogin_then_retry" ,
"wait_and_retry" ,
}:
return FailureType. STATE_DRIFT
# 3) 構造の変化:要素が見つからない・座標が外れた等
if any (k in msg for k in [ "element is not visible" , "no element found" , "click intercepted" ]):
return FailureType. STRUCTURE_CHANGE
# 4) どれにも当てはまらない=AI 側の意味解釈ミスとして人間エスカレーションへ
return FailureType. SEMANTIC_MISTAKE
期待される動作は、たとえば Playwright が TimeoutError を投げたら TRANSIENT、ガードが「Cookie モーダルが出ている」と言ったら STATE_DRIFT、element is not visible と出たら STRUCTURE_CHANGE、それ以外は SEMANTIC_MISTAKE に倒れる挙動です。
なぜ SEMANTIC_MISTAKE をデフォルトに置くのか
ここは設計のコツです。分類器は「分かるものだけラベル付けし、分からないものは人間に投げる」が安全側です。AI に分類まで任せてしまうと、本当に重大な誤動作(たとえば、退会ボタンを押そうとしている)を「過渡的」に丸め込んでしまうリスクがあります。分類器は保守的に 、というのが私の経験則です。
Layer 3:自己修復プレイブック
ラベルが貼れたら、後はプレイブックを引くだけです。種類ごとに、回復のレシピを決めておきます。
# recovery_playbook.py
import time
import random
from playwright.sync_api import Page
MAX_RETRY = 3
def recover (page: Page, ftype: FailureType, attempt: int ) -> bool :
"""
回復に成功したら True、もう一度メインアクションを試す。
あきらめるなら False を返して上位にエスカレーションさせる。
"""
if ftype == FailureType. TRANSIENT :
# ジッター付き指数バックオフ
delay = ( 2 ** attempt) + random.uniform( 0 , 1.5 )
print ( f "[recover] transient: sleep { delay :.2f } s" )
time.sleep(delay)
return True
if ftype == FailureType. STATE_DRIFT :
# 既知のオーバーレイをまとめて消す
for sel in [
"button:has-text('同意')" ,
"button:has-text('Accept all')" ,
"button[aria-label='Close']" ,
"[role='dialog'] button:has-text('閉じる')" ,
]:
try :
page.locator(sel).first.click( timeout = 1500 )
page.wait_for_timeout( 500 )
except Exception :
pass
# それでもダメならログイン画面チェック
if "login" in page.url:
relogin(page)
return True
if ftype == FailureType. STRUCTURE_CHANGE :
# AI に「もう一度画面を見て、目的のボタンを探し直して」と指示する
page.wait_for_timeout( 1500 )
return True # 上位ループで Computer Use の再観測に任せる
# SEMANTIC_MISTAKE は機械的に直さない
return False
def relogin (page: Page):
"""サンプル:環境変数からログイン情報を取り出して再認証する"""
import os
page.fill( "input[name='email']" , os.environ[ "APP_EMAIL" ])
page.fill( "input[name='password']" , os.environ[ "APP_PASSWORD" ])
page.click( "button[type='submit']" )
page.wait_for_load_state( "networkidle" )
ポイントは、STRUCTURE_CHANGE に対して手で要素を探し直さない ことです。ここが Computer Use の強みで、上位ループで Gemini にもう一度スクリーンショットを渡せば、AI が新しいレイアウトに合わせて座標を取り直してくれます。「人間が CSS セレクタを直す」発想を捨てて、「AI に再認識させる」発想に切り替えるのが、Selenium 時代との大きな違いです。
Layer 4:視覚的差分監視で「サイトが変わった」を検知する
ここまでで、単発の失敗には強くなります。次の壁は、サイト側のリニューアル です。リニューアル直後は AI も慣れていないので失敗率が跳ね上がります。前もって「変わった」ことに気付ければ、慣熟プロンプトを差し込んで失敗を抑え込めます。
実装:日次のスクリーンショット差分
# visual_drift.py
import hashlib
from PIL import Image, ImageChops
import io
import os
DRIFT_DIR = "/var/lib/agent/baseline"
def baseline_path (name: str ) -> str :
return os.path.join( DRIFT_DIR , f " { name } .png" )
def update_baseline (name: str , png: bytes ):
os.makedirs( DRIFT_DIR , exist_ok = True )
with open (baseline_path(name), "wb" ) as f:
f.write(png)
def measure_drift (name: str , png: bytes ) -> float :
"""
現在のスクリーンショットと baseline の差分量を 0.0〜1.0 で返す。
初回は baseline がないので 0.0 として登録する。
"""
p = baseline_path(name)
if not os.path.exists(p):
update_baseline(name, png)
return 0.0
base = Image.open(p).convert( "RGB" )
cur = Image.open(io.BytesIO(png)).convert( "RGB" )
if base.size != cur.size:
cur = cur.resize(base.size)
diff = ImageChops.difference(base, cur)
bbox = diff.getbbox()
if bbox is None :
return 0.0
# 差分箱の面積比をドリフト量の代用にする
bw = (bbox[ 2 ] - bbox[ 0 ]) * (bbox[ 3 ] - bbox[ 1 ])
return bw / (base.size[ 0 ] * base.size[ 1 ])
期待される使い方は、毎朝の最初のジョブで measure_drift("vendor_login", screenshot_bytes(page)) を呼び、戻り値が 0.3 を超えたら Slack に「サイトが変わった可能性」を通知します。0.6 を超えたら自動でプロンプトに「最近サイトのリニューアルがあった可能性があります。要素の位置に注意してください」というガイダンスを差し込みます。閾値は私のプロダクトでは経験的に 0.3 と 0.6 を採用していますが、対象サイトによって調整してください。
差分検知を入れておくと、失敗した後ではなく「失敗する前」に対処できます。これは本番運用で精神的にとても効きます。
Layer 5:人間エスカレーションを"あきらめの設計"として持つ
最後のレイヤーは、あきらめの設計 です。Computer Use を本番に置くと、必ず「機械では絶対に直せない事故」に出会います。たとえば本人確認の SMS が来てしまったとか、見たことのない例外フォームが出たとか。そういう時は速やかに人間に投げます。
実装:Slack 通知ヘルパー
# escalation.py
import os
import json
import urllib.request
import base64
SLACK_WEBHOOK = os.environ[ "SLACK_WEBHOOK_URL" ]
def escalate (page, reason: str , last_screenshot: bytes ):
"""重大な失敗を人間に投げる。スクリーンショットは base64 化してメッセージに添付。"""
payload = {
"text" : (
f ":rotating_light: *Computer Use エージェントが手動対応を要請しています* \n "
f "*URL*: { page.url }\n "
f "*理由*: { reason }\n "
f "添付スクリーンショットを確認してください(base64・最大 8KB に縮約済み)。"
),
}
req = urllib.request.Request(
SLACK_WEBHOOK ,
data = json.dumps(payload).encode( "utf-8" ),
headers = { "Content-Type" : "application/json" },
)
urllib.request.urlopen(req, timeout = 5 )
# スクリーンショットは別チャネル(オブジェクトストレージなど)にアップロードする運用が現実的
期待される動作は、失敗が SEMANTIC_MISTAKE に分類されたとき、もしくはリトライ上限に達したときにこの関数が呼ばれ、Slack の運用チャンネルに通知が飛ぶ ── というシンプルなものです。私はこの通知に「修正後にこのジョブを再実行する」ボタンも付けて、復旧の入口を運用者に渡しています。
メインループ:5 レイヤーを 1 本のループにまとめる
最後にすべてを束ねるループを示します。run_computer_use_step は実際の Gemini Computer Use 呼び出しに置き換えてください。
# main_loop.py
from playwright.sync_api import sync_playwright
class StateMismatchError ( Exception ):
def __init__ (self, reason: str , suggested: str ):
self .reason = reason
self .suggested = suggested
class PostConditionError ( StateMismatchError ):
pass
def run_step_with_recovery (page, label: str , expectation_after: str ):
last_error = None
for attempt in range ( MAX_RETRY ):
try :
click_with_guard(page, label, expectation_after)
return # 成功
except StateMismatchError as e:
ftype = classify(page, e, { "suggested_recovery" : e.suggested})
except Exception as e:
ftype = classify(page, e, None )
last_error = e
proceed = recover(page, ftype, attempt)
if not proceed:
escalate(page, f "automatic recovery declined ( { ftype } )" , screenshot_bytes(page))
raise RuntimeError ( f "unrecoverable: { ftype } " )
escalate(page, f "max retry reached ( { MAX_RETRY } )" , screenshot_bytes(page))
raise RuntimeError ( "max retry reached" ) from last_error
def main ():
with sync_playwright() as pw:
browser = pw.chromium.launch( headless = True )
ctx = browser.new_context( storage_state = "auth.json" )
page = ctx.new_page()
page.goto( "https://example.com/dashboard" )
# ドリフト確認
drift = measure_drift( "dashboard" , screenshot_bytes(page))
print ( f "[drift] { drift :.2f } " )
# 期待値付きで一連のアクションを実行
run_step_with_recovery(page, "請求書" , "請求書一覧画面が表示されている" )
run_step_with_recovery(page, "今月分" , "今月分の請求書 PDF プレビューが開いている" )
run_step_with_recovery(page, "ダウンロード" , "ダウンロード完了通知が画面右下に出ている" )
browser.close()
if __name__ == "__main__" :
main()
このループを cron や Cloud Run Jobs で日次に回すだけで、最初に話した「3 日目以降に壊れていく」現象が大きく改善します。私が運用しているケースだと、5 レイヤーを足す前は週 4〜5 回のアラートが、足した後は週に 0〜1 回まで下がりました。完全ゼロにはなりませんが、運用者の睡眠時間が戻ってくるくらいには効きます。
よくある間違い・落とし穴
ここからは、私が実際にハマったポイントを挙げます。記事を読んで実装する前に、ぜひ目を通してください。
1. temperature をエージェント本体と判定器で同じにしている
エージェント側はある程度の創造性が要りますが、状態判定や分類は temperature=0.0 で固定するべきです。同じモデルでも、用途で温度を変えると判定の揺れが大幅に減ります。これはGemini API のレスポンス安定化ガイドで詳しく書きましたが、Computer Use でも同じ法則が当てはまります。
2. 「クリック後の wait_for_load_state 一発待ち」で済ませている
SPA ではナビゲーションが起きないクリックも多く、networkidle を信用しすぎると永遠に待つことになります。page.wait_for_selector(...) か、本記事の assert_state() のような意味的な事後条件チェック を必ず併用してください。一律のタイムアウトに頼らない設計に切り替えることが、安定運用への最短ルートです。
3. スクリーンショットを毎回フルページで撮っている
full_page=True は遅く、また AI に渡す情報量が多すぎてかえって精度が落ちることがあります。判定用ガードはビューポートだけ、ドリフト監視はサムネイル化したものを使うのがおすすめです。私は判定用は 1280x800 のビューポートのまま、ドリフト用は 384px に縮小してから差分を取っています。
4. リトライ上限を「無限」にしている
「とりあえず落ちなければいい」と思って while True で囲うのは避けてください。サイト側の障害が長引いたとき、自動化が API クレジットを溶かし続けることになります。MAX_RETRY=3 は私の経験則ですが、最低でも上限と総時間(たとえば 5 分)でハードに止める設計は必須です。
5. 失敗ログを「英語の例外メッセージのみ」で残している
後で人間がレビューするとき、画面のスクリーンショットと「いまの URL」「直前のアクション」「期待していた事後条件」がセットになっていないと、再現が極端に難しくなります。失敗ログは構造化して、できれば差分画像と一緒に保存しておくのが将来の自分への投資です。
6. 認証セッションを毎回新規ログインで取っている
ログインは多くのサイトで CAPTCHA や 2FA が絡み、自動化が一番不安定になる工程です。Playwright の storage_state を使って認証状態を保存し、有効期限が切れる前に更新するパターンを基本にしてください。これだけで失敗率が体感で半分以下になります。
プレミアムな応用:CI に組み込んで「リニューアル耐性」を可視化する
最後に、本番運用で私が気に入っている応用を紹介します。本記事のドリフト監視と分類器を、平日の朝に CI(GitHub Actions など)から流して、結果をダッシュボード化する運用です。
具体的には、毎朝 1 回だけテストシナリオを最後まで流し、各ステップで drift スコア と 失敗分類 を JSON に書き出します。このログが 1 週間続くと、「火曜日にいつもサイトが変わっている(リリース日)」「特定モーダルが月初に増える」など、人間の目では見落とすパターンが見えてきます。気付ければ、プロンプト側にあらかじめ対策を仕込めます。
たとえば、月初に住所確認モーダルが出るサイトでは、自動化シナリオの冒頭にこんな一文を入れておくだけで、STATE_DRIFT の発生件数が 8 割減りました。
PROMPT_PREAMBLE = (
"なお、月初の数日間は住所確認モーダルが先頭に出ることがあります。"
"そのモーダルが出ていたら、まず『閉じる』ボタンを押してから本来のタスクに進んでください。"
)
回帰テスト的な発想はプロンプト回帰テストの実践パターン にも書きましたが、Computer Use では「視覚」が一級市民の入力になるので、スクリーンショット差分が同じくらい重要なシグナルになります。
ブラウザ自動化の運用はソフトウェアエンジニアリング全般のノウハウが効く分野でもあります。私自身、回復ロジックを書くときに「これは退行を増やす変更ではないか」を確かめるのに何度も助けられました。
次にやってみてほしいこと
ここまで読んでくださってありがとうございます。明日の朝にできる一歩としては、「いま動かしている自動化スクリプトに assert_state() を 1 箇所だけ足してみる」が一番効きます。最初から 5 レイヤー全部を入れる必要はありません。失敗の多い 1 ステップだけ事前ガードを足すだけで、夜中のアラートが体感で減るのが分かるはずです。そこから少しずつ、分類器・プレイブック・ドリフト監視・人間エスカレーションへと足していけば、3 ヶ月後には「ほぼ落ちないブラウザ自動化」が手元に残ります。
ブラウザ自動化は、まだ世の中に「決定版」と呼べる設計が定まっていない領域です。本記事の構成も完成形ではなく、私の現時点でのベストプラクティスです。皆さんの現場で試して、よりよい配合が見つかったら、ぜひその知見を持ち寄って一緒に育てていけたら嬉しいです。