feat(browser): auto-commit con 44 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user