feat(browser): auto-commit con 44 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:49:54 +02:00
parent e2c073b8b7
commit 5b10b419a2
44 changed files with 2543 additions and 28 deletions
+159
View File
@@ -0,0 +1,159 @@
---
description: "Modo launcher: das ordenes en lenguaje natural y Claude responde SOLO con la procedencia (registry/bash/heredoc) + el comando exacto, y lo ejecuta. Agiliza el lanzamiento de comandos y audita en vivo el Reg % (uso real de funciones del registry)."
---
# /modo_launcher — lanzamiento rápido registry-first
Activa un **modo de comportamiento** persistente. Mientras estás dentro, el usuario da órdenes en lenguaje natural y Claude responde con el **mínimo absoluto**: la procedencia del comando + el comando exacto + por qué, y lo ejecuta. Sin prosa, sin explicaciones largas, sin preámbulos.
El objetivo es doble:
1. **Agilizar** el lanzamiento de comandos (cero verborrea entre orden y ejecución).
2. **Auditar en vivo** que de verdad pasamos por funciones del registry antes que por bash inline — sube `Reg %` (objetivo 1 del Norte) y expone gaps reutilizables (objetivo 3).
## Activación
Al invocar `/modo_launcher` entras en **MODO LAUNCHER**. El modo permanece activo en todos los turnos siguientes hasta que el usuario escriba `salir` o `fin launcher`. No hay hook: el modo se sostiene por estas instrucciones mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el usuario puede re-invocar `/modo_launcher` para reanclarlo.
Al entrar, responde con una sola línea de confirmación y queda a la espera:
```
MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
```
## Comportamiento por orden (regla dura)
Para CADA orden del usuario mientras el modo esté activo:
1. **Registry-first.** Mapea la orden a una capacidad y busca primero en el registry vía FTS (`mcp__registry__fn_search`) o reconoce un ID conocido. Las funciones del registry SIEMPRE tienen prioridad sobre bash inline.
2. **Clasifica la procedencia** según la taxonomía de abajo.
3. **Ejecuta directo.** Identificado el comando, ejecútalo sin pedir permiso — salvo que sea destructivo (ver guarda).
4. **Responde en el formato fijo** (abajo), con la salida cruda del comando. Nada más.
## Formato de respuesta (OBLIGATORIO en cada orden)
```
FUENTE: <etiqueta>
CMD: <comando exacto>
WHY: <razón: match FTS, ID conocido, o "sin función → bash">
──────────
<salida cruda del comando>
```
- `FUENTE` es una de las etiquetas de la taxonomía.
- `CMD` es el comando literal lanzado (forma `./fn run <id> [args]` para legibilidad aunque la ejecución real vaya por MCP).
- `WHY` es una línea: qué match de búsqueda o qué ID justifica esa elección. Si fue un gap, dilo.
- Tras la regla `──────────`, la salida cruda. Cero comentario después salvo que el usuario pregunte.
## Taxonomía de procedencia
| Etiqueta | Qué es | Cómo se ejecuta |
|---|---|---|
| `registry-run` | Ejecutar UNA función o pipeline del registry | `mcp__registry__fn_run <id> [args]` (preferido); fallback `./fn run <id> [args]` |
| `registry-mcp` | Inspeccionar el registro (buscar, ver, código, deps, dominios) | `mcp__registry__fn_search` / `fn_show` / `fn_code` / `fn_uses` / `fn_list_domains` |
| `heredoc` | Componer N funciones con lógica intermedia (loops, dispatch) | Heredoc `python/.venv/bin/python3 - <<'PY' ... PY` importando del registry |
| `bash` | Comando shell puro: no existe función que lo cubra | Bash directo |
| `gap` | No hay función Y el patrón parece reutilizable | Ejecuta el bash equivalente y marca el candidato (ver abajo) |
### Preferencia de ejecución para `registry-run`
- Usa `mcp__registry__fn_run` cuando esté disponible (queda registrado en `call_monitor`, alimenta el bucle reactivo).
- Si el MCP `fn_run` no está habilitado (requiere `--enable-run`), cae a `./fn run <id>` por terminal. La línea `CMD` muestra siempre la forma `./fn run <id>` por legibilidad.
## Gaps: orden sin función en el registry
Cuando una orden no tenga función que la cubra:
1. Ejecuta el bash equivalente (`FUENTE: bash`).
2. Si el patrón parece **reutilizable** (firma genérica, se repetiría en otras tareas, ≥5 líneas de lógica), añade tras la salida UNA línea:
```
CANDIDATO → <nombre_propuesto>_<lang>_<domain>: <1 frase de qué haría>
```
No lances `fn-constructor` automáticamente dentro del modo (rompería el ritmo de lanzamiento). Solo marca. El usuario decide al salir si promueve los candidatos.
## Guarda de comandos destructivos
Ejecuta directo SALVO que el comando sea irreversible o de alto impacto. En esos casos, NO ejecutes: muestra el bloque con `FUENTE`/`CMD`/`WHY` y añade `⚠ DESTRUCTIVO — confirma con 'ok'` en vez de la salida. Espera el `ok` explícito del usuario antes de lanzar.
Patrones que exigen confirmación:
- `rm -rf`, borrado de archivos versionados, `> archivo` sobre archivos trackeados.
- `git push --force`, `git reset --hard`, `git clean`, borrado de ramas.
- SQL `DROP`, `DELETE` sin `WHERE`, `TRUNCATE`, borrar cualquier `.db`.
- `deploy`, `systemctl stop/restart/disable` de services, `fn sync` (escribe en el servidor).
- `kill -9` masivo, `format`, `mkfs`, `dd`, cambios en `fstab`.
Para todo lo demás (lecturas, búsquedas, `fn run` de funciones puras o idempotentes, `git status/add/commit`, listados), ejecuta directo.
## Salida del modo
Cuando el usuario escriba `salir` o `fin launcher`, cierra el modo con un resumen caveman de una tabla:
```
=== fin MODO LAUNCHER ===
ordenes: N
registry: X (run A / mcp B)
bash: Y
gaps: Z → [lista de candidatos marcados]
Reg %: X/(X+Y) de las ordenes ejecutables golpearon el registry
```
Si hubo candidatos a función (`gap`), recuérdalos y pregunta si promover alguno vía `fn-constructor`.
## Reglas duras del modo
- **Registry-first siempre.** Nunca escribas bash inline para lógica que ya es función del registry. Busca antes de teclear.
- **Cero prosa fuera del bloque.** Nada de "Claro, voy a…". Solo el bloque `FUENTE/CMD/WHY/salida`.
- **Una orden = un bloque.** Si la orden necesita varios pasos, encadénalos en un solo `CMD` o numera los bloques, pero mantén el formato.
- **No inventes IDs.** Si no encuentras función por búsqueda, es `gap`, no un `registry-run` falso.
- **El modo no exime de las reglas del repo** (`registry_calls.md`, `purity.md`, etc.). Solo cambia el estilo de respuesta.
## Ejemplo de sesión
```
tú: /modo_launcher
yo: MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
tú: busca funciones de slice
yo: FUENTE: registry-mcp
CMD: mcp__registry__fn_search query="slice"
WHY: inspección directa del registro
──────────
filter_slice_go_core — Filtra slice por predicado
chunk_slice_go_core — Parte slice en lotes de N
...
tú: mata lo que escuche en el puerto 8484
yo: FUENTE: registry-run
CMD: ./fn run port_kill_bash_infra 8484
WHY: match FTS 'port kill' → port_kill_bash_infra
──────────
killed pid 33120 on :8484
tú: enséñame el uso de disco de home
yo: FUENTE: bash
CMD: du -sh /home/enmanuel/* | sort -rh | head
WHY: sin función → utilidad de sistema puntual
──────────
12G /home/enmanuel/fn_registry
...
CANDIDATO → disk_usage_top_bash_shell: top-N directorios por tamaño en una ruta
tú: salir
yo: === fin MODO LAUNCHER ===
ordenes: 3
registry: 2 (run 1 / mcp 1)
bash: 1
gaps: 1 → disk_usage_top_bash_shell
Reg %: 2/3 (67%)
1 candidato marcado. ¿Promuevo disk_usage_top_bash_shell vía fn-constructor?
```
## Relación con otras reglas
- `registry_calls.md` — el modo es una capa de estilo sobre los tres patrones canónicos (inspect / run / compose).
- `registry_first.md` — el modo materializa "buscar antes de escribir" en cada orden.
- `function_growth_and_self_docs.md` — los candidatos marcados alimentan la promoción de patrones inline a funciones.
- `kiss.md` — sin hook, sin estado en disco: el modo vive solo en estas instrucciones.
+66
View File
@@ -0,0 +1,66 @@
---
id: cdp_activate_tab_go_browser
name: cdp_activate_tab
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Pone una pestaña Chrome en foreground (foco) por su ID via GET /json/activate/<id>. Sin WebSocket — solo HTTP. Útil para traer al frente una pestaña específica antes de capturar pantalla o interactuar con ella."
tags: [cdp, browser, tabs, navegator]
signature: "func CdpActivateTab(host string, port int, tabID string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_tabs.go"
example: |
tabs, _ := browser.CdpListTabs("localhost", 9222)
// Activar la primera pestaña cuyo título contenga "Dashboard"
for _, t := range tabs {
if strings.Contains(t.Title, "Dashboard") {
_ = browser.CdpActivateTab("localhost", 9222, t.ID)
break
}
}
params:
- name: host
desc: "Hostname de la instancia Chrome (vacío = localhost)"
- name: port
desc: "Puerto CDP de remote debugging (habitualmente 9222)"
- name: tabID
desc: "ID de la pestaña a activar, obtenido de CdpTab.ID via CdpListTabs"
output: "nil si la pestaña pasó a foreground correctamente; error si tabID está vacío, la conexión falla o Chrome devuelve status != 200"
---
## Ejemplo
```go
// Listar tabs y traer al frente la que corresponda a una URL concreta
tabs, err := browser.CdpListTabs("localhost", 9222)
if err != nil {
log.Fatal(err)
}
for _, t := range tabs {
if t.URL == "https://metabase.local/dashboard/1" {
if err := browser.CdpActivateTab("localhost", 9222, t.ID); err != nil {
log.Printf("error activando tab %s: %v", t.ID, err)
}
break
}
}
```
## Cuando usarla
Antes de hacer un screenshot o interactuar via CDP con una pestaña concreta que podría estar en segundo plano. También útil en dashboards que muestran el inventario de pestañas y necesitan enfocar una al hacer clic.
## Gotchas
- No requiere conexión WebSocket previa; usa HTTP puro contra `/json/activate/<id>`.
- Solo cambia el foco dentro del contexto CDP; si la ventana de Chrome está minimizada a nivel de OS, `activate` la pone como pestaña activa dentro de Chrome pero no restaura la ventana.
- El ID debe obtenerse de `CdpListTabs` — no es el índice visible del tab, es el UUID interno de Chrome.
- Si el tabID no existe, Chrome devuelve un status HTTP distinto de 200 y la función retorna error.
+12
View File
@@ -0,0 +1,12 @@
package browser
// CdpClearCookies borra TODAS las cookies del browser via Network.clearBrowserCookies.
// Equivalente a "Borrar datos de navegacion > Cookies" en Chrome.
// Cierra todas las sesiones activas — usar solo en tests o resets completos.
func CdpClearCookies(c *CDPConn) error {
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return err
}
_, err := c.sendCDP("Network.clearBrowserCookies", nil)
return err
}
+54
View File
@@ -0,0 +1,54 @@
---
id: cdp_clear_cookies_go_browser
name: cdp_clear_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Borra TODAS las cookies del browser via Network.clearBrowserCookies; equivalente a 'Borrar datos de navegacion > Cookies' en Chrome."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpClearCookies(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_clear_cookies.go"
example: |
conn, _ := CdpConnect(9222)
if err := CdpClearCookies(conn); err != nil {
log.Fatal(err)
}
// browser ahora sin cookies — todas las sesiones cerradas
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
output: "nil si se borraron todas las cookies; error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Reset completo antes de un test de login
if err := CdpClearCookies(conn); err != nil {
log.Fatal(err)
}
// A partir de aqui el browser no tiene sesion en ningun dominio
```
## Cuando usarla
Usar al inicio de un test e2e que necesita partir de un browser sin sesion previa, o cuando quieres resetear completamente el estado de autenticacion del browser en un entorno de CI.
## Gotchas
- Destructivo e irreversible: cierra TODAS las sesiones activas en todos los dominios del browser.
- Llama `Network.enable` internamente antes del clear; es idempotente.
- No afecta a LocalStorage ni SessionStorage — solo cookies.
- Para borrar solo una cookie especifica usar `CdpDeleteCookies` en su lugar.
- En un browser de perfil de usuario real (no headless de test) puede cerrar sesiones de trabajo activas.
+12 -8
View File
@@ -14,11 +14,19 @@ func CdpClick(c *CDPConn, selector string) error {
return fmt.Errorf("cdp click: conexion nula")
}
// Obtener coordenadas del centro del elemento
// Obtener coordenadas del centro del elemento, tras hacer scroll para que sea
// visible. Verificamos visibilidad: un elemento existente pero oculto
// (display:none, visibility:hidden, opacity 0 o tamaño 0) daria un rect en
// (0,0) y clicariamos en la esquina sin efecto — devolvemos error en su lugar.
js := fmt.Sprintf(`(function() {
var el = document.querySelector(%q);
if (!el) return null;
el.scrollIntoView({block:'center'});
var r = el.getBoundingClientRect();
var s = window.getComputedStyle(el);
var visible = r.width > 0 && r.height > 0 &&
s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0';
if (!visible) return '__HIDDEN__';
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
})()`, selector)
@@ -29,6 +37,9 @@ func CdpClick(c *CDPConn, selector string) error {
if coordStr == "" || coordStr == "null" {
return fmt.Errorf("cdp click: elemento %q no encontrado en el DOM", selector)
}
if strings.Contains(coordStr, "__HIDDEN__") {
return fmt.Errorf("cdp click: elemento %q existe pero no es visible/clickable (display:none, oculto, opacity 0 o tamaño 0)", selector)
}
// Parsear "{x:...,y:...}" — CdpEvaluate ya retorna el JSON como string
coordStr = strings.Trim(coordStr, `"`)
@@ -37,13 +48,6 @@ func CdpClick(c *CDPConn, selector string) error {
return fmt.Errorf("cdp click: parsear coordenadas %q: %w", coordStr, err)
}
// Hacer scroll al elemento para que este visible
scrollJS := fmt.Sprintf(`document.querySelector(%q).scrollIntoView({block:'center'})`, selector)
if _, err := CdpEvaluate(c, scrollJS); err != nil {
// No es fatal si el scroll falla
_ = err
}
// Despachar mousedown
mouseParams := map[string]any{
"type": "mousePressed",
+63
View File
@@ -0,0 +1,63 @@
---
id: cdp_close_tab_go_browser
name: cdp_close_tab
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Cierra una pestaña Chrome por su ID via GET /json/close/<id>. Sin WebSocket — solo HTTP. Util para limpiar pestañas abiertas por automatizaciones."
tags: [cdp, browser, tabs, navegator]
signature: "func CdpCloseTab(host string, port int, tabID string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_tabs.go"
example: |
tabs, _ := browser.CdpListTabs("localhost", 9222)
for _, t := range tabs {
if t.URL == "https://example.com" {
_ = browser.CdpCloseTab("localhost", 9222, t.ID)
}
}
params:
- name: host
desc: "Hostname de la instancia Chrome (vacío = localhost)"
- name: port
desc: "Puerto CDP de remote debugging (habitualmente 9222)"
- name: tabID
desc: "ID de la pestaña a cerrar, obtenido de CdpTab.ID via CdpListTabs"
output: "nil si la pestaña se cerró correctamente; error si tabID está vacío, la conexión falla o Chrome devuelve status != 200"
---
## Ejemplo
```go
// Listar tabs y cerrar la primera que coincida con una URL
tabs, err := browser.CdpListTabs("localhost", 9222)
if err != nil {
log.Fatal(err)
}
for _, t := range tabs {
if t.URL == "https://example.com/login" {
if err := browser.CdpCloseTab("localhost", 9222, t.ID); err != nil {
log.Printf("error cerrando tab %s: %v", t.ID, err)
}
}
}
```
## Cuando usarla
Después de terminar una sesión de scraping o automatización: cierra las pestañas abiertas programáticamente sin afectar el resto del perfil. También útil para liberar recursos cuando `CdpNewTab` ha creado muchas pestañas temporales.
## Gotchas
- No requiere conexión WebSocket previa; usa HTTP puro contra `/json/close/<id>`.
- Si Chrome ya cerró la pestaña (o el ID es inválido), devuelve error de status HTTP.
- El ID debe obtenerse de `CdpListTabs` — no es el índice visible del tab, es el UUID interno de Chrome.
- No espera confirmación de cierre; para saber si la pestaña desapareció, volver a llamar `CdpListTabs`.
+17 -14
View File
@@ -67,18 +67,9 @@ func CdpConnect(port int) (*CDPConn, error) {
return CdpConnectHost("localhost", port)
}
// CdpConnectHost es como CdpConnect pero permite especificar el host.
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
func CdpConnectHost(host string, port int) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
wsURL, err := cdpGetPageWSURL(host, port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
// Parsear la URL del WebSocket para extraer host y path
// cdpConnectWS abre la conexion CDP a partir de un webSocketDebuggerUrl ya resuelto.
// Es el helper compartido por CdpConnectHost y CdpConnectTarget para evitar duplicacion.
func cdpConnectWS(wsURL string, port int) (*CDPConn, error) {
u, err := url.Parse(wsURL)
if err != nil {
return nil, fmt.Errorf("cdp connect: parsear ws url %q: %w", wsURL, err)
@@ -96,8 +87,7 @@ func CdpConnectHost(host string, port int) (*CDPConn, error) {
}
// Realizar handshake WebSocket
path := u.RequestURI()
reader, err := wsHandshake(tcpConn, wsHost, path)
reader, err := wsHandshake(tcpConn, wsHost, u.RequestURI())
if err != nil {
tcpConn.Close()
return nil, fmt.Errorf("cdp connect: ws handshake: %w", err)
@@ -115,3 +105,16 @@ func CdpConnectHost(host string, port int) (*CDPConn, error) {
return c, nil
}
// CdpConnectHost es como CdpConnect pero permite especificar el host.
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
func CdpConnectHost(host string, port int) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
wsURL, err := cdpGetPageWSURL(host, port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
return cdpConnectWS(wsURL, port)
}
+56
View File
@@ -0,0 +1,56 @@
package browser
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// CdpConnectTarget se conecta a un target CDP DETERMINISTA identificado por match.
//
// Si host es "" se usa "localhost".
// match puede ser:
// - "" → primer target con Type "page" y WebSocketDebuggerURL no vacío (misma
// semántica que CdpConnectHost, útil como fallback compatible).
// - ID exacto del target (campo "id" en /json).
// - Substring case-insensitive de la URL del target.
//
// Retorna error si ningún target type=page satisface el match.
func CdpConnectTarget(host string, port int, match string) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json", host, port))
if err != nil {
return nil, fmt.Errorf("cdp connect target: listar targets: %w", err)
}
defer resp.Body.Close()
var targets []cdpTarget
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return nil, fmt.Errorf("cdp connect target: decode targets: %w", err)
}
matchLower := strings.ToLower(match)
for _, t := range targets {
if t.Type != "page" || t.WebSocketDebuggerURL == "" {
continue
}
if match == "" {
// Sin filtro: primera tab page disponible.
return cdpConnectWS(t.WebSocketDebuggerURL, port)
}
// Coincidencia por ID exacto o substring de URL (case-insensitive).
if t.ID == match || strings.Contains(strings.ToLower(t.URL), matchLower) {
return cdpConnectWS(t.WebSocketDebuggerURL, port)
}
}
if match == "" {
return nil, fmt.Errorf("cdp connect target: no hay ninguna tab 'page' disponible en %s:%d", host, port)
}
return nil, fmt.Errorf("cdp connect target: no hay tab 'page' que matchee %q en %s:%d", match, host, port)
}
+58
View File
@@ -0,0 +1,58 @@
---
name: cdp_connect_target
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpConnectTarget(host string, port int, match string) (*CDPConn, error)"
description: "Conecta por CDP a un target DETERMINISTA elegido por ID exacto o substring de URL, evitando engancharse a una pestaña al azar con el CDP global en 9222."
tags: [cdp, browser, connection, security, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: host
desc: "Host donde escucha el CDP. Vacío usa 'localhost'. Útil en WSL2 para apuntar a la IP de Windows."
- name: port
desc: "Puerto CDP del navegador (habitualmente 9222)."
- name: match
desc: "Filtro de target: vacío = primera tab page (compat con CdpConnectHost); ID exacto del target; o substring case-insensitive de la URL de la pestaña."
output: "*CDPConn listo para enviar comandos CDP al target elegido. Error si ninguna tab 'page' satisface el match."
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_connect_target.go"
---
## Ejemplo
```go
// Fijar la pestaña de GitHub para que el agente no toque otras abiertas
conn, err := browser.CdpConnectTarget("", 9222, "github.com")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// Por ID exacto de target (obtenido de GET http://localhost:9222/json)
conn2, err := browser.CdpConnectTarget("", 9222, "ABCD1234-target-id")
// Compatibilidad: sin filtro = primera tab page (igual que CdpConnect)
conn3, err := browser.CdpConnectTarget("", 9222, "")
```
## Cuando usarla
Cuando un agente debe atarse a UNA pestaña concreta (por URL) y NO a la primera al azar — crítico con CDP global en 9222 para no operar sobre pestañas ajenas (banca, correo, sesiones activas). Usar en lugar de `CdpConnect`/`CdpConnectHost` siempre que el contexto del agente sea "esta URL concreta" y no "cualquier tab disponible".
## Gotchas
- Si hay varias tabs cuya URL contiene el substring dado, se elige la **primera** que aparezca en `/json` (orden interno del navegador). Para mayor precisión, usar el ID exacto del target.
- El match de URL es substring **case-insensitive**; `"github"` matchea `"https://github.com/usuario/repo"`.
- Con CDP global en 9222 y muchas pestañas abiertas, un `match=""` sigue siendo tan arriesgado como `CdpConnect`. Especificar siempre el match en producción.
- La forma más segura para agentes automatizados es lanzar un perfil Chromium dedicado con `--user-data-dir` aislado y `--remote-debugging-port` propio, de modo que `/json` solo exponga las pestañas del agente.
- `WebSocketDebuggerURL` puede cambiar entre reinicios del navegador; recalcular en cada sesión, no cachear entre ejecuciones.
+15
View File
@@ -0,0 +1,15 @@
package browser
// CdpDeleteCookies borra las cookies que coincidan con name (y opcionalmente domain)
// via Network.deleteCookies. Si domain es "" se borran todas las cookies con ese
// nombre en cualquier dominio.
func CdpDeleteCookies(c *CDPConn, name, domain string) error {
params := map[string]any{
"name": name,
}
if domain != "" {
params["domain"] = domain
}
_, err := c.sendCDP("Network.deleteCookies", params)
return err
}
+61
View File
@@ -0,0 +1,61 @@
---
id: cdp_delete_cookies_go_browser
name: cdp_delete_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Borra las cookies que coincidan con name (+ domain opcional) via Network.deleteCookies; si domain es vacío elimina en todos los dominios."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpDeleteCookies(c *CDPConn, name, domain string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_delete_cookies.go"
example: |
conn, _ := CdpConnect(9222)
// Borrar cookie de sesion solo en el dominio concreto
err := CdpDeleteCookies(conn, "session_id", "app.example.com")
// Borrar en todos los dominios (sin filtro de dominio)
err = CdpDeleteCookies(conn, "tracking_cookie", "")
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
- name: name
desc: "Nombre exacto de la cookie a borrar; obligatorio para Network.deleteCookies"
- name: domain
desc: "Dominio donde borrar la cookie; cadena vacía borra en todos los dominios que tengan esa cookie"
output: "nil si la cookie fue borrada (o no existia); error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Borrar cookie de sesion solo en dominio especifico
if err := CdpDeleteCookies(conn, "session_id", "app.example.com"); err != nil {
log.Fatal(err)
}
// Borrar cookie en todos los dominios
if err := CdpDeleteCookies(conn, "analytics_token", ""); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Usar cuando necesitas forzar un logout de sesion especifica, limpiar una cookie de tracking antes de un test, o resetear el estado de autenticacion de un dominio concreto sin tocar el resto de cookies.
## Gotchas
- `name` es obligatorio en `Network.deleteCookies`; CDP devuelve error si se omite.
- Sin `domain`, CDP borra la cookie en TODOS los dominios que tengan esa cookie — puede cerrar sesiones inesperadas en otros dominios abiertos.
- No devuelve error si la cookie no existia; la operacion es idempotente.
- Para borrar todas las cookies de golpe usar `CdpClearCookies` en su lugar.
+83
View File
@@ -0,0 +1,83 @@
package browser
import (
"encoding/json"
"fmt"
)
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
// Retorna el resultado serializado como string.
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp eval in frame: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
}
// Page.enable es idempotente; necesario antes de crear mundos aislados
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
}
// Crear un mundo aislado en el frame indicado para no contaminar su contexto JS
ctxRes, err := c.sendCDP("Page.createIsolatedWorld", map[string]any{
"frameId": frameID,
"worldName": "fn_registry_isolated",
"grantUniveralAccess": false,
})
if err != nil {
return "", fmt.Errorf("cdp eval in frame: createIsolatedWorld: %w", err)
}
ctxIDRaw, ok := ctxRes["executionContextId"]
if !ok {
return "", fmt.Errorf("cdp eval in frame: executionContextId no encontrado en respuesta")
}
ctxID, ok := ctxIDRaw.(float64)
if !ok {
return "", fmt.Errorf("cdp eval in frame: executionContextId tipo inesperado: %T", ctxIDRaw)
}
// Evaluar la expresion en el contexto aislado del frame
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
"expression": expression,
"contextId": int(ctxID),
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return "", fmt.Errorf("cdp eval in frame: Runtime.evaluate: %w", err)
}
// Verificar excepcion JS
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
text, _ := excMap["text"].(string)
return "", fmt.Errorf("cdp eval in frame: excepcion JS en frame %q: %s", frameID, text)
}
// Extraer valor del resultado (mismo patron que CdpEvaluate)
resVal, ok := evRes["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("cdp eval in frame: resultado inesperado: %v", evRes)
}
value, ok := resVal["value"]
if !ok {
// undefined u otro tipo no serializable
typ, _ := resVal["type"].(string)
return typ, nil
}
// Strings tal cual; objetos/arrays JS a JSON real (no la repr de Go de "%v").
if s, ok := value.(string); ok {
return s, nil
}
b, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value), nil
}
return string(b), nil
}
+73
View File
@@ -0,0 +1,73 @@
---
id: cdp_eval_in_frame_go_browser
name: cdp_eval_in_frame
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame."
tags: [cdp, browser, iframe, javascript, eval, navegator]
signature: "func CdpEvalInFrame(c *CDPConn, frameID string, expression string) (string, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_eval_in_frame.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
// Tomar el primer iframe (índice 1, el 0 es el frame raíz)
result, err := CdpEvalInFrame(conn, frames[1].ID, "document.title")
fmt.Println(result) // "Título del iframe"
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame donde ejecutar el JS; obtenido de CdpListFrames (campo CdpFrame.ID)."
- name: expression
desc: "Expresión JavaScript a evaluar en el contexto del frame; puede ser una expresión simple o una Promise."
output: "Resultado de la expresión serializado como string (fmt.Sprintf del valor CDP); error si la conexión es nula, el frameID está vacío, la comunicación CDP falla o la expresión lanza una excepción JS."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// frames[0] es el frame raíz; frames[1] sería el primer iframe
iframeID := frames[1].ID
title, err := CdpEvalInFrame(conn, iframeID, "document.title")
if err != nil {
log.Fatal(err)
}
fmt.Println("Título del iframe:", title)
// Leer un elemento del DOM del iframe
text, _ := CdpEvalInFrame(conn, iframeID, "document.querySelector('h1').innerText")
fmt.Println("H1 del iframe:", text)
```
## Cuando usarla
Cuando necesites leer o manipular el DOM de un iframe específico sin afectar el contexto JS de la página principal. Útil para extraer datos de iframes de terceros, formularios embebidos o widgets. Obtén el `frameID` con `CdpListFrames` antes de llamar a esta función.
## Gotchas
- El mundo aislado (`fn_registry_isolated`) puede leer el DOM del iframe pero NO accede a variables JS definidas en el page-world del iframe (ej. `window.miVariable`). Para acceder a variables JS del frame, evalúa sin `createIsolatedWorld` usando el `contextId` principal del frame (no expuesto por esta función).
- Requiere `Page.enable` (se llama internamente, idempotente).
- Si el iframe tiene `sandbox` attribute sin `allow-scripts`, el CDP puede crear el mundo aislado pero las evaluaciones fallarán con excepción de seguridad.
- Cross-origin iframes en Chrome permiten evaluación CDP siempre que la conexión tenga acceso al target; no aplican las restricciones CORS de JS normal.
- El `frameID` debe obtenerse con `CdpListFrames`; si se pasa un ID obsoleto (frame recargado o destruido), `createIsolatedWorld` retorna error.
+13 -1
View File
@@ -1,6 +1,7 @@
package browser
import (
"encoding/json"
"fmt"
)
@@ -44,5 +45,16 @@ func CdpEvaluate(c *CDPConn, expression string) (string, error) {
return typ, nil
}
return fmt.Sprintf("%v", value), nil
// Strings se devuelven tal cual (sin comillas). Objetos y arrays JS, que Chrome
// deserializa a map/slice cuando returnByValue=true, se serializan a JSON real
// en vez de la repr de Go de fmt.Sprintf("%v") (que produciria "map[a:1]" en lugar
// de {"a":1}). Asi el caller puede parsear datos estructurados.
if s, ok := value.(string); ok {
return s, nil
}
b, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value), nil
}
return string(b), nil
}
+5 -3
View File
@@ -25,8 +25,10 @@ type FindByTextOpts struct {
// - "#<id>" si el elemento tiene id.
// - path "tag:nth-of-type(n) > tag:nth-of-type(n) > ..." si no.
//
// Retorna ("", nil) si no encuentra nada (no es error). Error solo si la
// evaluacion JS rompe (conexion CDP caida).
// Retorna error si no encuentra ningun elemento con ese texto. Antes devolvia
// ("", nil) en silencio, lo que hacia que el caller creyera que habia encontrado
// algo y operara sobre un selector vacio. Tambien error si la evaluacion JS rompe
// (conexion CDP caida).
func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp find by text: conexion nula")
@@ -96,7 +98,7 @@ func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error)
// CdpEvaluate retorna el valor stringificado. Para "" devuelve cadena vacia.
res = strings.TrimSpace(res)
if res == "" || res == "<nil>" {
return "", nil
return "", fmt.Errorf("cdp find by text: no se encontro elemento con texto %q", text)
}
return res, nil
}
+63
View File
@@ -0,0 +1,63 @@
package browser
// CdpCookie representa una cookie del browser tal como la devuelve CDP.
type CdpCookie struct {
Name string `json:"name"`
Value string `json:"value"`
Domain string `json:"domain"`
Path string `json:"path"`
Expires float64 `json:"expires"`
HTTPOnly bool `json:"httpOnly"`
Secure bool `json:"secure"`
SameSite string `json:"sameSite"`
}
// cookieFromMap convierte un map[string]any CDP a CdpCookie con casts defensivos.
func cookieFromMap(m map[string]any) CdpCookie {
c := CdpCookie{}
if v, ok := m["name"].(string); ok {
c.Name = v
}
if v, ok := m["value"].(string); ok {
c.Value = v
}
if v, ok := m["domain"].(string); ok {
c.Domain = v
}
if v, ok := m["path"].(string); ok {
c.Path = v
}
if v, ok := m["expires"].(float64); ok {
c.Expires = v
}
if v, ok := m["httpOnly"].(bool); ok {
c.HTTPOnly = v
}
if v, ok := m["secure"].(bool); ok {
c.Secure = v
}
if v, ok := m["sameSite"].(string); ok {
c.SameSite = v
}
return c
}
// CdpGetCookies devuelve todas las cookies del browser via Network.getAllCookies.
// El caller puede filtrar por dominio, nombre, etc. sobre el slice retornado.
func CdpGetCookies(c *CDPConn) ([]CdpCookie, error) {
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return nil, err
}
result, err := c.sendCDP("Network.getAllCookies", nil)
if err != nil {
return nil, err
}
raw, _ := result["cookies"].([]any)
cookies := make([]CdpCookie, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
cookies = append(cookies, cookieFromMap(m))
}
}
return cookies, nil
}
+59
View File
@@ -0,0 +1,59 @@
---
id: cdp_get_cookies_go_browser
name: cdp_get_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve todas las cookies del browser via Network.getAllCookies; el caller filtra por dominio o nombre sobre el slice []CdpCookie."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpGetCookies(c *CDPConn) ([]CdpCookie, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_cookies.go"
example: |
conn, _ := CdpConnect(9222)
cookies, err := CdpGetCookies(conn)
if err != nil { log.Fatal(err) }
for _, ck := range cookies {
if ck.Domain == "app.example.com" {
fmt.Printf("name=%s value=%s httpOnly=%v\n", ck.Name, ck.Value, ck.HTTPOnly)
}
}
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
output: "Slice de CdpCookie con todas las cookies del browser; error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
cookies, err := CdpGetCookies(conn)
if err != nil {
log.Fatal(err)
}
for _, ck := range cookies {
if ck.Domain == "app.example.com" {
fmt.Printf("name=%s value=%s httpOnly=%v\n", ck.Name, ck.Value, ck.HTTPOnly)
}
}
```
## Cuando usarla
Usar cuando necesitas inspeccionar el estado de cookies del browser tras un login CDP, antes de propagarlas a otro contexto, o para auditar sesiones activas en tests e2e.
## Gotchas
- Llama `Network.enable` internamente antes de `getAllCookies`; es idempotente pero suma latencia en la primera llamada.
- `Network.getAllCookies` devuelve cookies de TODOS los dominios del browser, no solo la tab activa. Filtrar por `Domain` en el caller.
- Las cookies HttpOnly son visibles via CDP aunque no lo sean desde JavaScript del browser.
- `Expires == -1` indica cookie de sesion (sin fecha de expiración).
+23
View File
@@ -0,0 +1,23 @@
package browser
import (
"fmt"
)
// CdpGetFrameHTML retorna el HTML completo (outerHTML del documentElement) de un iframe
// especifico usando CdpEvalInFrame con la expresion "document.documentElement.outerHTML".
func CdpGetFrameHTML(c *CDPConn, frameID string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get frame html: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp get frame html: frameID vacio")
}
html, err := CdpEvalInFrame(c, frameID, "document.documentElement.outerHTML")
if err != nil {
return "", fmt.Errorf("cdp get frame html: %w", err)
}
return html, nil
}
+70
View File
@@ -0,0 +1,70 @@
---
id: cdp_get_frame_html_go_browser
name: cdp_get_frame_html
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve el HTML completo (document.documentElement.outerHTML) de un iframe concreto componiendo sobre CdpEvalInFrame con un mundo aislado CDP."
tags: [cdp, browser, iframe, html, scraping, navegator]
signature: "func CdpGetFrameHTML(c *CDPConn, frameID string) (string, error)"
uses_functions: [cdp_eval_in_frame_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_frame_html.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
html, err := CdpGetFrameHTML(conn, frames[1].ID)
fmt.Println(html[:200]) // primeros 200 chars del HTML del iframe
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame cuyo HTML se quiere obtener; obtenido de CdpListFrames (campo CdpFrame.ID)."
output: "String con el HTML completo del iframe (outerHTML del documentElement); error si la conexión es nula, el frameID está vacío o la evaluación CDP falla."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// 1. Listar frames para obtener el ID del iframe deseado
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// frames[0] = frame raíz, frames[1] = primer iframe
for _, f := range frames {
if f.ParentID != "" { // es un iframe, no el raíz
html, err := CdpGetFrameHTML(conn, f.ID)
if err != nil {
log.Printf("error en frame %s: %v", f.ID, err)
continue
}
fmt.Printf("=== iframe %s (%s) ===\n%s\n", f.ID, f.URL, html[:min(500, len(html))])
}
}
```
## Cuando usarla
Cuando necesites el HTML completo de un iframe para parsearlo, scrapearlo o inspeccionarlo. Flujo típico: `CdpListFrames` → elegir frame por URL → `CdpGetFrameHTML` → parsear con `golang.org/x/net/html` o regexp.
## Gotchas
- El mundo aislado ve el DOM pero NO las variables JS del page-world del iframe; suficiente para leer `outerHTML` y hacer scraping estructural.
- `frameID` debe obtenerse de `CdpListFrames`; un ID obsoleto (frame recargado) provoca error en `CdpEvalInFrame`.
- Para iframes con contenido dinámico (renderizado por JS), espera a que el iframe termine de cargar antes de llamar a esta función; de lo contrario el HTML puede estar incompleto.
- En páginas con muchos iframes pesados, el outerHTML puede ser muy grande (MBs); considera evaluar selectores más específicos con `CdpEvalInFrame` si solo necesitas parte del DOM.
+54
View File
@@ -0,0 +1,54 @@
package browser
import (
"encoding/json"
"fmt"
"unicode/utf8"
)
// CdpGetText retorna el texto visible (innerText) de la pagina o de un elemento.
// Si selector es "" lee document.body.innerText completo.
// Si selector no matchea ningun elemento retorna error.
// Si maxBytes > 0 trunca al limite dado (corte rune-safe) y añade sufijo con total original.
// Si maxBytes <= 0 no hay limite.
func CdpGetText(c *CDPConn, selector string, maxBytes int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get text: conexion nula")
}
var expr string
if selector == "" {
expr = `document.body ? document.body.innerText : ""`
} else {
// Escapa el selector como string JSON para evitar inyeccion via comillas/backslash.
selectorJSON, err := json.Marshal(selector)
if err != nil {
return "", fmt.Errorf("cdp get text: escapar selector: %w", err)
}
expr = fmt.Sprintf(
`(function(){var e=document.querySelector(%s); return e ? e.innerText : "__FN_GET_TEXT_NOTFOUND__";})()`,
string(selectorJSON),
)
}
text, err := CdpEvaluate(c, expr)
if err != nil {
return "", fmt.Errorf("cdp get text: %w", err)
}
if selector != "" && text == "__FN_GET_TEXT_NOTFOUND__" {
return "", fmt.Errorf("cdp get text: elemento no encontrado: %s", selector)
}
if maxBytes > 0 && len(text) > maxBytes {
total := len(text)
// Corte rune-safe: retrocede hasta encontrar un rune valido completo.
cut := maxBytes
for cut > 0 && !utf8.RuneStart(text[cut]) {
cut--
}
text = text[:cut] + fmt.Sprintf("\n…[truncado, total %d bytes]", total)
}
return text, nil
}
+59
View File
@@ -0,0 +1,59 @@
---
name: cdp_get_text
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpGetText(c *CDPConn, selector string, maxBytes int) (string, error)"
description: "Retorna el texto visible (innerText) de la pagina o de un elemento CSS, con truncado opcional. Alternativa compacta a cdp_get_html cuando solo se necesita el texto legible."
tags: [cdp, browser, read, perception, navegator]
uses_functions: [cdp_evaluate_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, unicode/utf8]
params:
- name: c
desc: "Conexion CDP activa a una tab de Chrome. Debe estar conectada a una tab tipo 'page'."
- name: selector
desc: "Selector CSS del elemento del que leer el innerText. Si es cadena vacia, lee document.body.innerText (toda la pagina)."
- name: maxBytes
desc: "Limite maximo de bytes del texto retornado. Si es <= 0 no hay limite. Si el texto supera el limite, se trunca con corte rune-safe y se añade un sufijo con el total original."
output: "Texto visible del elemento o de toda la pagina. Si maxBytes > 0 y el texto supera el limite, retorna el texto truncado con sufijo '…[truncado, total N bytes]'. Error si el selector no matchea ningun elemento o si la conexion falla."
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_get_text.go"
---
## Ejemplo
```go
// Leer todo el body con limite de 20000 bytes (apto para LLM)
text, err := CdpGetText(conn, "", 20000)
if err != nil {
log.Fatal(err)
}
fmt.Println(text)
// Leer un elemento concreto sin limite
price, err := CdpGetText(conn, ".product-price", 0)
if err != nil {
// err contiene "elemento no encontrado: .product-price" si no existe en el DOM
log.Fatal(err)
}
fmt.Println(price)
```
## Cuando usarla
Para que un LLM lea el contenido de una pagina sin reventar su ventana de contexto. Preferir sobre `cdp_get_html` cuando solo necesitas el texto — innerText es 5-50x mas compacto que el HTML crudo. Usar `selector` para acotar a la seccion relevante (articulo, tabla, formulario) y `maxBytes` para garantizar el presupuesto de tokens.
## Gotchas
- `innerText` solo devuelve el texto de nodos visibles: elementos con `display:none` o `visibility:hidden` quedan excluidos. Si necesitas leer contenido oculto usa `cdp_get_html` y parsea.
- El truncado corta en boundary de rune pero puede partir a media frase o a medio parrafo. Si necesitas preservar estructura semantica, ajusta `maxBytes` con margen o usa el selector para acotar la region.
- Requiere conexion activa a una tab de tipo `page` (no `background_page`, no `service_worker`). Tabs en estado de carga pueden devolver texto parcial; esperar con `cdp_wait_load` si el contenido es dinamico.
- El selector se escapa via `json.Marshal` — caracteres especiales como comillas simples, backslash o comillas dobles en el selector CSS son seguros.
+35
View File
@@ -0,0 +1,35 @@
package browser
import "fmt"
// CdpHandleDialog instala un auto-handler que responde automaticamente a todos
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame
// la funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
// Page.handleJavaScriptDialog del protocolo CDP.
//
// IMPORTANTE: el handler interno despacha la respuesta en una goroutine nueva
// para evitar deadlock — el evento llega en la goroutine de lectura del
// WebSocket, y sendCDP bloquea esperando una respuesta que leeria esa misma
// goroutine si se llamara de forma sincrona.
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error) {
if c == nil {
return nil, fmt.Errorf("cdp handle dialog: conexion nula")
}
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return nil, fmt.Errorf("cdp handle dialog: %w", err)
}
cancel := c.OnEvent("Page.javascriptDialogOpening", func(method string, params map[string]any) {
p := map[string]any{"accept": accept}
if promptText != "" {
p["promptText"] = promptText
}
// go es OBLIGATORIO: el handler corre en la goroutine de lectura del
// WebSocket. Llamar sendCDP aqui directamente provoca deadlock porque
// sendCDP espera una respuesta que la misma goroutine deberia leer.
go c.sendCDP("Page.handleJavaScriptDialog", p) //nolint:errcheck
})
return cancel, nil
}
+74
View File
@@ -0,0 +1,74 @@
---
id: cdp_handle_dialog_go_browser
name: cdp_handle_dialog
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
tests: []
test_file_path: ""
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto."
tags: [cdp, browser, dialog, input, navegator]
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_handle_dialog.go"
example: |
// Aceptar automaticamente confirm() antes de navegar
cancel, _ := CdpHandleDialog(c, true, "")
defer cancel()
_ = CdpClick(c, "#delete-account-btn")
_ = CdpWaitIdle(c, 2000)
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: accept
desc: "true para aceptar/OK el dialogo; false para rechazar/Cancel. Para alert() el valor no importa (siempre se cierra), para confirm() determina el valor de retorno, para prompt() determina si se devuelve el texto o null."
- name: promptText
desc: "Texto a inyectar en dialogos prompt(). Vacio string para no inyectar texto. Ignorado en alert() y confirm()."
output: "cancel func() para des-registrar el handler cuando ya no se necesite, y error si la conexion es nula o Page.enable falla. El cancel devuelto es seguro llamarlo multiples veces."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
_ = CdpNavigate(conn, "https://example.com/admin")
_ = CdpWaitLoad(conn, 3000)
// Instalar handler antes de la accion que dispara el dialogo
cancel, err := CdpHandleDialog(conn, true, "")
if err != nil {
log.Fatal(err)
}
defer cancel()
// Este boton dispara confirm("¿Seguro que quieres borrar?")
// El handler lo acepta automaticamente sin bloquear
_ = CdpClick(conn, "#btn-delete-all")
_ = CdpWaitIdle(conn, 2000)
// Ejemplo con prompt(): responder con texto especifico
cancelPrompt, _ := CdpHandleDialog(conn, true, "mi-respuesta-secreta")
defer cancelPrompt()
_ = CdpClick(conn, "#btn-ask-password")
_ = CdpWaitIdle(conn, 1000)
```
## Cuando usarla
Instalar antes de cualquier accion que pueda disparar `alert()`, `confirm()`, `prompt()` o `beforeunload` en la pagina. Sin este handler, el dialogo bloquea el tab del navegador indefinidamente y todas las llamadas CDP siguientes se quedan colgadas esperando. Imprescindible en scraping de paneles de administracion, flujos de borrado con confirmacion, y paginas con `beforeunload` que pregunta si quieres salir.
## Gotchas
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion ya usa `go c.sendCDP(...)` para evitarlo — no modificar este patron.
- El handler se instala de forma permanente hasta que se llame `cancel()`. Si la pagina dispara multiples dialogos, todos seran respondidos con los mismos parametros `accept` y `promptText`.
- `Page.enable` es idempotente pero tiene coste de red; no llamar CdpHandleDialog en bucles tight.
- Para `beforeunload` (cuando el usuario cierra/navega fuera), `accept: true` permite la navegacion y `accept: false` la bloquea.
- Llamar `cancel()` no cierra dialogos ya abiertos; solo evita que los futuros sean respondidos automaticamente.
+73
View File
@@ -0,0 +1,73 @@
package browser
import (
"fmt"
)
// CdpFrame representa un frame/iframe del arbol de navegacion.
type CdpFrame struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
URL string `json:"url"`
Name string `json:"name"`
}
// CdpListFrames lista todos los frames de la pagina actual (frame raiz + iframes anidados)
// usando Page.getFrameTree. Retorna el arbol aplanado con cada frame y su parentId.
func CdpListFrames(c *CDPConn) ([]CdpFrame, error) {
if c == nil {
return nil, fmt.Errorf("cdp list frames: conexion nula")
}
// Page.enable es idempotente; necesario para que Page.getFrameTree funcione
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return nil, fmt.Errorf("cdp list frames: Page.enable: %w", err)
}
result, err := c.sendCDP("Page.getFrameTree", nil)
if err != nil {
return nil, fmt.Errorf("cdp list frames: Page.getFrameTree: %w", err)
}
frameTree, ok := result["frameTree"].(map[string]any)
if !ok {
return nil, fmt.Errorf("cdp list frames: frameTree no encontrado en respuesta")
}
var frames []CdpFrame
frameFlatten(frameTree, "", &frames)
return frames, nil
}
// frameFlatten recorre recursivamente el arbol de frames CDP y acumula CdpFrame.
// parentID es el ID del nodo padre; el frame raiz lo recibe vacio.
func frameFlatten(node map[string]any, parentID string, acc *[]CdpFrame) {
frameData, ok := node["frame"].(map[string]any)
if !ok {
return
}
f := CdpFrame{
ID: stringField(frameData, "id"),
ParentID: parentID,
URL: stringField(frameData, "url"),
Name: stringField(frameData, "name"),
}
*acc = append(*acc, f)
// Recorrer hijos
children, _ := node["childFrames"].([]any)
for _, child := range children {
childNode, ok := child.(map[string]any)
if !ok {
continue
}
frameFlatten(childNode, f.ID, acc)
}
}
// stringField extrae un campo string de un map[string]any de forma segura.
func stringField(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_list_frames_go_browser
name: cdp_list_frames
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Lista todos los frames/iframes de la pestaña activa usando Page.getFrameTree y devuelve el árbol aplanado con ID, parentID, URL y nombre de cada frame."
tags: [cdp, browser, iframe, frames, page, navegator]
signature: "func CdpListFrames(c *CDPConn) ([]CdpFrame, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_frames.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, err := CdpListFrames(conn)
for _, f := range frames {
fmt.Printf("frame %s parent=%s url=%s\n", f.ID, f.ParentID, f.URL)
}
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect; apunta a la pestaña cuyo árbol de frames se quiere inspeccionar."
output: "Slice de CdpFrame con ID, ParentID, URL y Name de cada frame aplanado; error si la conexión es nula, Page.enable falla o la respuesta CDP es inesperada."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
for _, f := range frames {
fmt.Printf("id=%-40s parent=%-40s url=%s\n", f.ID, f.ParentID, f.URL)
}
// Salida ejemplo:
// id=ABCD1234 parent= url=https://example.com
// id=EFGH5678 parent=ABCD1234 url=https://ads.example.com/iframe
```
## Cuando usarla
Antes de evaluar JS en un iframe con `CdpEvalInFrame`: necesitas el `frameID` exacto que usa CDP, no el `src` del iframe. También útil para auditar la estructura de frames de una página o detectar iframes de terceros.
## Gotchas
- Requiere que la pestaña ya esté cargada; si se llama justo tras `CdpNavigate` en páginas con lazy-load de iframes, puede devolver un listado incompleto — espera a `Page.loadEventFired` o usa un breve delay.
- `Page.enable` se llama internamente (idempotente); no hace falta llamarlo manualmente antes.
- El frame raíz tiene `ParentID` vacío. Los iframes anidados tienen como `ParentID` el `ID` del frame contenedor.
- `Name` puede ser vacío si el `<iframe>` no tiene atributo `name`.
@@ -0,0 +1,98 @@
package browser
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// jsQuote serializa s como literal string JavaScript con comillas dobles y
// caracteres escapados correctamente. Usa json.Marshal internamente para
// reutilizar el mismo escapado que JSON (compatible con JS).
func jsQuote(s string) string {
b, err := json.Marshal(s)
if err != nil {
// Fallback seguro: comillas dobles escapando backslash y comilla doble
return fmt.Sprintf("%q", s)
}
return string(b)
}
// CdpLoadStorageState lee el JSON generado por CdpSaveStorageState y restaura
// cookies y localStorage en la pestaña activa. Permite retomar una sesion
// autenticada sin repetir el login.
//
// CRITICO: el localStorage es por-origen. Antes de llamar a esta funcion hay
// que haber navegado al origen correcto (CdpNavigate al dominio). Orden
// correcto: navegar -> CdpLoadStorageState -> recargar pagina.
func CdpLoadStorageState(c *CDPConn, inPath string) error {
if c == nil {
return fmt.Errorf("cdp load storage state: conexion nula")
}
if inPath == "" {
return fmt.Errorf("cdp load storage state: inPath vacio")
}
data, err := os.ReadFile(inPath)
if err != nil {
return fmt.Errorf("cdp load storage state: leer archivo: %w", err)
}
var state CdpStorageState
if err := json.Unmarshal(data, &state); err != nil {
return fmt.Errorf("cdp load storage state: unmarshal: %w", err)
}
// Habilitar dominio Network para manipular cookies
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return fmt.Errorf("cdp load storage state: Network.enable: %w", err)
}
// Restaurar cookies. Network.setCookies aplica de forma fiable las cookies
// (sobre todo httpOnly y de sesión) cuando cada una lleva el campo `url`: de
// ahí deriva scheme y scope. getAllCookies no lo incluye, así que lo
// sintetizamos a partir de domain/secure/path cuando falta.
if len(state.Cookies) > 0 {
for _, ck := range state.Cookies {
if _, has := ck["url"]; has {
continue
}
dom, _ := ck["domain"].(string)
dom = strings.TrimPrefix(dom, ".")
if dom == "" {
continue
}
scheme := "http"
if sec, _ := ck["secure"].(bool); sec {
scheme = "https"
}
path, _ := ck["path"].(string)
if path == "" {
path = "/"
}
ck["url"] = scheme + "://" + dom + path
}
if _, err := c.sendCDP("Network.setCookies", map[string]any{
"cookies": state.Cookies,
}); err != nil {
return fmt.Errorf("cdp load storage state: setCookies: %w", err)
}
}
// Restaurar localStorage y sessionStorage — setItem por cada par clave/valor
for k, v := range state.LocalStorage {
expr := fmt.Sprintf("window.localStorage.setItem(%s, %s)", jsQuote(k), jsQuote(v))
if _, err := CdpEvaluate(c, expr); err != nil {
return fmt.Errorf("cdp load storage state: localStorage setItem %q: %w", k, err)
}
}
for k, v := range state.SessionStorage {
expr := fmt.Sprintf("window.sessionStorage.setItem(%s, %s)", jsQuote(k), jsQuote(v))
if _, err := CdpEvaluate(c, expr); err != nil {
return fmt.Errorf("cdp load storage state: sessionStorage setItem %q: %w", k, err)
}
}
return nil
}
@@ -0,0 +1,67 @@
---
id: cdp_load_storage_state_go_browser
name: cdp_load_storage_state
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Restaura cookies y localStorage desde un archivo JSON (generado por CdpSaveStorageState) en la pestaña activa, reanudando una sesión autenticada sin repetir el login."
tags: [cdp, browser, storage, session, cookies, localStorage, auth, navegator]
signature: "func CdpLoadStorageState(c *CDPConn, inPath string) error"
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_load_storage_state.go"
example: |
conn, _ := CdpConnect(9222)
defer CdpClose(conn)
CdpNavigate(conn, "https://app.example.com")
CdpLoadStorageState(conn, "/tmp/session.json")
CdpNavigate(conn, "https://app.example.com") // reload para que la app lea el localStorage restaurado
params:
- name: c
desc: "Conexión CDP activa apuntando a la pestaña donde se restaurará el estado."
- name: inPath
desc: "Ruta del archivo JSON producido previamente por CdpSaveStorageState."
output: "nil si cookies y localStorage se restauraron correctamente; error con contexto si el archivo no existe, el JSON es inválido o falla algún comando CDP."
---
## Ejemplo
```go
conn, err := CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer CdpClose(conn)
// 1. Navegar al origen correcto ANTES de restaurar
CdpNavigate(conn, "https://app.example.com")
// 2. Restaurar cookies + localStorage
if err := CdpLoadStorageState(conn, "/tmp/session.json"); err != nil {
log.Fatal(err)
}
// 3. Recargar para que la app lea el localStorage restaurado
CdpNavigate(conn, "https://app.example.com")
// A partir de aquí la sesión está activa — no se necesitó login
```
## Cuando usarla
Al inicio de un script de scraping autenticado, después de `CdpNavigate` al dominio objetivo y antes de cualquier interacción. Sustituye el flujo de login cuando ya existe un archivo de estado guardado con `CdpSaveStorageState`.
## Gotchas
- **Orden obligatorio: navegar → load → reload**. El localStorage es por-origen: si llamas a esta función antes de navegar al dominio correcto, los `setItem` escriben en el origen equivocado (p.ej. `about:blank`) y la app no los ve. Secuencia correcta: `CdpNavigate(dominio)``CdpLoadStorageState(...)``CdpNavigate(dominio)` de nuevo.
- **Cookies globales del perfil**: `Network.setCookies` restaura todas las cookies del archivo, que pueden ser de múltiples dominios. Esto es el comportamiento esperado y compatible con cómo las guardó `CdpSaveStorageState`.
- **Archivo inexistente o corrupto**: la función devuelve error explícito; comprueba que el archivo existe antes de llamarla (por ejemplo con `os.Stat`) si quieres un fallback a login completo.
- **Sesión expirada**: restaurar el estado no renueva tokens del servidor. Si la sesión expiró (cookies caducadas, JWT vencido), la app redirigirá a login igualmente. En ese caso re-autentícate y vuelve a guardar el estado.
+67
View File
@@ -0,0 +1,67 @@
package browser
import (
"fmt"
)
// CdpNavBack retrocede una entrada en el historial de navegacion de la pestana activa.
// Obtiene el historial via Page.getNavigationHistory, calcula el indice anterior y
// navega a esa entrada via Page.navigateToHistoryEntry.
// Retorna error si ya estamos al inicio del historial.
func CdpNavBack(c *CDPConn) error {
if c == nil {
return fmt.Errorf("cdp nav back: conexion nula")
}
result, err := c.sendCDP("Page.getNavigationHistory", nil)
if err != nil {
return fmt.Errorf("cdp nav back: obtener historial: %w", err)
}
currentIndexRaw, ok := result["currentIndex"]
if !ok {
return fmt.Errorf("cdp nav back: respuesta sin currentIndex")
}
currentIndex, ok := currentIndexRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav back: currentIndex tipo inesperado: %T", currentIndexRaw)
}
entriesRaw, ok := result["entries"]
if !ok {
return fmt.Errorf("cdp nav back: respuesta sin entries")
}
entries, ok := entriesRaw.([]any)
if !ok {
return fmt.Errorf("cdp nav back: entries tipo inesperado: %T", entriesRaw)
}
idx := int(currentIndex) - 1
if idx < 0 {
return fmt.Errorf("cdp nav back: ya en el inicio del historial")
}
if idx >= len(entries) {
return fmt.Errorf("cdp nav back: indice %d fuera de rango (len=%d)", idx, len(entries))
}
entry, ok := entries[idx].(map[string]any)
if !ok {
return fmt.Errorf("cdp nav back: entrada[%d] tipo inesperado: %T", idx, entries[idx])
}
entryIDRaw, ok := entry["id"]
if !ok {
return fmt.Errorf("cdp nav back: entrada sin campo id")
}
entryIDFloat, ok := entryIDRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav back: entry id tipo inesperado: %T", entryIDRaw)
}
entryID := int(entryIDFloat)
_, err = c.sendCDP("Page.navigateToHistoryEntry", map[string]any{"entryId": entryID})
if err != nil {
return fmt.Errorf("cdp nav back: navegar a entrada %d: %w", entryID, err)
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_nav_back_go_browser
name: cdp_nav_back
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Retrocede una entrada en el historial de navegación de la pestaña activa via Page.getNavigationHistory + Page.navigateToHistoryEntry. Equivalente a pulsar el botón Atrás del navegador."
tags: [cdp, browser, navigation, navegator]
signature: "func CdpNavBack(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_nav_back.go"
example: |
conn, _ := browser.CdpConnect(9222)
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://example.com/paso1")
_ = browser.CdpNavigate(conn, "https://example.com/paso2")
// Volver a /paso1
if err := browser.CdpNavBack(conn); err != nil {
log.Println(err)
}
params:
- name: c
desc: "Conexión CDP activa obtenida de CdpConnect, apuntando a la pestaña que se quiere retroceder"
output: "nil si navegó correctamente a la entrada anterior; error si ya estamos al inicio del historial, la conexión es nula o el cast de tipos falla (respuesta CDP malformada)"
---
## Ejemplo
```go
conn, err := browser.CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://metabase.local/dashboard/1")
_ = browser.CdpNavigate(conn, "https://metabase.local/question/42")
// Volver al dashboard
if err := browser.CdpNavBack(conn); err != nil {
log.Printf("no se pudo retroceder: %v", err)
}
```
## Cuando usarla
Cuando un flujo de automatización navega por varias páginas y necesita volver atrás sin conocer la URL anterior. Útil en scraping de paginaciones o en flujos de formularios multipaso donde la URL destino no es predecible.
## Gotchas
- Navega dentro del historial de ESA pestaña CDP (sesión WS), no del perfil en disco ni del historial global de Chrome.
- Si `currentIndex == 0` (primer elemento del historial), retorna error "ya en el inicio del historial" — no es un fallo de red, es estado válido.
- Requiere que `Page` esté habilitado en la sesión; Chrome lo activa automáticamente con la mayoría de conexiones CDP, pero si usas una sesión muy restrictiva puede fallar.
- No espera a que la página de destino cargue; encadenar con `CdpWaitLoad` o `CdpWaitIdle` si necesitas esperar la carga completa.
+64
View File
@@ -0,0 +1,64 @@
package browser
import (
"fmt"
)
// CdpNavForward avanza una entrada en el historial de navegacion de la pestana activa.
// Obtiene el historial via Page.getNavigationHistory, calcula el indice siguiente y
// navega a esa entrada via Page.navigateToHistoryEntry.
// Retorna error si ya estamos al final del historial (no hay entradas adelante).
func CdpNavForward(c *CDPConn) error {
if c == nil {
return fmt.Errorf("cdp nav forward: conexion nula")
}
result, err := c.sendCDP("Page.getNavigationHistory", nil)
if err != nil {
return fmt.Errorf("cdp nav forward: obtener historial: %w", err)
}
currentIndexRaw, ok := result["currentIndex"]
if !ok {
return fmt.Errorf("cdp nav forward: respuesta sin currentIndex")
}
currentIndex, ok := currentIndexRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav forward: currentIndex tipo inesperado: %T", currentIndexRaw)
}
entriesRaw, ok := result["entries"]
if !ok {
return fmt.Errorf("cdp nav forward: respuesta sin entries")
}
entries, ok := entriesRaw.([]any)
if !ok {
return fmt.Errorf("cdp nav forward: entries tipo inesperado: %T", entriesRaw)
}
idx := int(currentIndex) + 1
if idx >= len(entries) {
return fmt.Errorf("cdp nav forward: ya en el final del historial")
}
entry, ok := entries[idx].(map[string]any)
if !ok {
return fmt.Errorf("cdp nav forward: entrada[%d] tipo inesperado: %T", idx, entries[idx])
}
entryIDRaw, ok := entry["id"]
if !ok {
return fmt.Errorf("cdp nav forward: entrada sin campo id")
}
entryIDFloat, ok := entryIDRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav forward: entry id tipo inesperado: %T", entryIDRaw)
}
entryID := int(entryIDFloat)
_, err = c.sendCDP("Page.navigateToHistoryEntry", map[string]any{"entryId": entryID})
if err != nil {
return fmt.Errorf("cdp nav forward: navegar a entrada %d: %w", entryID, err)
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
---
id: cdp_nav_forward_go_browser
name: cdp_nav_forward
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Avanza una entrada en el historial de navegación de la pestaña activa via Page.getNavigationHistory + Page.navigateToHistoryEntry. Equivalente a pulsar el botón Adelante del navegador."
tags: [cdp, browser, navigation, navegator]
signature: "func CdpNavForward(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_nav_forward.go"
example: |
conn, _ := browser.CdpConnect(9222)
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://example.com/paso1")
_ = browser.CdpNavigate(conn, "https://example.com/paso2")
_ = browser.CdpNavBack(conn) // volver a /paso1
// Avanzar de nuevo a /paso2
if err := browser.CdpNavForward(conn); err != nil {
log.Println(err)
}
params:
- name: c
desc: "Conexión CDP activa obtenida de CdpConnect, apuntando a la pestaña que se quiere avanzar"
output: "nil si navegó correctamente a la entrada siguiente; error si ya estamos al final del historial, la conexión es nula o el cast de tipos falla (respuesta CDP malformada)"
---
## Ejemplo
```go
conn, err := browser.CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://metabase.local/dashboard/1")
_ = browser.CdpNavigate(conn, "https://metabase.local/question/42")
_ = browser.CdpNavBack(conn) // vuelve a /dashboard/1
// Avanzar de nuevo a /question/42
if err := browser.CdpNavForward(conn); err != nil {
log.Printf("no se pudo avanzar: %v", err)
}
```
## Cuando usarla
Cuando un flujo de automatización ha retrocedido con `CdpNavBack` y necesita volver a avanzar sin conocer la URL destino. Útil para recorrer un historial de páginas hacia adelante y hacia atrás de forma programática, por ejemplo en herramientas de replay de sesiones.
## Gotchas
- Navega dentro del historial de ESA pestaña CDP (sesión WS), no del perfil en disco ni del historial global de Chrome.
- Si `currentIndex` es el último elemento del historial (`currentIndex == len(entries) - 1`), retorna error "ya en el final del historial" — no es un fallo de red, es estado válido.
- El historial se trunca cuando se navega a una URL nueva estando en una entrada intermedia: las entradas "adelante" desaparecen, igual que en un navegador real.
- No espera a que la página de destino cargue; encadenar con `CdpWaitLoad` o `CdpWaitIdle` si necesitas esperar la carga completa.
+3 -2
View File
@@ -5,8 +5,9 @@ import (
)
// CdpNavigate navega a la URL indicada usando Page.navigate.
// Espera a que la carga este confirmada via Page.loadEventFired antes de retornar.
// El timeout de la navegacion es gestionado por Chrome internamente.
// NO espera a que la pagina cargue: retorna en cuanto Chrome acepta la navegacion
// (solo verifica que no haya errorText). Para esperar la carga real encadena
// despues CdpWaitLoad (document.readyState) o CdpWaitIdle (red en reposo).
func CdpNavigate(c *CDPConn, targetURL string) error {
if c == nil {
return fmt.Errorf("cdp navigate: conexion nula")
+69
View File
@@ -0,0 +1,69 @@
package browser
import "fmt"
// pressKeyEntry define los atributos CDP de una tecla especial.
type pressKeyEntry struct {
vk int
key string
code string
text string
}
// pressKeyTable mapea nombres de tecla a sus atributos CDP.
var pressKeyTable = map[string]pressKeyEntry{
"Enter": {vk: 13, key: "Enter", code: "Enter", text: "\r"},
"Tab": {vk: 9, key: "Tab", code: "Tab"},
"Escape": {vk: 27, key: "Escape", code: "Escape"},
"Backspace": {vk: 8, key: "Backspace", code: "Backspace"},
"Delete": {vk: 46, key: "Delete", code: "Delete"},
"ArrowUp": {vk: 38, key: "ArrowUp", code: "ArrowUp"},
"ArrowDown": {vk: 40, key: "ArrowDown", code: "ArrowDown"},
"ArrowLeft": {vk: 37, key: "ArrowLeft", code: "ArrowLeft"},
"ArrowRight": {vk: 39, key: "ArrowRight", code: "ArrowRight"},
"Home": {vk: 36, key: "Home", code: "Home"},
"End": {vk: 35, key: "End", code: "End"},
"PageUp": {vk: 33, key: "PageUp", code: "PageUp"},
"PageDown": {vk: 34, key: "PageDown", code: "PageDown"},
"Space": {vk: 32, key: " ", code: "Space", text: " "},
}
// CdpPressKey pulsa una tecla especial por nombre usando Input.dispatchKeyEvent.
// Soporta: Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft,
// ArrowRight, Home, End, PageUp, PageDown, Space.
// Actua sobre el elemento con foco activo en la pagina.
func CdpPressKey(c *CDPConn, keyName string) error {
if c == nil {
return fmt.Errorf("cdp press key: conexion nula")
}
entry, ok := pressKeyTable[keyName]
if !ok {
return fmt.Errorf("cdp press key: tecla no soportada: %s", keyName)
}
down := map[string]any{
"type": "keyDown",
"windowsVirtualKeyCode": entry.vk,
"key": entry.key,
"code": entry.code,
}
if entry.text != "" {
down["text"] = entry.text
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", down); err != nil {
return fmt.Errorf("cdp press key: keyDown %q: %w", keyName, err)
}
up := map[string]any{
"type": "keyUp",
"windowsVirtualKeyCode": entry.vk,
"key": entry.key,
"code": entry.code,
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", up); err != nil {
return fmt.Errorf("cdp press key: keyUp %q: %w", keyName, err)
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
---
id: cdp_press_key_go_browser
name: cdp_press_key
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
tests: []
test_file_path: ""
description: "Pulsa una tecla especial por nombre via Input.dispatchKeyEvent CDP (Enter, Tab, Escape, flechas, etc.) sobre el elemento con foco activo."
tags: [cdp, browser, input, keyboard, navegator]
signature: "func CdpPressKey(c *CDPConn, keyName string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_press_key.go"
example: |
// Enfocar un input y pulsar Enter para enviar el formulario
_ = CdpClick(c, "input[name='q']")
_ = CdpTypeText(c, "golang")
_ = CdpPressKey(c, "Enter")
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: keyName
desc: "Nombre de la tecla a pulsar. Valores soportados: Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, PageUp, PageDown, Space."
output: "nil si la tecla se despacho correctamente. Error si la conexion es nula, la tecla no esta en la tabla soportada, o CDP rechaza el evento."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Enfocar campo de busqueda, escribir y enviar con Enter
_ = CdpClick(conn, "input[name='q']")
_ = CdpTypeText(conn, "golang generics")
if err := CdpPressKey(conn, "Enter"); err != nil {
log.Fatal(err)
}
// Navegar en un desplegable con flechas
_ = CdpClick(conn, "#dropdown")
_ = CdpPressKey(conn, "ArrowDown")
_ = CdpPressKey(conn, "ArrowDown")
_ = CdpPressKey(conn, "Enter")
// Cerrar un modal con Escape
_ = CdpPressKey(conn, "Escape")
```
## Cuando usarla
Usar cuando necesites simular pulsaciones de teclas especiales sobre el elemento con foco: enviar formularios con Enter, navegar opciones con flechas, limpiar campos con Backspace/Delete, cerrar modales con Escape, o desplazarse con PageUp/PageDown. Para escribir texto normal usa CdpTypeText.
## Gotchas
- La tecla actua sobre el elemento con foco activo. Llama a CdpClick primero para enfocar el elemento objetivo.
- Teclas sin caracter imprimible (Tab, Escape, flechas, Home, End, PageUp, PageDown) no envian el campo "text" — Chrome lo requiere asi para distinguir navegacion de insercion.
- Enter envia `text: "\r"` que es lo que Chrome espera para confirmar formularios y autocompletados.
- Space envia `key: " "` y `text: " "` — funciona como barra espaciadora y como insercion de espacio en inputs.
- Si la tecla que necesitas no esta en la tabla, la funcion retorna error explicito en vez de silencio.
+120
View File
@@ -0,0 +1,120 @@
package browser
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// CdpStorageState agrupa cookies, localStorage y sessionStorage capturados de una
// pestaña activa.
type CdpStorageState struct {
Cookies []map[string]any `json:"cookies"`
LocalStorage map[string]string `json:"localStorage"`
SessionStorage map[string]string `json:"sessionStorage"`
}
// readWebStorage lee window.<store> (localStorage|sessionStorage) como mapa. Si el
// origen no permite acceso (about:blank, chrome://) devuelve un mapa vacío.
func readWebStorage(c *CDPConn, store string) map[string]string {
raw, err := CdpEvaluate(c, "JSON.stringify(Object.assign({}, window."+store+"))")
if err != nil {
return map[string]string{}
}
if raw == "" || raw == "undefined" || raw == "null" {
return map[string]string{}
}
var m map[string]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return map[string]string{}
}
return m
}
// cookieDomainMatchesHost indica si una cookie con `domain` aplica al `host` dado.
// Cubre el caso de dominios con punto inicial (".example.com") y subdominios.
func cookieDomainMatchesHost(domain, host string) bool {
if domain == "" || host == "" {
return false
}
d := strings.TrimPrefix(domain, ".")
return host == d || strings.HasSuffix(host, "."+d)
}
// storageStateToMaps convierte []any (respuesta CDP) a []map[string]any.
func storageStateToMaps(raw []any) []map[string]any {
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// CdpSaveStorageState captura cookies y localStorage de la pagina actual y los
// escribe como JSON a outPath. Permite restaurar la sesion autenticada en
// ejecuciones posteriores sin repetir el login.
func CdpSaveStorageState(c *CDPConn, outPath string) error {
if c == nil {
return fmt.Errorf("cdp save storage state: conexion nula")
}
if outPath == "" {
return fmt.Errorf("cdp save storage state: outPath vacio")
}
// Habilitar dominio Network para acceder a las cookies
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return fmt.Errorf("cdp save storage state: Network.enable: %w", err)
}
// Obtener todas las cookies del perfil
res, err := c.sendCDP("Network.getAllCookies", nil)
if err != nil {
return fmt.Errorf("cdp save storage state: getAllCookies: %w", err)
}
var cookies []map[string]any
if rawCookies, ok := res["cookies"].([]any); ok {
cookies = storageStateToMaps(rawCookies)
} else {
cookies = []map[string]any{}
}
// Filtrar al origen actual: Network.getAllCookies devuelve cookies de TODOS
// los dominios del perfil. Para guardar "la sesión de ESTE sitio" solo
// conservamos las que aplican al host cargado, evitando arrastrar cookies de
// otros sitios visitados en la misma sesión del navegador.
if host, herr := CdpEvaluate(c, "location.hostname"); herr == nil {
host = strings.TrimSpace(host)
if host != "" && host != "undefined" {
filtered := make([]map[string]any, 0, len(cookies))
for _, ck := range cookies {
dom, _ := ck["domain"].(string)
if cookieDomainMatchesHost(dom, host) {
filtered = append(filtered, ck)
}
}
cookies = filtered
}
}
// Capturar localStorage y sessionStorage del origen actualmente cargado.
state := CdpStorageState{
Cookies: cookies,
LocalStorage: readWebStorage(c, "localStorage"),
SessionStorage: readWebStorage(c, "sessionStorage"),
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("cdp save storage state: marshal: %w", err)
}
if err := os.WriteFile(outPath, data, 0644); err != nil {
return fmt.Errorf("cdp save storage state: escribir archivo: %w", err)
}
return nil
}
@@ -0,0 +1,62 @@
---
id: cdp_save_storage_state_go_browser
name: cdp_save_storage_state
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Captura cookies y localStorage de la página activa y los serializa a un archivo JSON para restaurar la sesión sin repetir el login."
tags: [cdp, browser, storage, session, cookies, localStorage, auth, navegator]
signature: "func CdpSaveStorageState(c *CDPConn, outPath string) error"
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_save_storage_state.go"
example: |
conn, _ := CdpConnect(9222)
defer CdpClose(conn)
CdpNavigate(conn, "https://app.example.com")
err := CdpSaveStorageState(conn, "/tmp/session.json")
params:
- name: c
desc: "Conexión CDP activa apuntando a la pestaña con la sesión autenticada."
- name: outPath
desc: "Ruta del archivo JSON de salida donde se escribirá el estado (cookies + localStorage)."
output: "nil si el archivo se escribió correctamente; error con contexto en caso de fallo de red, CDP o escritura."
---
## Ejemplo
```go
conn, err := CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer CdpClose(conn)
// Navegar y autenticarse manualmente o con scraping
CdpNavigate(conn, "https://app.example.com/dashboard")
// Guardar estado de la sesión
if err := CdpSaveStorageState(conn, "/tmp/session.json"); err != nil {
log.Fatal(err)
}
// /tmp/session.json contiene cookies + localStorage listos para restaurar
```
## Cuando usarla
Tras completar un login en el browser (manual o automatizado), antes de cerrar la sesión o como paso final del script de autenticación. En la próxima ejecución, llama a `CdpLoadStorageState` en vez de repetir el flujo de login.
## Gotchas
- **localStorage es por-origen**: solo captura el localStorage del origen actualmente cargado en la pestaña. Si necesitas preservar localStorage de múltiples dominios, guarda un estado por cada dominio navegado.
- **Cookies globales del perfil**: `Network.getAllCookies` devuelve todas las cookies del perfil de Chrome, no solo las del origen activo. El JSON puede ser grande si el perfil tiene muchas cookies.
- **Páginas especiales** (`about:blank`, `chrome://`, extensiones): `CdpEvaluate` sobre localStorage fallará; la función lo maneja devolviendo un mapa vacío de forma defensiva, así que no romperá — pero el localStorage quedará vacío en el JSON.
- **Permisos**: el archivo se escribe con `0644`; asegúrate de que el directorio de destino existe antes de llamar a la función.
+26
View File
@@ -0,0 +1,26 @@
package browser
import "fmt"
// CdpScroll desplaza la pagina via rueda del raton usando Input.dispatchMouseEvent.
// deltaY positivo desplaza hacia abajo; deltaX positivo desplaza hacia la derecha.
// El evento se despacha en las coordenadas (100, 100) del viewport, que
// generalmente cae sobre el contenido principal de la pagina.
func CdpScroll(c *CDPConn, deltaX, deltaY float64) error {
if c == nil {
return fmt.Errorf("cdp scroll: conexion nula")
}
params := map[string]any{
"type": "mouseWheel",
"x": 100.0,
"y": 100.0,
"deltaX": deltaX,
"deltaY": deltaY,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", params); err != nil {
return fmt.Errorf("cdp scroll: %w", err)
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
---
id: cdp_scroll_go_browser
name: cdp_scroll
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
tests: []
test_file_path: ""
description: "Desplaza la pagina via rueda del raton con Input.dispatchMouseEvent type mouseWheel; imprescindible para scroll infinito en SPAs."
tags: [cdp, browser, input, scroll, navegator]
signature: "func CdpScroll(c *CDPConn, deltaX, deltaY float64) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_scroll.go"
example: |
// Scroll hacia abajo 800px en una SPA con feed infinito
for i := 0; i < 5; i++ {
_ = CdpScroll(c, 0, 800)
_ = CdpWaitIdle(c, 1500)
}
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: deltaX
desc: "Desplazamiento horizontal en pixeles. Positivo = derecha, negativo = izquierda. 0 para scroll solo vertical."
- name: deltaY
desc: "Desplazamiento vertical en pixeles. Positivo = hacia abajo, negativo = hacia arriba. Valores tipicos: 300-800 por paso."
output: "nil si el evento de scroll se despacho correctamente. Error si la conexion es nula o CDP rechaza el evento."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
_ = CdpNavigate(conn, "https://news.ycombinator.com")
_ = CdpWaitLoad(conn, 3000)
// Scroll hacia abajo en 5 pasos con pausa entre cada uno
for i := 0; i < 5; i++ {
if err := CdpScroll(conn, 0, 600); err != nil {
log.Fatal(err)
}
// Esperar que la SPA cargue nuevo contenido
_ = CdpWaitIdle(conn, 1500)
}
// Volver al inicio
_ = CdpScroll(conn, 0, -99999)
```
## Cuando usarla
Usar para cargar contenido de scroll infinito en SPAs (Twitter, LinkedIn, feeds), para desplazarse hasta elementos fuera del viewport antes de interactuar con ellos, o para simular lectura humana de una pagina. Combinar con CdpWaitIdle entre scrolls para dar tiempo a que el framework cargue nuevo contenido.
## Gotchas
- El evento se despacha en las coordenadas fijas (100, 100) del viewport. Si la pagina tiene un panel lateral o header que ocupa esa zona, el scroll puede no afectar al contenedor principal. En ese caso, evaluar `window.scrollBy(deltaX, deltaY)` via CdpEvaluate como alternativa.
- deltaY positivo = hacia abajo (igual que WheelEvent nativo del navegador).
- Para SPAs con scroll infinito es imprescindible llamar CdpWaitIdle despues de cada CdpScroll; sin la pausa, los scrolls consecutivos llegan antes de que el framework procese el primero.
- No hay garantia de que el scroll llegue al valor exacto de deltaY: el navegador puede aplicar aceleracion o limitar el desplazamiento al final del contenido.
+12
View File
@@ -2,6 +2,7 @@ package browser
import (
"fmt"
"strings"
"time"
)
@@ -13,6 +14,17 @@ func CdpTypeText(c *CDPConn, text string) error {
return fmt.Errorf("cdp type text: conexion nula")
}
// Verificar que hay un campo editable enfocado. Sin foco, los caracteres se
// pierden silenciosamente (van a document.body). Devolvemos error claro en vez
// de "escribir a la nada".
focus, ferr := CdpEvaluate(c, `(function(){var a=document.activeElement;if(!a)return 'none';var t=a.tagName.toLowerCase();return (t==='input'||t==='textarea'||t==='select'||a.isContentEditable)?'ok':t;})()`)
if ferr != nil {
return fmt.Errorf("cdp type text: verificar foco: %w", ferr)
}
if strings.TrimSpace(focus) != "ok" {
return fmt.Errorf("cdp type text: no hay campo de texto enfocado (activeElement: %s); usa CdpClick sobre el input primero", strings.TrimSpace(focus))
}
// keyDown (con `text`) ya inserta el caracter en el elemento focado en
// Chrome — enviar ademas un evento "char" lo duplicaba en sitios que
// reaccionan a `input` events (DuckDuckGo, Google, etc.). Patron
+8
View File
@@ -83,7 +83,15 @@ func defaultWindowsUserDataDir() (string, error) {
}
// chromePathsLinux lista los binarios Linux-nativos de Chrome en orden de preferencia.
// Las rutas absolutas a los binarios REALES van primero: saltan el wrapper
// /usr/bin/chromium (un script que inyecta los flags de /etc/chromium.d/*, p.ej.
// --user-data-dir y --remote-debugging-port globales que pisarian el aislamiento
// del navegador del agente). Si no existen, se cae a los nombres de PATH — que
// pueden resolver al wrapper, en cuyo caso el aislamiento depende de que nuestros
// flags vayan al final (Chrome usa el ultimo --user-data-dir duplicado).
var chromePathsLinux = []string{
"/usr/lib/chromium/chromium",
"/usr/lib/chromium-browser/chromium-browser",
"chromium",
"chromium-browser",
"google-chrome",
@@ -0,0 +1,73 @@
---
name: render_ax_outline
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str"
description: "Convierte nodos AX tree CDP en un outline indentado jerárquico y legible. Nodos accionables (button, link, textbox, etc.) llevan #ref=nodeId para que el LLM pueda referenciarlos en acciones. Poda nodos ignored y roles sin valor semántico."
tags: [browser, cdp, ax-tree, perception, navegator, pure, llm]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: nodes
desc: "Lista de AXNode en formato CDP (campos: nodeId, role, name, childIds, parentId, ignored). Devuelto por cdp_get_ax_tree. Pasar trim_ax_tree(nodes) antes para reducir ruido."
- name: max_chars
desc: "Si > 0, trunca la salida a ese número de caracteres y añade '…[outline truncado]'. 0 = sin límite (default)."
output: "String multi-línea con el outline indentado. Nodos accionables llevan ' #ref=nodeId' alineado a columna 60. Vacío si nodes está vacío o todos los nodos son ignorados."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/core/render_ax_outline.py"
---
## Ejemplo
```python
from core.render_ax_outline import render_ax_outline
# Nodos de muestra (formato real CDP simplificado)
nodes = [
{"nodeId": "1", "role": {"value": "RootWebArea"}, "name": {"value": "Gmail"},
"childIds": ["2", "3"], "ignored": False},
{"nodeId": "2", "role": {"value": "navigation"}, "name": {"value": ""},
"childIds": ["4", "5"], "ignored": False},
{"nodeId": "3", "role": {"value": "main"}, "name": {"value": ""},
"childIds": ["6"], "ignored": False},
{"nodeId": "4", "role": {"value": "button"}, "name": {"value": "Redactar"},
"childIds": [], "ignored": False},
{"nodeId": "5", "role": {"value": "link"}, "name": {"value": "Recibidos (3)"},
"childIds": [], "ignored": False},
{"nodeId": "6", "role": {"value": "textbox"}, "name": {"value": "Buscar correo"},
"childIds": [], "ignored": False},
]
outline = render_ax_outline(nodes)
print(outline)
# RootWebArea "Gmail"
# navigation
# button "Redactar" #ref=4
# link "Recibidos (3)" #ref=5
# main
# textbox "Buscar correo" #ref=6
# Con límite de caracteres para contexto comprimido:
outline_short = render_ax_outline(nodes, max_chars=100)
```
## Cuando usarla
Después de obtener el AX tree con `cdp_get_ax_tree` (y opcionalmente podarlo con `trim_ax_tree`), cuando necesitas dar al LLM una vista compacta de la página para que decida qué elemento accionar. El outline con `#ref` permite al LLM responder "haz clic en #ref=4" sin ambigüedad. Úsala directamente o como parte del pipeline `cdp_perceive_outline_py_pipelines`.
## Gotchas
- Esta función es pura: no llama a Chrome ni tiene I/O. Solo transforma la lista de nodos → string.
- Pasar los nodos crudos de `cdp_get_ax_tree` funciona, pero el outline será más verboso. Usar `trim_ax_tree` antes reduce el ruido considerablemente.
- Nodos con `ignored: true` se saltan silenciosamente (no aparecen en el outline).
- Roles sin valor semántico (`none`, `presentation`) también se saltan; sus hijos se renderizan un nivel arriba.
- Si `max_chars` corta a mitad de un nodo accionable importante, el LLM no verá su `#ref`. Para páginas grandes usar `cdp_perceive_outline` con `max_chars=20000` o chunking via `chunk_ax_tree`.
+139
View File
@@ -0,0 +1,139 @@
"""Convierte una lista de AXNode CDP en un outline indentado legible por LLMs."""
# Roles que se consideran accionables (el LLM puede referirlos con #ref=nodeId).
_ACTIONABLE_ROLES = frozenset({
"button",
"link",
"textbox",
"searchbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"tab",
"option",
"switch",
"slider",
"spinbutton",
"treeitem",
"gridcell",
})
# Roles sin valor semántico para el outline: se omiten si tampoco tienen hijos.
_SKIP_ROLES = frozenset({"none", "presentation", "ignored"})
def _role_val(node: dict) -> str:
"""Extrae el valor de role del nodo CDP."""
r = node.get("role", {})
if isinstance(r, dict):
return r.get("value", "")
return str(r) if r else ""
def _name_val(node: dict) -> str:
"""Extrae el valor de name del nodo CDP."""
n = node.get("name", {})
if isinstance(n, dict):
return n.get("value", "")
return str(n) if n else ""
def render_ax_outline(nodes: list[dict], max_chars: int = 0) -> str:
"""Convierte nodos AX tree CDP en un outline indentado legible y accionable.
Reconstruye la jerarquía padre→hijo usando childIds/parentId y genera
una representación de texto con indentación de 2 espacios por nivel.
Los nodos accionables llevan un marcador #ref=nodeId para que el LLM
pueda referenciarlos en acciones posteriores.
Args:
nodes: Lista de AXNode en formato CDP (campos: nodeId, role, name,
childIds, parentId, ignored). Formato devuelto por
cdp_get_ax_tree. Se puede pasar trim_ax_tree(nodes) antes
para reducir ruido.
max_chars: Si > 0, trunca la salida a ese número de caracteres y
añade una línea final '…[outline truncado]'. 0 = sin límite.
Returns:
String multi-línea con el outline indentado. Vacío si nodes es vacío
o todos los nodos son ignorados/sin role útil.
"""
if not nodes:
return ""
# Construir lookup por nodeId
by_id: dict[str, dict] = {}
for node in nodes:
nid = node.get("nodeId")
if nid:
by_id[nid] = node
# Detectar nodos raíz: nodeId no aparece como childId de nadie visible
all_child_ids: set[str] = set()
for node in nodes:
for cid in node.get("childIds", []):
all_child_ids.add(cid)
roots = [n for n in nodes if n.get("nodeId") not in all_child_ids]
if not roots:
# Fallback: usar el primer nodo
roots = [nodes[0]]
lines: list[str] = []
def _render_node(node: dict, depth: int) -> None:
"""Renderiza un nodo y sus hijos recursivamente."""
if node.get("ignored", False):
return
role = _role_val(node)
if not role or role in _SKIP_ROLES:
# Nodos sin role útil: intentar renderizar hijos directamente
for cid in node.get("childIds", []):
child = by_id.get(cid)
if child:
_render_node(child, depth)
return
name = _name_val(node)
node_id = node.get("nodeId", "")
indent = " " * depth
# Construir línea base: role ["name"]
if name:
base = f'{indent}{role} "{name}"'
else:
base = f"{indent}{role}"
# Añadir #ref si es accionable
if role in _ACTIONABLE_ROLES and node_id:
# Alinear #ref a columna 60 para legibilidad, o adjunto si la línea es larga
ref_tag = f"#ref={node_id}"
if len(base) < 58:
base = base.ljust(60) + ref_tag
else:
base = base + " " + ref_tag
lines.append(base)
# Renderizar hijos en orden
for cid in node.get("childIds", []):
child = by_id.get(cid)
if child:
_render_node(child, depth + 1)
for root in roots:
_render_node(root, 0)
result = "\n".join(lines)
if max_chars > 0 and len(result) > max_chars:
result = result[:max_chars].rstrip()
result += "\n…[outline truncado]"
return result
@@ -0,0 +1,77 @@
---
name: cdp_perceive_outline
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def cdp_perceive_outline(debug_port: int, tab_id: str, max_chars: int = 20000) -> str"
description: "Pipeline de percepción: conecta a Chrome via CDP, obtiene el AX tree completo, lo poda y lo convierte en un outline indentado legible para LLMs. Cada nodo accionable lleva #ref=nodeId. Reemplaza enviar 1k-50k nodos JSON crudos al modelo."
tags: [browser, cdp, ax-tree, perception, navegator, llm]
uses_functions: [cdp_get_ax_tree_py_pipelines, trim_ax_tree_py_core, render_ax_outline_py_core]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [argparse, sys, os]
params:
- name: debug_port
desc: "Puerto de debug remoto de Chrome (ej. 9333). Chrome debe estar corriendo con --remote-debugging-port=PORT."
- name: tab_id
desc: "ID del tab CDP, campo 'id' de GET http://127.0.0.1:{port}/json/list. Usar cdp_list_tabs_go_browser para listarlo."
- name: max_chars
desc: "Límite de caracteres del outline resultante. Default 20000 (~5k tokens). 0 = sin límite. Si la página es muy densa, reducir a 10000 para no saturar el context window."
output: "String multi-línea con el outline indentado de la página. Nodos accionables tienen ' #ref=nodeId' alineado. El LLM puede responder 'haz clic en #ref=44' para operar la página."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/cdp_perceive_outline.py"
---
## Ejemplo
```bash
# Via fn run (patrón canónico para agentes)
./fn run cdp_perceive_outline --debug-port 9333 --tab-id <id>
# Obtener tab_id primero:
curl -s http://127.0.0.1:9333/json/list | python3 -m json.tool | grep '"id"'
./fn run cdp_perceive_outline --debug-port 9333 --tab-id "A1B2C3D4..." --max-chars 15000
```
```python
# Uso desde Python (heredoc o pipeline propio)
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.cdp_perceive_outline import cdp_perceive_outline
outline = cdp_perceive_outline(debug_port=9333, tab_id="A1B2C3D4...")
print(outline)
# RootWebArea "GitHub"
# navigation "Site navigation"
# link "Homepage" #ref=12
# button "Search" #ref=18
# main
# heading "Repositories"
# link "fn_registry" #ref=44
# textbox "Filter repositories" #ref=51
```
## Cuando usarla
Cuando un agente LLM necesita "ver" una página Chrome ya abierta para decidir qué elemento accionar a continuación. Sustituye enviar el AX tree crudo (1k-50k nodos JSON) al modelo por un outline compacto de ~200-500 líneas. El `#ref=nodeId` hace que el LLM pueda responder con una referencia exacta sin ambigüedad.
Flujo típico de un agente browser:
1. `cdp_list_tabs` → obtener `tab_id`
2. `cdp_perceive_outline` → outline compacto de la página
3. LLM decide acción (clic en #ref=44, texto en #ref=51, etc.)
4. `cdp_click_node` / `cdp_type_text` con el nodeId extraído del #ref
## Gotchas
- Chrome debe estar corriendo con `--remote-debugging-port=<port>`. En Linux nativo: `chromium --remote-debugging-port=9333 &`. Con CDP global activado en `/etc/chromium.d/cdp`, el puerto 9222 siempre está disponible.
- El tab no puede tener DevTools abierto (toma el debugger exclusivo). Cerrar DevTools antes de llamar.
- `Accessibility.getFullAXTree` puede tardar 2-10s en páginas muy pesadas (SPAs tipo Gmail con miles de nodos). El timeout total es 15s.
- El outline resultante puede superar `max_chars` en ~100 chars si el último nodo visible es muy largo. Usar margen holgado (ej. 18000 en vez de 20000 si el context window es ajustado).
- Si la página no tiene contenido accesible (ej. canvas puro, PDF embebido), el outline estará vacío o solo tendrá el RootWebArea. En ese caso usar CDP JS evaluation directamente.
- `tab_id` es el campo `"id"` del JSON de `/json/list`, no `"targetId"`. Son diferentes.
@@ -0,0 +1,79 @@
"""Pipeline: obtiene el AX tree de un tab Chrome y lo convierte en outline legible."""
import argparse
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pipelines.cdp_get_ax_tree import cdp_get_ax_tree
from core.trim_ax_tree import trim_ax_tree
from core.render_ax_outline import render_ax_outline
def cdp_perceive_outline(
debug_port: int,
tab_id: str,
max_chars: int = 20000,
) -> str:
"""Obtiene el AX tree de un tab Chrome y devuelve un outline indentado legible.
Compone tres pasos:
1. cdp_get_ax_tree — obtiene nodos crudos via CDP WebSocket.
2. trim_ax_tree — poda nodos irrelevantes (ignored, generic sin hijos, etc.).
3. render_ax_outline — convierte en outline indentado con #ref para accionables.
Args:
debug_port: Puerto de debug remoto de Chrome (ej. 9333).
Chrome debe estar corriendo con --remote-debugging-port=PORT.
tab_id: ID del tab CDP. Obtenerlo via GET http://127.0.0.1:{port}/json/list
o con cdp_list_tabs_go_browser.
max_chars: Límite de caracteres del outline resultante. 0 = sin límite.
Default 20000 (~5k tokens), apropiado para context window de Claude.
Returns:
String con el outline indentado. Cada nodo accionable tiene #ref=nodeId
para que el LLM pueda referenciarlo en acciones posteriores.
Raises:
RuntimeError: Si Chrome no responde, el tab no existe, o falla la conexión WS.
TimeoutError: Si Accessibility.getFullAXTree no responde en 15s.
"""
nodes = cdp_get_ax_tree(debug_port=debug_port, tab_id=tab_id)
trimmed = trim_ax_tree(nodes)
return render_ax_outline(trimmed, max_chars=max_chars)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Obtiene el outline del AX tree de un tab Chrome via CDP."
)
parser.add_argument(
"--debug-port",
type=int,
default=9222,
help="Puerto de debug remoto de Chrome (default: 9222).",
)
parser.add_argument(
"--tab-id",
required=True,
help="ID del tab CDP (campo 'id' de GET /json/list).",
)
parser.add_argument(
"--max-chars",
type=int,
default=20000,
help="Límite de caracteres del outline. 0 = sin límite (default: 20000).",
)
args = parser.parse_args()
try:
outline = cdp_perceive_outline(
debug_port=args.debug_port,
tab_id=args.tab_id,
max_chars=args.max_chars,
)
print(outline)
except (RuntimeError, TimeoutError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)