Compare commits
5 Commits
648ce63fc0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 029dbf57bd | |||
| 3f6b652f3f | |||
| 5b10b419a2 | |||
| e2c073b8b7 | |||
| 25054ff64e |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-analizador
|
||||
description: "Agente analizador (Fase 4) del ciclo reactivo. Lee `e2e_checks` declarados en app.md, ejecuta la suite via `e2e_run_checks_go_infra`, evalua assertions activas, calcula drift de metricas vs historico, persiste resultado en `e2e_runs` de operations.db y devuelve veredicto caveman pass/fail. NO modifica codigo ni propone fixes — eso es trabajo de fn-mejorador (Fase 5)."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-constructor
|
||||
description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-executor
|
||||
description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-mejorador
|
||||
description: "Agente mejorador (Fase 5) del ciclo reactivo. Lee resultados fallidos de fn-analizador desde `e2e_runs`/`assertion_results`, busca contexto en el registry, y crea proposals con evidencia trazable. NO modifica codigo: solo abre proposals para que un humano (o el bucle autonomo del issue 0069) decida."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Bash, Grep, Glob
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-orquestador
|
||||
description: "Meta-orquestador (Fase 6) del ciclo reactivo. Toma un issue o task_spec y recorre CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR despachando a fn-constructor/executor/recopilador/analizador/mejorador hasta convergencia, estancamiento, timeout o tope de iteraciones. Trabaja SIEMPRE en rama sandbox `auto/<issue>`, NUNCA mergea a master, persiste progreso en `task_runs`. Issue 0069."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-recopilador
|
||||
description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta. Modo extra `design-e2e <app_id>`: propone bloque `e2e_checks` para que la fase 4 (fn-analizador) pueda validar la app sin iteracion humana."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -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,100 @@
|
||||
---
|
||||
name: set_chrome_profile_appearance
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name> [--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]"
|
||||
description: "Personaliza la apariencia visual de un perfil Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen PNG/JPG custom, y/o un color de acento (hex #rrggbb). Con --color aplica el tinte tanto al círculo del avatar en Local State (profile_highlight_color, profile_color_seed, default_avatar_fill_color) como al tema completo del navegador en el Preferences del perfil (browser.theme.user_color2, browser_color_variant, extensions.theme.system_theme), tiñendo toolbar, frame, barra de pestañas y omnibox. Requiere que Chromium esté cerrado sobre el user-data-dir. Hace backup de Local State y Preferences antes de escribir y valida el JSON resultante."
|
||||
tags: [navegator, chromium, profile, browser, cdp, scraping, appearance, avatar, color]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/set_chrome_profile_appearance.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Raíz del user-data-dir de Chrome/Chromium donde vive el perfil. El directorio y Local State deben existir. Obligatorio."
|
||||
- name: --profile
|
||||
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, Automation, \"Profile 1\". El perfil debe existir previamente en info_cache de Local State. Obligatorio."
|
||||
- name: --avatar
|
||||
desc: "Índice entero 0..55 del avatar built-in de Chrome (56 avatares: animales, objetos, personas) o ruta absoluta/relativa a un archivo PNG/JPG para avatar custom. Con índice: sets avatar_icon=IDR_PROFILE_AVATAR_<N> e is_using_default_avatar=true. Con imagen: copia el archivo al perfil como 'Google Profile Picture.png' y sets is_using_default_avatar=false. Opcional; al menos uno de --avatar o --color debe darse."
|
||||
- name: --color
|
||||
desc: "Color de acento del perfil en hex #rrggbb, con o sin el '#' inicial. Se convierte a int32 con signo en formato ARGB 0xFFRRGGBB. Aplica el color en dos lugares: (1) Local State info_cache (profile_highlight_color, profile_color_seed, default_avatar_fill_color) para el círculo del avatar; (2) Preferences del perfil (browser.theme.user_color2 + browser_color_variant + extensions.theme.system_theme=0) para teñir toolbar, frame, barra de pestañas y omnibox. Opcional; al menos uno de --avatar o --color debe darse."
|
||||
- name: --variant
|
||||
desc: "Intensidad del tema de color aplicado al navegador (browser_color_variant). Entero 0..4: 0=system, 1=tonal_spot, 2=neutral, 3=vibrant (default), 4=expressive. Valores más altos dan tintes más saturados e identificables. Solo tiene efecto cuando se usa --color. Opcional."
|
||||
- name: --dry-run
|
||||
desc: "Describe las acciones que se ejecutarían (campos a modificar en Local State y Preferences, conversión de color, ruta del Preferences) sin escribir nada ni verificar si Chromium está corriendo. Emite JSON de resultado con dry_run:true."
|
||||
output: "JSON en stdout con los campos resultantes del perfil: {\"profile\":\"<dir>\",\"avatar_icon\":\"...\",\"is_using_default_avatar\":true|false,\"profile_highlight_color\":<int>,\"profile_color_seed\":<int>,\"default_avatar_fill_color\":<int>,\"theme_applied\":true|false,\"variant\":<int>,\"preferences_path\":\"...\",\"browser_theme_user_color2\":<int>,\"browser_theme_color_variant\":<int>,\"extensions_theme_system_theme\":<int>,\"backup\":\"Local State.bak.YYYYMMDD\"}. En dry-run: {\"profile\":\"...\",\"avatar_applied\":true|false,\"color_applied\":true|false,\"theme_applied\":true|false,\"variant\":<int>,\"dry_run\":true}. Mensajes de diagnóstico a stderr. Exit 0 en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source $HOME/fn_registry/bash/functions/browser/set_chrome_profile_appearance.sh
|
||||
|
||||
# Asignar avatar #30 y tinte verde a toolbar/frame/omnibox del perfil Automation
|
||||
# (verde #16a34a tiñe toda la chrome del navegador, no solo el círculo del avatar)
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile Automation \
|
||||
--avatar 30 \
|
||||
--color "#16a34a"
|
||||
# Salida JSON incluye: theme_applied:true, variant:3, browser_theme_user_color2:-15293622
|
||||
|
||||
# Color con intensidad personalizada (expressive = máxima saturación)
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile Scraping \
|
||||
--color "#1f6feb" \
|
||||
--variant 4
|
||||
|
||||
# Solo cambiar avatar (no toca Preferences del perfil)
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile "Profile 1" \
|
||||
--avatar 5
|
||||
|
||||
# Dry-run: ver qué se aplicaría en Local State y Preferences sin escribir
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile Automation \
|
||||
--avatar 30 \
|
||||
--color "#16a34a" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala para diferenciar visualmente los perfiles de un user-data-dir de automatización — un color y avatar distintos por perfil hacen inmediata la identificación en el selector de Chrome Y en la chrome del navegador (toolbar/frame visible mientras navega). Ejecútala justo después de `create_chrome_profile` (con `--no-launch`) o como paso independiente de personalización batch antes de lanzar sesiones CDP. Si solo quieres teñir el círculo del avatar (sin el tema), basta esta función; si quieres el tinte completo del navegador (lo más identificable), pasa `--color`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium debe estar cerrado**: Chrome reescribe `Local State` y `Preferences` completos desde memoria al cerrar; si se ejecuta mientras hay un proceso chromium vivo sobre el mismo user-data-dir, Chrome sobreescribirá los cambios al salir. La función detecta esto con `pgrep -x chromium` filtrando por cmdline y sale con exit 2 antes de modificar nada. Usa `pkill -TERM chromium` para cerrar y espera unos segundos.
|
||||
- **El tema se escribe en Preferences del perfil, distinto de Local State**: los cambios de color al avatar van en `<user-data-dir>/Local State` (global a todos los perfiles); los cambios de tema del navegador van en `<user-data-dir>/<profile_dir>/Preferences` (específico de cada perfil). La función hace backup de ambos archivos por separado antes de tocarlos.
|
||||
- **El perfil debe existir en info_cache**: esta función personaliza perfiles existentes; no los crea. Usa `create_chrome_profile` primero (con `--no-launch` basta para que aparezca en Local State) y luego `set_chrome_profile_appearance`.
|
||||
- **color es int32 con signo en ARGB**: Chrome almacena el color como entero con signo de 32 bits en formato `0xAARRGGBB`. Un color como `#16a34a` (verde) da ARGB `0xFF16A34A` → signed int32 `-15293622`. La función hace la conversión internamente; tú pasas siempre hex `#rrggbb`.
|
||||
- **En modo oscuro del sistema el tinte sale más apagado**: en temas oscuros del sistema el color se mezcla con el fondo oscuro y queda menos saturado. Para compensar, usa `--variant 3` (vibrant, default) o `--variant 4` (expressive); valores bajos como 1 o 2 pueden resultar casi imperceptibles en modo oscuro.
|
||||
- **`extensions.theme.system_theme` se fuerza a 0**: si el perfil usaba el tema GTK del sistema (`system_theme=1`), el GTK puede ignorar el `user_color`. Esta función lo fuerza a 0 (tema propio de Chrome) para que el `user_color2` tenga efecto. Si quieres devolver el perfil al tema del sistema, tendrás que resetear `system_theme` manualmente.
|
||||
- **Avatar custom (imagen) es best-effort**: el campo `gaia_picture_file_name` y `is_using_default_avatar=false` se aplican correctamente en Local State y la imagen se copia al directorio del perfil. Sin embargo, Chrome puede ignorar la foto de perfil en perfiles sin sesión Google activa (Chromium sin cuenta). El camino robusto y garantizado es usar el índice built-in (`--avatar 0..55`): 56 avatares (animales, objetos, personas) son más que suficientes para diferenciar perfiles de automatización.
|
||||
- **Backup diario**: se crea `Local State.bak.YYYYMMDD` y `Preferences.bak.YYYYMMDD` antes de cualquier escritura. Si ya existen los backups del día no se sobreescriben. Si el JSON resultante es inválido, se restaura automáticamente el backup correspondiente.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito |
|
||||
| 1 | Argumento obligatorio faltante, rango inválido o archivo de imagen no encontrado |
|
||||
| 2 | Lock: hay un chromium usando el mismo user-data-dir |
|
||||
| 3 | El perfil no existe en info_cache de Local State |
|
||||
| 4 | Error editando Local State o Preferences (JSON inválido tras escritura, restaurado backup) |
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.1.0 (2026-06-06) — --color ahora aplica también el tema del navegador (toolbar/frame/omnibox) escribiendo browser.theme.user_color2 + browser_color_variant en el Preferences del perfil, no solo el color del avatar en Local State. Nuevo flag --variant (0..4, default 3 vibrant). Verificado con captura en Chromium 148.
|
||||
@@ -0,0 +1,426 @@
|
||||
#!/usr/bin/env bash
|
||||
# set_chrome_profile_appearance — personaliza la apariencia visual de un perfil
|
||||
# Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen
|
||||
# PNG/JPG custom, y/o un color de acento (hex #rrggbb). Edita Local State Y el
|
||||
# Preferences del perfil (browser.theme.* para teñir toolbar/frame/omnibox).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
set_chrome_profile_appearance() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _udd=""
|
||||
local _profile_dir=""
|
||||
local _avatar=""
|
||||
local _color=""
|
||||
local _variant=3
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name>
|
||||
[--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]
|
||||
|
||||
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
|
||||
--profile Nombre de la carpeta del perfil, ej: Default, Automation,
|
||||
"Profile 1" (obligatorio). El perfil debe existir.
|
||||
--avatar Índice entero 0..55 del avatar built-in de Chrome, o ruta a
|
||||
un archivo PNG/JPG para avatar custom (opcional).
|
||||
--color Color de acento del perfil en formato hex #rrggbb, con o sin
|
||||
el '#' inicial (opcional). Aplica el color tanto al círculo
|
||||
del avatar (Local State) como al tema del navegador
|
||||
(toolbar/frame/omnibox via Preferences del perfil).
|
||||
--variant Intensidad del tema de color: 0=system, 1=tonal_spot,
|
||||
2=neutral, 3=vibrant (default), 4=expressive. Solo tiene
|
||||
efecto cuando se usa --color.
|
||||
--dry-run Describe las acciones sin modificar nada.
|
||||
|
||||
Al menos uno de --avatar o --color debe indicarse.
|
||||
|
||||
Exit codes:
|
||||
0 éxito
|
||||
1 error de argumento o validación
|
||||
2 lock: hay un chromium corriendo con este user-data-dir
|
||||
3 el perfil no existe en info_cache de Local State
|
||||
4 error editando Local State o Preferences (JSON inválido tras escritura)
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _udd="$2"; shift 2 ;;
|
||||
--profile) _profile_dir="$2"; shift 2 ;;
|
||||
--avatar) _avatar="$2"; shift 2 ;;
|
||||
--color) _color="$2"; shift 2 ;;
|
||||
--variant) _variant="$2"; shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "set_chrome_profile_appearance: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones obligatorias ──────────────────────────────────────────────
|
||||
if [[ -z "$_udd" ]]; then
|
||||
echo "set_chrome_profile_appearance: --user-data-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_profile_dir" ]]; then
|
||||
echo "set_chrome_profile_appearance: --profile es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_avatar" && -z "$_color" ]]; then
|
||||
echo "set_chrome_profile_appearance: al menos --avatar o --color debe indicarse" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validar --variant
|
||||
if ! [[ "$_variant" =~ ^[0-4]$ ]]; then
|
||||
echo "set_chrome_profile_appearance: --variant debe ser un entero 0..4, recibido: ${_variant}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Expandir ~ en el user-data-dir
|
||||
_udd="${_udd/#\~/$HOME}"
|
||||
|
||||
local _local_state="${_udd}/Local State"
|
||||
|
||||
# Verificar que user-data-dir y Local State existen
|
||||
if [[ ! -d "$_udd" ]]; then
|
||||
echo "set_chrome_profile_appearance: user-data-dir no encontrado: ${_udd}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f "$_local_state" ]]; then
|
||||
echo "set_chrome_profile_appearance: Local State no encontrado: ${_local_state}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── validar --avatar ──────────────────────────────────────────────────────
|
||||
local _avatar_index=-1
|
||||
local _avatar_image_path=""
|
||||
|
||||
if [[ -n "$_avatar" ]]; then
|
||||
if [[ "$_avatar" =~ ^[0-9]+$ ]]; then
|
||||
# Índice built-in
|
||||
_avatar_index=$(( _avatar ))
|
||||
if [[ $_avatar_index -lt 0 || $_avatar_index -gt 55 ]]; then
|
||||
echo "set_chrome_profile_appearance: índice de avatar fuera de rango (0..55): ${_avatar}" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Ruta a imagen custom
|
||||
local _img_path="${_avatar/#\~/$HOME}"
|
||||
if [[ ! -f "$_img_path" ]]; then
|
||||
echo "set_chrome_profile_appearance: archivo de imagen no encontrado: ${_img_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
_avatar_image_path="$_img_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── validar --color ───────────────────────────────────────────────────────
|
||||
local _color_hex=""
|
||||
if [[ -n "$_color" ]]; then
|
||||
_color_hex="${_color/#\#/}" # quitar # inicial si lo hay
|
||||
if ! [[ "$_color_hex" =~ ^[0-9a-fA-F]{6}$ ]]; then
|
||||
echo "set_chrome_profile_appearance: color hex inválido (espera rrggbb): ${_color}" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto ──────────
|
||||
# pgrep -x chromium lista solo procesos cuyo comm es exactamente "chromium",
|
||||
# nunca grep/pgrep/bash. Así evitamos auto-matchear el propio script cuando
|
||||
# el path del udd contiene "chromium" (p.ej. ~/.config/chromium-cdp).
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
local _p _busy=0
|
||||
for _p in $(pgrep -x chromium 2>/dev/null); do
|
||||
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd"; then
|
||||
_busy=1; break
|
||||
fi
|
||||
done
|
||||
if [[ $_busy -eq 1 ]]; then
|
||||
echo "set_chrome_profile_appearance: hay un chromium corriendo con este user-data-dir — ciérralo primero:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo " (Chrome reescribe Local State y Preferences al cerrar y pierde los cambios)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── verificar que el perfil existe en info_cache ──────────────────────────
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
local _profile_exists
|
||||
_profile_exists="$(python3 -c "
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
ic = data.get('profile', {}).get('info_cache', {})
|
||||
print('yes' if sys.argv[2] in ic else 'no')
|
||||
" "$_local_state" "$_profile_dir" 2>/dev/null || echo "no")"
|
||||
if [[ "$_profile_exists" != "yes" ]]; then
|
||||
echo "set_chrome_profile_appearance: perfil '${_profile_dir}' no existe en info_cache de Local State" >&2
|
||||
echo " Perfiles disponibles:" >&2
|
||||
python3 -c "
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
ic = data.get('profile', {}).get('info_cache', {})
|
||||
for k in ic: print(' ', k)
|
||||
" "$_local_state" >&2 2>/dev/null || true
|
||||
return 3
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── modo dry-run ──────────────────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== set_chrome_profile_appearance DRY-RUN ===" >&2
|
||||
echo " user-data-dir : ${_udd}" >&2
|
||||
echo " profile : ${_profile_dir}" >&2
|
||||
if [[ $_avatar_index -ge 0 ]]; then
|
||||
echo " avatar : built-in #${_avatar_index} → avatar_icon=chrome://theme/IDR_PROFILE_AVATAR_${_avatar_index}" >&2
|
||||
echo " is_using_default_avatar=true" >&2
|
||||
elif [[ -n "$_avatar_image_path" ]]; then
|
||||
local _dest_img="${_udd}/${_profile_dir}/Google Profile Picture.png"
|
||||
echo " avatar : imagen custom ${_avatar_image_path}" >&2
|
||||
echo " copiaría a ${_dest_img}" >&2
|
||||
echo " is_using_default_avatar=false" >&2
|
||||
echo " gaia_picture_file_name=Google Profile Picture.png" >&2
|
||||
fi
|
||||
if [[ -n "$_color_hex" ]]; then
|
||||
local _signed_preview
|
||||
_signed_preview="$(python3 -c "
|
||||
rgb = int('${_color_hex}', 16)
|
||||
argb = 0xFF000000 | rgb
|
||||
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
|
||||
print(signed)
|
||||
" 2>/dev/null || echo '?')"
|
||||
echo " color : #${_color_hex} → signed int32 ${_signed_preview}" >&2
|
||||
echo " Local State: profile_highlight_color, profile_color_seed, default_avatar_fill_color" >&2
|
||||
echo " Preferences: browser.theme.user_color2=${_signed_preview}, browser_color_variant=${_variant}, is_grayscale2=false" >&2
|
||||
echo " Preferences: extensions.theme.system_theme=0" >&2
|
||||
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
|
||||
echo " Preferences : ${_prefs_path}" >&2
|
||||
fi
|
||||
echo " Local State : ${_local_state}" >&2
|
||||
printf '{"profile":"%s","avatar_applied":%s,"color_applied":%s,"theme_applied":%s,"variant":%d,"dry_run":true}\n' \
|
||||
"$_profile_dir" \
|
||||
"$([[ -n "$_avatar" ]] && echo 'true' || echo 'false')" \
|
||||
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
|
||||
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
|
||||
"$_variant"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── backup de Local State (no sobreescribir el del mismo día) ────────────
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
local _backup="${_local_state}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_local_state" "$_backup"
|
||||
fi
|
||||
|
||||
# ── copiar imagen custom si es necesario ──────────────────────────────────
|
||||
local _copy_image_done=false
|
||||
if [[ -n "$_avatar_image_path" ]]; then
|
||||
local _profile_path="${_udd}/${_profile_dir}"
|
||||
mkdir -p "$_profile_path"
|
||||
cp "$_avatar_image_path" "${_profile_path}/Google Profile Picture.png"
|
||||
_copy_image_done=true
|
||||
fi
|
||||
|
||||
# ── editar Local State con python3 ────────────────────────────────────────
|
||||
if ! python3 - \
|
||||
"$_local_state" \
|
||||
"$_profile_dir" \
|
||||
"${_avatar_index}" \
|
||||
"${_avatar_image_path}" \
|
||||
"${_color_hex}" <<'PY'; then
|
||||
import sys, json
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
prof_dir = sys.argv[2]
|
||||
avatar_index = int(sys.argv[3]) # -1 = no cambiar avatar
|
||||
avatar_img = sys.argv[4] # "" = no usar imagen
|
||||
color_hex = sys.argv[5] # "" = no cambiar color
|
||||
|
||||
with open(ls_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
profile_section = data.setdefault("profile", {})
|
||||
info_cache = profile_section.setdefault("info_cache", {})
|
||||
|
||||
# El perfil debe existir (ya validado en bash, pero doble check)
|
||||
if prof_dir not in info_cache:
|
||||
print(f"error: perfil '{prof_dir}' no existe en info_cache", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
entry = info_cache[prof_dir]
|
||||
|
||||
# ── Avatar ────────────────────────────────────────────────────────────────────
|
||||
if avatar_index >= 0:
|
||||
# Avatar built-in: IDR_PROFILE_AVATAR_<N>
|
||||
entry["avatar_icon"] = f"chrome://theme/IDR_PROFILE_AVATAR_{avatar_index}"
|
||||
entry["is_using_default_avatar"] = True
|
||||
elif avatar_img:
|
||||
# Avatar custom imagen: Chrome necesita gaia_picture_file_name
|
||||
entry["avatar_icon"] = "chrome://theme/IDR_PROFILE_AVATAR_0"
|
||||
entry["is_using_default_avatar"] = False
|
||||
entry["gaia_picture_file_name"] = "Google Profile Picture.png"
|
||||
|
||||
# ── Color ─────────────────────────────────────────────────────────────────────
|
||||
if color_hex:
|
||||
rgb = int(color_hex, 16) # 0xRRGGBB
|
||||
argb = 0xFF000000 | rgb # alpha=FF opaco → 0xFFRRGGBB
|
||||
# Convertir a int32 con signo (Python usa enteros arbitrarios)
|
||||
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
|
||||
|
||||
entry["profile_highlight_color"] = signed
|
||||
entry["profile_color_seed"] = signed
|
||||
entry["default_avatar_fill_color"] = signed
|
||||
|
||||
with open(ls_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
echo "set_chrome_profile_appearance: error editando Local State con python3" >&2
|
||||
return 4
|
||||
fi
|
||||
|
||||
# ── validar JSON de Local State tras escritura ────────────────────────────
|
||||
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
|
||||
echo "set_chrome_profile_appearance: JSON inválido tras escribir Local State; restaurando backup" >&2
|
||||
cp "$_backup" "$_local_state"
|
||||
return 4
|
||||
fi
|
||||
|
||||
# ── editar Preferences del perfil (browser.theme.*) si hay color ─────────
|
||||
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
|
||||
local _prefs_backup=""
|
||||
local _theme_applied=false
|
||||
|
||||
if [[ -n "$_color_hex" ]]; then
|
||||
_theme_applied=true
|
||||
|
||||
# Backup de Preferences antes de escribir (mismo patrón que Local State)
|
||||
if [[ -f "$_prefs_path" ]]; then
|
||||
_prefs_backup="${_prefs_path}.bak.${_today}"
|
||||
if [[ ! -f "$_prefs_backup" ]]; then
|
||||
cp "$_prefs_path" "$_prefs_backup"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Editar/crear Preferences con python3
|
||||
if ! python3 - \
|
||||
"$_prefs_path" \
|
||||
"${_color_hex}" \
|
||||
"${_variant}" <<'PY'; then
|
||||
import sys, json, os
|
||||
|
||||
prefs_path = sys.argv[1]
|
||||
color_hex = sys.argv[2]
|
||||
variant = int(sys.argv[3])
|
||||
|
||||
# Calcular el signed int32 ARGB
|
||||
rgb = int(color_hex, 16)
|
||||
argb = 0xFF000000 | rgb
|
||||
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
|
||||
|
||||
# Cargar Preferences existente o arrancar desde vacío
|
||||
if os.path.isfile(prefs_path):
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
# ── browser.theme.* ──────────────────────────────────────────────────────────
|
||||
browser = data.setdefault("browser", {})
|
||||
theme = browser.setdefault("theme", {})
|
||||
|
||||
# Claves modernas (sufijo "2") — verificadas en Chromium 148
|
||||
theme["user_color2"] = signed
|
||||
theme["browser_color_variant"] = variant
|
||||
theme["is_grayscale2"] = False
|
||||
|
||||
# Claves legacy (sin sufijo "2") — compatibilidad con versiones anteriores
|
||||
theme["user_color"] = signed
|
||||
theme["color_variant"] = variant
|
||||
theme["is_grayscale"] = False
|
||||
|
||||
# ── extensions.theme.system_theme = 0 ────────────────────────────────────────
|
||||
# 0=color propio, 1=GTK, 2=Qt. Forzar 0 para que el user_color tenga efecto.
|
||||
extensions = data.setdefault("extensions", {})
|
||||
ext_theme = extensions.setdefault("theme", {})
|
||||
ext_theme["system_theme"] = 0
|
||||
|
||||
# Escribir directorio si no existe (perfil recién creado sin arrancar)
|
||||
os.makedirs(os.path.dirname(prefs_path), exist_ok=True)
|
||||
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
echo "set_chrome_profile_appearance: error editando Preferences con python3" >&2
|
||||
# Restaurar Preferences si teníamos backup
|
||||
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
|
||||
cp "$_prefs_backup" "$_prefs_path"
|
||||
elif [[ -f "$_prefs_path" ]]; then
|
||||
rm -f "$_prefs_path"
|
||||
fi
|
||||
return 4
|
||||
fi
|
||||
|
||||
# Validar JSON de Preferences tras escritura
|
||||
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_prefs_path" 2>/dev/null; then
|
||||
echo "set_chrome_profile_appearance: JSON inválido tras escribir Preferences; restaurando backup" >&2
|
||||
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
|
||||
cp "$_prefs_backup" "$_prefs_path"
|
||||
fi
|
||||
return 4
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── leer valores resultantes para el JSON de salida ───────────────────────
|
||||
local _result_json
|
||||
_result_json="$(python3 - "$_local_state" "$_profile_dir" "$_prefs_path" "$_theme_applied" "$_variant" <<'PY'
|
||||
import json, sys, os
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
prof_dir = sys.argv[2]
|
||||
prefs_path = sys.argv[3]
|
||||
theme_applied = sys.argv[4] == "true"
|
||||
variant = int(sys.argv[5])
|
||||
|
||||
data = json.load(open(ls_path))
|
||||
entry = data.get("profile", {}).get("info_cache", {}).get(prof_dir, {})
|
||||
|
||||
out = {
|
||||
"profile": prof_dir,
|
||||
"avatar_icon": entry.get("avatar_icon", ""),
|
||||
"is_using_default_avatar": entry.get("is_using_default_avatar", True),
|
||||
"profile_highlight_color": entry.get("profile_highlight_color", 0),
|
||||
"profile_color_seed": entry.get("profile_color_seed", 0),
|
||||
"default_avatar_fill_color": entry.get("default_avatar_fill_color", 0),
|
||||
"theme_applied": theme_applied,
|
||||
"variant": variant,
|
||||
"preferences_path": prefs_path if theme_applied else "",
|
||||
"backup": "Local State.bak." + __import__("datetime").date.today().strftime("%Y%m%d"),
|
||||
}
|
||||
|
||||
# Añadir valores de theme si se aplicó
|
||||
if theme_applied and os.path.isfile(prefs_path):
|
||||
try:
|
||||
prefs = json.load(open(prefs_path))
|
||||
bt = prefs.get("browser", {}).get("theme", {})
|
||||
out["browser_theme_user_color2"] = bt.get("user_color2", 0)
|
||||
out["browser_theme_color_variant"] = bt.get("browser_color_variant", 0)
|
||||
out["extensions_theme_system_theme"] = prefs.get("extensions", {}).get("theme", {}).get("system_theme", -1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(json.dumps(out, separators=(",",":")))
|
||||
PY
|
||||
)"
|
||||
|
||||
echo "$_result_json"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
set_chrome_profile_appearance "$@"
|
||||
fi
|
||||
@@ -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",
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CdpClickHuman hace click en el elemento identificado por selector CSS con
|
||||
@@ -53,31 +52,10 @@ func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error {
|
||||
toX := bx + bw/2 + offX
|
||||
toY := by + bh/2 + offY
|
||||
|
||||
// Mover el ratón con trayectoria humana
|
||||
if err := CdpMoveMouseHuman(c, toX, toY, opts); err != nil {
|
||||
return fmt.Errorf("cdp click human: mover raton: %w", err)
|
||||
}
|
||||
|
||||
// mousePressed
|
||||
clickParams := map[string]any{
|
||||
"type": "mousePressed",
|
||||
"x": toX,
|
||||
"y": toY,
|
||||
"button": "left",
|
||||
"clickCount": 1,
|
||||
}
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
return fmt.Errorf("cdp click human: mousePressed: %w", err)
|
||||
}
|
||||
|
||||
// Micro-pausa humana entre press y release (30–90 ms)
|
||||
pauseMs := 30 + rand.Intn(61)
|
||||
time.Sleep(time.Duration(pauseMs) * time.Millisecond)
|
||||
|
||||
// mouseReleased
|
||||
clickParams["type"] = "mouseReleased"
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
return fmt.Errorf("cdp click human: mouseReleased: %w", err)
|
||||
// Delegar en el primitivo compartido: mueve el ratón con trayectoria humana
|
||||
// y despacha press/release con micro-pausa.
|
||||
if err := CdpClickXYHuman(c, toX, toY, opts); err != nil {
|
||||
return fmt.Errorf("cdp click human: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package browser
|
||||
|
||||
import "fmt"
|
||||
|
||||
// refBoxCenter resuelve el centro (x,y) en coords de página de un nodo DOM por su
|
||||
// backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas).
|
||||
func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) {
|
||||
res, err := c.sendCDP("DOM.getBoxModel", map[string]any{"backendNodeId": backendNodeID})
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("getBoxModel ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
model, ok := res["model"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("ref %d: sin boxModel (nodo no visible o inexistente)", backendNodeID)
|
||||
}
|
||||
content, ok := model["content"].([]any)
|
||||
if !ok || len(content) < 8 {
|
||||
return 0, 0, fmt.Errorf("ref %d: content quad invalido", backendNodeID)
|
||||
}
|
||||
num := func(i int) float64 { f, _ := content[i].(float64); return f }
|
||||
cx := (num(0) + num(2) + num(4) + num(6)) / 4
|
||||
cy := (num(1) + num(3) + num(5) + num(7)) / 4
|
||||
return cx, cy, nil
|
||||
}
|
||||
|
||||
// CdpClickRef hace click humanizado (Bézier + jitter) sobre el elemento del #ref.
|
||||
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
|
||||
// Hace scroll al elemento si es necesario antes de calcular las coordenadas.
|
||||
func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp click ref: conexión nil")
|
||||
}
|
||||
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp click ref: %w", err)
|
||||
}
|
||||
return CdpClickXYHuman(c, cx, cy, opts)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: cdp_click_ref
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
||||
description: "Click humanizado (Bézier + jitter) sobre el elemento identificado por su #ref del AX outline. El #ref es el backendDOMNodeId estable del nodo DOM. Hace scroll al elemento si no está en viewport antes de calcular las coordenadas vía DOM.getBoxModel."
|
||||
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||
uses_functions: [cdp_click_xy_human_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa al tab objetivo."
|
||||
- name: backendNodeID
|
||||
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||
- name: opts
|
||||
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
|
||||
output: "nil si el click se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el click CDP falla."
|
||||
file_path: "functions/browser/cdp_click_ref.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Tras un page_perceive que devuelve outline con #ref=1234:
|
||||
conn, _ := CdpConnect(9222)
|
||||
err := CdpClickRef(conn, 1234, MouseHumanOpts{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras `page_perceive` / `render_ax_outline`, cuando el agente tiene el `#ref` de un elemento del outline y quiere hacer click sobre él sin necesitar un selector CSS — cierra el bucle percibir→actuar. Preferir sobre `CdpClickHuman` cuando el nodo viene del AX outline (más estable que un selector).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
|
||||
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado (display:none, fuera del shadow DOM accesible, o ya eliminado). El error describe la causa.
|
||||
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal) — si el elemento no es scrollable al viewport el click puede caer en coordenadas incorrectas.
|
||||
- El click va por `CdpClickXYHuman` (Bézier): no despaches `Input.dispatchMouseEvent` crudo en código que use esta función.
|
||||
@@ -0,0 +1,49 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CdpClickXYHuman hace click en las coordenadas absolutas (x, y) de la página con
|
||||
// comportamiento humano: mueve el ratón hasta el punto por una trayectoria de
|
||||
// Bézier cúbica (CdpMoveMouseHuman) y despacha mousePressed/mouseReleased con una
|
||||
// micro-pausa variable (30-90 ms) entre ambos.
|
||||
//
|
||||
// Es el PRIMITIVO de click compartido por las tres vías de acción del agente:
|
||||
// - por selector CSS → CdpClickHuman (obtiene el bbox y llama aquí).
|
||||
// - por #ref del AX tree → CdpClickRef (resuelve backendDOMNodeId → bbox → aquí).
|
||||
// - por visión → click sobre el bounding box que devuelve OCR/YOLO.
|
||||
// Construir un único primitivo evita tener tres caminos de click divergentes.
|
||||
func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp click xy human: conexion nula")
|
||||
}
|
||||
|
||||
// Mover el ratón hasta el destino con trayectoria humana.
|
||||
if err := CdpMoveMouseHuman(c, x, y, opts); err != nil {
|
||||
return fmt.Errorf("cdp click xy human: mover raton: %w", err)
|
||||
}
|
||||
|
||||
clickParams := map[string]any{
|
||||
"type": "mousePressed",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"button": "left",
|
||||
"clickCount": 1,
|
||||
}
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
return fmt.Errorf("cdp click xy human: mousePressed: %w", err)
|
||||
}
|
||||
|
||||
// Micro-pausa humana entre press y release (30-90 ms).
|
||||
time.Sleep(time.Duration(30+rand.Intn(61)) * time.Millisecond)
|
||||
|
||||
clickParams["type"] = "mouseReleased"
|
||||
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
|
||||
return fmt.Errorf("cdp click xy human: mouseReleased: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: cdp_click_xy_human_go_browser
|
||||
name: cdp_click_xy_human
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Click humanizado en coordenadas absolutas (x,y): mueve el ratón con trayectoria Bézier y despacha mousePressed/mouseReleased con micro-pausa variable. Primitivo de click compartido por las tres vías de acción del agente: por selector, por #ref del AX tree y por visión (bounding box de OCR/YOLO)."
|
||||
tags: [cdp, browser, action, humanized, click, navegator]
|
||||
signature: "func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error"
|
||||
uses_functions:
|
||||
- cdp_move_mouse_human_go_browser
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
file_path: "functions/browser/cdp_click_xy_human.go"
|
||||
example: |
|
||||
conn, _ := browser.CdpConnect(9333)
|
||||
defer browser.CdpClose(conn, 0)
|
||||
// Click humanizado en el centro de un elemento detectado por visión (bbox):
|
||||
browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{})
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa (de CdpConnect)."
|
||||
- name: x
|
||||
desc: "Coordenada X absoluta en la página, en px CSS del viewport."
|
||||
- name: y
|
||||
desc: "Coordenada Y absoluta en la página, en px CSS del viewport."
|
||||
- name: opts
|
||||
desc: "Opciones de la trayectoria humana (zero-value = defaults). Origen del movimiento via FromX/FromY."
|
||||
output: "error si el movimiento del ratón o el despacho de eventos falla; nil en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := browser.CdpConnect(9333)
|
||||
defer browser.CdpClose(conn, 0)
|
||||
// El centro del bounding box lo da el #ref del AX tree (DOM.getBoxModel) o la
|
||||
// detección de visión (OCR/YOLO). Aquí, click humanizado sobre ese punto:
|
||||
if err := browser.CdpClickXYHuman(conn, 412.0, 318.0, browser.MouseHumanOpts{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ya tienes las coordenadas de píxel del objetivo: el centro del bounding box de un elemento
|
||||
(resuelto por `#ref` del AX outline vía `DOM.getBoxModel`, o detectado por visión OCR/YOLO). Es el
|
||||
único primitivo de click del agente — no despaches `Input.dispatchMouseEvent` a mano.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Coordenadas en el sistema de la página (px CSS del viewport), no de pantalla física.
|
||||
- La humanización añade latencia (movimiento Bézier + micro-pausa). Para scraping masivo de alto
|
||||
volumen, el llamador debe usar un preset rápido de `MouseHumanOpts` (política de sesión `fast`),
|
||||
no humanización completa por acción.
|
||||
- El destino debe estar dentro del viewport visible; haz scroll al elemento antes si hace falta.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,19 @@
|
||||
package browser
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CdpHoverRef mueve el ratón con trayectoria humanizada (Bézier) sobre el
|
||||
// elemento del #ref. Útil para activar menús y tooltips que reaccionan a hover.
|
||||
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
|
||||
func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp hover ref: conexión nil")
|
||||
}
|
||||
// scroll al elemento si no está visible; ignorar error (no fatal)
|
||||
_, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID})
|
||||
cx, cy, err := refBoxCenter(c, backendNodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp hover ref: %w", err)
|
||||
}
|
||||
return CdpMoveMouseHuman(c, cx, cy, opts)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: cdp_hover_ref
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error"
|
||||
description: "Mueve el ratón con trayectoria humanizada (Bézier) sobre el elemento identificado por su #ref del AX outline. Útil para activar menús desplegables, tooltips y cualquier interacción que dependa de hover. El #ref es el backendDOMNodeId estable del nodo DOM."
|
||||
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||
uses_functions: [cdp_move_mouse_human_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa al tab objetivo."
|
||||
- name: backendNodeID
|
||||
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||
- name: opts
|
||||
desc: "Opciones de trayectoria humanizada (jitter, velocidad, curva Bézier). Zero-value da humanización por defecto."
|
||||
output: "nil si el movimiento de ratón se completó; error si la conexión es nil, el nodo no tiene boxModel visible, o el movimiento CDP falla."
|
||||
file_path: "functions/browser/cdp_hover_ref.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Activar un menú desplegable cuyo trigger tiene #ref=9999:
|
||||
conn, _ := CdpConnect(9222)
|
||||
err := CdpHoverRef(conn, 9999, MouseHumanOpts{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// esperar a que el menú aparezca y re-percibir el outline
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras `page_perceive` / `render_ax_outline`, cuando el agente necesita hacer hover sobre un elemento del `#ref` para revelar contenido oculto (menús, submenús, tooltips, dropdowns) — cierra el bucle percibir→actuar para interacciones hover. Seguir con otro `page_perceive` tras el hover para capturar el nuevo estado del DOM.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
|
||||
- `DOM.getBoxModel` falla si el elemento no está en el DOM renderizado. El error describe la causa.
|
||||
- `DOM.scrollIntoViewIfNeeded` se invoca antes del cálculo de coordenadas pero su fallo se ignora (no fatal).
|
||||
- Solo mueve el ratón — no hace click. Para activar elementos que requieren click usar `CdpClickRef`.
|
||||
- Algunos menús hover requieren un pequeño `time.Sleep` o `CdpWaitIdle` tras el hover para que el DOM se actualice antes del siguiente `page_perceive`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
package browser
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CdpTypeRef enfoca el elemento del #ref vía CDP y escribe el texto dado.
|
||||
// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive.
|
||||
// Equivale a: focus(ref) → CdpTypeText. El elemento debe aceptar input de texto.
|
||||
func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp type ref: conexión nil")
|
||||
}
|
||||
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
|
||||
return fmt.Errorf("cdp type ref: focus ref %d: %w", backendNodeID, err)
|
||||
}
|
||||
return CdpTypeText(c, text)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: cdp_type_ref
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error"
|
||||
description: "Enfoca el elemento identificado por su #ref del AX outline vía DOM.focus y escribe el texto dado usando CdpTypeText. El #ref es el backendDOMNodeId estable del nodo DOM. El elemento debe aceptar input de texto (input, textarea, contenteditable)."
|
||||
tags: [cdp, browser, action, ref, humanized, navegator]
|
||||
uses_functions: [cdp_type_text_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: c
|
||||
desc: "Conexión CDP activa al tab objetivo."
|
||||
- name: backendNodeID
|
||||
desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline."
|
||||
- name: text
|
||||
desc: "Texto a escribir en el elemento enfocado. Se envía carácter a carácter simulando escritura humana."
|
||||
output: "nil si el focus y la escritura se completaron; error si la conexión es nil, DOM.focus falla, o CdpTypeText falla."
|
||||
file_path: "functions/browser/cdp_type_ref.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Tras un page_perceive que devuelve un input con #ref=5678:
|
||||
conn, _ := CdpConnect(9222)
|
||||
err := CdpTypeRef(conn, 5678, "hola mundo")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras `page_perceive` / `render_ax_outline`, cuando el agente quiere escribir en un campo de texto identificado por su `#ref` — cierra el bucle percibir→actuar para inputs. Preferir sobre secuencias manuales `DOM.focus` + `CdpTypeText` cuando el nodo viene directamente del AX outline.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir antes de actuar.
|
||||
- `DOM.focus` falla si el elemento no es focusable (no es `input`, `textarea`, `contenteditable`, o similar). El error indica el ref y la causa.
|
||||
- Si el elemento necesita un click previo para activarse (algunos inputs con JS custom), combinar con `CdpClickRef` antes de `CdpTypeRef`.
|
||||
- No hace scroll previo — si el elemento no está visible en el viewport el focus CDP puede fallar en algunos navegadores. Combinar con `CdpClickRef` (que sí hace scroll) si hay dudas.
|
||||
@@ -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,173 @@
|
||||
"""Convierte una lista de AXNode CDP en un outline indentado legible por LLMs."""
|
||||
|
||||
|
||||
# Roles que se consideran accionables (el LLM puede referirlos con #ref).
|
||||
_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 (sus hijos se elevan).
|
||||
_SKIP_ROLES = frozenset({"none", "presentation", "ignored"})
|
||||
|
||||
# Límite de profundidad: evita RecursionError en árboles AX patológicos.
|
||||
_MAX_DEPTH = 60
|
||||
|
||||
|
||||
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 _value_val(node: dict) -> str:
|
||||
"""Estado actual del nodo: texto escrito en un input, valor de un slider o
|
||||
combobox, etc. El LLM necesita saber qué hay ya en el campo."""
|
||||
v = node.get("value", {})
|
||||
if isinstance(v, dict):
|
||||
val = v.get("value", "")
|
||||
return str(val) if val not in (None, "") else ""
|
||||
return str(v) if v else ""
|
||||
|
||||
|
||||
def _ref_id(node: dict) -> str:
|
||||
"""Ref ESTABLE para acciones por referencia.
|
||||
|
||||
Usa backendDOMNodeId (apunta al nodo DOM real, estable mientras el nodo viva)
|
||||
en lugar del nodeId del AX tree, que es efímero y cambia en cada
|
||||
Accessibility.getFullAXTree. Esto hace que el #ref que lee el LLM siga siendo
|
||||
válido cuando actúa sobre él un instante después. Fallback al nodeId si el
|
||||
backend no viene poblado.
|
||||
"""
|
||||
bid = node.get("backendDOMNodeId")
|
||||
if bid is not None:
|
||||
return str(bid)
|
||||
return str(node.get("nodeId", ""))
|
||||
|
||||
|
||||
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=<backendDOMNodeId> para que el LLM pueda
|
||||
referenciarlos en acciones posteriores (click_ref/type_ref/hover_ref). El
|
||||
estado actual de inputs (texto escrito, valor) se muestra como `= 'valor'`.
|
||||
|
||||
Args:
|
||||
nodes: Lista de AXNode en formato CDP (campos: nodeId, backendDOMNodeId,
|
||||
role, name, value, childIds, parentId, ignored). Formato devuelto
|
||||
por cdp_get_ax_tree. Se puede pasar trim_ax_tree(nodes) antes para
|
||||
reducir ruido (conserva backendDOMNodeId y value.value).
|
||||
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 ""
|
||||
|
||||
# Lookup por nodeId (la jerarquía childIds usa nodeId, no backendDOMNodeId).
|
||||
by_id: dict[str, dict] = {}
|
||||
for node in nodes:
|
||||
nid = node.get("nodeId")
|
||||
if nid:
|
||||
by_id[nid] = node
|
||||
|
||||
# Detectar nodos raíz: nodeId que 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:
|
||||
roots = [nodes[0]]
|
||||
|
||||
lines: list[str] = []
|
||||
visited: set[str] = set() # guard de ciclo: un nodeId no se renderiza dos veces
|
||||
|
||||
def _render_node(node: dict, depth: int) -> None:
|
||||
"""Renderiza un nodo y sus hijos recursivamente."""
|
||||
nid = node.get("nodeId")
|
||||
if depth > _MAX_DEPTH or (nid and nid in visited):
|
||||
return
|
||||
if nid:
|
||||
visited.add(nid)
|
||||
|
||||
if node.get("ignored", False):
|
||||
return
|
||||
|
||||
role = _role_val(node)
|
||||
if not role or role in _SKIP_ROLES:
|
||||
# Nodos sin role útil: elevar los hijos al nivel actual.
|
||||
for cid in node.get("childIds", []):
|
||||
child = by_id.get(cid)
|
||||
if child:
|
||||
_render_node(child, depth)
|
||||
return
|
||||
|
||||
name = _name_val(node)
|
||||
indent = " " * depth
|
||||
|
||||
if name:
|
||||
base = f'{indent}{role} "{name}"'
|
||||
else:
|
||||
base = f"{indent}{role}"
|
||||
|
||||
# Estado actual del campo (texto escrito, valor del slider/combobox).
|
||||
value = _value_val(node)
|
||||
if value:
|
||||
base += f" = {value!r}"
|
||||
|
||||
# Ref accionable, sin padding (el relleno con espacios gastaba tokens).
|
||||
if role in _ACTIONABLE_ROLES:
|
||||
ref = _ref_id(node)
|
||||
if ref:
|
||||
base += f" #ref={ref}"
|
||||
|
||||
lines.append(base)
|
||||
|
||||
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