Files
maestro/docs/tools/browseweb.md
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

25 KiB
Raw Blame History

BrowseWeb 詳細ガイド

ヘッドレスブラウザで Web ページを操作するツール。同一ジョブ内ではブラウザコンテキスト(Cookie・ログイン状態)が永続化される。

ログインが必要なサイト(X / 管理画面など)を継続的にスクレイピングするなら、保存済みログインセッションを使うとよい。詳細は ReadToolDoc({ name: "BrowserSessions" })(または browse-sessions.md)を参照。

2 つのモード

1. 基本モード — URL を開いてテキスト取得

BrowseWeb({ url: "https://example.com" })

ローカルで生成した HTML をブラウザで確認したい場合は、workspace ルートからの 相対パス をそのまま渡す(推奨)。

BrowseWeb({ url: "output/viewer.html" })

例:

  • output/viewer.html を開く → BrowseWeb({ url: "output/viewer.html" })
  • input/sample.html を開く → BrowseWeb({ url: "input/sample.html" })

内部的には実行中ジョブの workspace 絶対パスと結合され file:// URL に変換される。../ で workspace 外に出るパスは拒否される。file:/// で始まる絶対 URL を直接渡すことも可能だが、workspace 外を指すものは拒否される。

オプション:

  • waitFor: 待機する CSS セレクタ(省略時は load イベント完了まで待機)
  • extractSelector: 特定要素のテキストだけ抽出する CSS セレクタ
  • screenshot: スクリーンショットを保存するファイル名(例: "page.png" → output/page.png)。縦長ページは既定で分割保存(下記)
  • screenshotSegments: false で分割せずフルページ 1 枚(既定: 分割あり)
  • screenshotMaxSegments: 分割の最大枚数(既定 10)
  • timeout: タイムアウト(ms、デフォルト 60000)

2. アクションモード — 連続操作

BrowseWeb({
  actions: [
    { type: "goto", url: "https://example.com/login" },
    { type: "fill", ref: "e3", value: "[email protected]" },
    { type: "click", ref: "e5" },
    { type: "getText" }
  ]
})

利用可能な type:

  • goto — url で指定したページに遷移
  • click — selector または ref で要素をクリック
  • fill — selector または ref の input/textarea に value を入力
  • screenshot — value で指定したファイル名で保存(省略時 screenshot.png)。縦長ページは既定で分割保存(下記)。segments: false でフルページ 1 枚、maxSegments で枚数上限を調整
  • getText — 全ページのスナップショット(ref 注釈付き)または selector 内のテキストを取得
  • wait — ms ミリ秒待機(最大 30000)
  • dumpHtml — ref または selector(省略時 body)の outerHTML を取得(脱出口、後述)

スクリーンショットの分割保存

縦長ページを 1 枚で撮ると画像が極端に縦長になり、細部が潰れて読み取りづらくなる。BrowseWeb は既定で、ページを 1 画面ぶん(ビューポート高さ)ごとに区切って複数枚に分割保存する。

  • 1 画面に収まるページはそのまま output/page.png の 1 枚(連番なし)
  • 2 画面ぶん以上あるページは output/page-001.png, output/page-002.png … と連番で保存し、戻り値に全ファイルを列挙する
  • 無限スクロールなどの暴走を防ぐため、既定で最大 10 枚まで(screenshotMaxSegments / アクションの maxSegments で変更)。上限で打ち切った場合は戻り値にその旨を表示する
  • 分割せずフルページ 1 枚が欲しいときは screenshotSegments: false(基本モード)またはアクションの segments: false
// 縦長レポートを 1 画面ぶんずつ分割して撮る(既定)
BrowseWeb({ url: "output/report.html", screenshot: "report.png" })
// → output/report-001.png, output/report-002.png, ...

// フルページ 1 枚で撮りたいとき
BrowseWeb({ url: "output/report.html", screenshot: "report.png", screenshotSegments: false })

長文ページの取得(preview + ファイル保存)

getText (selector 有無問わず) およびスナップショットの戻り値が 5000 文字を超える 場合、フルテキストはワークスペースの logs/browse/{ISO-timestamp}-{hash}.txt に保存され、戻り値は 先頭 5000 文字 + 続きの取得方法案内 になる:

(先頭 5000 文字)
... (truncated; full 38214 chars saved to logs/browse/2026-05-07T09-30-12-a1b2c3d4.txt — Read({file_path:"logs/browse/2026-05-07T09-30-12-a1b2c3d4.txt", offset, limit}) で続きを取得可能)

続きを読みたい場合は Read ツールで offset / limit を指定:

Read({ file_path: "logs/browse/2026-05-07T09-30-12-a1b2c3d4.txt", offset: 200, limit: 200 })

5000 文字以下のページなら従来通り全文が直接返り、ファイルは作成されない。

ref 注釈の仕組み(重要)

BrowseWeb({ url }) や getText の出力には、操作可能な要素が以下のような注釈付きで埋め込まれる:

ようこそ
{e1 link "ホーム" href="/"} {e2 link "製品" href="/products"}
ログインしてください
{e3 textbox name="email" placeholder="メールアドレス"}
{e4 textbox name="password"}
{e5 button "ログイン"}
  • e1, e2, ... の ID(ref)は出現順に自動採番される
  • 各 ref は内部的に Playwright で解釈可能なセレクタ(data-testid / id / [name] / aria-label / nth-of-type CSS chain の優先順)にマッピングされている
  • click/fill アクションで ref: "e5" のように指定するだけで操作できる
  • CSS セレクタを自分で組み立てる必要がない

検出される要素の範囲

ref が振られるのは以下の要素:

  • 標準 HTML タグ: <a> / <button> / <input> / <select> / <textarea> / <label> / <summary> / <details> / <option>
  • ARIA role: button / link / menuitem / menuitemcheckbox / menuitemradio / tab / option / checkbox / radio / switch / combobox / listbox / slider / spinbutton / textbox / searchbox / treeitem
  • [onclick] / [tabindex>=0] / [contenteditable=true] 属性
  • JavaScript で addEventListener('click'|'mousedown'|'pointerdown', ...) 経由で listener が後付けされた要素(jQuery / vanilla JS / Vue / Svelte の compile 後コードで多用される)
  • open shadow DOM 内部の上記要素
  • iframe 内の上記要素(同一オリジン / cross-origin 共に対応。Stripe Elements / OAuth / reCAPTCHA など)

検出されないもの: closed shadow DOM、<canvas> / WebGL の描画内容、React の onClick={...}(ただし React コンポーネントは大抵 <button> か role="button" を使うので別経路で拾える)。

iframe 内の要素

iframe を含むページの getText の出力は、メインフレームのテキストの後ろに フレームごとのセクション が並ぶ形式になる。メインフレーム本文中には iframe の位置に [[IFRAME ...]] プレースホルダーが残るので、フレームの出現順や種別が把握できる:

これは決済画面です
[[IFRAME name=card title=Card details src=https://js.stripe.com/v3/elements]]
[ボタン] {e3 button "支払う"}

--- iframe f1 url="https://js.stripe.com/v3/elements/..." name="card" ---
{f1.e1 textbox "Card number"}
{f1.e2 textbox "MM / YY"}
{f1.e3 textbox "CVC"}
--- end iframe f1 ---

iframe 内の要素を click / fill / dumpHtml したいときは、frame ID prefix 付きの ref を指定するだけ:

BrowseWeb({
  actions: [
    { type: "fill", ref: "f1.e1", value: "4242 4242 4242 4242" },
    { type: "fill", ref: "f1.e2", value: "12 / 30" },
    { type: "fill", ref: "f1.e3", value: "123" },
    { type: "click", ref: "e3" }   // メインフレームの「支払う」ボタン
  ]
})

frame ID (f1, f2, …) は getText 取得時の出現順に採番される。同じページに同じ iframe が複数ある場合は src/name で見分けてセクションヘッダーで識別する。

cross-origin iframe (Stripe / OAuth / reCAPTCHA など) でも Playwright が内部で透過的に DOM を取得するので、同じ感覚で操作できる。ただし iframe の中身が完全に読み込まれる前に snapshot を取ると [empty] や [cannot inspect: ...] が出ることがあるので、その場合は wait を挟んで再取得する。

状態属性

ref 注釈の末尾には ARIA 状態が列挙される。エージェントは「いまトグルが開いてるか」「チェック済みか」「無効化されてるか」を判断できる:

{e3 tab "設定" selected}
{e7 button "保存" disabled}
{e2 combobox "国" expanded haspopup}
{e9 checkbox "規約に同意" checked}
{e5 button "メニュー" pressed}

利用される状態: expanded / collapsed / pressed / selected / checked / mixed / disabled / required / haspopup

ref はいつリセットされる?

  • ページ遷移(goto または click でナビゲーションが発生)したとき
  • 同一ジョブ内でも、ナビゲーション後は getText を呼んで新しいスナップショットを取得する
  • 同一ジョブが終わるとブラウザコンテキストごと破棄される

ワークフロー例

例1: ログインしてダッシュボードのデータを取得

// Step 1: ログインページを開いて要素を確認
BrowseWeb({ url: "https://app.example.com/login" })
// → 出力に {e3: input[email]}, {e4: input[password]}, {e5: button "ログイン"} が含まれる

// Step 2: フォーム入力 → 送信 → 遷移後の状態を取得
BrowseWeb({
  actions: [
    { type: "fill", ref: "e3", value: "[email protected]" },
    { type: "fill", ref: "e4", value: "p@ssword" },
    { type: "click", ref: "e5" },
    { type: "getText" }   // ← ダッシュボードの新 ref を取得
  ]
})

// Step 3: ダッシュボードでさらにナビゲート(Cookie が維持されているため再ログイン不要)
BrowseWeb({ url: "https://app.example.com/dashboard/orders" })

例2: 複数ページを順に巡回

// 検索結果ページを開く
BrowseWeb({ url: "https://example.com/search?q=foo" })
// → {e1: link "結果1" href="/item/1"}, {e2: link "結果2" href="/item/2"} ...

// 各リンクの href を確認したら、url 直接指定で各ページへ
BrowseWeb({ url: "https://example.com/item/1" })
BrowseWeb({ url: "https://example.com/item/2" })

例3: 動的ページの読み込み待ち

BrowseWeb({
  url: "https://app.example.com/spa",
  waitFor: ".content-loaded"   // この CSS セレクタが現れるまで待つ
})

ユーザーに手動操作を委譲する(noVNC 経由のハンドオフ)

BrowseWeb で詰まったとき、エージェントは InteractiveBrowse を呼んでブラウザの操作権をユーザーに渡せる。

使うべき場面

  1. ログイン / 2FA / SSO 同意画面 — パスワードや TOTP / プッシュ通知を agent に持たせず、ユーザーに直接入力してもらう
  2. CAPTCHA / bot 検証 — reCAPTCHA、画像選択、Cloudflare チャレンジ等
  3. BrowseWeb の click が空振りし続ける — dumpHtml でも構造が複雑すぎて selector が組めない、closed shadow DOM、ドラッグ&ドロップが必須等
  4. canvas / WebGL ベースの UI — 地図ペインや図形エディタなど DOM では addressable でない領域
  5. 画面状態を目視確認したい — agent が想定通りの画面にいるか不安なとき

フロー

// Step 1: ユーザーに引き継ぐ宣言
InteractiveBrowse({
  url: "https://example.com/login",
  reason: "ログインが必要です。ID / パスワードを入力して、画面右下の release ボタンを押してください。"
})
// → ジョブが waiting_human に遷移し、UI に noVNC リンクが表示される
// → ユーザーがブラウザ画面で操作 → release を押すとジョブが再開
// → 戻り値に sessionId が含まれる

ジョブ再開後、agent は 同じ sessionId で BrowseWithSession を呼んで続きを引き継ぐ:

// Step 2: ユーザーが完了させた状態 (ログイン済み等) で続行
BrowseWithSession({
  sessionId: "abc-123",                      // InteractiveBrowse の戻り値の sessionId
  url: "https://example.com/dashboard",
  action: "getText"                           // または click / fill / screenshot
})

reason の書き方

ユーザーに何をしてほしいかは reason フィールドで明確に伝えること。UI に表示される。良い例:

  • 「ログインしてください。完了したら release を押してください」
  • 「reCAPTCHA を解いてください。完了したら release を押してください」
  • 「カートに入れたい商品を選んでください。完了したら release を押してください」

制約

  • InteractiveBrowse は ローカルタスク経由のジョブ でのみ使える(taskId が必要)。Gitea Issue 直接実行や taskId が立たない subtask root では使えない
  • noVNC が orchestrator にインストール / 設定されていない環境ではエラー(Xvfb / x11vnc / websockify が必要、config.yaml の browser.display_mode: novnc 設定。旧 captcha_solve は自動移行)
  • ユーザーが release を押さない限りジョブは進まない。長時間放置すると browser.auth_timeout(デフォルト 10 分)で timeout

既存の Browser Sessions 機能との違い

機能 用途
Browser Sessions (Settings UI から保存) スケジュール実行や定期タスクなど agent しか動いていない時間帯 に、過去にログイン済みの cookie / storageState を再利用
InteractiveBrowse ジョブ実行中、その場で ユーザーがブラウザを操作してログインや人間判断を行う

定期タスクで毎回 InteractiveBrowse を呼ぶのは非効率なので、定常運用のサイトは Browser Sessions として登録するのが正解。「初回ログイン or セッション切れ時だけ InteractiveBrowse」のような使い分けが望ましい。

トラブルシューティング

  • 「ref "e5" not found in current snapshot」と出る: ページ遷移後で ref がリセットされている。getText で新しいスナップショットを取得する
  • テキストが取れない / 空に近い: ページが SPA で JavaScript で描画されている。waitFor で描画完了を待つ
  • ボタンを押せない / click しても何も起きない:
    1. 要素が visible でない可能性。先に getText で本当に存在するか確認
    2. ref 注釈に disabled が出ていないか確認
    3. dumpHtml({ ref: "..." }) で要素の生 HTML を見て、独自 selector を組む
    4. それでもダメなら InteractiveBrowse でユーザーに引き継ぐ
  • <div> に click 反応する独自 UI が ref に出ない: addEventListener フックで多くは検出されるが、React の onClick={...} (root delegation) や el.onclick = fn 直接代入は捕捉できない。dumpHtml で構造を見て selector を直接組むか、InteractiveBrowse で渡す
  • ログインが維持されない: 別ジョブから呼んでいる可能性。同一ジョブ内なら維持される。定常運用は Browser Sessions に保存する

ファイルダウンロード

リンククリック等でブラウザがファイルダウンロードを開始すると、自動的に workspace の output/ 配下に保存される。戻り値の末尾に以下の形式で通知される:

[download] saved output/report.csv (12345 bytes)
  • ファイル名は server-suggested 名から path traversal 対策と禁則文字置換を経て決定される
  • 衝突時は foo-1.csv, foo-2.csv 形式で番号付与される
  • 失敗時は [download] FAILED <name>: <reason> と出る
  • ダウンロードされたファイルは続く Read, ReadPdf, ReadExcel, Bash 等の tool で参照できる
  • 履歴は logs/downloads.jsonl に追記される (DownloadFile と同じファイル、source: 'BrowseWeb' フィールドで区別)

ダウンロードを認証付きで行いたい場合は、Browser Sessions 機能で対象サイトのログインセッションを保存し、タスクで bind した状態で BrowseWeb を呼ぶこと。

ページの魚拓(本文+画像)

BrowseWeb が開いたページは、本文とそのページに載っていた画像が source/ に自動で溜まる。出典メタ(URL・取得日時・サイズ・tool: "BrowseWeb")は source/index.jsonl に 1 行ずつ追記される。WebFetch / DownloadFile と同じソースライブラリに合流するので、後から追加調査やグラウンディングに使える。

**属している場所で挙動は変わらない。**スペースのタスクでも、タスク一覧から直接作ったタスクでも、定期実行・サブタスクでも同じように溜まる(issue #1097 / 由来 #1093)。

何が・どこに

ページ本文 source/<ページ名>.md
ページに載っていた画像 source/images/<名前>.<拡張子>
索引 source/index.jsonl(1 行 1 件)

索引の画像行は kind: "image" を持ち、page_url / page_file でどのページに載っていた画像かを指す。sha256(中身の指紋)・width / height も入る。ページ本文の行は従来どおり kind を持たない。

保存された画像は ReadImage({ file_path: "source/images/xxx.png" }) でそのまま読める。画像のための新しいツールは無い。

取り方

画像は ���いているページの文脈で取りに行く(page.request。Cookie・User-Agent はブラウザコンテキストのもの、Referer は開いている URL)。

**画像の URL を別のツール(DownloadFile 等)に渡して取り直してはいけない。**ログインが要るページ・リンク元を見て弾くサイト・期限つきの署名が付いた URL がいずれも取れなくなり、この機能の価値が消える。ここがスクリーンショット依存を外せる理由。

候補は 2 つの経路から並べる。

  1. document.images の currentSrc(srcset / picture で実際に選ばれた 1 枚)。実寸(naturalWidth / naturalHeight)が取れるので寸法の下限が効く
  2. Resource Timing(performance.getEntriesByType('resource'))のうち initiatorType が css / img のもの。スタイルシートの背景として敷かれた画像はここにしか出ない——記事の主役の写真が背景で敷かれている作りのサイトでは、<img> だけ見ていると図が丸ごと落ちる。実寸は取れないので、下限の判定は見送って残す側に倒す

並び順は <img> が先。取りに行く回数には上限があるので、フォントや装飾で枠を食い潰させない。資源の一覧には画像でないもの(フォント等)も混じるが、種類で選り分けない——画像かどうかは取ってきた中身の先頭バイトで決める。

data: URL はその場で復号する。blob: は取りに行けないので対象外。インラインの <svg> 要素と <canvas> は、そもそも取りに行く URL が無いので入らない。

page.request はページの route 傍受を通らないので、取得前に SSRF の関所(ssrfCheck)を通す。ページに <img src="http://169.254.169.254/..."> を仕込まれても内部へ取りに行かせない。許可ホスト(tools.webfetch_allowed_hosts)は BrowseWeb 本体と同じものを使う。

歯止め(意味は見ない)

「これは飾りだから捨てる」という判定は書かない。書けば必ず漏れるし、漏れて困るのは「あとから欲しかったのに無い」側。歯止めは意味を見ないものだけ。

歯止め 既定 config.yaml(tools 配下)
中身の指紋で畳む 常時 (設定なし)
1 ページあたりの枚数 20 browse_image_max_per_page
1 枚あたりの容量 5MB browse_image_max_size_mb
容量の下限 1024 バイト browse_image_min_bytes
寸法の下限 32 ピクセル browse_image_min_dimension
取りに行く回数 枚数の上限 × 3(既定 60 回) (設定なし。枚数の上限に連動)

寸法の下限は 実寸が取れたときだけ効く。取れていない画像は残す側に倒す(飾りは入る前提)。

**回数の上限は「残した枚数」とは別に数える。**残した枚数だけを見ていると、1 枚も残らないページ(配信元が落ちている・下限を下回る画像ばかり)で候補の全件に取りに行ってしまう。1 枚あたり 15 秒待つので、候補が数百あるページを開いただけでブラウザ操作が何分も返らなくなる。

溜まったものを自動で削除する仕組みは入れない。索引に参照が残るので、「どのページからも参照が無い」「1 ページでしか使われず小さい」といった条件で後から掃除する道は残る。

その他

  • 同一 URL のページ本文は重複保存しない(既に index にあればスキップ)。ただし本文が既出でも画像は取りに行く——WebFetch で本文を読んでから、図が要ると気づいてブラウザで開き直す流れを塞がないため。再訪の抑止は 3 段。①このプロセスでこのページの収集を試したか(hasPageArchiveAttempt。索引に痕跡が残らない「1 枚も溜まらなかったページ」がここに当たる。中断で切ったときは記憶しない)、②索引にこのページの画像行があるか、③すり抜けた分は指紋の判定が止める
  • 中身が同じ画像は実ファイルを 1 枚に畳み、索引の行だけ足す(ロゴ・アイコンが何ページぶんも積み上がらない)
  • 空・エラー結果、http(s) 以外(file:// 等)の URL はソース化しない
  • スクリーンショットはソースではないので output/ に残り、source/ には入らない
  • 魚拓は副作用。失敗してもブラウザ操作そのものは通る。戻り値には [archive] source/images/ に 7 枚(索引: source/index.jsonl) の形で 1 行だけ足る。落ちた枚数は理由ごとに分けて出す(上限・下限で対象外 / 画像として読めない中身 / 取得できず)
  • 本文を取らない操作列(goto → screenshot 等)でも goto 直後に魚拓する。BrowseWithSession も同じ経路を通る

形式の判別

拡張子と content-type は見ない。どちらも当てにならないため——オブジェクトストレージの署名つき URL は拡張子を持たず application/octet-stream で配られることが多く、逆に content-type: image/png でも中身が HTML のエラーページということがある。

判別は中身の先頭バイトで行う(sniffImageExtension)。PNG / JPEG / GIF / WebP / BMP / TIFF / ICO / AVIF / SVG を見分け、どれにも当たらないものだけ捨てる。形式の判別であって、意味の判定ではない。AVIF は主ブランドが mif1 で avif が互換ブランド側に載る配り方があるので、ftyp ボックスの互換ブランドの並びまで見る。

保存した画像は ReadImage がそのまま読める。SVG / AVIF / TIFF / ICO はモデルが直接受け取れないので、ReadImage 側が PNG に起こしてから渡す。唯一の例外は BMP を格納した古い形式の .ico(sharp が読めない)で、この場合は理由付きのエラーが返る。溜まってはいるので、必要なら別途変換できる。

動画・音声は対象外(<img> にも Resource Timing の img / css にも出ない)。フォントは候補には入りうるが、先頭バイトで画像でないと分かるので保存されない。

SSRF 保護

ローカル/プライベート IP(127.x.x.x, 10.x.x.x, 172.16-31.x.x, 192.168.x.x, ::1, fc00::/7 等)へのアクセスはデフォルトでブロックされる。社内ホストへアクセスする必要がある場合は、Settings UI の「SSRF Allowed Hosts」に追加する。

既知の残留リスク: 時間差 DNS rebinding (TOCTOU)

リクエスト傍受時に全 DNS レコードを検証してから route.continue() するが、その後の接続では Chromium が独自に DNS を再解決する。検証と接続の間(サブ秒 TTL)でレコードを書き換える rebinding ホストは、理論上プライベート IP への接続を成立させられる。Playwright の route 傍受には接続先アドレスを pin する手段がなく、この方式に内在する制約として受容している(issue #467)。WebFetch / DownloadFile / MCP はソケットを pin する pinnedFetch(src/net/ssrf-strict.ts)経由のため影響を受けない。完全に塞ぐには pinning forward proxy が必要だが、意図的に導入していない。