Set-of-Mark Prompting: Dale a un Agente del Navegador Algo que pueda Hacer Clic
Lead Scraping Automation Engineer
TL;DR:
- Un modelo de visión que solicitó coordenadas de píxeles en una página de catálogo en vivo no alcanzó el objetivo ni una sola vez — 0 de 5 intentos en una sesión, 0 de 15 en tres — y no fue aleatorio: a temperatura 0 devolvió casi el mismo punto en cada intento.
- La indicación de Set-of-Mark — numerando cada elemento clickeable y pidiendo al modelo un índice en lugar de una coordenada — obtuvo 5 de 5 en la misma sesión y 15 de 15 en total, en la misma página con el mismo modelo.
- La marcación no es gratuita: el aviso creció de 1,343 a 2,100 tokens (+56.4%), aproximadamente $0.00021 por decisión a la tarifa del modelo probado.
- En un navegador de CDP remoto,
page.set_viewport_size()no redimensiona el área de visualización del diseño. En doce sesiones nuevas en tres ejecuciones independientes,innerWidthno se alteró por la llamada y la ventana real varió de 800 px a 3,840 px — nunca el tamaño solicitado — mientras que la captura de pantalla siempre fue exactamente el tamaño solicitado. - Esa es la causa raíz: la imagen sobre la que el modelo razona y el espacio en el que aterriza su clic son diferentes marcos de coordenadas, y el desplazamiento cambia por sesión. Un índice resuelto a través del DOM es inmune a todo esto.
- El plan gratuito de Scrapeless cubre las ejecuciones de navegador en la nube en esta guía.
Un agente de navegador tiene que convertir "abre el segundo libro" en un clic en algún lugar específico. Esa conversión — anclaje — es donde la mayoría de las ejecuciones del agente fallan silenciosamente. El modelo lee la página correctamente, explica su plan correctamente, y luego hace clic en un lugar que no es lo que acaba de describir.
La solución habitual es la indicación de Set-of-Mark: dibuja un cuadro numerado sobre cada elemento clickeable, entrega al modelo la imagen más una lista de los números, y déjalo responder 60 en lugar de (564, 623). La técnica proviene de el documento de Set-of-Mark de Microsoft, y para ahora está en la mayoría de las pilas de agentes de alguna forma.
Lo que es difícil de encontrar es una medición. Esta guía construye ambos enfoques contra la misma página en vivo con el mismo modelo, corre cada uno cinco veces en una sola sesión, y reporta lo que realmente ocurrió — incluyendo el detalle del navegador remoto que explica por qué la versión de coordenadas falla de manera tan constante.
Por qué fallan las coordenadas
Comienza con la versión fallida, porque la forma de su falla es la parte interesante.
La tarea: en un catálogo de libros en vivo, abrir la página de producto para el libro titulado Soumission. El modelo obtiene una captura de pantalla y se le pide un punto de clic.
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()
El modelo no se niega, no es indulgente, ni devuelve nada malformado. Responde de inmediato y con confianza:
text
model answered: {"x": 564, "y": 623}
click (564,623) lands on -> <BODY>
prompt tokens: 1343
<BODY> significa que el clic aterrizó en nada — fondo de página vacío, no un enlace en absoluto. El agente ahora reportará que hizo clic, esperará una navegación que nunca ocurre y continuará con un plan basado en un paso que silenciosamente no tuvo lugar.
Ejecuta esto cinco veces en la misma sesión y la falla no se promedia:
| prueba | respuesta | elemento impactado | resultado |
|---|---|---|---|
| 1 | (564, 623) | <BODY> |
FALLA |
| 2 | (563, 630) | <BODY> |
FALLA |
| 3 | (563, 630) | <BODY> |
FALLA |
| 4 | (563, 630) | <BODY> |
FALLA |
| 5 | (564, 623) | <BODY> |
FALLA |
0 de 5. Una falla inestable es sobrevivible — haces tres muestras, tomas la mayoría, y sigues adelante. Esta falla es estable. A temperatura 0 el modelo devuelve el mismo punto dentro de unos pocos píxeles, y cada repetición aterriza en el mismo espacio muerto. La votación de auto-consistencia devolvería la respuesta incorrecta tres veces y la llamaría consenso.
Repetir toda la comparación en tres sesiones separadas dio el mismo veredicto cada vez: 0 de 15 para coordenadas. Lo que cambió entre sesiones fue solo cómo falló — en una sesión cada clic aterrizó en el producto al lado del objetivo en lugar de en un fondo vacío. El objetivo que alcanzó cambió con la sesión; que no lo alcanzara no.
El desajuste de marco subyacente
La explicación obvia es que los modelos de visión son simplemente malos en coordenadas precisas, lo cual la literatura sobre anclaje de GUI respalda ampliamente. En un navegador remoto hay una segunda causa acumulada encima de esto, y vale la pena conocerla porque también rompe enfoques que no involucran un modelo en absoluto.
Pregunta a la página qué tan grande cree que es, antes y después de establecer el área de visualización:
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()
Cuatro sesiones nuevas:
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() tuvo ningún efecto sobre el área de visualización del diseño — innerWidth y innerHeight son idénticos antes y después de la llamada en las cuatro ejecuciones. El tamaño real de la ventana tampoco es algo que elegiste. Las ejecuciones repetidas del mismo script devolvieron 2,560×1,408, 1,920×1,070, 1,440×839, 800×570 y 3,840×1,050 entre otros; en doce sesiones en tres ejecuciones el área de visualización del diseño nunca fue el tamaño solicitado, y los Anchos oscilaron entre 800 px y 3,840 px.
La captura de pantalla, mientras tanto, tenía exactamente el tamaño solicitado cada vez. Observa lo que eso hace en run4: una página dispuesta para una ventana de 3,840 px, capturada en un lienzo de 1,280 px, produjo un 5 KB PNG, mientras que las otras ejecuciones produjeron de 116 a 196 KB. El recorte capturó principalmente un diseño vacío. Esa captura de pantalla es lo que se le habría pedido al modelo que razonara.
El mismo script contra un navegador lanzado localmente se comporta de la forma que esperarías, que es lo que hace que esto sea fácil de pasar por alto en el desarrollo:
text
local innerWH: [1280, 1400]
after set_viewport_size: [1280, 900]
Localmente, la llamada funciona y los marcos están de acuerdo. Conéctate a un navegador remoto y la llamada deja de funcionar silenciosamente.
Así que la página se dispone para una ventana de 3,840 px, el PNG entregado al modelo tiene 1,280 px de ancho, y las coordenadas que el modelo lee de ese PNG se usan contra un DOM usando la geometría más amplia. Los dos marcos están relacionados por un factor que cambia cada vez que abres una sesión, que es exactamente por qué el fallo recae en un producto vecino en una sesión y en un fondo vacío en otra. Cuando connect_over_cdp se conecta a un navegador que ya está en funcionamiento, Playwright es un cliente de ese navegador en lugar de su propietario, y la emulación del viewport es una propiedad de los contextos que crea él mismo, una distinción que la documentación del contexto de Playwright señala de manera explícita.
El modelo no está adivinando aleatoriamente. Está leyendo la imagen correctamente y respondiendo en el marco de la imagen, y luego ese número se gasta en uno diferente.
Marcando Elementos en su Lugar
Set-of-Mark elimina el problema del marco al nunca transportar una coordenada a través de él. El modelo devuelve un índice; el índice se resuelve de nuevo en un elemento por la página misma, en la propia geometría de la página.
El paso de marcado recorre los elementos interactivos, filtra los que un usuario podría hacer clic realmente, dibuja un cuadro numerado sobre cada uno y devuelve una lista paralela. Guárdalo como 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;
}
Los filtros de tamaño y visibilidad importan más de lo que parecen. Sin la prueba width < 8 || height < 8, marcas píxeles de seguimiento y envoltorios colapsados; sin la prueba de límites del viewport, marcas el pie de página completo de una página larga y entregas al modelo números de cosas que no puede ver. Ambos producen menús donde los índices y la imagen no coinciden, lo cual es peor que no tener marcas en absoluto.
La superposición es una capa position:fixed con pointer-events:none, por lo que se pinta sobre la página sin interceptar el clic que estás a punto de hacer y sin reflujo de nada debajo.
El centro de cada elemento se captura desde getBoundingClientRect() en el momento del marcado, en el sistema de coordenadas de la página. Ese es el número que utilizará el clic, y nunca hace un viaje redondo a través de la imagen.
El Ciclo Completo
Marcar, tomar una captura de pantalla, pedir un índice y hacer clic en él:
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()
Salida:
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
Cinco pruebas, misma sesión, mismo modelo, misma temperatura:
| prueba | índice | etiqueta | aterrizó en | resultado |
|---|---|---|---|---|
| 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 de 5, en la sesión donde las coordenadas obtuvieron 0 de 5 y 15 de 15 en las tres sesiones.
El determinismo ahora corta en la otra dirección. La misma propiedad que hizo que el fallo de coordenadas fuera irreparable —una respuesta estable a temperatura 0— hace que la versión marcada sea confiablemente correcta en lugar de confiablemente incorrecta.
Espera que los números absolutos se muevan entre sesiones. Debido a que el viewport de diseño es lo que sea que sea la ventana remota, el mismo script marcó 85 elementos y eligió el índice 60 en una sesión, y 52 elementos y el índice 43 en otra. Ambos aterrizaron en soumission_998. Los recuentos dependen de la sesión; el destino no, que es lo que resolver a través del DOM te ofrece.
Lo Que Cuesta
Marcar añade el menú de elementos al aviso, y el menú crece con la página:
| enfoque | tokens del aviso | costo por decisión |
|---|---|---|
| captura de pantalla en bruto → coordenadas | 1,343 | $0.00014 |
| set-of-mark → índice | 2,100 | $0.00021 |
| delta | +757 (+56.4%) | +49.1% |
El aviso de captura de pantalla en bruto es un fijo de 1,343 tokens porque la imagen es de tamaño fijo; el aviso marcado varía con cuántos elementos decidiste marcar. Cincuenta y seis por ciento más aviso para una página con 85 elementos marcados, a un costo donde una decisión cuesta alrededor de una quinta parte de un centavo.
Esa escala es el verdadero argumento para los filtros de visibilidad arriba: cada elemento que decides no marcar son tokens que no gastas y un índice que el modelo no puede elegir por error. Contra un cambio del 0 al 100% en si el agente hace clic en la cosa correcta, no es una llamada cercana.
Donde Esto Aún Se Rompe
Set-of-Mark corrige la anclaje. No corrige todo.
Las superficies de Canvas y WebGL no tienen elementos para marcar. Un mapa, un lienzo de gráficos, o un juego se renderiza en píxeles sin estructura DOM subyacente. Marcar no encuentra nada y vuelves a coordenadas, o a cualquier gancho de accesibilidad que el componente exponga.
Las marcas están limitadas al viewport. Todo lo que está fuera de la vista está desmarcado por diseño, por lo que un agente que necesita un elemento más abajo tiene que desplazarse y volver a marcar. Trata el marcado como un paso por observación, no como una configuración única.
Las páginas densas producen menús largos. Una página con 400 controles genera un menú de 400 líneas, y la factura de tokens se presenta en cada paso del bucle. Filtra por rol, por región, o por proximidad a la tarea antes de marcar.
El índice es tan bueno como la etiqueta. Un elemento cuyo nombre accesible está vacío aparece como [31] <a> y el modelo no tiene nada con qué razonar. Ese es el mismo problema de nombrado que enfrentan los lectores de pantalla, y la solución es la misma que la Computación de Nombres y Descripciones Accesibles ya especifica: preferir aria-label, recurrir al título o al texto cercano.
Re-marcar después de cada navegación es obligatorio. Los índices son posicionales y se reasignan en el siguiente render. Mantener un índice a través de una transición de página es un error que parece una falla de anclaje.
Ejecutándolo en un Navegador en la Nube
Todo lo anterior se ejecutó contra el Navegador de Raspado Scrapeless a través de CDP, que es la razón por la cual la detección de viewport se presentó en primer lugar — un chromium.launch() local te da el viewport que pediste y oculta el problema hasta que despliegas.
Conectarse es una URL de WebSocket:
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 fija la región de salida, que es importante para cualquier catálogo que localice precios o disponibilidad. El resto del bucle no cambia — la API de Playwright es la misma ya sea que lance el navegador o se adjunte a uno, porque ambos hablan el Protocolo de Chrome DevTools por debajo. Si el protocolo subyacente es desconocido, el informe de CDP cubre el transporte, y el bucle del agente de uso de computadora cubre el ciclo de observar-decir-actuar en el que se inscribe este paso de anclaje.
Dos hábitos se trasladan de las mediciones anteriores. Lee innerWidth y innerHeight de la página en lugar de asumir el viewport que solicitaste — en una sesión remota esos son números diferentes. Y resuelve cada clic a través de un elemento que la página te entregó, nunca a través de una coordenada calculada a partir de una imagen.
Conclusión
La medición es contundente. En la misma página, con el mismo modelo y la misma indicación, pedir coordenadas de píxeles obtuvo el elemento 0 veces de 15; pedir un índice lo consiguió 15 veces de 15. La falla de coordenadas no fue ruido que un muestreo repetido suavizaría — a temperatura 0 devolvió el mismo punto y falló de la misma manera cada vez.
Debajo de la debilidad de anclaje del modelo se encuentra un error más simple: en un navegador remoto la captura de pantalla y el DOM no están en el mismo sistema de coordenadas, y set_viewport_size() no los pondrá allí. Numerar los elementos elude ambos problemas a cambio de unos pocos cientos de tokens.
¿Listo para construir el bucle contra un navegador gestionado? Comienza gratis con Scrapeless — el plan gratuito cubre cada ejecución en esta guía, y los precios escalan a partir de ahí.
FAQ
P: ¿Por qué mi agente del navegador hace clic en el elemento equivocado aunque describe el correcto?
Porque describir y localizar son habilidades separadas. El modelo lee la página correctamente y luego tiene que emitir un número preciso, y los números precisos a partir de una imagen son su salida más débil. Agrega un navegador remoto, donde la captura de pantalla y el DOM usan diferentes marcos de coordenadas, y el error deja de ser ocasional y se vuelve sistemático — en las ejecuciones anteriores falló en cada uno de los 15 intentos.
P: ¿Es Set-of-Mark mejor que enviar el DOM o el árbol de accesibilidad?
Resuelven mitades diferentes. Una representación textual le dice al modelo qué existe; las marcas le indican dónde se encuentran esas cosas en la imagen que está viendo y le dan un token que se resuelve de vuelta a un elemento real. Las marcas también permanecen pequeñas: un índice y una etiqueta corta por elemento, mientras que el HTML en bruto en una página de catálogo modesta llega a cinco cifras de tokens.
Q: ¿Cuántos tokens añade la marcación?
En la página probada, 757 tokens, llevando el prompt de 1,343 a 2,100 — aproximadamente un 56% más por 85 elementos marcados. El prompt de captura de pantalla en bruto es fijo porque el tamaño de la imagen es fijo; el prompt marcado escala con el número de marcas, por lo que los filtros de visibilidad y tamaño están controlando costos tanto como controlando la precisión.
Q: ¿Funciona esto en un navegador remoto o en la nube?
Funciona mejor allí y es más necesario allí. Porque el índice se resuelve a través de getBoundingClientRect() dentro de la página, no se ve afectado por la discrepancia entre el tamaño de la captura de pantalla y el verdadero viewport de diseño de la ventana remota — la discrepancia que hizo que el enfoque de coordenadas fallara cada vez.
Q: ¿Necesito volver a marcar después de cada acción?
Sí. Los índices se asignan en orden de documento sobre los elementos actualmente visibles, por lo que se reasignan después de cualquier navegación, desplazamiento o actualización del DOM. Marca como parte de cada paso de observación; reutilizar un índice de una captura de pantalla anterior es la forma más común en que esta técnica se implementa incorrectamente.
Q: ¿Qué pasa en las páginas sin elementos clicables para marcar?
Las superficies de Canvas, WebGL y video se renderizan sin una estructura DOM que enumerar, por lo que la marcación no devuelve nada útil. Esos necesitan una estrategia diferente: ganchos de accesibilidad donde el componente los proporciona, o un enfoque de coordenadas con la discrepancia del marco corregida explícitamente.
En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.



