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

387 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BrowseWeb 詳細ガイド
ヘッドレスブラウザで Web ページを操作するツール。同一ジョブ内ではブラウザコンテキスト(Cookie・ログイン状態)が永続化される。
ログインが必要なサイト(X / 管理画面など)を継続的にスクレイピングするなら、保存済みログインセッションを使うとよい。詳細は `ReadToolDoc({ name: "BrowserSessions" })`(または [browse-sessions.md](./browse-sessions.md))を参照。
## 2 つのモード
### 1. 基本モード — URL を開いてテキスト取得
```js
BrowseWeb({ url: "https://example.com" })
```
ローカルで生成した HTML をブラウザで確認したい場合は、workspace ルートからの **相対パス** をそのまま渡す(推奨)。
```js
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. アクションモード — 連続操作
```js
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`
```js
// 縦長レポートを 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` を指定:
```js
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 を指定するだけ:
```js
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: ログインしてダッシュボードのデータを取得
```js
// 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: 複数ページを順に巡回
```js
// 検索結果ページを開く
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: 動的ページの読み込み待ち
```js
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 が想定通りの画面にいるか不安なとき
### フロー
```js
// Step 1: ユーザーに引き継ぐ宣言
InteractiveBrowse({
url: "https://example.com/login",
reason: "ログインが必要です。ID / パスワードを入力して、画面右下の release ボタンを押してください。"
})
// → ジョブが waiting_human に遷移し、UI に noVNC リンクが表示される
// → ユーザーがブラウザ画面で操作 → release を押すとジョブが再開
// → 戻り値に sessionId が含まれる
```
ジョブ再開後、agent は **同じ sessionId で `BrowseWithSession`** を呼んで続きを引き継ぐ:
```js
// 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 が必要だが、意図的に導入していない。