387 lines
25 KiB
Markdown
387 lines
25 KiB
Markdown
# 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 が必要だが、意図的に導入していない。
|