セット・オブ・マークプロンプティング:ブラウザエージェントがクリックできるものを与える
Lead Scraping Automation Engineer
TL;DR:
- ビジョンモデルがライブカタログページでピクセル座標を求めた場合、ターゲットを毎回外した — 1セッションで5回中0回、3セッションで15回中0回 — そしてそれはランダムではなかった: 温度0では、毎回ほぼ同じポイントを返した。
- セット・オブ・マークのプロンプト — すべてのクリック可能な要素に番号を付けて、モデルに座標の代わりにインデックスを求める — は同じセッションで5回中5回、そして全体で15回中15回の成功を収めた。同じページで同じモデルを使用している。
- マーキングは無料ではない: プロンプトは1,343から2,100トークン(+56.4%)に増加し、テストしたモデルのレートで1回の決定につき約$0.00021となった。
- リモートCDPブラウザでは、
page.set_viewport_size()レイアウトビューポートをリサイズしない。3回の独立した実行で12回の新しいセッションを通じて、innerWidthは呼び出しによって変化せず、実際のウィンドウは800 pxから3,840 pxの範囲だった — 要求されたサイズには決してならなかった — 一方、スクリーンショットは常に要求されたサイズと正確に一致していた。 - それが根本原因である: モデルが推論する画像とクリックが着地するスペースは異なる座標フレームであり、オフセットはセッションごとに変わる。DOMを通じて解決されたインデックスはすべてに対して免疫がある。
- Scrapelessの無料プランは、このガイドのクラウドブラウザ実行をカバーしている。
ブラウザエージェントは、「2冊目の本を開く」を特定の場所でクリックするように変換する必要がある。その変換 — 基盤の確立 — がほとんどのエージェントの実行が静かに失敗する場所である。モデルはページを正しく読み取り、その計画を正しく説明した後、直前に説明したものとは異なる場所をクリックする。
通常の修正方法はセット・オブ・マークのプロンプト: すべてのクリック可能な要素の上に番号付きのボックスを描き、モデルに画像と数字のリストを渡し、60の代わりに(564, 623)に答えさせる。この技術は マイクロソフトのセット・オブ・マーク論文 に由来し、現在では多くのエージェントスタックのいくつかの形で使用されている。
見つけるのが難しいのは測定である。このガイドは、同じモデルの同じライブページに対して両方のアプローチを構築し、各々を単一のセッションで5回実行し、実際に起こったことを報告する — リモートブラウザの詳細を含めて、座標版がなぜこれほど一貫して失敗するのかを説明する。
なぜ座標が失敗するのか
失敗したバージョンから始めよう、その失敗の形が面白い部分だから。
タスク: ライブの書籍カタログで、Soumissionというタイトルの本の製品ページを開く。モデルはスクリーンショットを取得し、クリックポイントを求められる。
python
import base64, json, os, urllib.request
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
TARGET = "https://books.toscrape.com/"
TASK = "open the product page for the book titled 'Soumission'"
MODEL = "google/gemini-2.5-flash-lite"
CDP = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
"token": os.environ["SCRAPELESS_API_KEY"], "sessionTTL": 300, "proxyCountry": "US"})
def ask(png, prompt):
body = {"model": MODEL, "max_tokens": 200, "temperature": 0,
"messages": [{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {
"url": "data:image/png;base64," + base64.b64encode(png).decode()}}]}]}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions", data=json.dumps(body).encode(),
headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
"Content-Type": "application/json"})
d = json.load(urllib.request.urlopen(req, timeout=120))
return d["choices"][0]["message"]["content"].strip(), d["usage"]
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(CDP)
page = browser.new_page()
page.set_viewport_size({"width": 1280, "height": 1400})
page.goto(TARGET, wait_until="domcontentloaded")
answer, usage = ask(page.screenshot(),
f"This is a 1280x1400 browser screenshot. To {TASK}, give the pixel coordinates "
f'of the element to click. Reply with JSON only: {{"x":int,"y":int}}')
print("model answered:", answer)
c = json.loads(answer[answer.index("{"):answer.rindex("}") + 1])
landed = page.evaluate(
"([x,y]) => { const e = document.elementFromPoint(x,y);"
"return e ? (e.closest('a')?.getAttribute('href') || '<'+e.tagName+'>') : null }",
[c["x"], c["y"]])
print(f"click ({c['x']},{c['y']}) lands on -> {landed}")
print("prompt tokens:", usage["prompt_tokens"])
browser.close()
モデルは拒否せず、曖昧な答えも返さず、すぐに自信を持って応答する:
text
model answered: {"x": 564, "y": 623}
click (564,623) lands on -> <BODY>
prompt tokens: 1343
<BODY> はクリックが何にも着地しなかったことを意味する — 空のページの背景で、リンクではまったくない。エージェントはこれからクリックしたと報告し、決して起こらないナビゲーションを待ち、その後、静かに起こらなかったステップに基づいて計画を続行する。
同じセッションで5回実行しても、失敗は平均化しない:
| 試行 | 応答 | ヒットした要素 | 結果 |
|---|---|---|---|
| 1 | (564, 623) | <BODY> |
MISS |
| 2 | (563, 630) | <BODY> |
MISS |
| 3 | (563, 630) | <BODY> |
MISS |
| 4 | (563, 630) | <BODY> |
MISS |
| 5 | (564, 623) | <BODY> |
MISS |
0 of 5. 不安定な失敗は生存可能である — 3回サンプリングし、過半数を取って進む。この失敗は安定している。温度0ではモデルは数ピクセル内で同じポイントを返し、毎回同じ死んだスペースに着地する。自己整合性投票は、間違った答えを3回返し、合意として扱う。
3つの独立したセッションで全体の比較を繰り返すと、毎回同じ判定が出た: 0 of 15 座標に対して。セッション間で変わったのはどのように外れたかだけだった — 1つのセッションでは、すべてのクリックがターゲットの隣の製品に着地し、空の背景ではなくなった。ヒットしたターゲットはセッションごとに変わった; 外れたことは変わらなかった。
裏にあるフレームの不一致
明白な説明は、ビジョンモデルが単に正確な座標が苦手であることであり、これはGUI基盤の文献が長々と支持している。リモートブラウザでは、これに重なる二つ目の原因があり、それもまたモデルをまったく使用しないアプローチを壊すため、知っておく価値がある。
ページに自分が思っている大きさを尋ねる、ビューポートを設定する前と後に:
python
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
CDP = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
"token": os.environ["SCRAPELESS_API_KEY"], "sessionTTL": 300, "proxyCountry": "US"})
with sync_playwright() as p:
for run in range(1, 5):
browser = p.chromium.connect_over_cdp(CDP)
page = browser.new_page()
before = page.evaluate("() => [innerWidth, innerHeight]")
page.set_viewport_size({"width": 1280, "height": 900})
page.goto("https://books.toscrape.com/", wait_until="domcontentloaded")
after = page.evaluate("() => [innerWidth, innerHeight]")
shot = page.screenshot()
print(f"run{run}: innerWH before={before} after={after} png_bytes={len(shot)}")
browser.close()
4つの新しいセッション:
text
run1: innerWH before=[1550, 1050] after=[1550, 1050] png_bytes=196373
run2: innerWH before=[1240, 560] after=[1240, 560] png_bytes=116304
run3: innerWH before=[1680, 1002] after=[1680, 1002] png_bytes=171659
run4: innerWH before=[3840, 1152] after=[3840, 1152] png_bytes=5289
set_viewport_size() はレイアウトビューポートに影響を及ぼさなかった — innerWidth と innerHeight はすべての四回の実行で呼び出しの前後で同じである。実際のウィンドウサイズもまた、自分が選んだものではない。同じスクリプトの繰り返し実行は、2,560×1,408、1,920×1,070、1,440×839、800×570、3,840×1,050などを返した; 3回の実行で12のセッションを通じて、レイアウトビューポートは一度も要求されたサイズにはならず、幅は800 pxから3,840 pxの範囲だった。
スクリーンショットは、その都度要求されたサイズと完全に一致していました。run4で何が起こるか見てください:3,840 pxウィンドウ用にレイアウトされたページが1,280 pxキャンバスにキャプチャされ、他の実行が116~196 KBを生成したのに対し、5 KB PNGが生成されました。クロップは主に空のレイアウトをキャッチしました。そのスクリーンショットがモデルに対して推論を求められるものであったのです。
ローカルで立ち上げたブラウザに対する同じスクリプトは、予想通りに動作し、これが開発時に見逃される原因です:
text
local innerWH: [1280, 1400]
after set_viewport_size: [1280, 900]
ローカルではコールが機能し、フレームも一致しています。リモートブラウザに接続すると、そのコールは静かに動作を停止します。
したがって、ページは3,840 pxウィンドウ用にレイアウトされ、モデルに渡されるPNGは1,280 pxの幅で、モデルがそのPNGから読み取る座標は、より広いジオメトリを使用したDOMに対して消費されます。2つのフレームは、セッションを開くたびに変化する係数によって関連しており、これがまさに、connect_over_cdpがすでに実行中のブラウザに接続したときに、Playwrightがそのブラウザの所有者ではなくクライアントである理由です、そしてビューポートのエミュレーションは、それが自ら作成するコンテキストのプロパティです — これはPlaywrightのコンテキストドキュメントが明示的に示す区別です。
モデルは無作為に推測しているわけではありません。画像を正しく読み取り、画像のフレームで回答し、その後、その数値が異なるもので消費されます。
要素のマーク付けの代わりに
Set-of-Markは、座標をそれを横断することなく転送しないことでフレームの問題を解決します。モデルはインデックスを返し、そのインデックスはページ自体によって、ページのジオメトリ内で要素に解決されます。
マーク付けパスはインタラクティブな要素を走査し、ユーザーが実際にクリックできるものにフィルターし、各要素の上に番号付きのボックスを描画し、平行リストを返します。これをmark.jsとして保存します:
javascript
() => {
const sel = 'a[href], button, input, select, textarea, [role="button"], [onclick]';
const out = [];
document.querySelectorAll('#som-layer').forEach(n => n.remove());
const layer = document.createElement('div');
layer.id = 'som-layer';
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647';
document.body.appendChild(layer);
const vw = innerWidth, vh = innerHeight;
let i = 0;
for (const el of document.querySelectorAll(sel)) {
const r = el.getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue;
if (r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw) continue;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
const box = document.createElement('div');
box.style.cssText = `position:absolute;left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px;border:2px solid #E11D48;box-sizing:border-box`;
const tag = document.createElement('div');
tag.textContent = i;
tag.style.cssText = `position:absolute;left:${r.left}px;top:${Math.max(0, r.top - 14)}px;background:#E11D48;color:#fff;font:bold 12px monospace;padding:0 4px;line-height:14px`;
layer.append(box, tag);
out.push({ i, tag: el.tagName.toLowerCase(),
name: (el.innerText || el.getAttribute('aria-label') || el.value || '').trim().slice(0, 60),
x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) });
i++;
}
return out;
}
サイズと可視性フィルターは見た目以上に重要です。width < 8 || height < 8テストがないと、トラッキングピクセルや折りたたまれたラッパーがマークされます。ビューポートの境界テストがないと、長いページのフッター全体がマークされ、モデルには見えないものの数値が渡されます。どちらも、インデックスと画像が不一致のメニューを生成し、これは全くマークがないよりも悪化します。
オーバーレイはposition:fixedレイヤーの1つでpointer-events:noneを持っているので、クリックしようとしているものを妨げることなく、ページの上に描画され、下にあるものを再フローすることはありません。
各要素の中心は、マーク付け時にページの座標系でgetBoundingClientRect()からキャプチャされます。それがクリックで使用される数値であり、画像を経由してのラウンドトリップは一切行われません。
フルループ
マーク付け、スクリーンショット、インデックスの要求、クリック:
python
import base64, json, os, pathlib, urllib.request
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
TARGET = "https://books.toscrape.com/"
TASK = "open the product page for the book titled 'Soumission'"
MODEL = "google/gemini-2.5-flash-lite"
MARK_JS = pathlib.Path("mark.js").read_text()
CDP = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
"token": os.environ["SCRAPELESS_API_KEY"], "sessionTTL": 300, "proxyCountry": "US"})
def ask(png, prompt):
body = {"model": MODEL, "max_tokens": 200, "temperature": 0,
"messages": [{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {
"url": "data:image/png;base64," + base64.b64encode(png).decode()}}]}]}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions", data=json.dumps(body).encode(),
headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
"Content-Type": "application/json"})
d = json.load(urllib.request.urlopen(req, timeout=120))
return d["choices"][0]["message"]["content"].strip(), d["usage"]
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(CDP)
page = browser.new_page()
page.goto(TARGET, wait_until="domcontentloaded")
marks = page.evaluate(MARK_JS)
shot = page.screenshot()
page.evaluate("() => document.querySelectorAll('#som-layer').forEach(n => n.remove())")
print(f"marked {len(marks)} interactive elements")
menu = "\n".join(f"[{m['i']}] <{m['tag']}> {m['name']}" for m in marks)
answer, usage = ask(shot,
f"The screenshot has numbered red marks on every clickable element.\n{menu}\n\n"
f"To {TASK}, which mark do you click? Reply with JSON only: "
'{"index":int}. Do not explain.')
print("model answered:", answer)
idx = json.loads(answer[answer.index("{"):answer.rindex("}") + 1])["index"]
chosen = next(m for m in marks if m["i"] == idx)
print(f"chose [{idx}] {chosen['name']!r}")
with page.expect_navigation(wait_until="domcontentloaded"):
page.mouse.click(chosen["x"], chosen["y"])
print("landed on:", page.url)
print("prompt tokens:", usage["prompt_tokens"])
browser.close()
出力:
text
marked 85 interactive elements
model answered: {"index": 60}
chose [60] 'Soumission'
landed on: https://books.toscrape.com/catalogue/soumission_998/index.html
prompt tokens: 2100
5回の試行、同じセッション、同じモデル、同じ温度:
| 試行 | インデックス | ラベル | ランドオン | 結果 |
|---|---|---|---|---|
| 1 | 60 | Soumission | soumission_998 |
HIT |
| 2 | 60 | Soumission | soumission_998 |
HIT |
| 3 | 60 | Soumission | soumission_998 |
HIT |
| 4 | 60 | Soumission | soumission_998 |
HIT |
| 5 | 60 | Soumission | soumission_998 |
HIT |
5 of 5、座標が5回中0回得点したセッションで、3つのセッション全体で15回中15回です。
決定論は今度は他の方向に切り替わります。座標失敗を修正不可能にした同じ性質 — 温度0での安定した答え — が、マークされたバージョンを信頼できる正しいものにするのです。
セッション間で絶対的な数値が変動することを期待してください。レイアウトビューポートはリモートウィンドウの状態によって変わるため、同じスクリプトが1つのセッションで85の要素をマークし、インデックス60を選択し、別のセッションでは52の要素とインデックス43を選択しました。どちらもsoumission_998にランディングしました。カウントはセッション依存ですが、宛先は依存しません。これがDOMを介して解決することの利点です。
それにかかるコスト
マーク付けは要素メニューをプロンプトに追加し、メニューはページと共に増えます:
| アプローチ | プロンプトトークン | 決定ごとのコスト |
|---|---|---|
| 生のスクリーンショット → 座標 | 1,343 | $0.00014 |
| セットオブマーク → インデックス | 2,100 | $0.00021 |
| デルタ | +757 (+56.4%) | +49.1% |
生のスクリーンショットプロンプトは固定の1,343トークンです。なぜなら、画像が固定サイズだからです;マーク付きプロンプトは、マークすることに決めた要素の数によってスケーリングします。85のマークされた要素を持つページに対して56%多くのプロンプトで、1つの決定が約0.0002ドルのコストがかかります。
そのスケーリングは、上記の可視性フィルターの本当の主張です:マークを拒否するすべての要素は、支出しないトークンとなり、モデルが誤って選ぶことのできないインデックスになります。エージェントが正しいものをクリックするかどうかの0から100%の振れ幅に対して、これは接近戦ではありません。
これがまだ壊れる場所
Set-of-Markはグラウンディングを修正しますが、すべてを修正するわけではありません。
キャンバスとWebGL表面にはマークする要素がありません。 地図、チャートキャンバス、またはゲームは、下にDOM構造がないピクセルにレンダリングされます。マークングは何も見つけられず、あなたは座標か、コンポーネントが公開するアクセシビリティフックに戻ります。
マークはビューポートに依存します。 フォールドの下にあるすべてのものは、設計上マークされていませんので、下の方にある要素が必要なエージェントはスクロールして再マークする必要があります。マークングは観察ごとのステップとして扱ってください、一度限りのセットアップではありません。
密なページは長いメニューを生み出します。 400のコントロールを持つページは400行のメニューを生成し、トークンビルはループのすべてのステップに現れます。マークする前に、役割、地域、またはタスクに対する近接性でフィルタリングしてください。
インデックスはラベルの良さに依存します。 アクセシブルな名前が空の要素は[31] <a>として表示され、モデルはそれに基づいて推論するものがありません。それは、スクリーンリーダーが直面する同じ名前付けの問題であり、修正はアクセシブル名と説明の計算で指定されているものと同じで、aria-labelを優先し、タイトルまたは近くのテキストにフォールバックします。
すべてのナビゲーションの後に再マークすることは必須です。 インデックスは位置に依存し、次のレンダリングで再割り当てされます。ページ遷移を跨いでインデックスを保持することは、グラウンディング失敗のように見えるバグです。
クラウドブラウザでの実行
上記のすべては、Scrapeless Scraping Browser に対してCDPで実行されました。これが、ビューポートの探索がそもそも出現した理由です — ローカルのchromium.launch()は、あなたが要求したビューポートを提供し、デプロイするまで問題を隠します。
接続はWebSocketのURLです:
python
import os
from urllib.parse import urlencode
CDP = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
"token": os.environ["SCRAPELESS_API_KEY"],
"sessionTTL": 300,
"proxyCountry": "US",
})
print(CDP.split("?")[0])
proxyCountryは、価格や可用性をローカライズするカタログにとって重要な出入口エリアを固定します。ループの残りは変更されていません — PlaywrightのAPIは、ブラウザを起動した場合でも、ブラウザに接続した場合でも同じで、どちらも下でChrome DevToolsプロトコルを使用しています。下のプロトコルが不明な場合、CDPプライマがトランスポートをカバーし、コンピュータ使用エージェントループがこのグラウンディングステップがスロットする観察-決定-行動サイクルをカバーします。
上記の測定から2つの習慣が引き継がれます。要求したビューポートを仮定するのではなく、ページからinnerWidthとinnerHeightを読み取ります — リモートセッションではそれらは異なる数字です。そして、画像から計算された座標を通じてではなく、ページが渡した要素を通じてすべてのクリックを解決します。
結論
この測定は鈍いです。同じページ、同じモデル、同じプロンプトで、ピクセル座標を要求することは15回中0回正しい要素を取得しました; インデックスを要求することは15回中15回正しい要素を取得しました。座標の失敗は、繰り返しサンプリングで平滑化されるようなノイズではなく — 温度0では同じポイントを返し、毎回同じように失敗しました。
モデルのグラウンディングの弱点の下には、より単純なバグがあります:リモートブラウザでは、スクリーンショットとDOMは同じ座標系にありません、そしてset_viewport_size()はそれらをそこに置くことはありません。要素に番号を付けることで、いずれの問題にも回避策を提供し、数百のトークンの代償で解決します。
管理されたブラウザに対してループを構築する準備はできましたか? Scrapelessで無料トライアルを開始 — 無料プランはこのガイド内のすべての実行をカバーし、料金はそこからスケールします。
FAQ
Q: なぜ私のブラウザエージェントは、正しい要素を説明しているにもかかわらず、誤った要素をクリックするのですか?
説明と位置特定は別の能力だからです。モデルはページを正しく読み取り、その後正確な数値を出力する必要があり、画像からの精密な数値はその最も弱い出力です。リモートブラウザを追加すると、スクリーンショットとDOMが異なる座標フレームを使用するため、エラーは時折のものではなく体系的になり、この上記の実行では、15回の試行のすべてでミスしました。
Q: Set-of-Markは、DOMやアクセシビリティツリーを送信するよりも優れていますか?
異なる半分を解決します。テキスト表現は、モデルに何が存在するかを伝えます。マークは、それらの物体が見ている画像のどこにあるかを示し、実際の要素に戻るトークンを与えます。マークはまた、小さく保たれます — 各要素につきインデックスと短いラベル — 一方、控えめなカタログページの生のHTMLは、トークンの五桁に達します。
Q: マークを付けると、いくつのトークンが増えますか?
テストページでは757トークンが追加され、提示が1,343から2,100に増加しました — 85のマーク付き要素に対して約56%の増加です。生のスクリーンショットのプロンプトは画像サイズが固定されているため固定されており、マーク付きプロンプトはマークの数に応じてスケールするため、可視性とサイズフィルターは正確性の制御と同じくらい費用管理も行っています。
Q: これはリモートまたはクラウドブラウザで機能しますか?
そこでの方がよく機能し、そこでの必要性も高いです。インデックスがページ内の getBoundingClientRect() を通じて解決されるため、スクリーンショットのサイズとリモートウィンドウの実レイアウトビューポート間の不一致の影響を受けません — この不一致が座標アプローチが毎回失敗した原因です。
Q: 各アクションの後に再度マークする必要がありますか?
はい。インデックスは現在表示されている要素の文書順序で割り当てられるため、ナビゲーション、スクロール、またはDOMの更新後に再割り当てされます。各観察ステップの一部としてマークしてください。以前のスクリーンショットからインデックスを再利用するのは、この技術が誤って実装される最も一般的な方法です。
Q: マークするクリック可能な要素がないページではどうなりますか?
Canvas、WebGL、およびビデオサーフェスは、列挙するためのDOM構造なしでレンダリングされるため、マークは有用なものを返しません。これらは異なる戦略が必要です — コンポーネントがそれらを提供するアクセシビリティフック、または明示的に修正されたフレームの不一致を伴う座標アプローチです。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



