はじめに
こんにちは、ジパンクでソフトウェアエンジニアをしている村崎です。
自然言語でブラウザ操作を指示できるStagehandというフレームワークをご存知でしょうか?
社内でブラウザ自動操作を検証するにあたり、Stagehandの仕組みを参考にできないかと調査しました。 本記事では、Stagehandの特徴と仕組みを紹介し、調査で得られた以下の知見を共有します。
- LLMにWebページ情報を伝える方法
- 自然言語の内容をブラウザ操作に変換する方法
- ブラウザ操作の結果をLLMが評価する方法
Stagehandとは
StagehandはBrowserbaseが開発しているAIを搭載したWebブラウジング自動化フレームワークです。Playwrightをベースにしており、自然言語によるWeb操作を可能にするAPIが提供されています。Playwrightと互換性があるので状況に応じてAPIを使い分けるといったことが可能です。
以下はPlaywrightとStagehandの比較画像です。左のStagehandの例が使用感をイメージしやすいです。
https://docs.stagehand.dev/get_started/walkthrough
Stagehandには主要なAPIとして以下があります。
act: Webページ上で特定のアクションを実行(例:クリック、入力)extract: 定義したzodスキーマに従ってWebページから構造化されたデータを抽出observe: Webページ上の要素を探索し、それらに対する操作の候補(セレクタ、説明、実行すべきアクションなど)を取得
これらのAPIは我々が普段使う自然言語で指示できるのが特徴です。 PlaywrightやPuppeteerなどのブラウザ操作フレームワークではXPathやCSSなどのセレクタを記述する必要がありましたが、Stagehandでは自然言語で指示できるので気軽かつUIの変更にも強いことが特徴です。
本記事ではactを中心に解説します。
動かしてみる
まずは動かしてみます。公式ドキュメントのクイックスタート1でもいいですが、ここでは0からセットアップしてみます。
環境
- Node.js v23.6.0
- pnpm v9.14.4
pnpm init pnpm add @browserbasehq/stagehand touch .env touch index.ts
今回はAnthropicのAPIキーを使用します。.envにAPIキーを記述しておきます。
# .env ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY
例として事前に簡易的なGoogleフォームを作成し、Stagehandに入力してもらうことにします。
入力する値variablesを指定しない場合はAIがそれっぽい値を入力してくれます。
// index.ts import { Stagehand } from "@browserbasehq/stagehand"; async function run() { // Stagehandの初期化 const stagehand = new Stagehand({ env: "LOCAL", debugDom: true, headless: false, modelName: "claude-3-5-sonnet-20241022", modelClientOptions: { apiKey: process.env.ANTHROPIC_API_KEY, }, }); await stagehand.init(); const { page } = stagehand; // フォームのページに遷移 await page.goto( "https://docs.google.com/forms/d/e/1FAIpQLSfU83aQYOvdoqhGu9J8EJYTtMNtpbO7S6-xPYfib3N0RlN61g/viewform" ); // フォームの入力 await page.act({ action: "filling all form fields", variables: { name: "John Doe", email: "john.doe@example.com", age: "26", gender: "male", }, }); await stagehand.close(); } run();
実行します。
node --env-file=.env index.ts
ブラウザが立ち上がり、フォームに入力している様子が確認できました。 inputの入力だけでなく、ラジオボタンのようなUIにも対応しています。
サイズの都合上2倍速にしています
処理の流れ
Stagehandの処理の流れは大きく7ステップに分けることができ、以下の通りです。
sequenceDiagram
participant User as ユーザー
participant Stagehand as Stagehand
participant Browser as ブラウザ(Playwright)
participant LLM as LLM
User->>Stagehand: 自然言語による指示
activate Stagehand
Stagehand->>Browser: 1. DOMの収集と整形
activate Browser
Browser-->>Stagehand: 結果
deactivate Browser
Stagehand->>Stagehand: 2. プロンプト生成
Stagehand->>LLM: 3. LLM呼び出し(act)
activate LLM
LLM-->>Stagehand: 応答
deactivate LLM
loop 完了するまでループ
Stagehand->>Browser: 4. 操作指示
activate Browser
Browser->>Browser: 実行
Browser-->>Stagehand: 応答
deactivate Browser
Stagehand->>Browser: 5. DOMの収集と整形
activate Browser
Browser-->>Stagehand: 結果
deactivate Browser
Stagehand->>Stagehand: 6. 評価プロンプト生成
Stagehand->>LLM: 7. LLM呼び出し(verifyActCompletion)
activate LLM
LLM-->>Stagehand: 結果 (true/false)
deactivate LLM
alt 結果 = true
note over Stagehand: 終了
end
end
Stagehand-->>User: 結果
deactivate Stagehand
1. DOMの収集と整形
DOM情報をPlaywright経由で取得し、LLMに渡すための整形をします。
ここでは以下のような工夫がされています。
- interactiveな要素またはテキストを対象にする
- 実際に見えているかつ有効な要素のみを対象にする
- XPathを複数用意し実行時に確実に要素をターゲットできるようにする
説明のため簡略化したコードを以下に示します。
export async function processElements() { const DOMQueue: ChildNode[] = [...document.body.childNodes]; const candidateElements: ChildNode[] = []; while (DOMQueue.length > 0) { const element = DOMQueue.pop(); let shouldAddElement = false; if (element && isElementNode(element)) { const childrenCount = element.childNodes.length; for (let i = childrenCount - 1; i >= 0; i--) { const child = element.childNodes[i]; DOMQueue.push(child as ChildNode); } if (isInteractiveElement(element)) { if (isActive(element) && isVisible(element)) { shouldAddElement = true; } } if (isLeafElement(element)) { if (isActive(element) && isVisible(element)) { shouldAddElement = true; } } } if (element && isTextNode(element) && isTextVisible(element)) { shouldAddElement = true; } if (shouldAddElement) { candidateElements.push(element); } } const selectorMap: Record<number, string[]> = {}; let outputString = ""; const xpathLists = await Promise.all( candidateElements.map(async (element) => { if (xpathCache.has(element)) { return xpathCache.get(element); } const xpaths = await generateXPaths(element); xpathCache.set(element, xpaths); return xpaths; }) ); candidateElements.forEach((element, index) => { const xpaths = xpathLists[index]; let elementOutput = ""; if (isTextNode(element)) { const textContent = element.textContent?.trim(); if (textContent) { elementOutput += `${index + indexOffset}:${textContent}\n`; } } else if (isElementNode(element)) { const tagName = element.tagName.toLowerCase(); const attributes = collectEssentialAttributes(element); const openingTag = `<${tagName}${attributes ? " " + attributes : ""}>`; const closingTag = `</${tagName}>`; const textContent = element.textContent?.trim() || ""; elementOutput += `${index + indexOffset}:${openingTag}${textContent}${closingTag}\n`; } outputString += elementOutput; selectorMap[index + indexOffset] = xpaths; }); return { outputString, selectorMap, }; }
また、実際にはDOM収集をViewport単位でchunkすることでコンテキスト長を短くするような工夫がされています。
https://docs.stagehand.dev/get_started/walkthrough#chunking
実行するとGoogleフォームでは以下のような出力が得られます。
{ "outputString": "0:<div class=\"F9yp7e ikZYwf LgNcQe\">入力テスト用フォーム</div>\\n1:入力テスト用フォーム\\n2:<a href=\"https://accounts.google.com/Login?continue=https%3A%2F%2Fdocs.google.com%2Fforms%2Fd%2Fe%2F1FAIpQLSfU83aQYOvdoqhGu9J8EJYTtMNtpbO7S6-xPYfib3N0RlN61g%2Fviewform%3Ffbzx%3D6820845041060437054\">Google にログイン</a>\\n3:Google にログイン\\n4:すると作業内容を保存できます。\\n5:<a class=\"TYUeKb\">詳細</a>\\n6:詳細\\n7:<div class=\"md0UAd\">* 必須の質問です</div>\\n8:* 必須の質問です\\n9:<span class=\"M7eMe\">氏名</span>\\n10:氏名\\n11:<span id=\"i5\" class=\"vnumgf\" aria-label=\"必須の質問\">*</span>\\n12:*\\n13:<input class=\"whsOnd zHQkBf\" type=\"text\" data-initial-dir=\"auto\" data-initial-value=\"\"></input>\\n14:回答を入力\\n15:<span class=\"M7eMe\">メールアドレス</span>\\n16:メールアドレス\\n17:<span id=\"i10\" class=\"vnumgf\" aria-label=\"必須の質問\">*</span>\\n18:*\\n19:<input class=\"whsOnd zHQkBf\" type=\"text\" data-initial-dir=\"auto\" data-initial-value=\"\"></input>\\n20:回答を入力\\n21:<span class=\"M7eMe\">年齢</span>\\n22:年齢\\n23:<span id=\"i15\" class=\"vnumgf\" aria-label=\"必須の質問\">*</span>\\n24:*\\n25:<input class=\"whsOnd zHQkBf\" type=\"text\" data-initial-dir=\"auto\" data-initial-value=\"\"></input>\\n26:回答を入力\\n27:<span class=\"M7eMe\">性別</span>\\n28:性別\\n29:<span id=\"i20\" class=\"vnumgf\" aria-label=\"必須の質問\">*</span>\\n30:*\\n31:<label class=\"docssharedWizToggleLabeledContainer ajBQVb\">男性</label>\\n32:<div id=\"i21\" class=\"Od2TWd hYsg7c\" aria-label=\"男性\" data-value=\"男性\"></div>\\n33:<span class=\"aDTYNe snByac OvPDhc OIC90c\">男性</span>\\n34:男性\\n35:<label class=\"docssharedWizToggleLabeledContainer ajBQVb\">女性</label>\\n36:<div id=\"i24\" class=\"Od2TWd hYsg7c\" aria-label=\"女性\" data-value=\"女性\"></div>\\n37:<span class=\"aDTYNe snByac OvPDhc OIC90c\">女性</span>\\n38:女性\\n39:<div class=\"U26fgb mUbCce fKz7Od M9Bg4d\" aria-label=\"Google に問題を報告\" data-tooltip=\"Google に問題を報告\" data-tooltip-position=\"right\" data-tooltip-vertical-offset=\"1\" data-tooltip-horizontal-offset=\"0\"></div>\\n", "selectorMap": { "0": [ "/html/body[1]/div[1]/div[2]/form[1]/div[2]/div[1]/div[1]/div[1]/div[2]/div[1]/div[1]", "//div[@role='heading']" ], "1": [ "/html/body[1]/div[1]/div[2]/form[1]/div[2]/div[1]/div[1]/div[1]/div[2]/div[1]/div[1]", "//div[@role='heading']" ], "2": [ "/html/body[1]/div[1]/div[2]/form[1]/div[2]/div[1]/div[1]/div[1]/div[4]/div[1]/div[1]/a[1]", "//html/body/div/div[2]/form/div[2]/div/div[1]/div/div[4]/div[1]/div/a[1]" ], // 長いので省略 "39": [ "/html/body[1]/div[1]/div[3]/div[1]", "//div[@aria-label='Google に問題を報告']" ] } }
2. プロンプト生成
ユーザーの入力とDOMを元にプロンプトを生成します。
プロンプト部分はGeminiに翻訳してもらいました。
const actSystemPrompt = ` # 指示 あなたはブラウザ自動化アシスタントです。あなたの仕事は、Playwright コマンドを実行することで、複数回のモデル呼び出しを通じてユーザーの目標を達成することです。 ## 入力 あなたは以下の情報を受け取ります。 1. ユーザーの全体的な目標 2. これまでに実行したステップ 3. 目標に近づくために考慮すべき、このチャンク内のアクティブな DOM 要素のリスト 4. (オプション) ユーザーが提供した変数のリスト。これらは目標達成のために使用できます。変数を使用するには、特別な \`<|変数名|>\` 構文を使用する必要があります。 5. (オプション) ユーザーによって提供されるカスタム指示。ユーザーの指示が現在のタスクに関係ない場合は無視してください。そうでない場合は、必ず指示に従ってください。 ## あなたの目標 / 仕様 あなたは \`doAction\` と \`skipSection\` という 2 つのツールを呼び出すことができます。\`doAction\` は Playwright のアクションのみを実行します。ユーザーの目標を正確に実行してください。他のアクションを実行したり、目標の範囲を超えたりしないでください。 Playwright アクションを実行した後にユーザーの目標が達成される場合は、\`completed\` を \`true\` に設定してください。不明な場合は、\`completed\` を \`true\` に設定する方が良いでしょう。 注記 1: 目標とは関係のないクッキーや広告のポップアップがページ上にある場合は、続行する前にまずそれを閉じてみてください。これは、目標の達成を妨げる可能性があるためです。 注記 2: 探しているものが、操作する必要がある要素の背後に隠れている場合があります。例えば、スライダー、ボタンなどです。 繰り返しますが、Playwright アクションを実行した後にユーザーの目標が達成される場合は、\`completed\` を \`true\` に設定してください。また、ユーザーがカスタム指示を提供した場合、何があってもそれに従うことが必須です。 # ユーザー提供のカスタム指示 アクションを実行する際には、ユーザーの指示を念頭に置いてください。ユーザーの指示が現在のタスクに関係ない場合は無視してください。 ユーザー指示: ${userInstructions} `; const actUserPrompt = ` # 私の目標 ${action} # これまでのステップ ${steps} # 現在アクティブな DOM 要素 ${domElements} # 変数 (もしあれば) ${変数名} (例: <|USERNAME|>, <|PASSWORD|>) `; export function buildActSystemPrompt( userProvidedInstructions?: string ): ChatMessage { return { role: "system", content: [ actSystemPrompt, buildUserInstructionsString(userProvidedInstructions), ] .filter(Boolean) .join("\n\n"), }; } export function buildActUserPrompt( action: string, steps = "None", domElements: string, variables?: Record<string, string> ): ChatMessage { let actUserPrompt = ` # My Goal ${action} # Steps You've Taken So Far ${steps} # Current Active Dom Elements ${domElements} `; if (variables && Object.keys(variables).length > 0) { actUserPrompt += ` # Variables ${Object.keys(variables) .map((key) => `<|${key.toUpperCase()}|>`) .join("\n")} `; } return { role: "user", content: actUserPrompt, }; }
ツールの使用を促しています。ツールの定義は以下になっていて、doActionというツールがPlaywrightのメソッドに対応しています。
export const actTools: LLMTool[] = [ { type: "function", name: "doAction", description: "execute the next playwright step that directly accomplishes the goal", parameters: { type: "object", required: ["method", "element", "args", "step", "completed"], properties: { method: { type: "string", description: "The playwright function to call.", }, element: { type: "number", description: "The element number to act on", }, args: { type: "array", description: "The required arguments", items: { type: "string", description: "The argument to pass to the function", }, }, step: { type: "string", description: "human readable description of the step that is taken in the past tense. Please be very detailed.", }, why: { type: "string", description: "why is this step taken? how does it advance the goal?", }, completed: { type: "boolean", description: "true if the goal should be accomplished after this step", }, }, }, }, { type: "function", name: "skipSection", description: "skips this area of the webpage because the current goal cannot be accomplished here", parameters: { type: "object", properties: { reason: { type: "string", description: "reason that no action is taken", }, }, }, }, ];
3. LLM呼び出し(act)
2のプロンプトを元に複数モデル対応のために抽象化されたllmClientのメソッドを呼び出します。
説明のため簡略化したコードを以下に示します。
export async function act({ action, domElements, steps, llmClient, retries = 0, variables, userProvidedInstructions, }: ActCommandParams): Promise<ActCommandResult | null> { const messages: ChatMessage[] = [ buildActSystemPrompt(userProvidedInstructions), buildActUserPrompt(action, steps, domElements, variables), ]; const response = await llmClient.createChatCompletion({ options: { messages, temperature: 0.1, top_p: 1, frequency_penalty: 0, presence_penalty: 0, tool_choice: "auto" as const, tools: actTools, }, }); const toolCalls = response.choices[0].message.tool_calls; if (toolCalls && toolCalls.length > 0) { if (toolCalls[0].function.name === "skipSection") { return null; } return JSON.parse(toolCalls[0].function.arguments); } else { if (retries >= 2) { return null; } return act({ action, domElements, steps, llmClient, retries: retries + 1, }); } }
例えばGoogleフォームでは、responseは以下のような出力が得られます。
tool_callsの中にdoActionが含まれていて、DOMの要素が特定できていることがわかります。
{ "id": "msg_01HQfS1FjfkYabdQ6rvL2LGb", "object": "chat.completion", "created": 1739458387233, "model": "claude-3-5-sonnet-20241022", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "I'll help you fill out all the form fields using the provided variables. I'll fill them one by one.\n\n1. First, let's fill in the name field:", "tool_calls": [ { "id": "toolu_01VB46AkBQFuHrHr7J3B6jtH", "type": "function", "function": { "name": "doAction", "arguments": "{\"method\":\"fill\",\"element\":13,\"args\":[\"<|NAME|>\"],\"step\":\"Filled in the name field with the provided name value\",\"why\":\"This fills in the required name field of the form\",\"completed\":false}" } } ] }, "finish_reason": "tool_use" } ], "usage": { "prompt_tokens": 1965, "completion_tokens": 199, "total_tokens": 2164 } }
4. 操作指示
XPathから要素を特定し、_performPlaywrightMethodで対応するPlaywrightのメソッドを実行します。
説明のため簡略化したコードを以下に示します。
const elementId = response["element"]; const xpaths = selectorMap[elementId]; const method = response["method"]; const args = response["args"]; const elementLines = outputString.split("\n"); const elementText = elementLines .find((line) => line.startsWith(`${elementId}:`)) ?.split(":")[1] || "Element not found"; let foundXpath: string | null = null; let locator: Locator | null = null; for (const xp of xpaths) { const candidate = this.stagehandPage.page.locator(`xpath=${xp}`).first(); await candidate.waitFor({ state: "attached", timeout: 2000 }); foundXpath = xp; locator = candidate; break; } if (!foundXpath || !locator) { throw new Error("None of the provided XPaths could be located."); } await this._performPlaywrightMethod( method, args, foundXpath ); // _performPlaywrightMethodの実装 private async _performPlaywrightMethod( method: string, args: unknown[], xpath: string, ) { const locator = this.stagehandPage.page.locator(`xpath=${xpath}`).first(); if (method === "scrollIntoView") { try { await locator.evaluate((element: HTMLElement) => { element.scrollIntoView({ behavior: "smooth", block: "center" }); }); } catch (e) { throw new PlaywrightCommandException(e.message); } } else if (method === "fill" || method === "type") { try { await locator.fill(""); await locator.click(); const text = args[0]?.toString(); for (const char of text) { await this.stagehandPage.page.keyboard.type(char, { delay: Math.random() * 50 + 25, }); } } catch (e) { throw new PlaywrightCommandException(e.message); } } else if (method === "press") { try { const key = args[0]?.toString(); await this.stagehandPage.page.keyboard.press(key); } catch (e) { throw new PlaywrightCommandException(e.message); } } else if (method === "click") { try { const isRadio = await locator.evaluate((el) => { return el instanceof HTMLInputElement && el.type === "radio"; }); const clickArg = args.length ? args[0] : undefined; if (isRadio) { const inputId = await locator.evaluate((el) => el.id); let labelLocator; if (inputId) { labelLocator = this.stagehandPage.page.locator( `label[for="${inputId}"]`, ); } if (!labelLocator || (await labelLocator.count()) < 1) { labelLocator = this.stagehandPage.page .locator(`xpath=${xpath}/ancestor::label`) .first(); } if ((await labelLocator.count()) < 1) { labelLocator = locator .locator(`xpath=following-sibling::label`) .first(); if ((await labelLocator.count()) < 1) { labelLocator = locator .locator(`xpath=preceding-sibling::label`) .first(); } } if ((await labelLocator.count()) > 0) { await labelLocator.click(clickArg); } else { await locator.click(clickArg); } } else { const clickArg = args.length ? args[0] : undefined; await locator.click(clickArg); } } catch (e) { throw new PlaywrightCommandException(e.message); } const newOpenedTab = await Promise.race([ new Promise<Page | null>((resolve) => { this.stagehandPage.context.once("page", (page) => resolve(page)); setTimeout(() => resolve(null), 1_500); }), ]); if (newOpenedTab) { await newOpenedTab.close(); await this.stagehandPage.page.goto(newOpenedTab.url()); await this.stagehandPage.page.waitForLoadState("domcontentloaded"); } await Promise.race([ this.stagehandPage.page.waitForLoadState("networkidle"), new Promise((resolve) => setTimeout(resolve, 5_000)), ]); } }
5. DOMの収集と整形
4で操作した後のDOM情報を再度収集します。基本的には1と同様の処理ですが、ここではchunk関係なく全てのDOMを収集します。
6. 評価プロンプト生成
ユーザーの目標、これまでのステップ、現在のDOM情報を基に、目標が達成されたかどうかをLLMに判断させるためのプロンプトを生成します。
const verifyActCompletionSystemPrompt = ` あなたはブラウザ自動化アシスタントです。あなたには、達成すべき目標と、これまでに実行されたステップのリストが与えられています。あなたの仕事は、提供された情報に基づいて、ユーザーの目標が達成されたかどうかを判断することです。 # 入力 あなたは以下の情報を受け取ります。 1. ユーザーの目標:ユーザーが達成したいことの明確な説明。 2. これまでのステップ:これまでに実行されたアクションのリスト。 # あなたのタスク 提供された情報を分析し、ユーザーの目標が完全に達成されたかどうかを判断してください。 # 出力 boolean値を返します。 - true:これまでのステップと現在のページに基づいて、目標が明確に達成された場合。 - false:目標が達成されていない場合、または達成されたかどうかに不確実性がある場合。 # 重要な考慮事項 - 偽陽性(実際には達成されていないのに達成されたと判断すること)は許容されます。偽陰性(実際には達成されているのに達成されていないと判断すること)は許容されません。 - ページ上にエラーの証拠があるか、目標達成の過程で何か問題が発生したかを探してください。もしエラーが見つからなければ、trueを返してください。 `; export function buildVerifyActCompletionSystemPrompt(): ChatMessage { return { role: "system", content: verifyActCompletionSystemPrompt, }; } export function buildVerifyActCompletionUserPrompt( goal: string, steps = "None", domElements: string | undefined ): ChatMessage { let actUserPrompt = ` # My Goal ${goal} # Steps You've Taken So Far ${steps} `; if (domElements) { actUserPrompt += ` # Active DOM Elements on the current page ${domElements} `; } return { role: "user", content: actUserPrompt, }; }
7. LLM呼び出し(verifyActCompletion)
ここまで実行したステップの情報を追加した後、6のプロンプトを使用してLLMを呼び出し目標が達成されたかどうか(trueまたはfalse)の判定を受け取ります。 以降は結果がtrueになるまで1~7を繰り返すことで目標の達成を目指します。
説明のため簡略化したコードを以下に示します。
const newStepString = (!steps.endsWith("\n") ? "\n" : "") + `## Step: ${response.step}\n` + ` Element: ${elementText}\n` + ` Action: ${response.method}\n` + ` Reasoning: ${response.why}\n`; steps += newStepString; export async function verifyActCompletion({ goal, steps, llmClient, domElements, logger, }: VerifyActCompletionParams): Promise<boolean> { const verificationSchema = z.object({ completed: z.boolean().describe("true if the goal is accomplished"), }); type VerificationResponse = z.infer<typeof verificationSchema>; const response = await llmClient.createChatCompletion<VerificationResponse>({ options: { messages: [ buildVerifyActCompletionSystemPrompt(), buildVerifyActCompletionUserPrompt(goal, steps, domElements), ], temperature: 0.1, top_p: 1, frequency_penalty: 0, presence_penalty: 0, response_model: { name: "Verification", schema: verificationSchema, }, }, }); if (!response || typeof response !== "object") { return false; } if (response.completed === undefined) { return false; } return response.completed; }
最後に
本記事ではStagehandがブラウザ操作する仕組みを実装から追ってみました。 仕組みの理解を通じて、自社プロダクトへの組み込みや社内業務の自動化におけるヒントを得ていただけたら幸いです。 最後まで読んでいただきありがとうございました。
当社のプロダクトやAI活用に関してご質問・ご相談がある方は、下記のフォームからお気軽にお問い合わせください。