Files
web_scraping/LLM_BROWSER_GUIDE.md
T
egutierrez 2527fd306a docs(browser): guía de control de navegador para LLMs (CDP anti-colapso)
Añade LLM_BROWSER_GUIDE.md: guía operativa para que un agente LLM (Claude u
otro) controle los navegadores del equipo via Chrome DevTools Protocol sin
saturar su contexto. Cubre la conexión (puertos 9222 diario / 9333 dedicado),
las capacidades por eje (ventanas, pestañas, network, DOM, ejecución de JS) con
recetas lanzables, y sobre todo la lectura de páginas mediante accessibility
tree recortado (cdp_get_ax_tree + trim_ax_tree, render a outline) en lugar de
volcar el HTML crudo, que es la causa principal de colapso de contexto.

Incluye una tabla de presupuesto de tokens por acción, recetas de tarea
end-to-end (entender página, scraping, login+SPA, mapear API oculta), los
gotchas heredados de CHROMIUM_SYSTEM.md y la hoja de ruta del futuro servidor
MCP de navegador (cada capacidad como tool que devuelve representaciones
compactas en el borde). project.md referencia la nueva guía.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:52:40 +02:00

22 KiB
Raw Blame History

Guía de control de navegador para LLMs (CDP anti-colapso)

Guía operativa para que un agente LLM (Claude u otro) controle los navegadores del equipo via Chrome DevTools Protocol (CDP) sin saturar su propio contexto. El problema central que esta guía resuelve: una página web cruda (HTML completo, outerHTML, screenshots como datos) puede ocupar decenas de miles de tokens y colapsar la ventana de contexto del modelo. La solución es operar siempre sobre representaciones compactas — sobre todo el accessibility tree (AX tree) recortado — y devolver únicamente lo extraído, nunca la página entera.

Complementa, no sustituye:

  • project.md — qué es el proyecto y para qué sirve.
  • CONVENTIONS.md — las 9 reglas operativas (ventana fija, perfil dedicado, esperas inteligentes, ratón humano, perfiles solo-uBlock, CDP global).
  • CHROMIUM_SYSTEM.md — mapa físico de la configuración de Chromium en este equipo (puertos, /etc/chromium.d/, perfiles, proxy mitm, gotchas de sistema).

Todas las rutas de esta guía son relativas a la raíz del repo fn_registry.


TL;DR — la regla de oro

  1. Nunca vuelques la página entera al contexto. Prohibido script-navegador html (devuelve el outerHTML completo) salvo que vayas a procesarlo fuera del contexto (a archivo, a un pipe). Prohibido pegar screenshots como "datos".
  2. Para entender una página, usa un AX snapshot recortado (cdp_get_ax_tree + trim_ax_tree, render a outline de texto). Es la vista semántica de la página (roles + nombres accesibles), ya sin ruido de divs/spans vacíos. Típicamente 10-50x más pequeña que el HTML.
  3. Para extraer datos, ejecuta JavaScript que devuelva solo el resultado (script-navegador eval), no el DOM. Devuelve un JSON pequeño con los campos que necesitas.
  4. Para páginas enormes, trocea el AX tree con chunk_ax_tree y procesa chunk a chunk.
  5. Espera condiciones reales (carga, red en reposo, selector presente), nunca sleep ciegos.

0. Prerrequisitos y conexión

Atajos de ruta usados en esta guía

# Desde la raíz del repo fn_registry:
SN=projects/web_scraping/apps/script_navegador/script-navegador   # binario Go ya compilado
PY=python/.venv/bin/python3                                        # venv del registry (tiene websocket-client)

Si el binario script-navegador no existiera, recompílalo: cd projects/web_scraping/apps/script_navegador && CGO_ENABLED=1 go build -o script-navegador .

Mapa de puertos CDP (ver CHROMIUM_SYSTEM.md para el detalle)

Puerto Quién Cuándo usarlo
9222 Chromium diario del usuario (CDP global loopback, siempre activo) Tareas sobre sitios donde el usuario ya tiene sesión/cookies/login. Opera sobre su navegación real.
9333 (o el que pidas) Navegador de automatización dedicado, perfil aislado Scraping limpio que no debe tocar la sesión personal. Empieza solo con uBlock.

Dos procesos Chromium no pueden compartir el mismo --remote-debugging-port.

Comprobar que hay un Chrome al que conectarse

curl -s --max-time 2 http://127.0.0.1:9222/json/version | head -c 300   # vacío => no hay CDP en 9222

Si no responde, lanza uno (perfil dedicado de automatización recomendado para no tocar lo del usuario):

# Visible (recomendado al desarrollar el flujo), perfil dedicado del proyecto, ventana fija 1366x768:
$SN launch --port 9333 --profile-directory Automation

# Conectarse a un Chrome ya vivo no requiere launch: los comandos rápidos usan --port.

Gotcha de sistema: lanzar chromium directamente desde una tool de shell del agente puede dar exit-144 (el harness mata el cgroup). script-navegador launch ya esquiva esto. Si lanzas chromium a mano, usa systemd-run --user --unit=<x> --collect chromium .... Ver CHROMIUM_SYSTEM.md y la memoria harness-exit-144-chromium.

Elegir la pestaña (tab) sobre la que operar

Los comandos rápidos del binario (open, click, eval...) actúan sobre la primera tab de tipo page. Las funciones AX en cambio reciben un tab_id explícito. Para listarlos:

$SN tabs                                              # legible: título | url de cada page
curl -s http://127.0.0.1:9222/json | $PY -c 'import sys,json; [print(t["id"], t["url"]) for t in json.load(sys.stdin) if t["type"]=="page"]'

1. Presupuesto de contexto (el corazón anti-colapso)

Acción Coste típico en tokens Veredicto
script-navegador html (outerHTML completo) 5.000 100.000+ nunca al contexto
Screenshot pegado como imagen/base64 enorme solo a archivo (shot /tmp/x.png), descríbelo, no lo pegues
AX tree completo (cdp_get_ax_tree sin recortar) 5.000 50.000 ⚠️ recórtalo siempre antes de mirarlo
AX tree recortado (trim_ax_tree, render outline) 200 3.000 vista por defecto para entender
eval que devuelve un JSON de campos extraídos 50 1.000 vista por defecto para extraer

Reglas duras:

  • Si necesitas el HTML para procesarlo (regex, parser), mándalo a archivo y trabájalo con herramientas, no lo leas entero: $SN html > /tmp/page.html y luego grep/funciones del registry.
  • Antes de mirar un AX tree, pásalo por trim_ax_tree. Si sigue siendo grande, chunk_ax_tree y procesa por partes.
  • Para extraer datos estructurados, prefiere un eval con un snippet JS que ya devuelva el array de objetos final. El DOM se queda en el navegador; al contexto solo llega el resultado.

2. Capacidades — tabla maestra

Eje Qué hay hoy Cómo se invoca Gap (hoy a mano via CDP crudo)
Ejecutar JS cdp_evaluate $SN eval '<js>'
AX tree (semántico) cdp_get_ax_tree + trim_ax_tree + chunk_ax_tree + llm_propose_scraping_schema heredoc Python (§3.6) render a outline texto (no existe función; snippet en §3.6)
DOM leer/interactuar cdp_get_html cdp_find_by_text cdp_click cdp_click_text cdp_type_text cdp_wait_element $SN open/click/type/wait/html set-attribute / remove-node dedicados (se hacen via eval)
Pestañas crear cdp_new_tab cdp_open_url_and_wait; listar cdp_list_tabs $SN tabs; heredoc cerrar y activar/focus tab (CDP crudo, §3.2)
Network capturar cdp_har_record; cookies cdp_set_cookie; HLS extract_hls_from_cdp_tab heredoc / fn run interceptar / bloquear / modificar (Fetch domain, §3.3)
Ventanas todo (get/set bounds, min/max/fullscreen) via CDP crudo (§3.1)

Los gaps están listados como candidatos a función / tool MCP en §6.


3. Recetas por capacidad

3.1 Ventanas (CRUD) — hoy via CDP crudo

No hay función del registry todavía. El control de ventana es browser-level (no de página), así que se habla por el WebSocket de /json/version (webSocketDebuggerUrl), no por el de una tab. Secuencia: Browser.getWindowForTargetwindowIdBrowser.setWindowBounds.

# Mover/redimensionar la ventana de un target (tab). Requiere el targetId de la tab.
PORT=9222 PY=$PY $PY - <<'PYEOF'
import os, json, urllib.request, websocket
port = int(os.environ["PORT"])
# 1) targetId de la primera page
tabs = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json"))
page = next(t for t in tabs if t["type"] == "page")
target_id = page["id"]
# 2) ws browser-level
ver = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version"))
ws = websocket.create_connection(ver["webSocketDebuggerUrl"])
def call(mid, method, params):
    ws.send(json.dumps({"id": mid, "method": method, "params": params}))
    while True:
        m = json.loads(ws.recv())
        if m.get("id") == mid:
            return m
win = call(1, "Browser.getWindowForTarget", {"targetId": target_id})["result"]
wid = win["windowId"]
# 3) set bounds (normal). Para maximizar: {"windowState":"maximized"} solo.
call(2, "Browser.setWindowBounds", {"windowId": wid,
     "bounds": {"left": 0, "top": 0, "width": 1366, "height": 768, "windowState": "normal"}})
print(json.dumps(call(3, "Browser.getWindowForTarget", {"targetId": target_id})["result"]))
ws.close()
PYEOF

Estados válidos de windowState: normal, minimized, maximized, fullscreen. Para maximizar/minimizar, manda solo {"windowState": "maximized"} sin width/height.

3.2 Pestañas (CRUD)

# CREATE — abrir URL en tab nuevo y esperar a que cargue (devuelve tab_id):
TAB_ID="$(PORT=9222 URL='https://example.com' $PY - <<'PYEOF'
import os, sys; sys.path.insert(0, "python/functions")
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
print(cdp_open_url_and_wait(int(os.environ["PORT"]), os.environ["URL"], timeout_s=20))
PYEOF
)"; echo "tab=$TAB_ID"

# READ — listar tabs:
$SN tabs
curl -s http://127.0.0.1:9222/json | $PY -c 'import sys,json; [print(t["id"], t["title"][:40], t["url"]) for t in json.load(sys.stdin) if t["type"]=="page"]'

# DELETE — cerrar un tab por id (endpoint HTTP, sin WebSocket):
curl -s "http://127.0.0.1:9222/json/close/$TAB_ID" >/dev/null && echo "cerrado $TAB_ID"

# UPDATE — activar / traer al frente un tab por id:
curl -s "http://127.0.0.1:9222/json/activate/$TAB_ID" >/dev/null && echo "activado $TAB_ID"

cerrar y activar son gaps de función (se hacen con curl al endpoint /json/). Candidatos a cdp_close_tab / cdp_activate_tab en §6.

3.3 Network (capturar / interceptar)

Capturar tráfico (mapear las APIs reales que usa una página — recon/pentesting). La función cdp_har_record registra todas las peticiones HTTP/WS que ocurren mientras corre una acción y devuelve un HAR 1.2:

# Capturar el tráfico de una recarga de página y guardarlo como HAR:
PORT=9222 OUT=/tmp/capture.har $PY - <<'PYEOF'
import os, sys, json; sys.path.insert(0, "functions")  # las cdp_*_go_browser son Go; ver nota
# NOTA: cdp_har_record es Go. Para HAR desde Python usa el flujo de web_proxy (mitm) o
# llama al binario que la compone. Aquí el patrón equivalente en CDP crudo:
PYEOF
echo "Para HAR: usa la app web_proxy (proxy mitm 8889) o cdp_har_record vía un binario Go."

Nota de stack: cdp_har_record, cdp_set_cookie, etc. son funciones Go (*_go_browser). Desde el agente se consumen compiladas dentro de un binario (como script-navegador) o, para captura de tráfico de largo recorrido, via la app web_proxy (mitmproxy, proxy 127.0.0.1:8889, UI http://127.0.0.1:8081). Ver CHROMIUM_SYSTEM.md §"Proxy / captura mitm".

Interceptar / bloquear / modificar peticiones (ej. bloquear imágenes y trackers para acelerar y limpiar el scraping) — gap de función, hoy via CDP crudo con el dominio Fetch:

# Bloquear patrones de URL en la tab actual mientras dure la conexión (Ctrl-C para soltar):
PORT=9222 TAB_ID="$TAB_ID" BLOCK='*.png,*.jpg,*.gif,*doubleclick*,*googlesyndication*' $PY - <<'PYEOF'
import os, json, fnmatch, urllib.request, websocket
port=int(os.environ["PORT"]); tab=os.environ["TAB_ID"]
patterns=[p.strip() for p in os.environ["BLOCK"].split(",") if p.strip()]
tabs=json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json"))
ws_url=next(t["webSocketDebuggerUrl"] for t in tabs if t["id"]==tab)
ws=websocket.create_connection(ws_url)
ws.send(json.dumps({"id":1,"method":"Fetch.enable","params":{"patterns":[{"urlPattern":"*"}]}}))
print("interceptando; Ctrl-C para parar")
while True:
    m=json.loads(ws.recv())
    if m.get("method")=="Fetch.requestPaused":
        rid=m["params"]["requestId"]; url=m["params"]["request"]["url"]
        if any(fnmatch.fnmatch(url, p) for p in patterns):
            ws.send(json.dumps({"id":99,"method":"Fetch.failRequest","params":{"requestId":rid,"errorReason":"BlockedByClient"}}))
        else:
            ws.send(json.dumps({"id":99,"method":"Fetch.continueRequest","params":{"requestId":rid}}))
PYEOF

Candidato a cdp_intercept_requests / cdp_block_urls en §6.

3.4 DOM — leer e interactuar

$SN open  https://example.com           # navega la tab activa, espera carga + red en reposo (SPAs)
$SN wait  '#resultados'                  # espera a que un selector exista (timeout-ms configurable)
$SN click '.boton-aceptar'               # click con trayectoria de ratón Bézier (anti-detección)
$SN click '.x' --instant                 # click directo (si el elemento no tiene bounding box)
$SN type  'input[name=q]' 'mi búsqueda'  # enfoca con click y escribe carácter a carácter
$SN shot  /tmp/page.png                  # screenshot a ARCHIVO (no al contexto). --full = página entera

Modificar el DOM (set attribute, quitar nodos, rellenar valores) — vía eval:

$SN eval 'document.querySelector("#banner")?.remove(); "removed"'
$SN eval 'document.querySelector("input#token").value="abc"; "set"'

Click robusto por texto visible (cuando el selector CSS es frágil) — usa cdp_find_by_text / cdp_click_text (Go, dentro del binario) o, equivalente en eval:

$SN eval '[...document.querySelectorAll("button")].find(b=>b.innerText.trim()==="Siguiente")?.click(); "clicked"'

3.5 Ejecutar JavaScript — la vía de extracción compacta

eval devuelve el resultado serializado. Haz que el JS devuelva solo lo extraído, no el DOM:

# Título y nº de resultados — devuelve 2 valores, no la página:
$SN eval 'JSON.stringify({title: document.title, n: document.querySelectorAll(".item").length})'

# Extraer una tabla a array de objetos (esto es lo que llega al contexto, ~compacto):
$SN eval '
JSON.stringify([...document.querySelectorAll("table.precios tbody tr")].map(tr => {
  const td = tr.querySelectorAll("td");
  return { producto: td[0]?.innerText.trim(), precio: td[1]?.innerText.trim() };
}))'

3.6 AX tree — la vista semántica anti-colapso (receta estrella)

El accessibility tree es cómo los lectores de pantalla "ven" la página: una jerarquía de roles (button, link, heading, textbox, list...) con su nombre accesible. trim_ax_tree ya descarta nodos ignorados, generic/none vacíos y StaticText vacíos, y colapsa cadenas de un solo hijo. El resultado es una vista compacta y navegable de la página.

Receta canónica — AX snapshot a outline de texto (compón cdp_get_ax_tree + trim_ax_tree y renderiza a líneas indentadas role "nombre"):

# Requiere TAB_ID (ver §3.2). Devuelve un outline compacto, NO el HTML.
PORT=9222 TAB_ID="$TAB_ID" MAXLINES=400 $PY - <<'PYEOF'
import os, sys; sys.path.insert(0, "python/functions")
from pipelines.cdp_get_ax_tree import cdp_get_ax_tree
from core.trim_ax_tree import trim_ax_tree

port=int(os.environ["PORT"]); tab=os.environ["TAB_ID"]; maxlines=int(os.environ.get("MAXLINES","400"))
nodes = trim_ax_tree(cdp_get_ax_tree(port, tab))

by_id = {n["nodeId"]: n for n in nodes}
children = set(c for n in nodes for c in n.get("childIds", []))
roots = [n for n in nodes if n["nodeId"] not in children] or nodes[:1]

def field(n, k):
    v = n.get(k, {})
    return v.get("value", "") if isinstance(v, dict) else (v or "")

out, count = [], 0
def walk(n, depth):
    global count
    if count >= maxlines: return
    role = field(n, "role"); name = field(n, "name")
    if role:                                   # omite nodos sin role
        line = "  "*depth + role + (f' "{name}"' if name else "")
        out.append(line[:200]); count += 1
    for cid in n.get("childIds", []):
        ch = by_id.get(cid)
        if ch: walk(ch, depth+1)
for r in roots: walk(r, 0)

print("\n".join(out))
if count >= maxlines:
    print(f"... [truncado a {maxlines} líneas; sube MAXLINES o usa chunk_ax_tree]")
PYEOF

Salida típica (compacta, lista para razonar):

WebArea "Example Domain"
  heading "Example Domain"
  paragraph "This domain is for use in illustrative examples..."
  link "More information..."

Página enorme — trocea con chunk_ax_tree y procesa chunk a chunk (cada chunk ≤ max_chars y arranca con un nodo context que da el path desde la raíz):

PORT=9222 TAB_ID="$TAB_ID" $PY - <<'PYEOF'
import os, sys, json; sys.path.insert(0, "python/functions")
from pipelines.cdp_get_ax_tree import cdp_get_ax_tree
from core.trim_ax_tree import trim_ax_tree
from core.chunk_ax_tree import chunk_ax_tree
nodes = trim_ax_tree(cdp_get_ax_tree(int(os.environ["PORT"]), os.environ["TAB_ID"]))
chunks = chunk_ax_tree(nodes, max_chars=25000)
print(f"{len(nodes)} nodos -> {len(chunks)} chunks")   # procesa chunks[i] de uno en uno
PYEOF

Proponer un schema de scraping automáticamente desde el AX tree (orquesta trim → chunk → LLM): función llm_propose_scraping_schema(url, ax_tree, ...) (*_py_infra).


4. Recetas de tarea end-to-end

4.1 Entender una página desconocida sin colapsar

  1. $SN open <url> (o crea tab con cdp_open_url_and_wait, §3.2) y captura el TAB_ID.
  2. AX snapshot a outline (§3.6). Esto es lo que lees, no el HTML.
  3. Si necesitas un detalle puntual, $SN eval '<js que devuelve solo ese dato>'.

4.2 Extraer datos estructurados (scraping)

  1. Abre y espera ($SN open ya espera carga + red en reposo; para SPAs añade $SN wait '<selector>').
  2. AX snapshot para localizar los contenedores/roles relevantes.
  3. Un único $SN eval que devuelva el array de objetos final (§3.5). Solo el resultado llega al contexto. Persiste a vaults/ o a archivo si es grande.

4.3 Login + scrape de un SPA

  1. $SN open <login_url>; $SN type 'input[name=user]' '<u>'; $SN type 'input[name=pass]' '<p>'; $SN click 'button[type=submit]'.
  2. $SN wait '<selector que solo existe logueado>' (espera real, no sleep).
  3. Navega y extrae con eval (§4.2). Sobre el puerto 9222 reaprovechas la sesión ya logueada del usuario; sobre un perfil dedicado, el login persiste en su user-data-dir entre ejecuciones.

4.4 Mapear la API oculta de un sitio (recon)

  1. Arranca la captura: app web_proxy (proxy mitm 127.0.0.1:8889) o cdp_har_record envolviendo la acción. Para acelerar, bloquea imágenes/trackers con el interceptor Fetch (§3.3).
  2. Navega/interactúa para disparar las llamadas ($SN open, $SN click).
  3. Consulta el tráfico: ./web_proxy query "~d dominio.com" --last, ./web_proxy har salida.har. Los endpoints XHR/fetch que aparecen son las APIs reales del sitio.

5. Gotchas (además de los de CHROMIUM_SYSTEM.md)

  • No leas el HTML al contexto. Es el error de colapso nº 1. AX outline o eval con resultado.
  • tab_idtargetId de ventana. Las funciones AX usan el id de /json (la tab). El control de ventana (§3.1) usa el webSocketDebuggerUrl browser-level de /json/version.
  • Page.loadEventFired no dispara en SPAs con routing sin recarga. Usa $SN wait '<selector>' o cdp_wait_idle (red en reposo) en lugar de fiarte del load event.
  • Selector de perfil: si lanzas chromium con varios perfiles sin --profile-directory, Chrome se atasca en el picker y CDP opera sobre una ventana vacía. Siempre pasa el perfil. Ver CHROMIUM_SYSTEM.md.
  • --remote-allow-origins=* entre comillas en zsh (el * se expande como glob).
  • Lock por user-data-dir: dos chromium no comparten el mismo --user-data-dir. Para automatizar en paralelo a la sesión del usuario, usa un --user-data-dir dedicado.
  • El proxy mitm (8889) puede ralentizar sitios externos. Si una tab se cuelga durante captura, sube timeouts o revisa el upstream del proxy. Ver CHROMIUM_SYSTEM.md.
  • AX tree obsoleto tras cambios JS: el snapshot es del momento. Tras un click que muta el DOM, vuelve a pedir el AX tree.

6. Gaps → hoja de ruta del MCP

Cuando montemos el servidor MCP de navegador (para que cualquier LLM use estas capacidades como tools nativas, sin recordar comandos), cada fila se vuelve una tool. Las marcadas "función pendiente" hoy se hacen con el CDP crudo de §3 y son candidatas a fn-constructor antes/junto al MCP.

Tool MCP propuesta Estado de la capacidad subyacente Origen
browser_tabs_list / _new / _open_wait función existe cdp_list_tabs, cdp_new_tab, cdp_open_url_and_wait
browser_tab_close / _activate función pendiente (/json/close, /json/activate) §3.2
browser_ax_snapshot (outline compacto) función pendiente (compón get+trim+render) §3.6
browser_ax_chunks chunk_ax_tree §3.6
browser_eval cdp_evaluate §3.5
browser_click / _type / _wait funciones Go §3.4
browser_screenshot (a archivo, devuelve ruta) cdp_screenshot §3.4
browser_window_get / _set_bounds función pendiente (Browser.*WindowBounds) §3.1
browser_net_capture_har cdp_har_record (Go) §3.3
browser_net_block / _intercept función pendiente (Fetch domain) §3.3
browser_set_cookie cdp_set_cookie (Go)

Principio de diseño del MCP: las tools devuelven siempre representaciones compactas — AX outline, JSON de campos, rutas de archivo para screenshots/HAR — nunca el HTML o la imagen cruda. El anti-colapso se hace cumplir en el borde de la tool, no en la disciplina del LLM.


7. Cheatsheet

SN=projects/web_scraping/apps/script_navegador/script-navegador
PY=python/.venv/bin/python3

$SN launch --port 9333 --profile-directory Automation   # chrome dedicado aislado
$SN tabs                                                 # listar pestañas
$SN open  https://sitio.com                              # navegar + esperar
$SN wait  '#listo'                                       # esperar selector
$SN click '.btn'                                         # click humano (Bézier)
$SN type  'input[name=q]' 'texto'                        # escribir
$SN eval  'JSON.stringify({t:document.title})'           # extraer compacto
$SN shot  /tmp/x.png                                     # screenshot a archivo

# AX snapshot compacto (NO html) -> ver §3.6 para el heredoc completo
# tab nuevo:    cdp_open_url_and_wait(port, url)         (§3.2)
# cerrar tab:   curl .../json/close/<id>                 (§3.2)
# bloquear net: Fetch.enable + failRequest               (§3.3)
# ventana:      Browser.getWindowForTarget/setWindowBounds (§3.1)

Norma única que lo resume todo: para mirar una página → AX outline; para sacar datos → eval que devuelve solo el resultado; el HTML crudo y los screenshots van a archivo, jamás al contexto.