AIエージェントニュース編集部

Stagehandでブラウザ自動化:A11yツリーでトークン浪費と操作失敗を防ぐ

ブラウザ自動化にLLMを組み込むと、ページ全体のHTMLやスクリーンショットを毎ステップ送る設計になりがちです。その結果、トークン消費とレイテンシが増え、UIの小さな変更でも操作が失敗します。この記事では、オープンソースのブラウザ自動化フレームワーク Stagehand を使い、Accessibility Tree(A11yツリー)を軸にこの問題を抑える方法を整理します。act・extract・observe の使い分けから、壊れにくいセレクタの特定、本番運用での再利用とフォールバックまでを扱います。

生DOMや画像渡しの限界:トークン消費の跳ね上がりとレイテンシの壁

生HTMLをそのままLLMに渡す方式は、実装が簡単な反面、現代のWebページでは非効率です。SPAのDOMには、インラインスタイル、データ属性、ビルドで生成されたクラス名、トラッキング用のスクリプトタグが大量に含まれます。操作対象の判断にほとんど寄与しない情報で、コンテキストが埋まります。ページによっては、HTMLだけで数万トークン規模になります。

スクリーンショットを渡す方式にも制約があります。画像の入力トークンはモデルごとの課金ルールで決まるため、解像度が上がるほどコストが膨らみます。さらに、座標指定のクリックは、ビューポート幅やフォントの違いで簡単にずれます。画像はテキストより推論も重くなりがちで、1ステップごとの待ち時間が積み上がります。

もう1つの問題は再現性です。LLMにDOMを丸ごと見せると、どの要素を選ぶかの判断がプロンプトとページ構造の組み合わせに左右されます。ABテストで1つラッパー要素が増えただけで、選択結果が変わることがあります。入力を小さく、意味のある情報に絞る設計が必要です。

Stagehandのアーキテクチャ:Playwright上でA11yツリーを活用する仕組み

Stagehand は Browserbase が公開している、ブラウザ操作用のSDKです。公式ドキュメントでは、自然言語で記述する act・extract・observe と、通常のコードによる制御を同じワークフローで混在させる設計が特徴として挙げられています。決まった手順はコードで書き、変化しやすい部分だけをLLMに任せる、という分担です。

中核にあるのが Accessibility Tree です。ブラウザはDOMから、スクリーンリーダー向けにロール(button、link、textbox など)、名前、状態だけを持つツリーを組み立てます。LLMに渡す情報をこのツリーに絞ると、スタイル関連の属性やレイアウト用のdivが落ちます。ボタンのラベルや入力欄の名前のように、操作の意味を決める情報が残ります。これが トークン削減 の主な理由です。削減幅はページ構造しだいなので、自分の対象ページで計測してください。

実行基盤について、従来の Stagehand は Playwright の上に構築され、page オブジェクトに act などを追加する形でした。新しいメジャーバージョンでは、ブラウザとの通信をCDP(Chrome DevTools Protocol)ベースに寄せる変更が入っています。以降のコードは Playwright の page を使う従来系の書き方です。新しい系統では初期化やページ取得のAPIが異なるため、導入するバージョンの公式ドキュメントでシグネチャを確認してください。考え方はどちらも同じで、A11yツリーを入力にして、解決した要素に対してブラウザ操作を実行します。

基本実装:act・extract・observe を使い分けた確実な要素操作

3つのAPIは役割が違います。act は1回の操作(クリック、入力、選択)を実行します。extract はスキーマに沿って構造化データを取り出します。observe は実行せず、操作候補の要素と推奨アクションを返します。

import { Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod";

const stagehand = new Stagehand({
  env: "LOCAL",
  modelName: process.env.STAGEHAND_MODEL, // 利用するモデル名を環境変数で指定
});
await stagehand.init();
const page = stagehand.page;

await page.goto("https://example.com/products");

// 1操作 = 1 act。複数の指示を詰め込まない
await page.act("検索ボックスに「keyboard」と入力する");
await page.act("検索ボタンをクリックする");

// 構造化して取り出す
const result = await page.extract({
  instruction: "検索結果の先頭5件の商品名と価格を取得する",
  schema: z.object({
    items: z.array(z.object({
      name: z.string(),
      price: z.string(),
    })),
  }),
});
console.log(result.items);

ポイントは、act を1アクション1呼び出しに保つことです。「検索して最初の結果を開く」のような複合指示は、途中で失敗したときに原因の切り分けが難しくなります。小さく分けておくと、どのステップで失敗したかをログで追えます。

extract では、Zodスキーマで型を強制してください。出力形式のブレが抑えられ、後続処理の型安全性も得られます。抽出対象が多いページでは、指示文の範囲も具体的に絞ります。「ページ内の全商品」ではなく「先頭5件」のように件数を限定します。

トークン消費を抑えつつ壊れにくいセレクタを特定する実践テクニック

効果が大きいのは、observe で一度解決した結果を act に渡すパターンです。observe は要素の XPath などのセレクタ、説明、メソッドを含む候補を返します。それをそのまま act に渡せば、act 側で再度LLM推論を走らせずに実行できる仕組みです。

const [target] = await page.observe("ログインボタンを探す");
console.log(target); // selector, description, method などを確認
await page.act(target); // 解決済みのアクションを実行

LLMを呼ぶ回数を減らせるので、レイテンシとコストの両方に効きます。さらに、observe の結果を保存しておくと、同じ画面を繰り返し操作するジョブで再利用できます。最近のバージョンにはキャッシュ関連のオプションもあります。対応状況は導入バージョンの公式ドキュメントで確認してください。

壊れにくさの面では、プロンプトの書き方が効きます。「3番目の青いボタン」のような見た目や位置の指定は避けます。A11yツリーに現れるロールと名前で指定します。たとえば「名前が『注文を確定』のボタン」と書きます。ラベルが変わらない限り、レイアウト変更に影響されません。

トークンをさらに絞るには、範囲を限定します。extract や observe には対象領域を指定するオプションがあります(バージョンにより名称が異なる可能性があります)。ページ全体のツリーではなく、main 要素や商品一覧コンテナの部分木だけを渡すイメージです。ヘッダーやフッター、サイドバーが毎回入力に含まれる無駄を省けます。

機密値の扱いにも注意が必要です。パスワードのような値は、プロンプト文字列に直接埋め込まないでください。act の variables 機能を使うと、%password% のようなプレースホルダーで指示し、実際の値はローカルで差し込めます。LLMプロバイダに値を送らずに済みます。

本番運用の勘所:セッションの再利用・タイムアウト設計・失敗時のフォールバック

セッションの再利用から始めます。ログイン済みの状態を毎回作り直すと、遅いうえに不安定です。ローカル実行ならブラウザのユーザーデータディレクトリを固定します。Browserbase のクラウド実行ならセッションIDで再接続する方法があります。いずれもオプション名はバージョンで変わるため、導入時に確認してください。認証が必要なジョブは、ログインを別ジョブに切り出し、その後のジョブは保存済みセッションから始める構成が扱いやすいです。

タイムアウト設計では、ネットワーク待ちと推論待ちを分けて考えます。ページ遷移は Playwright 側の待機で制御し、LLMが絡む act や extract には個別の上限を設けます。Stagehand には DOM の落ち着きを待つ設定(domSettleTimeoutMs など)もあります。無限ローダーや常時更新される広告が多いページでは、この値を短くしないと、待機だけで時間を使います。ジョブ全体にも上限を置き、ハングしたセッションを確実に破棄してください。

失敗時のフォールバックは三段構えが現実的です。

  1. act が失敗したら、同じ指示で observe を呼び、候補が返るか確認してリトライする
  2. それでも失敗する箇所は、Playwright の page.locator と getByRole による決め打ちの操作に切り替える
  3. 最終的に失敗した場合は、そのときのA11yツリー、URL、スクリーンショットを保存して終了する
async function safeAct(page, instruction: string, fallback: () => Promise<void>) {
  try {
    await page.act(instruction);
  } catch {
    const candidates = await page.observe(instruction);
    if (candidates.length > 0) {
      await page.act(candidates[0]);
    } else {
      await fallback(); // 例: page.getByRole("button", { name: "送信" }).click()
    }
  }
}

安定して動く箇所をPlaywrightのコードに「固める」運用も有効です。LLMに任せた操作のうち、ログから毎回同じ要素に解決されると分かったものは、ロールと名前による固定のロケーターに置き換えます。LLM呼び出しが減り、性能も再現性も上がります。Stagehandは探索と初期実装に使い、安定した部分から決定的なコードへ移す、という進め方を基本にしてください。

関連記事