From 5b10b419a255627d68ff38a990dab9ee5a8f0038 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 6 Jun 2026 12:49:54 +0200 Subject: [PATCH] feat(browser): auto-commit con 44 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/modo_launcher.md | 159 ++++++++++++++++++ functions/browser/cdp_activate_tab.md | 66 ++++++++ functions/browser/cdp_clear_cookies.go | 12 ++ functions/browser/cdp_clear_cookies.md | 54 ++++++ functions/browser/cdp_click.go | 20 ++- functions/browser/cdp_close_tab.md | 63 +++++++ functions/browser/cdp_connect.go | 31 ++-- functions/browser/cdp_connect_target.go | 56 ++++++ functions/browser/cdp_connect_target.md | 58 +++++++ functions/browser/cdp_delete_cookies.go | 15 ++ functions/browser/cdp_delete_cookies.md | 61 +++++++ functions/browser/cdp_eval_in_frame.go | 83 +++++++++ functions/browser/cdp_eval_in_frame.md | 73 ++++++++ functions/browser/cdp_evaluate.go | 14 +- functions/browser/cdp_find_by_text.go | 8 +- functions/browser/cdp_get_cookies.go | 63 +++++++ functions/browser/cdp_get_cookies.md | 59 +++++++ functions/browser/cdp_get_frame_html.go | 23 +++ functions/browser/cdp_get_frame_html.md | 70 ++++++++ functions/browser/cdp_get_text.go | 54 ++++++ functions/browser/cdp_get_text.md | 59 +++++++ functions/browser/cdp_handle_dialog.go | 35 ++++ functions/browser/cdp_handle_dialog.md | 74 ++++++++ functions/browser/cdp_list_frames.go | 73 ++++++++ functions/browser/cdp_list_frames.md | 62 +++++++ functions/browser/cdp_load_storage_state.go | 98 +++++++++++ functions/browser/cdp_load_storage_state.md | 67 ++++++++ functions/browser/cdp_nav_back.go | 67 ++++++++ functions/browser/cdp_nav_back.md | 62 +++++++ functions/browser/cdp_nav_forward.go | 64 +++++++ functions/browser/cdp_nav_forward.md | 64 +++++++ functions/browser/cdp_navigate.go | 5 +- functions/browser/cdp_press_key.go | 69 ++++++++ functions/browser/cdp_press_key.md | 67 ++++++++ functions/browser/cdp_save_storage_state.go | 120 +++++++++++++ functions/browser/cdp_save_storage_state.md | 62 +++++++ functions/browser/cdp_scroll.go | 26 +++ functions/browser/cdp_scroll.md | 67 ++++++++ functions/browser/cdp_type_text.go | 12 ++ functions/browser/chrome_launch.go | 8 + python/functions/core/render_ax_outline.md | 73 ++++++++ python/functions/core/render_ax_outline.py | 139 +++++++++++++++ .../pipelines/cdp_perceive_outline.md | 77 +++++++++ .../pipelines/cdp_perceive_outline.py | 79 +++++++++ 44 files changed, 2543 insertions(+), 28 deletions(-) create mode 100644 .claude/commands/modo_launcher.md create mode 100644 functions/browser/cdp_activate_tab.md create mode 100644 functions/browser/cdp_clear_cookies.go create mode 100644 functions/browser/cdp_clear_cookies.md create mode 100644 functions/browser/cdp_close_tab.md create mode 100644 functions/browser/cdp_connect_target.go create mode 100644 functions/browser/cdp_connect_target.md create mode 100644 functions/browser/cdp_delete_cookies.go create mode 100644 functions/browser/cdp_delete_cookies.md create mode 100644 functions/browser/cdp_eval_in_frame.go create mode 100644 functions/browser/cdp_eval_in_frame.md create mode 100644 functions/browser/cdp_get_cookies.go create mode 100644 functions/browser/cdp_get_cookies.md create mode 100644 functions/browser/cdp_get_frame_html.go create mode 100644 functions/browser/cdp_get_frame_html.md create mode 100644 functions/browser/cdp_get_text.go create mode 100644 functions/browser/cdp_get_text.md create mode 100644 functions/browser/cdp_handle_dialog.go create mode 100644 functions/browser/cdp_handle_dialog.md create mode 100644 functions/browser/cdp_list_frames.go create mode 100644 functions/browser/cdp_list_frames.md create mode 100644 functions/browser/cdp_load_storage_state.go create mode 100644 functions/browser/cdp_load_storage_state.md create mode 100644 functions/browser/cdp_nav_back.go create mode 100644 functions/browser/cdp_nav_back.md create mode 100644 functions/browser/cdp_nav_forward.go create mode 100644 functions/browser/cdp_nav_forward.md create mode 100644 functions/browser/cdp_press_key.go create mode 100644 functions/browser/cdp_press_key.md create mode 100644 functions/browser/cdp_save_storage_state.go create mode 100644 functions/browser/cdp_save_storage_state.md create mode 100644 functions/browser/cdp_scroll.go create mode 100644 functions/browser/cdp_scroll.md create mode 100644 python/functions/core/render_ax_outline.md create mode 100644 python/functions/core/render_ax_outline.py create mode 100644 python/functions/pipelines/cdp_perceive_outline.md create mode 100644 python/functions/pipelines/cdp_perceive_outline.py diff --git a/.claude/commands/modo_launcher.md b/.claude/commands/modo_launcher.md new file mode 100644 index 00000000..7351f4a5 --- /dev/null +++ b/.claude/commands/modo_launcher.md @@ -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: +CMD: +WHY: +────────── + +``` + +- `FUENTE` es una de las etiquetas de la taxonomía. +- `CMD` es el comando literal lanzado (forma `./fn run [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 [args]` (preferido); fallback `./fn run [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 ` por terminal. La línea `CMD` muestra siempre la forma `./fn run ` 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 → __: <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. diff --git a/functions/browser/cdp_activate_tab.md b/functions/browser/cdp_activate_tab.md new file mode 100644 index 00000000..33d32a12 --- /dev/null +++ b/functions/browser/cdp_activate_tab.md @@ -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/. 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/`. +- 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. diff --git a/functions/browser/cdp_clear_cookies.go b/functions/browser/cdp_clear_cookies.go new file mode 100644 index 00000000..9b9f71c6 --- /dev/null +++ b/functions/browser/cdp_clear_cookies.go @@ -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 +} diff --git a/functions/browser/cdp_clear_cookies.md b/functions/browser/cdp_clear_cookies.md new file mode 100644 index 00000000..68e742f2 --- /dev/null +++ b/functions/browser/cdp_clear_cookies.md @@ -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. diff --git a/functions/browser/cdp_click.go b/functions/browser/cdp_click.go index b174d54b..9f9b36e4 100644 --- a/functions/browser/cdp_click.go +++ b/functions/browser/cdp_click.go @@ -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", diff --git a/functions/browser/cdp_close_tab.md b/functions/browser/cdp_close_tab.md new file mode 100644 index 00000000..8938e55a --- /dev/null +++ b/functions/browser/cdp_close_tab.md @@ -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/. 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/`. +- 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`. diff --git a/functions/browser/cdp_connect.go b/functions/browser/cdp_connect.go index e4eef455..7a898901 100644 --- a/functions/browser/cdp_connect.go +++ b/functions/browser/cdp_connect.go @@ -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) +} diff --git a/functions/browser/cdp_connect_target.go b/functions/browser/cdp_connect_target.go new file mode 100644 index 00000000..0cbeba3f --- /dev/null +++ b/functions/browser/cdp_connect_target.go @@ -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) +} diff --git a/functions/browser/cdp_connect_target.md b/functions/browser/cdp_connect_target.md new file mode 100644 index 00000000..b3ee2a62 --- /dev/null +++ b/functions/browser/cdp_connect_target.md @@ -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. diff --git a/functions/browser/cdp_delete_cookies.go b/functions/browser/cdp_delete_cookies.go new file mode 100644 index 00000000..1b7eb5fc --- /dev/null +++ b/functions/browser/cdp_delete_cookies.go @@ -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 +} diff --git a/functions/browser/cdp_delete_cookies.md b/functions/browser/cdp_delete_cookies.md new file mode 100644 index 00000000..a5074097 --- /dev/null +++ b/functions/browser/cdp_delete_cookies.md @@ -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. diff --git a/functions/browser/cdp_eval_in_frame.go b/functions/browser/cdp_eval_in_frame.go new file mode 100644 index 00000000..6f31db02 --- /dev/null +++ b/functions/browser/cdp_eval_in_frame.go @@ -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 +} diff --git a/functions/browser/cdp_eval_in_frame.md b/functions/browser/cdp_eval_in_frame.md new file mode 100644 index 00000000..2fb454aa --- /dev/null +++ b/functions/browser/cdp_eval_in_frame.md @@ -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. diff --git a/functions/browser/cdp_evaluate.go b/functions/browser/cdp_evaluate.go index 459b08a5..99207174 100644 --- a/functions/browser/cdp_evaluate.go +++ b/functions/browser/cdp_evaluate.go @@ -1,6 +1,7 @@ package browser import ( + "encoding/json" "fmt" ) @@ -44,5 +45,16 @@ func CdpEvaluate(c *CDPConn, expression string) (string, error) { return typ, nil } - return fmt.Sprintf("%v", value), nil + // Strings se devuelven tal cual (sin comillas). Objetos y arrays JS, que Chrome + // deserializa a map/slice cuando returnByValue=true, se serializan a JSON real + // en vez de la repr de Go de fmt.Sprintf("%v") (que produciria "map[a:1]" en lugar + // de {"a":1}). Asi el caller puede parsear datos estructurados. + if s, ok := value.(string); ok { + return s, nil + } + b, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value), nil + } + return string(b), nil } diff --git a/functions/browser/cdp_find_by_text.go b/functions/browser/cdp_find_by_text.go index dee0222b..c0010735 100644 --- a/functions/browser/cdp_find_by_text.go +++ b/functions/browser/cdp_find_by_text.go @@ -25,8 +25,10 @@ type FindByTextOpts struct { // - "#" 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 == "" { - return "", nil + return "", fmt.Errorf("cdp find by text: no se encontro elemento con texto %q", text) } return res, nil } diff --git a/functions/browser/cdp_get_cookies.go b/functions/browser/cdp_get_cookies.go new file mode 100644 index 00000000..38ce77a3 --- /dev/null +++ b/functions/browser/cdp_get_cookies.go @@ -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 +} diff --git a/functions/browser/cdp_get_cookies.md b/functions/browser/cdp_get_cookies.md new file mode 100644 index 00000000..389b5b66 --- /dev/null +++ b/functions/browser/cdp_get_cookies.md @@ -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). diff --git a/functions/browser/cdp_get_frame_html.go b/functions/browser/cdp_get_frame_html.go new file mode 100644 index 00000000..7fbeb31f --- /dev/null +++ b/functions/browser/cdp_get_frame_html.go @@ -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 +} diff --git a/functions/browser/cdp_get_frame_html.md b/functions/browser/cdp_get_frame_html.md new file mode 100644 index 00000000..db42600c --- /dev/null +++ b/functions/browser/cdp_get_frame_html.md @@ -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. diff --git a/functions/browser/cdp_get_text.go b/functions/browser/cdp_get_text.go new file mode 100644 index 00000000..902ab90e --- /dev/null +++ b/functions/browser/cdp_get_text.go @@ -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 +} diff --git a/functions/browser/cdp_get_text.md b/functions/browser/cdp_get_text.md new file mode 100644 index 00000000..a23c811c --- /dev/null +++ b/functions/browser/cdp_get_text.md @@ -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. diff --git a/functions/browser/cdp_handle_dialog.go b/functions/browser/cdp_handle_dialog.go new file mode 100644 index 00000000..64e4ff8c --- /dev/null +++ b/functions/browser/cdp_handle_dialog.go @@ -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 +} diff --git a/functions/browser/cdp_handle_dialog.md b/functions/browser/cdp_handle_dialog.md new file mode 100644 index 00000000..a433a085 --- /dev/null +++ b/functions/browser/cdp_handle_dialog.md @@ -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. diff --git a/functions/browser/cdp_list_frames.go b/functions/browser/cdp_list_frames.go new file mode 100644 index 00000000..498c2ef1 --- /dev/null +++ b/functions/browser/cdp_list_frames.go @@ -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 +} diff --git a/functions/browser/cdp_list_frames.md b/functions/browser/cdp_list_frames.md new file mode 100644 index 00000000..6c58994e --- /dev/null +++ b/functions/browser/cdp_list_frames.md @@ -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 `