8 Commits

Author SHA1 Message Date
egutierrez 029dbf57bd feat(core): auto-commit con 10 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 13:20:36 +02:00
egutierrez 3f6b652f3f chore(agents): subir los 6 agentes fn de sonnet a opus
Los agentes del ciclo reactivo (constructor, executor, recopilador,
analizador, mejorador, orquestador) corrian con model: sonnet. Se suben
todos a model: opus para mejorar la calidad del codigo generado y del
razonamiento durante el ciclo CONSTRUIR -> EJECUTAR -> RECOPILAR ->
ANALIZAR -> MEJORAR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:17:46 +02:00
egutierrez 5b10b419a2 feat(browser): auto-commit con 44 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 12:49:54 +02:00
egutierrez e2c073b8b7 feat(browser): set_chrome_profile_appearance v1.1.0 — color tiñe el tema del navegador
Antes --color solo escribía los campos de color en Local State (info_cache), que
únicamente tiñen el círculo del avatar en el selector de perfiles. Ahora --color
aplica además el tema del navegador (toolbar, frame/bordes, barra de pestañas y
omnibox), que es lo que permite identificar un perfil de un vistazo.

El tema vive en el Preferences del perfil, no en Local State. La función ahora
escribe browser.theme.user_color2 (SkColor ARGB con signo), browser_color_variant
y is_grayscale2, y fuerza extensions.theme.system_theme=0. Escribe también las
claves legacy sin sufijo "2" por compatibilidad de versiones. Nuevo flag
--variant <0..4> (default 3 vibrant) para la intensidad del tinte. Backup y
validación del Preferences con el mismo patrón que Local State.

Claves verificadas empíricamente con captura de pantalla en Chromium 148: un
perfil lanzado con estas claves muestra la toolbar y el frame teñidos del color.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:12:37 +02:00
egutierrez 25054ff64e feat(browser): set_chrome_profile_appearance — avatar + color de perfiles Chrome
Nueva función Bash del dominio browser para personalizar la apariencia de un
perfil Chrome/Chromium y diferenciarlo de un vistazo. Edita
`profile.info_cache.<perfil>` en el Local State del user-data-dir:

- `--avatar <N>`: avatar built-in de Chrome (índice 0..55) vía
  `avatar_icon = chrome://theme/IDR_PROFILE_AVATAR_<N>`. Camino robusto.
- `--avatar <ruta.png>`: avatar custom best-effort (copia la imagen al perfil y
  marca `is_using_default_avatar=false`); ver gotchas del .md.
- `--color <#rrggbb>`: color del perfil. Convierte el hex a int32 con signo en
  formato ARGB (0xAARRGGBB) y lo aplica a `profile_highlight_color`,
  `profile_color_seed` y `default_avatar_fill_color`.

Sigue el patrón de create/delete_chrome_profile: backup del Local State antes de
escribir, validación del JSON resultante con restauración del backup si queda
inválido, guard de SingletonLock (chromium debe estar cerrado), idempotente y
con --dry-run. No crea perfiles (eso es create_chrome_profile); requiere que el
perfil ya exista. Probada con --avatar 26 --color #1f6feb y casos edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:57:12 +02:00
egutierrez 648ce63fc0 chore: auto-commit (1 archivos)
- .claude/settings.local.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 01:38:36 +02:00
Egutierrez 685224ccb2 fix(browser): guards chromium-por-udd dejan de auto-matchear el propio grep
Bug descubierto al ejecutar el reset real: los guards y los kills usaban
'pgrep -af [c]hromium | grep -F <udd>'. Como la ruta del user-data-dir contiene
la cadena 'chromium' (~/.config/chromium-cdp), el propio proceso grep/ugrep —cuyo
cmdline incluye <udd>— era capturado por pgrep, dando un falso positivo perpetuo:
el guard creía siempre que había un chromium abierto y delete/restore abortaban
con exit 2, y el lazo de cierre nunca convergía.

Fix en delete_chrome_profile, restore_chrome_bookmarks, create_chrome_profile y el
pipeline reset_chrome_profiles: enumerar por PID con 'pgrep -x chromium' (comm
exactamente 'chromium', nunca grep/pgrep/bash) y leer /proc/PID/cmdline para
comprobar el udd. Validado: reset destructivo real de los 4 perfiles completó OK,
cada perfil quedó con solo uBlock + web_proxy y los bookmarks restaurados.
2026-06-06 01:37:51 +02:00
Egutierrez ae841ceedb feat(browser): CRUD de perfiles Chromium + pipeline reset_chrome_profiles
Cinco funciones nuevas (dominio browser, grupo navegator) que cierran los gaps
de gestión de perfiles, más un pipeline que las orquesta:

- backup_chrome_bookmarks / restore_chrome_bookmarks: backup y restore de los
  archivos Bookmarks (copia byte a byte verbatim para preservar el checksum
  interno; en Chromium 148 los bookmarks no están bajo el super_mac de Secure
  Preferences). Guard por user-data-dir (no global).
- delete_chrome_profile: borra la carpeta del perfil + limpia su entrada en
  Local State (info_cache, profiles_order, last_active_profiles, last_used).
- create_chrome_profile: lanza chromium headless (vía systemd-run) para que la
  managed policy instale la whitelist de extensiones, y asigna el nombre legible
  en Local State. Mata todo el árbol de chromium del udd antes de editar Local
  State (los hijos zygote/gpu no repiten --user-data-dir pero referencian la ruta).
- list_chrome_profile_extensions (Go): lista extensiones de un perfil con
  ID/name/version/location/enabled/fromPolicy. 7 unit tests.
- reset_chrome_profiles (pipeline): backup -> cerrar chromium -> delete -> create
  -> restore -> verify. Destructivo (--yes), --dry-run seguro.

Validado: unit tests Go verdes, backup/restore byte-idéntico, delete limpia Local
State, create instala la forcelist global (uBlock + web_proxy) en perfiles nuevos.
2026-06-06 01:24:21 +02:00
75 changed files with 5482 additions and 65 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
---
+159
View File
@@ -0,0 +1,159 @@
---
description: "Modo launcher: das ordenes en lenguaje natural y Claude responde SOLO con la procedencia (registry/bash/heredoc) + el comando exacto, y lo ejecuta. Agiliza el lanzamiento de comandos y audita en vivo el Reg % (uso real de funciones del registry)."
---
# /modo_launcher — lanzamiento rápido registry-first
Activa un **modo de comportamiento** persistente. Mientras estás dentro, el usuario da órdenes en lenguaje natural y Claude responde con el **mínimo absoluto**: la procedencia del comando + el comando exacto + por qué, y lo ejecuta. Sin prosa, sin explicaciones largas, sin preámbulos.
El objetivo es doble:
1. **Agilizar** el lanzamiento de comandos (cero verborrea entre orden y ejecución).
2. **Auditar en vivo** que de verdad pasamos por funciones del registry antes que por bash inline — sube `Reg %` (objetivo 1 del Norte) y expone gaps reutilizables (objetivo 3).
## Activación
Al invocar `/modo_launcher` entras en **MODO LAUNCHER**. El modo permanece activo en todos los turnos siguientes hasta que el usuario escriba `salir` o `fin launcher`. No hay hook: el modo se sostiene por estas instrucciones mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el usuario puede re-invocar `/modo_launcher` para reanclarlo.
Al entrar, responde con una sola línea de confirmación y queda a la espera:
```
MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
```
## Comportamiento por orden (regla dura)
Para CADA orden del usuario mientras el modo esté activo:
1. **Registry-first.** Mapea la orden a una capacidad y busca primero en el registry vía FTS (`mcp__registry__fn_search`) o reconoce un ID conocido. Las funciones del registry SIEMPRE tienen prioridad sobre bash inline.
2. **Clasifica la procedencia** según la taxonomía de abajo.
3. **Ejecuta directo.** Identificado el comando, ejecútalo sin pedir permiso — salvo que sea destructivo (ver guarda).
4. **Responde en el formato fijo** (abajo), con la salida cruda del comando. Nada más.
## Formato de respuesta (OBLIGATORIO en cada orden)
```
FUENTE: <etiqueta>
CMD: <comando exacto>
WHY: <razón: match FTS, ID conocido, o "sin función → bash">
──────────
<salida cruda del comando>
```
- `FUENTE` es una de las etiquetas de la taxonomía.
- `CMD` es el comando literal lanzado (forma `./fn run <id> [args]` para legibilidad aunque la ejecución real vaya por MCP).
- `WHY` es una línea: qué match de búsqueda o qué ID justifica esa elección. Si fue un gap, dilo.
- Tras la regla `──────────`, la salida cruda. Cero comentario después salvo que el usuario pregunte.
## Taxonomía de procedencia
| Etiqueta | Qué es | Cómo se ejecuta |
|---|---|---|
| `registry-run` | Ejecutar UNA función o pipeline del registry | `mcp__registry__fn_run <id> [args]` (preferido); fallback `./fn run <id> [args]` |
| `registry-mcp` | Inspeccionar el registro (buscar, ver, código, deps, dominios) | `mcp__registry__fn_search` / `fn_show` / `fn_code` / `fn_uses` / `fn_list_domains` |
| `heredoc` | Componer N funciones con lógica intermedia (loops, dispatch) | Heredoc `python/.venv/bin/python3 - <<'PY' ... PY` importando del registry |
| `bash` | Comando shell puro: no existe función que lo cubra | Bash directo |
| `gap` | No hay función Y el patrón parece reutilizable | Ejecuta el bash equivalente y marca el candidato (ver abajo) |
### Preferencia de ejecución para `registry-run`
- Usa `mcp__registry__fn_run` cuando esté disponible (queda registrado en `call_monitor`, alimenta el bucle reactivo).
- Si el MCP `fn_run` no está habilitado (requiere `--enable-run`), cae a `./fn run <id>` por terminal. La línea `CMD` muestra siempre la forma `./fn run <id>` por legibilidad.
## Gaps: orden sin función en el registry
Cuando una orden no tenga función que la cubra:
1. Ejecuta el bash equivalente (`FUENTE: bash`).
2. Si el patrón parece **reutilizable** (firma genérica, se repetiría en otras tareas, ≥5 líneas de lógica), añade tras la salida UNA línea:
```
CANDIDATO → <nombre_propuesto>_<lang>_<domain>: <1 frase de qué haría>
```
No lances `fn-constructor` automáticamente dentro del modo (rompería el ritmo de lanzamiento). Solo marca. El usuario decide al salir si promueve los candidatos.
## Guarda de comandos destructivos
Ejecuta directo SALVO que el comando sea irreversible o de alto impacto. En esos casos, NO ejecutes: muestra el bloque con `FUENTE`/`CMD`/`WHY` y añade `⚠ DESTRUCTIVO — confirma con 'ok'` en vez de la salida. Espera el `ok` explícito del usuario antes de lanzar.
Patrones que exigen confirmación:
- `rm -rf`, borrado de archivos versionados, `> archivo` sobre archivos trackeados.
- `git push --force`, `git reset --hard`, `git clean`, borrado de ramas.
- SQL `DROP`, `DELETE` sin `WHERE`, `TRUNCATE`, borrar cualquier `.db`.
- `deploy`, `systemctl stop/restart/disable` de services, `fn sync` (escribe en el servidor).
- `kill -9` masivo, `format`, `mkfs`, `dd`, cambios en `fstab`.
Para todo lo demás (lecturas, búsquedas, `fn run` de funciones puras o idempotentes, `git status/add/commit`, listados), ejecuta directo.
## Salida del modo
Cuando el usuario escriba `salir` o `fin launcher`, cierra el modo con un resumen caveman de una tabla:
```
=== fin MODO LAUNCHER ===
ordenes: N
registry: X (run A / mcp B)
bash: Y
gaps: Z → [lista de candidatos marcados]
Reg %: X/(X+Y) de las ordenes ejecutables golpearon el registry
```
Si hubo candidatos a función (`gap`), recuérdalos y pregunta si promover alguno vía `fn-constructor`.
## Reglas duras del modo
- **Registry-first siempre.** Nunca escribas bash inline para lógica que ya es función del registry. Busca antes de teclear.
- **Cero prosa fuera del bloque.** Nada de "Claro, voy a…". Solo el bloque `FUENTE/CMD/WHY/salida`.
- **Una orden = un bloque.** Si la orden necesita varios pasos, encadénalos en un solo `CMD` o numera los bloques, pero mantén el formato.
- **No inventes IDs.** Si no encuentras función por búsqueda, es `gap`, no un `registry-run` falso.
- **El modo no exime de las reglas del repo** (`registry_calls.md`, `purity.md`, etc.). Solo cambia el estilo de respuesta.
## Ejemplo de sesión
```
tú: /modo_launcher
yo: MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
tú: busca funciones de slice
yo: FUENTE: registry-mcp
CMD: mcp__registry__fn_search query="slice"
WHY: inspección directa del registro
──────────
filter_slice_go_core — Filtra slice por predicado
chunk_slice_go_core — Parte slice en lotes de N
...
tú: mata lo que escuche en el puerto 8484
yo: FUENTE: registry-run
CMD: ./fn run port_kill_bash_infra 8484
WHY: match FTS 'port kill' → port_kill_bash_infra
──────────
killed pid 33120 on :8484
tú: enséñame el uso de disco de home
yo: FUENTE: bash
CMD: du -sh /home/enmanuel/* | sort -rh | head
WHY: sin función → utilidad de sistema puntual
──────────
12G /home/enmanuel/fn_registry
...
CANDIDATO → disk_usage_top_bash_shell: top-N directorios por tamaño en una ruta
tú: salir
yo: === fin MODO LAUNCHER ===
ordenes: 3
registry: 2 (run 1 / mcp 1)
bash: 1
gaps: 1 → disk_usage_top_bash_shell
Reg %: 2/3 (67%)
1 candidato marcado. ¿Promuevo disk_usage_top_bash_shell vía fn-constructor?
```
## Relación con otras reglas
- `registry_calls.md` — el modo es una capa de estilo sobre los tres patrones canónicos (inspect / run / compose).
- `registry_first.md` — el modo materializa "buscar antes de escribir" en cada orden.
- `function_growth_and_self_docs.md` — los candidatos marcados alimentan la promoción de patrones inline a funciones.
- `kiss.md` — sin hook, sin estado en disco: el modo vive solo en estas instrucciones.
+11 -5
View File
@@ -1,4 +1,14 @@
{
"permissions": {
"allow": [
"Bash(CGO_ENABLED=1 go test *)",
"Bash(sqlite3 *)"
]
},
"enabledMcpjsonServers": [
"registry",
"jupyter"
],
"hooks": {
"PreToolUse": [
{
@@ -49,9 +59,5 @@
]
}
]
},
"enabledMcpjsonServers": [
"registry",
"jupyter"
]
}
}
@@ -0,0 +1,79 @@
---
name: backup_chrome_bookmarks
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]... [--backup-dir <dir>] [--dry-run]"
description: "Copia byte a byte los archivos Bookmarks de perfiles Chrome/Chromium a un directorio de backup con timestamp ISO. Descubre automáticamente todos los perfiles con Bookmarks si no se especifica ninguno. Preserva el checksum interno del archivo sin parsear ni reserializar el JSON. No requiere que Chromium esté cerrado."
tags: [navegator, chromium, bookmarks, backup, browser, scraping]
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/backup_chrome_bookmarks.sh"
params:
- name: --user-data-dir
desc: "(obligatorio) Ruta raíz del user-data-dir de Chrome/Chromium. Ej: ~/.config/chromium-cdp"
- name: --profile
desc: "Nombre de carpeta de perfil a respaldar (repetible). Si no se pasa ninguno se descubren todos los perfiles que contengan un archivo Bookmarks, excluyendo System Profile."
- name: --backup-dir
desc: "Directorio raíz donde se crearán los backups. Default: ~/.local/share/web_scraping/bookmarks-backups"
- name: --dry-run
desc: "Muestra a stderr qué archivos se copiarían y sus tamaños sin escribir nada en disco. El JSON de salida se emite igualmente."
output: "JSON en stdout: {backup_dir: \"<dir>\", ts: \"<YYYYMMDDTHHmmss>\", profiles: [{profile: \"<name>\", src: \"<path>\", dst: \"<path>\", bytes: N}, ...]}. Perfiles sin Bookmarks se omiten silenciosamente. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# Backup de todos los perfiles del chromium-cdp (descubrimiento automático)
source $HOME/fn_registry/bash/functions/browser/backup_chrome_bookmarks.sh
backup_chrome_bookmarks --user-data-dir "$HOME/.config/chromium-cdp"
# Previsualizar sin tocar nada
backup_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium-cdp" \
--dry-run
# Backup de perfiles específicos
backup_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium-cdp" \
--profile Default \
--profile Personal \
--profile "Profile 1"
# Backup a directorio personalizado
backup_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium-cdp" \
--backup-dir "$HOME/vaults/backups/bookmarks"
# Salida esperada (ejemplo):
# {"backup_dir":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups","ts":"20260605T143022","profiles":[{"profile":"Default","src":"/home/enmanuel/.config/chromium-cdp/Default/Bookmarks","dst":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups/20260605T143022/Default/Bookmarks","bytes":4218}]}
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run backup_chrome_bookmarks_bash_browser -- \
--user-data-dir "$HOME/.config/chromium-cdp" --dry-run
```
## Cuando usarla
Úsala antes de cualquier sesión de scraping o automatización que modifique bookmarks de Chromium, para tener un snapshot recuperable. También útil como paso previo en pipelines que reorganizan o importan marcadores masivamente. Combínala con `rotate_backups_bash_infra` para aplicar política de retención sobre el directorio de backups.
## Gotchas
- **Copia verbatim para preservar checksum**: el archivo `Bookmarks` de Chromium incluye un campo `checksum` calculado sobre el contenido. Esta función usa `cp -p` sin tocar el contenido — si parseases y reserializases el JSON (con `jq`, `python3 json.dump`, etc.) el checksum quedaría inválido y Chromium podría resetear o ignorar los marcadores al arrancar.
- **No requiere Chromium cerrado**: a diferencia de `clean_chrome_profile_extensions`, esta función solo lee el archivo `Bookmarks`. Chromium no mantiene un lock exclusivo sobre él — la copia es segura con el navegador abierto. El archivo refleja el estado en disco en ese instante; cambios en vuelo en memoria no estarán en el backup hasta que Chromium los persista.
- **Perfiles sin Bookmarks se omiten silenciosamente**: si un perfil existe pero no tiene el archivo `Bookmarks` (perfil recién creado sin haber abierto el navegador), se salta sin error. Solo aparece en el JSON de salida si fue respaldado.
- **System Profile excluido siempre**: el perfil `System Profile` es un perfil interno de Chromium sin datos de usuario y se excluye del descubrimiento automático.
- **Sin jq ni python3**: la emisión del JSON de salida se construye con printf de bash puro, sin dependencias externas.
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# backup_chrome_bookmarks — copia byte a byte los archivos Bookmarks de perfiles
# Chrome/Chromium a un directorio de backup con timestamp. Preserva el checksum
# interno del archivo sin parsear ni reserializar el JSON.
set -euo pipefail
backup_chrome_bookmarks() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir=""
local _profiles=()
local _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups"
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]...
[--backup-dir <dir>] [--dry-run]
--user-data-dir (obligatorio) Raíz de perfiles de Chrome/Chromium.
Ej: ~/.config/chromium-cdp
--profile <name> Nombre de carpeta de perfil a respaldar (repetible).
Si no se pasa ninguno → respalda TODOS los perfiles con
un archivo Bookmarks (excluye System Profile).
--backup-dir <dir> Directorio raíz para backups.
Default: ~/.local/share/web_scraping/bookmarks-backups
--dry-run Muestra qué copiaría sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--backup-dir) _backup_dir="$2"; shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "backup_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones ──────────────────────────────────────────────────────────
if [[ -z "$_user_data_dir" ]]; then
echo "backup_chrome_bookmarks: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ ! -d "$_user_data_dir" ]]; then
echo "backup_chrome_bookmarks: directorio no encontrado: ${_user_data_dir}" >&2
return 1
fi
# ── descubrir perfiles si no se pasó ninguno ───────────────────────────────
if [[ ${#_profiles[@]} -eq 0 ]]; then
local _candidate
while IFS= read -r -d '' _candidate; do
local _pname
_pname="$(basename "$_candidate")"
# Excluir System Profile (perfil interno de Chromium sin datos de usuario)
if [[ "$_pname" == "System Profile" ]]; then
continue
fi
if [[ -f "${_candidate}/Bookmarks" ]]; then
_profiles+=("$_pname")
fi
done < <(find "$_user_data_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
fi
if [[ ${#_profiles[@]} -eq 0 ]]; then
echo "backup_chrome_bookmarks: no se encontraron perfiles con archivo Bookmarks en: ${_user_data_dir}" >&2
return 1
fi
# ── timestamp único para este backup ──────────────────────────────────────
local _ts
_ts="$(date +%Y%m%dT%H%M%S)"
# ── procesar perfiles ─────────────────────────────────────────────────────
# Construir el array de resultados JSON manualmente (sin jq ni python3)
local _results="["
local _first=1
local _profile
for _profile in "${_profiles[@]}"; do
local _src="${_user_data_dir}/${_profile}/Bookmarks"
# Si el perfil no tiene Bookmarks, se omite sin error
if [[ ! -f "$_src" ]]; then
continue
fi
local _dst="${_backup_dir}/${_ts}/${_profile}/Bookmarks"
local _bytes
_bytes="$(wc -c < "$_src")"
if [[ $_dry_run -eq 1 ]]; then
echo "backup_chrome_bookmarks: [dry-run] cp -p \"${_src}\" -> \"${_dst}\" (${_bytes} bytes)" >&2
else
local _dst_dir
_dst_dir="$(dirname "$_dst")"
mkdir -p "$_dst_dir"
cp -p "$_src" "$_dst"
fi
# Escapar comillas dobles en el path por si acaso
local _src_esc="${_src//\"/\\\"}"
local _dst_esc="${_dst//\"/\\\"}"
local _profile_esc="${_profile//\"/\\\"}"
local _entry
_entry="$(printf '{"profile":"%s","src":"%s","dst":"%s","bytes":%s}' \
"$_profile_esc" "$_src_esc" "$_dst_esc" "$_bytes")"
if [[ $_first -eq 1 ]]; then
_results+="$_entry"
_first=0
else
_results+=",${_entry}"
fi
done
_results+="]"
# ── emitir resultado JSON ──────────────────────────────────────────────────
local _backup_dir_esc="${_backup_dir//\"/\\\"}"
printf '{"backup_dir":"%s","ts":"%s","profiles":%s}\n' \
"$_backup_dir_esc" "$_ts" "$_results"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
backup_chrome_bookmarks "$@"
fi
@@ -0,0 +1,93 @@
---
name: create_chrome_profile
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible> [--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]"
description: "Crea un perfil Chrome/Chromium nuevo en un user-data-dir: opcionalmente lanza chromium headless vía systemd-run para que la managed policy instale las extensiones forzadas (uBlock, web_proxy) y luego edita Local State para asignar el nombre legible al perfil. Con --no-launch crea solo la estructura de carpetas y la entrada en Local State sin arrancar Chrome."
tags: [navegator, chromium, profile, browser, cdp, headless, scraping]
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/create_chrome_profile.sh"
params:
- name: --user-data-dir
desc: "Raíz del user-data-dir de Chrome/Chromium. Puede no existir; la función lo crea. Obligatorio."
- name: --profile
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, \"Profile 1\", Automation. Obligatorio."
- name: --name
desc: "Nombre legible visible en el selector de perfil de Chrome, por ejemplo: Work, Aurgi, Bot. Obligatorio."
- name: --port
desc: "Puerto CDP para el lanzamiento headless. Default: 9250. Usar un valor distinto al 9222 global para no colisionar."
- name: --chrome-path
desc: "Ruta absoluta al binario chromium/chrome. Si se omite, auto-detecta: chromium, chromium-browser, google-chrome, brave-browser."
- name: --no-launch
desc: "No lanza chromium. Solo crea la carpeta del perfil y edita Local State con el nombre legible. El perfil no tendrá extensiones instaladas. Útil para tests y CRUD offline."
- name: --timeout-sec
desc: "Segundos máximos esperando a que Preferences aparezca tras el lanzamiento headless. Default: 25."
- name: --dry-run
desc: "Describe las acciones que se ejecutarían sin lanzar ni escribir nada. Emite el JSON de resultado con dry_run:true."
output: "JSON en stdout: {\"profile\":\"<dir-name>\",\"name\":\"<legible>\",\"launched\":true|false,\"preferences_created\":true|false}. En dry-run añade \"dry_run\":true. Exit 0 en éxito."
---
## Ejemplo
```bash
source $HOME/fn_registry/bash/functions/browser/create_chrome_profile.sh
# Modo offline (no lanza Chrome, solo CRUD de Local State — seguro para tests)
create_chrome_profile \
--user-data-dir /tmp/test_udd \
--profile "Automation" \
--name "Aurgi Bot" \
--no-launch
# Salida: {"profile":"Automation","name":"Aurgi Bot","launched":false,"preferences_created":false}
# Modo normal: lanza headless para que la policy instale uBlock y web_proxy,
# luego asigna nombre en Local State
create_chrome_profile \
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
--profile "Profile 1" \
--name "Work" \
--port 9250
# Salida: {"profile":"Profile 1","name":"Work","launched":true,"preferences_created":true}
# Dry-run: describe acciones sin ejecutar nada
create_chrome_profile \
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
--profile "Default" \
--name "Scraping" \
--dry-run
```
## Cuando usarla
Úsala para aprovisionar perfiles nuevos en un user-data-dir de automatización antes de lanzar sesiones CDP con `script-navegador` o funciones del grupo `navegator`. En modo normal (sin `--no-launch`) la managed policy instala automáticamente uBlock y la extensión web_proxy en el perfil nuevo; en `--no-launch` sirve para tests unitarios o para crear la entrada de Local State sin depender de Chrome.
## Gotchas
- **Lanzar chromium desde Bash tool de Claude da exit-144**: la función usa `systemd-run --user --collect` para aislar el proceso en su propio cgroup, evitando que el harness del agente lo mate. Esto es obligatorio; lanzar con `&` / `setsid` daría exit-144 en el contexto del agente.
- **La managed policy instala las extensiones al arrancar el perfil**: NO pasar `--disable-extensions` — rompería la forcelist. Las extensiones force-listed (`ExtensionInstallForcelist` en `/etc/chromium/policies/managed/extensions.json`) se instalan en el perfil durante el primer arranque; en el headless inicial puede no completar la descarga si no hay red o si el timeout es corto.
- **Dos chromium NO pueden compartir el mismo user-data-dir**: si ya hay un chromium corriendo sobre `--user-data-dir`, la función detecta `SingletonLock` y sale con exit 2 antes de lanzar. Para perfiles de automatización paralela, usa un `--user-data-dir` dedicado por perfil.
- **Local State debe editarse con Chrome muerto**: la función para el unit de systemd y espera la desaparición de `SingletonLock` antes de editar `Local State`. Si se edita mientras Chrome está vivo, Chrome sobreescribe el archivo desde memoria al salir y los cambios de nombre se pierden.
- **`--remote-allow-origins=*` necesita comillas en zsh**: el glob `*` se expande si no va entre comillas. La función pasa el flag correctamente internamente, pero si lo pasas tú en otros scripts acuérdate de las comillas.
- **Perfil diario en `~/.config/chromium-cdp`**: en este equipo el fragmento `/etc/chromium.d/cdp` redirige el user-data-dir global a `~/.config/chromium-cdp`. Para automatización usar siempre un `--user-data-dir` dedicado fuera de `~/.config/`.
- **Timeout corto puede dar `preferences_created: false`**: el perfil headless tarda entre 2-8 segundos en crear `Preferences` según la carga del sistema. Si se aumenta `--timeout-sec` a 45-60 en máquinas lentas se evitan falsos timeouts.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito |
| 1 | Argumento obligatorio faltante o binario no encontrado |
| 2 | Lock: ya hay un chromium usando el mismo user-data-dir |
| 3 | Timeout esperando a que Preferences se cree |
| 4 | Error editando Local State (JSON inválido tras escritura) |
@@ -0,0 +1,309 @@
#!/usr/bin/env bash
# create_chrome_profile — crea un perfil Chrome/Chromium nuevo en un user-data-dir,
# opcionalmente lanzando chromium headless para que la managed policy instale las
# extensiones forzadas (uBlock, web_proxy). Edita Local State para asignar el nombre
# legible al perfil.
set -euo pipefail
create_chrome_profile() {
# ── defaults ──────────────────────────────────────────────────────────────
local _udd=""
local _profile_dir=""
local _name=""
local _port=9250
local _chrome_path=""
local _no_launch=0
local _timeout_sec=25
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible>
[--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
--profile Nombre de la carpeta del perfil dentro de user-data-dir, ej: Default,
"Profile 1", Automation (obligatorio).
--name Nombre legible que aparece en el selector de perfil, ej: Work, Aurgi
(obligatorio).
--port Puerto CDP para el lanzamiento headless. Default: 9250.
Usar un puerto distinto al 9222 global para no chocar.
--chrome-path Ruta explícita al binario chromium/chrome. Auto-detecta si se omite.
--no-launch No lanza chromium. Crea la carpeta y edita Local State offline.
El perfil no tendrá extensiones instaladas; útil para tests/CRUD.
--timeout-sec Segundos esperando a que Preferences aparezca tras el lanzamiento.
Default: 25.
--dry-run Describe las acciones sin lanzar ni escribir nada.
Exit codes:
0 éxito
1 error de argumento o validación
2 lock: ya hay un chromium usando este user-data-dir
3 timeout esperando a que Preferences se cree
4 error editando Local State (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 ;;
--name) _name="$2"; shift 2 ;;
--port) _port="$2"; shift 2 ;;
--chrome-path) _chrome_path="$2"; shift 2 ;;
--no-launch) _no_launch=1; shift ;;
--timeout-sec) _timeout_sec="$2"; shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "create_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones obligatorias ──────────────────────────────────────────────
if [[ -z "$_udd" ]]; then
echo "create_chrome_profile: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ -z "$_profile_dir" ]]; then
echo "create_chrome_profile: --profile es obligatorio" >&2
return 1
fi
if [[ -z "$_name" ]]; then
echo "create_chrome_profile: --name es obligatorio" >&2
return 1
fi
local _profile_path="${_udd}/${_profile_dir}"
local _local_state="${_udd}/Local State"
local _prefs_file="${_profile_path}/Preferences"
# ── guard: lock por user-data-dir ─────────────────────────────────────────
# Dos procesos chromium no pueden compartir el mismo user-data-dir.
if [[ $_dry_run -eq 0 && $_no_launch -eq 0 ]]; then
local _singleton="${_udd}/SingletonLock"
if [[ -e "$_singleton" ]]; then
echo "create_chrome_profile: ya hay un chromium corriendo con --user-data-dir=${_udd}" >&2
echo " (encontrado: ${_singleton})" >&2
echo " Ciérralo o usa un user-data-dir distinto." >&2
return 2
fi
fi
# ── detección del binario chromium ────────────────────────────────────────
local _bin=""
if [[ -n "$_chrome_path" ]]; then
if [[ ! -x "$_chrome_path" ]]; then
echo "create_chrome_profile: binario no encontrado o no ejecutable: ${_chrome_path}" >&2
return 1
fi
_bin="$_chrome_path"
elif [[ $_no_launch -eq 0 ]]; then
for _candidate in chromium chromium-browser google-chrome brave-browser; do
if command -v "$_candidate" &>/dev/null; then
_bin="$_candidate"
break
fi
done
if [[ -z "$_bin" ]]; then
echo "create_chrome_profile: no se encontró binario chromium en PATH" >&2
echo " Probados: chromium, chromium-browser, google-chrome, brave-browser" >&2
echo " Usa --chrome-path o --no-launch." >&2
return 1
fi
fi
# ── modo dry-run ──────────────────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== create_chrome_profile DRY-RUN ===" >&2
echo " user-data-dir : ${_udd}" >&2
echo " profile : ${_profile_dir}" >&2
echo " name : ${_name}" >&2
if [[ $_no_launch -eq 1 ]]; then
echo " modo : --no-launch (sin chromium)" >&2
echo " acciones : mkdir -p ${_profile_path}" >&2
echo " editar ${_local_state} → info_cache + profiles_order" >&2
else
echo " binario : ${_bin}" >&2
echo " puerto CDP : ${_port}" >&2
echo " timeout : ${_timeout_sec}s" >&2
echo " acciones : systemd-run unit=create-prof-<rand> chromium headless" >&2
echo " poll Preferences hasta ${_timeout_sec}s" >&2
echo " systemctl --user stop unit" >&2
echo " editar ${_local_state} → info_cache + profiles_order" >&2
fi
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":false,"dry_run":true}\n' \
"$_profile_dir" "$_name"
return 0
fi
# ── crear directorio del perfil ───────────────────────────────────────────
mkdir -p "$_profile_path"
# ── también asegurar que user-data-dir existe ──────────────────────────────
mkdir -p "$_udd"
# ── modo --no-launch: solo estructura + Local State ────────────────────────
local _launched=false
local _prefs_created=false
if [[ $_no_launch -eq 1 ]]; then
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
if [[ -f "$_prefs_file" ]]; then
_prefs_created=true
fi
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":%s}\n' \
"$_profile_dir" "$_name" "$_prefs_created"
return 0
fi
# ── lanzar chromium headless vía systemd-run ──────────────────────────────
# systemd-run --user aísla el proceso del cgroup del agente (evita exit-144).
# NO se pasa --disable-extensions para que la managed policy instale las
# extensiones force-listed (uBlock, web_proxy).
local _rand
_rand="$(tr -dc 'a-z0-9' </dev/urandom | head -c 8 2>/dev/null || echo "$$")"
local _unit="create-prof-${_rand}"
systemd-run \
--user \
--collect \
--unit="$_unit" \
--setenv=DISPLAY=:0 \
--setenv=XAUTHORITY="${HOME}/.Xauthority" \
"$_bin" \
"--user-data-dir=${_udd}" \
"--profile-directory=${_profile_dir}" \
"--headless=new" \
"--no-first-run" \
"--remote-debugging-port=${_port}" \
"--remote-allow-origins=*" \
"about:blank" 2>/dev/null || true
_launched=true
# ── poll: esperar a que Preferences exista ────────────────────────────────
local _elapsed=0
while [[ $_elapsed -lt $_timeout_sec ]]; do
if [[ -f "$_prefs_file" ]]; then
_prefs_created=true
break
fi
sleep 1
(( _elapsed++ )) || true
done
# ── detener el unit Y matar TODO el árbol de chromium de este udd ───────────
# Necesario para poder editar Local State sin que Chrome lo sobreescriba. Ni el
# `systemctl stop` ni un `pkill -f --user-data-dir=` bastan: los procesos hijos
# (zygote/gpu/renderer) no repiten el flag --user-data-dir pero sí referencian la
# ruta del user-data-dir en otros argumentos. Los matamos por PID seleccionando
# los procesos chromium cuyo cmdline contiene la ruta del udd (seguro: no mata
# este propio script porque filtramos por '[c]hromium').
systemctl --user kill -s SIGKILL "$_unit" 2>/dev/null || true
systemctl --user stop "$_unit" 2>/dev/null || true
# Matar por PID los procesos cuyo comm es exactamente "chromium" (pgrep -x) y cuyo cmdline
# contiene la ruta del udd. Usamos pgrep -x para NO auto-matchear grep/pgrep: el path del udd
# contiene la cadena "chromium" (~/.config/chromium-cdp).
local _wait=0 _p _pids
while :; do
_pids=""
for _p in $(pgrep -x chromium 2>/dev/null); do
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _pids="$_pids $_p"
done
[[ -z "${_pids// }" ]] && break
# shellcheck disable=SC2086
kill -TERM $_pids 2>/dev/null || true
sleep 0.5
(( _wait++ )) || true
if [[ $_wait -ge 20 ]]; then
# shellcheck disable=SC2086
kill -9 $_pids 2>/dev/null || true
break
fi
done
rm -f "${_udd}/SingletonLock" 2>/dev/null || true
if [[ "$_prefs_created" == false ]]; then
echo "create_chrome_profile: timeout (${_timeout_sec}s) esperando a que se cree: ${_prefs_file}" >&2
echo " El directorio del perfil puede existir pero está vacío." >&2
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":false,"error":"timeout"}\n' \
"$_profile_dir" "$_name"
return 3
fi
# ── editar Local State para asignar nombre legible ────────────────────────
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":true}\n' \
"$_profile_dir" "$_name"
}
# ── helper: editar Local State con python3 ────────────────────────────────────
# Crea/actualiza info_cache.<profile_dir> con name + is_using_default_name=false
# y añade profile_dir a profiles_order si no está.
_update_local_state() {
local _udd="$1"
local _local_state="$2"
local _profile_dir="$3"
local _name="$4"
local _today
_today="$(date +%Y%m%d)"
# Si Local State no existe, crear una estructura mínima
if [[ ! -f "$_local_state" ]]; then
printf '{"profile":{"info_cache":{},"profiles_order":[]}}\n' > "$_local_state"
fi
# Backup antes de modificar (no sobreescribir el del mismo día)
local _backup="${_local_state}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_local_state" "$_backup"
fi
# Editar con python3
if ! python3 - "$_local_state" "$_profile_dir" "$_name" <<'PY'; then
import sys, json
ls_path = sys.argv[1]
prof_dir = sys.argv[2]
prof_name = sys.argv[3]
with open(ls_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Asegurar estructura profile
profile_section = data.setdefault("profile", {})
info_cache = profile_section.setdefault("info_cache", {})
# Crear o actualizar la entrada del perfil en info_cache
entry = info_cache.setdefault(prof_dir, {})
entry["name"] = prof_name
entry["is_using_default_name"] = False
# Añadir a profiles_order si no está
order = profile_section.setdefault("profiles_order", [])
if prof_dir not in order:
order.append(prof_dir)
with open(ls_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
echo "create_chrome_profile: error editando Local State con python3" >&2
return 4
fi
# Validar JSON tras escritura
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
echo "create_chrome_profile: JSON inválido tras escribir Local State; restaurando backup" >&2
cp "$_backup" "$_local_state"
return 4
fi
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
create_chrome_profile "$@"
fi
@@ -0,0 +1,93 @@
---
name: delete_chrome_profile
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]"
description: "Borra por completo uno o varios perfiles Chrome/Chromium: elimina la carpeta del perfil del disco y limpia todas sus referencias en Local State (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups). Requiere que Chromium esté cerrado. Hace backup automático de Local State antes de editar y valida el JSON resultante restaurando el backup si es inválido."
tags: [navegator, chromium, profile, cleanup, browser, scraping]
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/delete_chrome_profile.sh"
params:
- name: --user-data-dir
desc: "Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio). Ej: ~/.config/chromium"
- name: --profile
desc: "Nombre de la carpeta del perfil a borrar (repetible, mínimo uno obligatorio). Ej: 'Default', 'Profile 1'"
- name: --dry-run
desc: "Muestra qué carpetas borraría y qué claves de Local State quitaría sin tocar nada. No activa el guard de chromium cerrado."
output: "JSON en stdout. Modo real: {deleted:[{profile, dir_removed, local_state_cleaned}...], last_used:'<nuevo>', backup:'Local State.bak.YYYYMMDD'}. Modo dry-run: {dry_run:true, would_delete:[{profile, dir_exists, would_remove, local_state_would_clean}...]}. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# Cerrar Chromium primero (OBLIGATORIO en modo real)
pkill -TERM chromium
# Borrar un perfil
source $HOME/fn_registry/bash/functions/browser/delete_chrome_profile.sh
delete_chrome_profile \
--user-data-dir "$HOME/.config/chromium" \
--profile "Profile 1"
# Salida: {"deleted":[{"profile":"Profile 1","dir_removed":true,"local_state_cleaned":true}],"last_used":"Default","backup":"Local State.bak.20260606"}
# Borrar varios perfiles a la vez
delete_chrome_profile \
--user-data-dir "$HOME/.config/chromium" \
--profile "Profile 1" \
--profile "Profile 2"
# Previsualizar sin tocar nada (no requiere Chromium cerrado)
delete_chrome_profile \
--user-data-dir "$HOME/.config/chromium" \
--profile "Profile 1" \
--dry-run
# Salida: {"dry_run":true,"would_delete":[{"profile":"Profile 1","dir_exists":true,"would_remove":true,"local_state_would_clean":true}]}
# Con un user-data-dir sintético para pruebas
mkdir -p /tmp/test_udd/Default /tmp/test_udd/"Profile 1"
echo '{"profile":{"info_cache":{"Default":{},"Profile 1":{}},"profiles_order":["Default","Profile 1"],"last_active_profiles":["Profile 1"],"last_used":"Profile 1"},"variations_google_groups":{}}' \
> "/tmp/test_udd/Local State"
delete_chrome_profile --user-data-dir /tmp/test_udd --profile "Profile 1" --dry-run
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run delete_chrome_profile_bash_browser -- \
--user-data-dir "$HOME/.config/chromium" --profile "Profile 1" --dry-run
```
## Cuando usarla
Úsala cuando necesites limpiar completamente un perfil de Chromium: antes de crear un perfil de scraping fresco, para depurar problemas de perfiles corruptos, o para liberar espacio eliminando perfiles de sesión temporales. A diferencia de borrar solo la carpeta, esta función también retira las referencias de `Local State` para que Chromium no muestre el perfil fantasma ni intente acceder a él al arrancar.
## Gotchas
- **Chromium DEBE estar cerrado antes de ejecutar en modo real**. Chromium reescribe `Local State` desde memoria al cerrar y desharía todos los cambios. La función comprueba `pgrep -x chromium` y aborta con exit 2 si detecta procesos vivos. En `--dry-run` este check no se activa.
- **Operación destructiva e irreversible**: todos los datos del perfil (cookies, logins guardados, historial, caché, contraseñas) se pierden permanentemente al borrar la carpeta. No hay papelera.
- **Backup automático de Local State**: antes de editar, la función crea `<udd>/Local State.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. Restaurar manualmente: `cp "Local State.bak.YYYYMMDD" "Local State"`.
- **Validación JSON tras edición**: si el JSON de Local State queda inválido (raro pero posible con perfiles con nombres muy especiales), la función restaura el backup automáticamente y sale con exit != 0.
- **Nombres de perfil con espacios**: los nombres como `"Profile 1"` se pasan entre comillas al script Python. El parsing usa `json.loads` por lo que los espacios no dan problemas, pero deben pasarse correctamente en el shell: `--profile "Profile 1"`.
- **python3 > jq > warning**: usa python3 para editar Local State, jq como fallback. Si ninguno está disponible, las carpetas se borran pero Local State queda sin modificar (Chromium podría mostrar perfiles fantasma al arrancar).
- **last_used reasignado automáticamente**: si el perfil borrado era el `last_used`, la función asigna el primer perfil restante en `info_cache`. Si no queda ningún perfil, `last_used` queda como cadena vacía.
- **No afecta a `--profile Default` si es el único perfil**: lo borrará igualmente — Chromium puede quedar sin ningún perfil configurado y recreará Default al arrancar.
## Exit codes
| Código | Significado |
|--------|-------------|
| 0 | Éxito o dry-run completado |
| 1 | Argumento inválido, directorio o Local State no encontrado, JSON inválido tras edición |
| 2 | Chromium está corriendo (solo en modo real) |
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# delete_chrome_profile — borra por completo uno o varios perfiles Chrome/Chromium:
# elimina la carpeta del perfil y limpia todas las referencias en Local State
# (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups).
set -euo pipefail
delete_chrome_profile() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir=""
local _profiles=()
local _dry_run=0
# ── parse args ─────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]
--user-data-dir <dir> Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio).
--profile <name> Nombre de la carpeta del perfil, ej. "Default" o "Profile 1"
(repetible, al menos uno obligatorio).
--dry-run Muestra qué borraría y qué claves de Local State quitaría
sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
2 chromium está corriendo (solo en modo real)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "delete_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones de argumentos ────────────────────────────────────────────
if [[ -z "$_user_data_dir" ]]; then
echo "delete_chrome_profile: --user-data-dir es obligatorio" >&2
return 1
fi
if [[ ${#_profiles[@]} -eq 0 ]]; then
echo "delete_chrome_profile: se requiere al menos un --profile" >&2
return 1
fi
if [[ ! -d "$_user_data_dir" ]]; then
echo "delete_chrome_profile: user-data-dir no encontrado: ${_user_data_dir}" >&2
return 1
fi
local _local_state="${_user_data_dir}/Local State"
if [[ ! -f "$_local_state" ]]; then
echo "delete_chrome_profile: Local State no encontrado: ${_local_state}" >&2
return 1
fi
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ──
# Por-udd, no global. Comprobamos por PID con comm=chromium (pgrep -x) y leemos su cmdline,
# para NO auto-matchear el propio `grep`/`pgrep` del pipe: como el path del udd contiene la
# cadena "chromium" (p.ej. ~/.config/chromium-cdp), un `pgrep -af '[c]hromium' | grep <udd>`
# se detecta a sí mismo. pgrep -x chromium solo lista procesos cuyo nombre es exactamente
# "chromium" (el navegador), nunca grep/pgrep/bash.
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 -- "$_user_data_dir"; then
_busy=1; break
fi
done
if [[ $_busy -eq 1 ]]; then
echo "delete_chrome_profile: hay un chromium con este user-data-dir abierto — ciérralo antes de borrar perfiles:" >&2
echo " pkill -TERM chromium" >&2
echo "(Chromium reescribe Local State desde memoria al cerrar y desharía el borrado)" >&2
return 2
fi
fi
local _today
_today="$(date +%Y%m%d)"
# ── modo dry-run ──────────────────────────────────────────────────────────
if [[ $_dry_run -eq 1 ]]; then
echo "=== delete_chrome_profile DRY-RUN ===" >&2
local _p
for _p in "${_profiles[@]}"; do
local _pdir="${_user_data_dir}/${_p}"
if [[ -d "$_pdir" ]]; then
echo " [borraría] rm -rf ${_pdir}" >&2
else
echo " [no existe] ${_pdir}" >&2
fi
echo " [Local State] quitaría claves para perfil: '${_p}'" >&2
echo " profile.info_cache.${_p}" >&2
echo " profile.profiles_order (entrada '${_p}')" >&2
echo " profile.last_active_profiles (entrada '${_p}')" >&2
echo " profile.last_used (si == '${_p}', reasignar)" >&2
echo " variations_google_groups.${_p} (si existe)" >&2
done
# Construir JSON de dry-run inline
local _dry_items="" _dry_first=1
for _p in "${_profiles[@]}"; do
local _pdir="${_user_data_dir}/${_p}"
local _sep="" _exists="false"
[[ $_dry_first -eq 0 ]] && _sep=","
_dry_first=0
[[ -d "$_pdir" ]] && _exists="true"
_dry_items+="${_sep}{\"profile\":\"${_p}\",\"dir_exists\":${_exists},\"would_remove\":${_exists},\"local_state_would_clean\":true}"
done
printf '{"dry_run":true,"would_delete":[%s]}\n' "$_dry_items"
return 0
fi
# ── backup de Local State (no sobreescribir el del día) ───────────────────
local _backup="${_local_state}.bak.${_today}"
if [[ ! -f "$_backup" ]]; then
cp "$_local_state" "$_backup"
fi
# ── borrar carpetas de perfil ──────────────────────────────────────────────
local _deleted_results=() # "profile|dir_removed|ls_cleaned"
local _p
for _p in "${_profiles[@]}"; do
local _pdir="${_user_data_dir}/${_p}"
local _dir_removed=false
if [[ -d "$_pdir" ]]; then
rm -rf "$_pdir"
_dir_removed=true
fi
_deleted_results+=("${_p}|${_dir_removed}|false")
done
# ── construir lista Python de perfiles a eliminar ─────────────────────────
local _py_profiles_list=""
for _p in "${_profiles[@]}"; do
_py_profiles_list+="\"${_p}\","
done
_py_profiles_list="[${_py_profiles_list%,}]"
# ── editar Local State con python3 ────────────────────────────────────────
local _ls_cleaned=false
if command -v python3 >/dev/null 2>&1; then
python3 - "$_local_state" "$_py_profiles_list" <<'PY'
import sys, json
ls_path = sys.argv[1]
profiles_to_delete = json.loads(sys.argv[2])
with open(ls_path, "r", encoding="utf-8") as f:
data = json.load(f)
profile_section = data.get("profile", {})
# 1. profile.info_cache — eliminar cada perfil
info_cache = profile_section.get("info_cache", {})
for p in profiles_to_delete:
info_cache.pop(p, None)
# 2. profile.profiles_order — quitar entradas del perfil
if "profiles_order" in profile_section and isinstance(profile_section["profiles_order"], list):
profile_section["profiles_order"] = [
x for x in profile_section["profiles_order"] if x not in profiles_to_delete
]
# 3. profile.last_active_profiles — quitar entradas del perfil
if "last_active_profiles" in profile_section and isinstance(profile_section["last_active_profiles"], list):
profile_section["last_active_profiles"] = [
x for x in profile_section["last_active_profiles"] if x not in profiles_to_delete
]
# 4. profile.last_used — reasignar si apunta a un perfil borrado
last_used = profile_section.get("last_used", "")
if last_used in profiles_to_delete:
remaining = [k for k in info_cache.keys() if k not in profiles_to_delete]
profile_section["last_used"] = remaining[0] if remaining else ""
# 5. variations_google_groups — limpiar entradas del perfil (si existe)
vgg = data.get("variations_google_groups", {})
for p in profiles_to_delete:
vgg.pop(p, None)
with open(ls_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
_ls_cleaned=true
# ── fallback con jq ───────────────────────────────────────────────────────
elif command -v jq >/dev/null 2>&1; then
local _tmp_ls
_tmp_ls="$(mktemp)"
local _jq_expr="."
for _p in "${_profiles[@]}"; do
_jq_expr+=" | del(.profile.info_cache[\"${_p}\"])"
_jq_expr+=" | del(.variations_google_groups[\"${_p}\"])"
_jq_expr+=" | if .profile.profiles_order then .profile.profiles_order -= [\"${_p}\"] else . end"
_jq_expr+=" | if .profile.last_active_profiles then .profile.last_active_profiles -= [\"${_p}\"] else . end"
done
if jq "${_jq_expr}" "$_local_state" > "$_tmp_ls" 2>/dev/null; then
mv "$_tmp_ls" "$_local_state"
_ls_cleaned=true
else
echo "delete_chrome_profile: advertencia — jq falló editando Local State" >&2
rm -f "$_tmp_ls"
fi
else
echo "delete_chrome_profile: advertencia — ni python3 ni jq disponibles; carpetas borradas pero Local State no modificado" >&2
fi
# ── validar que el JSON resultante sigue siendo parseable ─────────────────
if [[ "$_ls_cleaned" == "true" ]]; then
if command -v python3 >/dev/null 2>&1; then
if ! python3 -c "import sys, json; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
echo "delete_chrome_profile: JSON de Local State inválido tras edición — restaurando backup" >&2
cp "$_backup" "$_local_state"
return 1
fi
fi
fi
# ── actualizar _deleted_results con ls_cleaned ────────────────────────────
local _updated_results=()
for _entry in "${_deleted_results[@]}"; do
local _ep _edr _els
IFS='|' read -r _ep _edr _els <<< "$_entry"
_updated_results+=("${_ep}|${_edr}|${_ls_cleaned}")
done
# ── leer last_used resultante ──────────────────────────────────────────────
local _new_last_used=""
if command -v python3 >/dev/null 2>&1; then
_new_last_used="$(python3 -c "
import sys, json
data = json.load(open(sys.argv[1]))
print(data.get('profile', {}).get('last_used', ''))
" "$_local_state" 2>/dev/null || echo "")"
fi
# ── construir JSON de resultado inline ────────────────────────────────────
local _result_items="" _res_first=1
for _entry in "${_updated_results[@]+"${_updated_results[@]}"}"; do
local _pn _dr _lc
IFS='|' read -r _pn _dr _lc <<< "$_entry"
local _rsep=""
[[ $_res_first -eq 0 ]] && _rsep=","
_res_first=0
_result_items+="${_rsep}{\"profile\":\"${_pn}\",\"dir_removed\":${_dr},\"local_state_cleaned\":${_lc}}"
done
printf '{"deleted":[%s],"last_used":"%s","backup":"Local State.bak.%s"}\n' \
"$_result_items" "$_new_last_used" "$_today"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then
delete_chrome_profile "$@"
fi
@@ -0,0 +1,93 @@
---
name: restore_chrome_bookmarks
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "restore_chrome_bookmarks --backup-dir <ts-dir> [--user-data-dir <dir>] [--profile <name>]... [--dry-run]"
description: "Restaura archivos Bookmarks de Chrome/Chromium desde un directorio de backup generado por backup_chrome_bookmarks hacia los perfiles destino en user-data-dir. Copia byte a byte con cp -p para preservar el checksum MD5 interno del archivo. Nunca parsea ni reserializa el JSON. Requiere que Chromium esté cerrado antes de ejecutar."
tags: [navegator, chromium, bookmarks, restore, browser, scraping, profile]
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/restore_chrome_bookmarks.sh"
params:
- name: --backup-dir
desc: "Directorio de backup con timestamp generado por backup_chrome_bookmarks. Debe contener subdirectorios <profile>/Bookmarks. OBLIGATORIO."
- name: --user-data-dir
desc: "Ruta raíz del user-data-dir de Chrome/Chromium destino. Default: ~/.config/chromium"
- name: --profile
desc: "Nombre del perfil a restaurar (repetible, ej. Default, Profile 1). Si no se pasa ninguno se restauran TODOS los perfiles presentes en el backup-dir."
- name: --dry-run
desc: "Muestra qué archivos se copiarían y cuáles Bookmarks.bak se borrarían, sin tocar nada en disco."
output: "JSON en stdout: {\"restored\": [{\"profile\": \"Default\", \"dst\": \"<path>\", \"bytes\": N}, ...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
---
## Ejemplo
```bash
# PASO 1 — cerrar Chromium (OBLIGATORIO en modo real)
pkill -TERM chromium
# PASO 2 — restaurar todos los perfiles desde el backup más reciente
source $HOME/fn_registry/bash/functions/browser/restore_chrome_bookmarks.sh
restore_chrome_bookmarks \
--user-data-dir "$HOME/.config/chromium" \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00"
# Restaurar solo un perfil concreto
restore_chrome_bookmarks \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--profile Default
# Restaurar dos perfiles específicos
restore_chrome_bookmarks \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--profile Default \
--profile "Profile 1"
# Previsualizar sin tocar nada (no necesita Chromium cerrado)
restore_chrome_bookmarks \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--dry-run
# Salida esperada:
# {"restored":[{"profile":"Default","dst":"/home/enmanuel/.config/chromium/Default/Bookmarks","bytes":12453}]}
```
También ejecutable directamente con `fn run`:
```bash
cd $HOME/fn_registry
./fn run restore_chrome_bookmarks_bash_browser -- \
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
--dry-run
```
## Cuando usarla
Úsala después de una sesión de scraping o automatización que haya alterado los bookmarks, o para recuperar bookmarks tras formatear/recrear un perfil de Chromium. Combínala con `backup_chrome_bookmarks` (que genera el `--backup-dir` con la estructura esperada) para tener un ciclo completo de backup/restore. También útil para propagar bookmarks de un perfil o PC a otro.
## Gotchas
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium mantiene los bookmarks en memoria y los reescribe al archivo `Bookmarks` al cerrar; si restauras con Chromium abierto, el proceso sobreescribirá tu restauración al cerrarse. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` este check se omite.
- **Copia verbatim — nunca reserializar el JSON**. El archivo `Bookmarks` contiene un campo `checksum` con el MD5 del propio contenido JSON (calculado por Chromium internamente). Si se parsea y reserializa el JSON (aunque sea equivalente), el checksum queda inválido y Chromium descarta silenciosamente el archivo y regenera uno vacío. Esta función usa `cp -p` para garantizar que los bytes son idénticos al original.
- **En Chromium 148 los bookmarks NO están bajo `super_mac` de Secure Preferences**. No es necesario tocar `Preferences` ni `Secure Preferences` al restaurar bookmarks (a diferencia de extensiones). La función solo opera sobre el archivo `Bookmarks`.
- **`Bookmarks.bak` residual se borra**. Chromium crea `Bookmarks.bak` como copia de seguridad interna. Si existe antes de la restauración, esta función lo borra para que Chromium no lo use como fallback en lugar del archivo recién restaurado.
- **El directorio destino del perfil se crea si no existe**. Si el perfil aún no tiene directorio en `user-data-dir`, se crea con `mkdir -p`. Chromium lo inicializará correctamente la primera vez que arranque con ese perfil.
- **Opera por perfil**. Si no pasas `--profile`, restaura todos los perfiles presentes en el backup. Pasa `--profile` explícito para restaurar selectivamente y evitar sobreescribir perfiles sin querer.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito o dry-run completado |
| 1 | Argumento inválido, backup-dir/user-data-dir no encontrado, o perfil no presente en backup |
| 2 | Chromium está corriendo (solo en modo real) |
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# restore_chrome_bookmarks — restaura archivos Bookmarks de un backup generado por
# backup_chrome_bookmarks hacia los perfiles destino en user-data-dir.
# Copia byte a byte con cp -p (nunca parsea ni reserializa el JSON).
set -euo pipefail
restore_chrome_bookmarks() {
# ── defaults ──────────────────────────────────────────────────────────────
local _user_data_dir="${HOME}/.config/chromium"
local _backup_dir=""
local _profiles=()
local _dry_run=0
# ── parse args ────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: restore_chrome_bookmarks --backup-dir <ts-dir>
[--user-data-dir <dir>] [--profile <name>]... [--dry-run]
--user-data-dir Raíz de perfiles destino. Default: ~/.config/chromium
--backup-dir Directorio de backup con timestamp generado por
backup_chrome_bookmarks. Debe contener subdirectorios
<profile>/Bookmarks. OBLIGATORIO.
--profile <name> Perfil a restaurar (repetible). Si no se pasa ninguno
se restauran TODOS los perfiles presentes en backup-dir.
--dry-run Muestra qué se copiaría sin tocar nada.
Exit codes:
0 éxito (o dry-run completado)
1 error de argumento o validación
2 chromium está corriendo (solo en modo real)
EOF
return 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
--backup-dir) _backup_dir="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
-h|--help) _usage; return 0 ;;
*) echo "restore_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
esac
done
# ── validaciones ──────────────────────────────────────────────────────────
if [[ -z "$_backup_dir" ]]; then
echo "restore_chrome_bookmarks: --backup-dir es obligatorio" >&2
return 1
fi
if [[ ! -d "$_backup_dir" ]]; then
echo "restore_chrome_bookmarks: backup-dir no encontrado: ${_backup_dir}" >&2
return 1
fi
if [[ ! -d "$_user_data_dir" ]]; then
echo "restore_chrome_bookmarks: user-data-dir no encontrado: ${_user_data_dir}" >&2
return 1
fi
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ──
# Por-udd, no global. Comprobamos por PID con comm=chromium (pgrep -x) y leemos su cmdline,
# para NO auto-matchear el propio `grep`/`pgrep`: el path del udd contiene "chromium"
# (~/.config/chromium-cdp), así que un `pgrep -af '[c]hromium' | grep <udd>` se detecta a sí mismo.
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 -- "$_user_data_dir"; then
_busy=1; break
fi
done
if [[ $_busy -eq 1 ]]; then
echo "restore_chrome_bookmarks: hay un chromium con este user-data-dir abierto — ciérralo antes de restaurar:" >&2
echo " pkill -TERM chromium" >&2
echo "(Chromium reescribe Bookmarks desde memoria al cerrar y desharía la restauración)" >&2
return 2
fi
fi
# ── determinar perfiles a restaurar ───────────────────────────────────────
local _target_profiles=()
if [[ ${#_profiles[@]} -gt 0 ]]; then
# Perfiles explícitos: verificar que existen en el backup
local _p
for _p in "${_profiles[@]}"; do
if [[ ! -f "${_backup_dir}/${_p}/Bookmarks" ]]; then
echo "restore_chrome_bookmarks: backup no contiene perfil '${_p}': ${_backup_dir}/${_p}/Bookmarks" >&2
return 1
fi
_target_profiles+=("$_p")
done
else
# Autodescubrir todos los perfiles en el backup
local _profile_path
while IFS= read -r -d '' _profile_path; do
local _pname
_pname="$(basename "$(dirname "$_profile_path")")"
_target_profiles+=("$_pname")
done < <(find "$_backup_dir" -mindepth 2 -maxdepth 2 -name "Bookmarks" -print0 | sort -z)
if [[ ${#_target_profiles[@]} -eq 0 ]]; then
echo "restore_chrome_bookmarks: no se encontraron archivos Bookmarks en: ${_backup_dir}" >&2
return 1
fi
fi
# ── restaurar cada perfil ─────────────────────────────────────────────────
local _restored_json=""
local _first=1
local _prof
for _prof in "${_target_profiles[@]}"; do
local _src="${_backup_dir}/${_prof}/Bookmarks"
local _dst_dir="${_user_data_dir}/${_prof}"
local _dst="${_dst_dir}/Bookmarks"
local _dst_bak="${_dst_dir}/Bookmarks.bak"
# Tamaño del archivo fuente para el JSON de salida
local _bytes=0
if [[ -f "$_src" ]]; then
_bytes="$(wc -c < "$_src")"
# Eliminar espacios que wc puede añadir en algunas plataformas
_bytes="${_bytes// /}"
fi
if [[ $_dry_run -eq 1 ]]; then
echo "=== restore_chrome_bookmarks DRY-RUN ===" >&2
echo " Perfil : ${_prof}" >&2
echo " src : ${_src}" >&2
echo " dst : ${_dst}" >&2
echo " bytes : ${_bytes}" >&2
if [[ -f "$_dst_bak" ]]; then
echo " .bak : borraría ${_dst_bak}" >&2
fi
else
# Crear directorio destino si no existe
mkdir -p "$_dst_dir"
# Copiar byte a byte preservando timestamps (NUNCA reserializar)
cp -p "$_src" "$_dst"
# Borrar Bookmarks.bak residual si existe
if [[ -f "$_dst_bak" ]]; then
rm -f "$_dst_bak"
fi
fi
# Construir fragmento JSON para este perfil
local _entry
_entry="$(printf '{"profile":"%s","dst":"%s","bytes":%s}' \
"$_prof" "$_dst" "$_bytes")"
if [[ $_first -eq 1 ]]; then
_restored_json="${_entry}"
_first=0
else
_restored_json+=",$_entry"
fi
done
# ── emitir resultado JSON ─────────────────────────────────────────────────
printf '{"restored":[%s]}\n' "$_restored_json"
}
# ── auto-ejecución ────────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
restore_chrome_bookmarks "$@"
fi
@@ -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,67 @@
---
name: reset_chrome_profiles
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "reset_chrome_profiles --user-data-dir <dir> [--profile \"<dir>=<legible>\"]... [--backup-dir <dir>] [--base-port 9250] [--keep <ext_id>]... [--dry-run] [--yes]"
description: "Pipeline de reset destructivo de perfiles de Chromium: hace backup de los bookmarks de todos los perfiles, cierra el chromium que use ese user-data-dir, borra los perfiles (carpeta + Local State), los recrea (la managed policy reinstala la whitelist de extensiones uBlock + web_proxy), restaura los bookmarks y verifica que cada perfil quedó solo con la whitelist. DESTRUCTIVO: se pierden cookies, logins, historial y contraseñas; solo los bookmarks se preservan. Requiere --yes en modo real."
tags: [launcher, navegator, chromium, pipeline, profile, reset]
uses_functions:
- backup_chrome_bookmarks_bash_browser
- delete_chrome_profile_bash_browser
- create_chrome_profile_bash_browser
- restore_chrome_bookmarks_bash_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: "--user-data-dir <dir>"
desc: "Raíz del user-data-dir de Chromium cuyos perfiles se resetean (ej. ~/.config/chromium-cdp)."
- name: "--profile <dir=legible>"
desc: "Perfil a resetear, formato carpeta=nombre-legible (repetible). Default los 4 reales: Default=Work, Personal=Personal, 'Profile 1'=Aurgi, Automation=Automation."
- name: "--backup-dir <dir>"
desc: "Directorio donde se guardan los backups de bookmarks. Default ~/.local/share/web_scraping/bookmarks-backups."
- name: "--base-port <N>"
desc: "Puerto CDP base para recrear perfiles (cada perfil usa base+i). Default 9250."
- name: "--keep <ext_id>"
desc: "ID de extensión esperada tras el reset (repetible). Default uBlock Origin Lite + web_proxy toggle. Solo se usa en la verificación final."
- name: "--dry-run"
desc: "Previsualiza los 6 pasos sin tocar el sistema."
- name: "--yes"
desc: "Confirma la operación destructiva (obligatorio en modo real)."
output: "Ejecuta backup → cerrar chromium → delete → create → restore → verify. Emite el progreso de cada paso y un resumen. Sale 0 si todo OK y cada perfil quedó solo con la whitelist; != 0 si falla algún paso o la verificación detecta extensiones fuera de la whitelist."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/reset_chrome_profiles.sh"
---
## Ejemplo
```bash
# Previsualizar el reset de los 4 perfiles del chromium diario (no toca nada)
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --dry-run
# Reset real (destructivo): backup bookmarks, borrar+recrear los 4 perfiles, restaurar bookmarks
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --yes
# Reset de un solo perfil con nombre legible
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" \
--profile "Automation=Automation" --yes
```
## Cuando usarla
Cuando quieras dejar los perfiles de un Chromium **limpios desde cero** conservando solo la whitelist de extensiones (uBlock + la de captura del web_proxy) y preservando los bookmarks, pero descartando todo el resto del estado (cookies, logins, historial). Útil para volver a un estado conocido de scraping/captura o para limpiar perfiles contaminados. La managed policy de `/etc` ya fuerza la whitelist, así que los perfiles recreados nacen correctos.
## Gotchas
- **DESTRUCTIVO**: cookies, logins, historial y contraseñas de los perfiles se pierden de forma irreversible. Solo los bookmarks se preservan (backup + restore byte a byte). Por eso requiere `--yes` en modo real.
- **Cierra el chromium del user-data-dir indicado** (pkill por `--user-data-dir`), no cualquier chromium. Si tienes otro chromium con otro user-data-dir, no se toca.
- **Depende de la managed policy**: los perfiles recreados solo tendrán uBlock + web_proxy si la policy de `/etc/chromium/policies/managed/extensions.json` las fuerza (ver `apply_chromium_extension_policy_bash_browser`). Si la policy no está, los perfiles nacen sin extensiones.
- La verificación final comprueba las carpetas en `<profile>/Extensions/`; para una auditoría detallada (nombre, versión, enabled, fromPolicy) usar `list_chrome_profile_extensions_go_browser`.
- Lanzar chromium desde el Bash tool da exit-144; `create_chrome_profile` usa `systemd-run --user` internamente para evitarlo.
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# reset_chrome_profiles — Pipeline de reset destructivo de perfiles de Chromium.
#
# Compone funciones del registry para: hacer backup de los bookmarks de todos los perfiles,
# cerrar chromium, borrar los perfiles (carpeta + entradas en Local State), recrearlos
# (la managed policy reinstala la whitelist de extensiones: uBlock + web_proxy), restaurar
# los bookmarks y verificar que cada perfil quedó solo con la whitelist.
#
# DESTRUCTIVO: borra cookies, logins, historial y contraseñas de los perfiles. Solo los
# bookmarks se preservan (backup + restore). Requiere --yes en modo real (o --dry-run).
#
# Uso:
# reset_chrome_profiles --user-data-dir <dir>
# [--profile "<dir>=<legible>"]... [--backup-dir <dir>] [--base-port 9250]
# [--keep <ext_id>]... [--dry-run] [--yes]
#
# Defaults de --profile (los 4 perfiles reales): "Default=Work" "Personal=Personal"
# "Profile 1=Aurgi" "Automation=Automation".
# Default de --keep (whitelist esperada tras el reset): uBlock Origin Lite + web_proxy toggle.
reset_chrome_profiles() {
local _udd="" _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups"
local _base_port=9250 _dry_run=0 _yes=0
local -a _profiles=()
local -a _keep=()
while [[ $# -gt 0 ]]; do
case "$1" in
--user-data-dir) _udd="$2"; shift 2 ;;
--profile) _profiles+=("$2"); shift 2 ;;
--backup-dir) _backup_dir="$2"; shift 2 ;;
--base-port) _base_port="$2"; shift 2 ;;
--keep) _keep+=("$2"); shift 2 ;;
--dry-run) _dry_run=1; shift ;;
--yes) _yes=1; shift ;;
-h|--help)
grep '^#' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; return 0 ;;
*) echo "reset_chrome_profiles: argumento desconocido: $1" >&2; return 1 ;;
esac
done
if [[ -z "$_udd" ]]; then
echo "reset_chrome_profiles: --user-data-dir es obligatorio" >&2; return 1
fi
if [[ ${#_profiles[@]} -eq 0 ]]; then
_profiles=("Default=Work" "Personal=Personal" "Profile 1=Aurgi" "Automation=Automation")
fi
if [[ ${#_keep[@]} -eq 0 ]]; then
_keep=("ddkjiahejlhfcafbddmgiahcphecmpfh" "nanldmckabfghgdebblpfbdbhphhbnde")
fi
# Localizar las funciones del registry que componemos.
local _dir _root _browser
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_root="$(cd "$_dir/../../.." && pwd)"
_browser="$_root/bash/functions/browser"
local _f
for _f in backup_chrome_bookmarks restore_chrome_bookmarks delete_chrome_profile create_chrome_profile; do
if [[ ! -f "$_browser/$_f.sh" ]]; then
echo "reset_chrome_profiles: falta función $_f en $_browser" >&2; return 1
fi
# shellcheck disable=SC1090
source "$_browser/$_f.sh"
done
echo "=== reset_chrome_profiles ==="
echo " user-data-dir : $_udd"
echo " perfiles : ${_profiles[*]}"
echo " whitelist ext : ${_keep[*]}"
echo " backup-dir : $_backup_dir"
echo " modo : $([[ $_dry_run -eq 1 ]] && echo DRY-RUN || echo REAL)"
echo ""
# Confirmación obligatoria en modo real.
if [[ $_dry_run -eq 0 && $_yes -eq 0 ]]; then
echo "reset_chrome_profiles: operación DESTRUCTIVA (se pierden cookies/logins/historial)." >&2
echo " Repite con --yes para confirmar, o usa --dry-run para previsualizar." >&2
return 3
fi
# ── [1/6] Backup de bookmarks (solo lee; chromium puede estar abierto) ──────
echo "[1/6] Backup de bookmarks..."
local _bk_json _ts_dir
if [[ $_dry_run -eq 1 ]]; then
backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir" --dry-run
_ts_dir="<dry-run>"
else
_bk_json="$(backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir")" || {
echo "reset_chrome_profiles: backup falló" >&2; return 1; }
echo "$_bk_json"
_ts_dir="$(printf '%s' "$_bk_json" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["backup_dir"]+"/"+d["ts"])')"
echo " backup en: $_ts_dir"
fi
echo ""
# ── [2/6] Cerrar chromium que tenga ESTE user-data-dir abierto ─────────────
echo "[2/6] Cerrando chromium con --user-data-dir=$_udd ..."
if [[ $_dry_run -eq 1 ]]; then
echo " (dry-run: no se cierra nada)"
else
# Por-PID con comm=chromium (pgrep -x) para no auto-matchear grep/pgrep (el path del udd
# contiene la cadena "chromium").
local _p _kpids _i=0
_kpids=""
for _p in $(pgrep -x chromium 2>/dev/null); do
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _kpids="$_kpids $_p"
done
if [[ -n "${_kpids// }" ]]; then
# shellcheck disable=SC2086
kill -TERM $_kpids 2>/dev/null || true
while :; do
_kpids=""
for _p in $(pgrep -x chromium 2>/dev/null); do
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _kpids="$_kpids $_p"
done
[[ -z "${_kpids// }" ]] && break
_i=$((_i+1)); [[ $_i -ge 20 ]] && { kill -9 $_kpids 2>/dev/null || true; break; }
sleep 0.5
done
echo " chromium cerrado."
else
echo " (no había chromium con ese user-data-dir)"
fi
fi
echo ""
# ── [3/6] Borrar perfiles (carpeta + Local State) ──────────────────────────
echo "[3/6] Borrando perfiles..."
local _del_args=() _pair _pdir
for _pair in "${_profiles[@]}"; do
_pdir="${_pair%%=*}"
_del_args+=(--profile "$_pdir")
done
if [[ $_dry_run -eq 1 ]]; then
delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" --dry-run
else
delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" || {
echo "reset_chrome_profiles: delete falló" >&2; return 1; }
fi
echo ""
# ── [4/6] Recrear perfiles (la policy reinstala la whitelist al arrancar) ───
echo "[4/6] Recreando perfiles..."
local _idx=0 _name _port
for _pair in "${_profiles[@]}"; do
_pdir="${_pair%%=*}"; _name="${_pair#*=}"; _port=$((_base_port + _idx))
if [[ $_dry_run -eq 1 ]]; then
create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" --dry-run
else
create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" || {
echo "reset_chrome_profiles: create de '$_pdir' falló" >&2; return 1; }
fi
_idx=$((_idx+1))
done
echo ""
# ── [5/6] Restaurar bookmarks ──────────────────────────────────────────────
echo "[5/6] Restaurando bookmarks..."
if [[ $_dry_run -eq 1 ]]; then
echo " (dry-run: restauraría desde el backup recién creado)"
else
restore_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_ts_dir" || {
echo "reset_chrome_profiles: restore falló (continúo a verify)" >&2; }
fi
echo ""
# ── [6/6] Verificar extensiones por perfil (carpetas en Extensions/) ───────
echo "[6/6] Verificando extensiones (esperado: solo la whitelist)..."
if [[ $_dry_run -eq 1 ]]; then
echo " (dry-run: verificaría que cada perfil tiene solo ${_keep[*]})"
echo ""
echo "reset_chrome_profiles: DRY-RUN completado, nada se modificó."
return 0
fi
local _ok=1
for _pair in "${_profiles[@]}"; do
_pdir="${_pair%%=*}"
local _extdir="$_udd/$_pdir/Extensions"
local -a _present=()
if [[ -d "$_extdir" ]]; then
local _e
for _e in "$_extdir"/*/; do
_e="$(basename "$_e")"
[[ "$_e" == "Temp" || "$_e" == "*" ]] && continue
_present+=("$_e")
done
fi
# Comprobar que todo lo presente está en la whitelist.
local _extra=()
local _id _found
for _id in "${_present[@]}"; do
_found=0
local _k
for _k in "${_keep[@]}"; do [[ "$_id" == "$_k" ]] && _found=1; done
[[ $_found -eq 0 ]] && _extra+=("$_id")
done
if [[ ${#_extra[@]} -gt 0 ]]; then
echo "$_pdir: extensiones fuera de whitelist: ${_extra[*]}"
_ok=0
else
echo "$_pdir: ${_present[*]:-<vacío, aún sin arrancar>}"
fi
done
echo ""
if [[ $_ok -eq 1 ]]; then
echo "reset_chrome_profiles: OK — perfiles recreados, bookmarks restaurados, solo la whitelist presente."
return 0
else
echo "reset_chrome_profiles: verificación con avisos (revisar arriba)." >&2
return 1
fi
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
reset_chrome_profiles "$@"
fi
+66
View File
@@ -0,0 +1,66 @@
---
id: cdp_activate_tab_go_browser
name: cdp_activate_tab
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Pone una pestaña Chrome en foreground (foco) por su ID via GET /json/activate/<id>. Sin WebSocket — solo HTTP. Útil para traer al frente una pestaña específica antes de capturar pantalla o interactuar con ella."
tags: [cdp, browser, tabs, navegator]
signature: "func CdpActivateTab(host string, port int, tabID string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_tabs.go"
example: |
tabs, _ := browser.CdpListTabs("localhost", 9222)
// Activar la primera pestaña cuyo título contenga "Dashboard"
for _, t := range tabs {
if strings.Contains(t.Title, "Dashboard") {
_ = browser.CdpActivateTab("localhost", 9222, t.ID)
break
}
}
params:
- name: host
desc: "Hostname de la instancia Chrome (vacío = localhost)"
- name: port
desc: "Puerto CDP de remote debugging (habitualmente 9222)"
- name: tabID
desc: "ID de la pestaña a activar, obtenido de CdpTab.ID via CdpListTabs"
output: "nil si la pestaña pasó a foreground correctamente; error si tabID está vacío, la conexión falla o Chrome devuelve status != 200"
---
## Ejemplo
```go
// Listar tabs y traer al frente la que corresponda a una URL concreta
tabs, err := browser.CdpListTabs("localhost", 9222)
if err != nil {
log.Fatal(err)
}
for _, t := range tabs {
if t.URL == "https://metabase.local/dashboard/1" {
if err := browser.CdpActivateTab("localhost", 9222, t.ID); err != nil {
log.Printf("error activando tab %s: %v", t.ID, err)
}
break
}
}
```
## Cuando usarla
Antes de hacer un screenshot o interactuar via CDP con una pestaña concreta que podría estar en segundo plano. También útil en dashboards que muestran el inventario de pestañas y necesitan enfocar una al hacer clic.
## Gotchas
- No requiere conexión WebSocket previa; usa HTTP puro contra `/json/activate/<id>`.
- Solo cambia el foco dentro del contexto CDP; si la ventana de Chrome está minimizada a nivel de OS, `activate` la pone como pestaña activa dentro de Chrome pero no restaura la ventana.
- El ID debe obtenerse de `CdpListTabs` — no es el índice visible del tab, es el UUID interno de Chrome.
- Si el tabID no existe, Chrome devuelve un status HTTP distinto de 200 y la función retorna error.
+12
View File
@@ -0,0 +1,12 @@
package browser
// CdpClearCookies borra TODAS las cookies del browser via Network.clearBrowserCookies.
// Equivalente a "Borrar datos de navegacion > Cookies" en Chrome.
// Cierra todas las sesiones activas — usar solo en tests o resets completos.
func CdpClearCookies(c *CDPConn) error {
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return err
}
_, err := c.sendCDP("Network.clearBrowserCookies", nil)
return err
}
+54
View File
@@ -0,0 +1,54 @@
---
id: cdp_clear_cookies_go_browser
name: cdp_clear_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Borra TODAS las cookies del browser via Network.clearBrowserCookies; equivalente a 'Borrar datos de navegacion > Cookies' en Chrome."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpClearCookies(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_clear_cookies.go"
example: |
conn, _ := CdpConnect(9222)
if err := CdpClearCookies(conn); err != nil {
log.Fatal(err)
}
// browser ahora sin cookies — todas las sesiones cerradas
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
output: "nil si se borraron todas las cookies; error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Reset completo antes de un test de login
if err := CdpClearCookies(conn); err != nil {
log.Fatal(err)
}
// A partir de aqui el browser no tiene sesion en ningun dominio
```
## Cuando usarla
Usar al inicio de un test e2e que necesita partir de un browser sin sesion previa, o cuando quieres resetear completamente el estado de autenticacion del browser en un entorno de CI.
## Gotchas
- Destructivo e irreversible: cierra TODAS las sesiones activas en todos los dominios del browser.
- Llama `Network.enable` internamente antes del clear; es idempotente.
- No afecta a LocalStorage ni SessionStorage — solo cookies.
- Para borrar solo una cookie especifica usar `CdpDeleteCookies` en su lugar.
- En un browser de perfil de usuario real (no headless de test) puede cerrar sesiones de trabajo activas.
+12 -8
View File
@@ -14,11 +14,19 @@ func CdpClick(c *CDPConn, selector string) error {
return fmt.Errorf("cdp click: conexion nula")
}
// Obtener coordenadas del centro del elemento
// Obtener coordenadas del centro del elemento, tras hacer scroll para que sea
// visible. Verificamos visibilidad: un elemento existente pero oculto
// (display:none, visibility:hidden, opacity 0 o tamaño 0) daria un rect en
// (0,0) y clicariamos en la esquina sin efecto — devolvemos error en su lugar.
js := fmt.Sprintf(`(function() {
var el = document.querySelector(%q);
if (!el) return null;
el.scrollIntoView({block:'center'});
var r = el.getBoundingClientRect();
var s = window.getComputedStyle(el);
var visible = r.width > 0 && r.height > 0 &&
s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0';
if (!visible) return '__HIDDEN__';
return JSON.stringify({x: r.left + r.width/2, y: r.top + r.height/2});
})()`, selector)
@@ -29,6 +37,9 @@ func CdpClick(c *CDPConn, selector string) error {
if coordStr == "" || coordStr == "null" {
return fmt.Errorf("cdp click: elemento %q no encontrado en el DOM", selector)
}
if strings.Contains(coordStr, "__HIDDEN__") {
return fmt.Errorf("cdp click: elemento %q existe pero no es visible/clickable (display:none, oculto, opacity 0 o tamaño 0)", selector)
}
// Parsear "{x:...,y:...}" — CdpEvaluate ya retorna el JSON como string
coordStr = strings.Trim(coordStr, `"`)
@@ -37,13 +48,6 @@ func CdpClick(c *CDPConn, selector string) error {
return fmt.Errorf("cdp click: parsear coordenadas %q: %w", coordStr, err)
}
// Hacer scroll al elemento para que este visible
scrollJS := fmt.Sprintf(`document.querySelector(%q).scrollIntoView({block:'center'})`, selector)
if _, err := CdpEvaluate(c, scrollJS); err != nil {
// No es fatal si el scroll falla
_ = err
}
// Despachar mousedown
mouseParams := map[string]any{
"type": "mousePressed",
+4 -26
View File
@@ -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 (3090 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
+40
View File
@@ -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)
}
+51
View File
@@ -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.
+49
View File
@@ -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
}
+62
View File
@@ -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.
+63
View File
@@ -0,0 +1,63 @@
---
id: cdp_close_tab_go_browser
name: cdp_close_tab
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Cierra una pestaña Chrome por su ID via GET /json/close/<id>. Sin WebSocket — solo HTTP. Util para limpiar pestañas abiertas por automatizaciones."
tags: [cdp, browser, tabs, navegator]
signature: "func CdpCloseTab(host string, port int, tabID string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_tabs.go"
example: |
tabs, _ := browser.CdpListTabs("localhost", 9222)
for _, t := range tabs {
if t.URL == "https://example.com" {
_ = browser.CdpCloseTab("localhost", 9222, t.ID)
}
}
params:
- name: host
desc: "Hostname de la instancia Chrome (vacío = localhost)"
- name: port
desc: "Puerto CDP de remote debugging (habitualmente 9222)"
- name: tabID
desc: "ID de la pestaña a cerrar, obtenido de CdpTab.ID via CdpListTabs"
output: "nil si la pestaña se cerró correctamente; error si tabID está vacío, la conexión falla o Chrome devuelve status != 200"
---
## Ejemplo
```go
// Listar tabs y cerrar la primera que coincida con una URL
tabs, err := browser.CdpListTabs("localhost", 9222)
if err != nil {
log.Fatal(err)
}
for _, t := range tabs {
if t.URL == "https://example.com/login" {
if err := browser.CdpCloseTab("localhost", 9222, t.ID); err != nil {
log.Printf("error cerrando tab %s: %v", t.ID, err)
}
}
}
```
## Cuando usarla
Después de terminar una sesión de scraping o automatización: cierra las pestañas abiertas programáticamente sin afectar el resto del perfil. También útil para liberar recursos cuando `CdpNewTab` ha creado muchas pestañas temporales.
## Gotchas
- No requiere conexión WebSocket previa; usa HTTP puro contra `/json/close/<id>`.
- Si Chrome ya cerró la pestaña (o el ID es inválido), devuelve error de status HTTP.
- El ID debe obtenerse de `CdpListTabs` — no es el índice visible del tab, es el UUID interno de Chrome.
- No espera confirmación de cierre; para saber si la pestaña desapareció, volver a llamar `CdpListTabs`.
+17 -14
View File
@@ -67,18 +67,9 @@ func CdpConnect(port int) (*CDPConn, error) {
return CdpConnectHost("localhost", port)
}
// CdpConnectHost es como CdpConnect pero permite especificar el host.
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
func CdpConnectHost(host string, port int) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
wsURL, err := cdpGetPageWSURL(host, port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
// Parsear la URL del WebSocket para extraer host y path
// cdpConnectWS abre la conexion CDP a partir de un webSocketDebuggerUrl ya resuelto.
// Es el helper compartido por CdpConnectHost y CdpConnectTarget para evitar duplicacion.
func cdpConnectWS(wsURL string, port int) (*CDPConn, error) {
u, err := url.Parse(wsURL)
if err != nil {
return nil, fmt.Errorf("cdp connect: parsear ws url %q: %w", wsURL, err)
@@ -96,8 +87,7 @@ func CdpConnectHost(host string, port int) (*CDPConn, error) {
}
// Realizar handshake WebSocket
path := u.RequestURI()
reader, err := wsHandshake(tcpConn, wsHost, path)
reader, err := wsHandshake(tcpConn, wsHost, u.RequestURI())
if err != nil {
tcpConn.Close()
return nil, fmt.Errorf("cdp connect: ws handshake: %w", err)
@@ -115,3 +105,16 @@ func CdpConnectHost(host string, port int) (*CDPConn, error) {
return c, nil
}
// CdpConnectHost es como CdpConnect pero permite especificar el host.
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
func CdpConnectHost(host string, port int) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
wsURL, err := cdpGetPageWSURL(host, port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
return cdpConnectWS(wsURL, port)
}
+56
View File
@@ -0,0 +1,56 @@
package browser
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// CdpConnectTarget se conecta a un target CDP DETERMINISTA identificado por match.
//
// Si host es "" se usa "localhost".
// match puede ser:
// - "" → primer target con Type "page" y WebSocketDebuggerURL no vacío (misma
// semántica que CdpConnectHost, útil como fallback compatible).
// - ID exacto del target (campo "id" en /json).
// - Substring case-insensitive de la URL del target.
//
// Retorna error si ningún target type=page satisface el match.
func CdpConnectTarget(host string, port int, match string) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json", host, port))
if err != nil {
return nil, fmt.Errorf("cdp connect target: listar targets: %w", err)
}
defer resp.Body.Close()
var targets []cdpTarget
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return nil, fmt.Errorf("cdp connect target: decode targets: %w", err)
}
matchLower := strings.ToLower(match)
for _, t := range targets {
if t.Type != "page" || t.WebSocketDebuggerURL == "" {
continue
}
if match == "" {
// Sin filtro: primera tab page disponible.
return cdpConnectWS(t.WebSocketDebuggerURL, port)
}
// Coincidencia por ID exacto o substring de URL (case-insensitive).
if t.ID == match || strings.Contains(strings.ToLower(t.URL), matchLower) {
return cdpConnectWS(t.WebSocketDebuggerURL, port)
}
}
if match == "" {
return nil, fmt.Errorf("cdp connect target: no hay ninguna tab 'page' disponible en %s:%d", host, port)
}
return nil, fmt.Errorf("cdp connect target: no hay tab 'page' que matchee %q en %s:%d", match, host, port)
}
+58
View File
@@ -0,0 +1,58 @@
---
name: cdp_connect_target
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpConnectTarget(host string, port int, match string) (*CDPConn, error)"
description: "Conecta por CDP a un target DETERMINISTA elegido por ID exacto o substring de URL, evitando engancharse a una pestaña al azar con el CDP global en 9222."
tags: [cdp, browser, connection, security, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: host
desc: "Host donde escucha el CDP. Vacío usa 'localhost'. Útil en WSL2 para apuntar a la IP de Windows."
- name: port
desc: "Puerto CDP del navegador (habitualmente 9222)."
- name: match
desc: "Filtro de target: vacío = primera tab page (compat con CdpConnectHost); ID exacto del target; o substring case-insensitive de la URL de la pestaña."
output: "*CDPConn listo para enviar comandos CDP al target elegido. Error si ninguna tab 'page' satisface el match."
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_connect_target.go"
---
## Ejemplo
```go
// Fijar la pestaña de GitHub para que el agente no toque otras abiertas
conn, err := browser.CdpConnectTarget("", 9222, "github.com")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// Por ID exacto de target (obtenido de GET http://localhost:9222/json)
conn2, err := browser.CdpConnectTarget("", 9222, "ABCD1234-target-id")
// Compatibilidad: sin filtro = primera tab page (igual que CdpConnect)
conn3, err := browser.CdpConnectTarget("", 9222, "")
```
## Cuando usarla
Cuando un agente debe atarse a UNA pestaña concreta (por URL) y NO a la primera al azar — crítico con CDP global en 9222 para no operar sobre pestañas ajenas (banca, correo, sesiones activas). Usar en lugar de `CdpConnect`/`CdpConnectHost` siempre que el contexto del agente sea "esta URL concreta" y no "cualquier tab disponible".
## Gotchas
- Si hay varias tabs cuya URL contiene el substring dado, se elige la **primera** que aparezca en `/json` (orden interno del navegador). Para mayor precisión, usar el ID exacto del target.
- El match de URL es substring **case-insensitive**; `"github"` matchea `"https://github.com/usuario/repo"`.
- Con CDP global en 9222 y muchas pestañas abiertas, un `match=""` sigue siendo tan arriesgado como `CdpConnect`. Especificar siempre el match en producción.
- La forma más segura para agentes automatizados es lanzar un perfil Chromium dedicado con `--user-data-dir` aislado y `--remote-debugging-port` propio, de modo que `/json` solo exponga las pestañas del agente.
- `WebSocketDebuggerURL` puede cambiar entre reinicios del navegador; recalcular en cada sesión, no cachear entre ejecuciones.
+15
View File
@@ -0,0 +1,15 @@
package browser
// CdpDeleteCookies borra las cookies que coincidan con name (y opcionalmente domain)
// via Network.deleteCookies. Si domain es "" se borran todas las cookies con ese
// nombre en cualquier dominio.
func CdpDeleteCookies(c *CDPConn, name, domain string) error {
params := map[string]any{
"name": name,
}
if domain != "" {
params["domain"] = domain
}
_, err := c.sendCDP("Network.deleteCookies", params)
return err
}
+61
View File
@@ -0,0 +1,61 @@
---
id: cdp_delete_cookies_go_browser
name: cdp_delete_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Borra las cookies que coincidan con name (+ domain opcional) via Network.deleteCookies; si domain es vacío elimina en todos los dominios."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpDeleteCookies(c *CDPConn, name, domain string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_delete_cookies.go"
example: |
conn, _ := CdpConnect(9222)
// Borrar cookie de sesion solo en el dominio concreto
err := CdpDeleteCookies(conn, "session_id", "app.example.com")
// Borrar en todos los dominios (sin filtro de dominio)
err = CdpDeleteCookies(conn, "tracking_cookie", "")
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
- name: name
desc: "Nombre exacto de la cookie a borrar; obligatorio para Network.deleteCookies"
- name: domain
desc: "Dominio donde borrar la cookie; cadena vacía borra en todos los dominios que tengan esa cookie"
output: "nil si la cookie fue borrada (o no existia); error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Borrar cookie de sesion solo en dominio especifico
if err := CdpDeleteCookies(conn, "session_id", "app.example.com"); err != nil {
log.Fatal(err)
}
// Borrar cookie en todos los dominios
if err := CdpDeleteCookies(conn, "analytics_token", ""); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Usar cuando necesitas forzar un logout de sesion especifica, limpiar una cookie de tracking antes de un test, o resetear el estado de autenticacion de un dominio concreto sin tocar el resto de cookies.
## Gotchas
- `name` es obligatorio en `Network.deleteCookies`; CDP devuelve error si se omite.
- Sin `domain`, CDP borra la cookie en TODOS los dominios que tengan esa cookie — puede cerrar sesiones inesperadas en otros dominios abiertos.
- No devuelve error si la cookie no existia; la operacion es idempotente.
- Para borrar todas las cookies de golpe usar `CdpClearCookies` en su lugar.
+83
View File
@@ -0,0 +1,83 @@
package browser
import (
"encoding/json"
"fmt"
)
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
// Retorna el resultado serializado como string.
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp eval in frame: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
}
// Page.enable es idempotente; necesario antes de crear mundos aislados
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
}
// Crear un mundo aislado en el frame indicado para no contaminar su contexto JS
ctxRes, err := c.sendCDP("Page.createIsolatedWorld", map[string]any{
"frameId": frameID,
"worldName": "fn_registry_isolated",
"grantUniveralAccess": false,
})
if err != nil {
return "", fmt.Errorf("cdp eval in frame: createIsolatedWorld: %w", err)
}
ctxIDRaw, ok := ctxRes["executionContextId"]
if !ok {
return "", fmt.Errorf("cdp eval in frame: executionContextId no encontrado en respuesta")
}
ctxID, ok := ctxIDRaw.(float64)
if !ok {
return "", fmt.Errorf("cdp eval in frame: executionContextId tipo inesperado: %T", ctxIDRaw)
}
// Evaluar la expresion en el contexto aislado del frame
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
"expression": expression,
"contextId": int(ctxID),
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return "", fmt.Errorf("cdp eval in frame: Runtime.evaluate: %w", err)
}
// Verificar excepcion JS
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
excMap, _ := exc.(map[string]any)
text, _ := excMap["text"].(string)
return "", fmt.Errorf("cdp eval in frame: excepcion JS en frame %q: %s", frameID, text)
}
// Extraer valor del resultado (mismo patron que CdpEvaluate)
resVal, ok := evRes["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("cdp eval in frame: resultado inesperado: %v", evRes)
}
value, ok := resVal["value"]
if !ok {
// undefined u otro tipo no serializable
typ, _ := resVal["type"].(string)
return typ, nil
}
// Strings tal cual; objetos/arrays JS a JSON real (no la repr de Go de "%v").
if s, ok := value.(string); ok {
return s, nil
}
b, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value), nil
}
return string(b), nil
}
+73
View File
@@ -0,0 +1,73 @@
---
id: cdp_eval_in_frame_go_browser
name: cdp_eval_in_frame
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame."
tags: [cdp, browser, iframe, javascript, eval, navegator]
signature: "func CdpEvalInFrame(c *CDPConn, frameID string, expression string) (string, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_eval_in_frame.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
// Tomar el primer iframe (índice 1, el 0 es el frame raíz)
result, err := CdpEvalInFrame(conn, frames[1].ID, "document.title")
fmt.Println(result) // "Título del iframe"
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame donde ejecutar el JS; obtenido de CdpListFrames (campo CdpFrame.ID)."
- name: expression
desc: "Expresión JavaScript a evaluar en el contexto del frame; puede ser una expresión simple o una Promise."
output: "Resultado de la expresión serializado como string (fmt.Sprintf del valor CDP); error si la conexión es nula, el frameID está vacío, la comunicación CDP falla o la expresión lanza una excepción JS."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// frames[0] es el frame raíz; frames[1] sería el primer iframe
iframeID := frames[1].ID
title, err := CdpEvalInFrame(conn, iframeID, "document.title")
if err != nil {
log.Fatal(err)
}
fmt.Println("Título del iframe:", title)
// Leer un elemento del DOM del iframe
text, _ := CdpEvalInFrame(conn, iframeID, "document.querySelector('h1').innerText")
fmt.Println("H1 del iframe:", text)
```
## Cuando usarla
Cuando necesites leer o manipular el DOM de un iframe específico sin afectar el contexto JS de la página principal. Útil para extraer datos de iframes de terceros, formularios embebidos o widgets. Obtén el `frameID` con `CdpListFrames` antes de llamar a esta función.
## Gotchas
- El mundo aislado (`fn_registry_isolated`) puede leer el DOM del iframe pero NO accede a variables JS definidas en el page-world del iframe (ej. `window.miVariable`). Para acceder a variables JS del frame, evalúa sin `createIsolatedWorld` usando el `contextId` principal del frame (no expuesto por esta función).
- Requiere `Page.enable` (se llama internamente, idempotente).
- Si el iframe tiene `sandbox` attribute sin `allow-scripts`, el CDP puede crear el mundo aislado pero las evaluaciones fallarán con excepción de seguridad.
- Cross-origin iframes en Chrome permiten evaluación CDP siempre que la conexión tenga acceso al target; no aplican las restricciones CORS de JS normal.
- El `frameID` debe obtenerse con `CdpListFrames`; si se pasa un ID obsoleto (frame recargado o destruido), `createIsolatedWorld` retorna error.
+13 -1
View File
@@ -1,6 +1,7 @@
package browser
import (
"encoding/json"
"fmt"
)
@@ -44,5 +45,16 @@ func CdpEvaluate(c *CDPConn, expression string) (string, error) {
return typ, nil
}
return fmt.Sprintf("%v", value), nil
// Strings se devuelven tal cual (sin comillas). Objetos y arrays JS, que Chrome
// deserializa a map/slice cuando returnByValue=true, se serializan a JSON real
// en vez de la repr de Go de fmt.Sprintf("%v") (que produciria "map[a:1]" en lugar
// de {"a":1}). Asi el caller puede parsear datos estructurados.
if s, ok := value.(string); ok {
return s, nil
}
b, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value), nil
}
return string(b), nil
}
+5 -3
View File
@@ -25,8 +25,10 @@ type FindByTextOpts struct {
// - "#<id>" si el elemento tiene id.
// - path "tag:nth-of-type(n) > tag:nth-of-type(n) > ..." si no.
//
// Retorna ("", nil) si no encuentra nada (no es error). Error solo si la
// evaluacion JS rompe (conexion CDP caida).
// Retorna error si no encuentra ningun elemento con ese texto. Antes devolvia
// ("", nil) en silencio, lo que hacia que el caller creyera que habia encontrado
// algo y operara sobre un selector vacio. Tambien error si la evaluacion JS rompe
// (conexion CDP caida).
func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp find by text: conexion nula")
@@ -96,7 +98,7 @@ func CdpFindByText(c *CDPConn, text string, opts FindByTextOpts) (string, error)
// CdpEvaluate retorna el valor stringificado. Para "" devuelve cadena vacia.
res = strings.TrimSpace(res)
if res == "" || res == "<nil>" {
return "", nil
return "", fmt.Errorf("cdp find by text: no se encontro elemento con texto %q", text)
}
return res, nil
}
+63
View File
@@ -0,0 +1,63 @@
package browser
// CdpCookie representa una cookie del browser tal como la devuelve CDP.
type CdpCookie struct {
Name string `json:"name"`
Value string `json:"value"`
Domain string `json:"domain"`
Path string `json:"path"`
Expires float64 `json:"expires"`
HTTPOnly bool `json:"httpOnly"`
Secure bool `json:"secure"`
SameSite string `json:"sameSite"`
}
// cookieFromMap convierte un map[string]any CDP a CdpCookie con casts defensivos.
func cookieFromMap(m map[string]any) CdpCookie {
c := CdpCookie{}
if v, ok := m["name"].(string); ok {
c.Name = v
}
if v, ok := m["value"].(string); ok {
c.Value = v
}
if v, ok := m["domain"].(string); ok {
c.Domain = v
}
if v, ok := m["path"].(string); ok {
c.Path = v
}
if v, ok := m["expires"].(float64); ok {
c.Expires = v
}
if v, ok := m["httpOnly"].(bool); ok {
c.HTTPOnly = v
}
if v, ok := m["secure"].(bool); ok {
c.Secure = v
}
if v, ok := m["sameSite"].(string); ok {
c.SameSite = v
}
return c
}
// CdpGetCookies devuelve todas las cookies del browser via Network.getAllCookies.
// El caller puede filtrar por dominio, nombre, etc. sobre el slice retornado.
func CdpGetCookies(c *CDPConn) ([]CdpCookie, error) {
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return nil, err
}
result, err := c.sendCDP("Network.getAllCookies", nil)
if err != nil {
return nil, err
}
raw, _ := result["cookies"].([]any)
cookies := make([]CdpCookie, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
cookies = append(cookies, cookieFromMap(m))
}
}
return cookies, nil
}
+59
View File
@@ -0,0 +1,59 @@
---
id: cdp_get_cookies_go_browser
name: cdp_get_cookies
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve todas las cookies del browser via Network.getAllCookies; el caller filtra por dominio o nombre sobre el slice []CdpCookie."
tags: [cdp, browser, cookie, network, navegator]
signature: "func CdpGetCookies(c *CDPConn) ([]CdpCookie, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_cookies.go"
example: |
conn, _ := CdpConnect(9222)
cookies, err := CdpGetCookies(conn)
if err != nil { log.Fatal(err) }
for _, ck := range cookies {
if ck.Domain == "app.example.com" {
fmt.Printf("name=%s value=%s httpOnly=%v\n", ck.Name, ck.Value, ck.HTTPOnly)
}
}
params:
- name: c
desc: "Conexion CDP activa al browser (obtenida con CdpConnect)"
output: "Slice de CdpCookie con todas las cookies del browser; error si falla la comunicacion CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
cookies, err := CdpGetCookies(conn)
if err != nil {
log.Fatal(err)
}
for _, ck := range cookies {
if ck.Domain == "app.example.com" {
fmt.Printf("name=%s value=%s httpOnly=%v\n", ck.Name, ck.Value, ck.HTTPOnly)
}
}
```
## Cuando usarla
Usar cuando necesitas inspeccionar el estado de cookies del browser tras un login CDP, antes de propagarlas a otro contexto, o para auditar sesiones activas en tests e2e.
## Gotchas
- Llama `Network.enable` internamente antes de `getAllCookies`; es idempotente pero suma latencia en la primera llamada.
- `Network.getAllCookies` devuelve cookies de TODOS los dominios del browser, no solo la tab activa. Filtrar por `Domain` en el caller.
- Las cookies HttpOnly son visibles via CDP aunque no lo sean desde JavaScript del browser.
- `Expires == -1` indica cookie de sesion (sin fecha de expiración).
+23
View File
@@ -0,0 +1,23 @@
package browser
import (
"fmt"
)
// CdpGetFrameHTML retorna el HTML completo (outerHTML del documentElement) de un iframe
// especifico usando CdpEvalInFrame con la expresion "document.documentElement.outerHTML".
func CdpGetFrameHTML(c *CDPConn, frameID string) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get frame html: conexion nula")
}
if frameID == "" {
return "", fmt.Errorf("cdp get frame html: frameID vacio")
}
html, err := CdpEvalInFrame(c, frameID, "document.documentElement.outerHTML")
if err != nil {
return "", fmt.Errorf("cdp get frame html: %w", err)
}
return html, nil
}
+70
View File
@@ -0,0 +1,70 @@
---
id: cdp_get_frame_html_go_browser
name: cdp_get_frame_html
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Devuelve el HTML completo (document.documentElement.outerHTML) de un iframe concreto componiendo sobre CdpEvalInFrame con un mundo aislado CDP."
tags: [cdp, browser, iframe, html, scraping, navegator]
signature: "func CdpGetFrameHTML(c *CDPConn, frameID string) (string, error)"
uses_functions: [cdp_eval_in_frame_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_get_frame_html.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, _ := CdpListFrames(conn)
html, err := CdpGetFrameHTML(conn, frames[1].ID)
fmt.Println(html[:200]) // primeros 200 chars del HTML del iframe
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: frameID
desc: "ID del frame cuyo HTML se quiere obtener; obtenido de CdpListFrames (campo CdpFrame.ID)."
output: "String con el HTML completo del iframe (outerHTML del documentElement); error si la conexión es nula, el frameID está vacío o la evaluación CDP falla."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// 1. Listar frames para obtener el ID del iframe deseado
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
// frames[0] = frame raíz, frames[1] = primer iframe
for _, f := range frames {
if f.ParentID != "" { // es un iframe, no el raíz
html, err := CdpGetFrameHTML(conn, f.ID)
if err != nil {
log.Printf("error en frame %s: %v", f.ID, err)
continue
}
fmt.Printf("=== iframe %s (%s) ===\n%s\n", f.ID, f.URL, html[:min(500, len(html))])
}
}
```
## Cuando usarla
Cuando necesites el HTML completo de un iframe para parsearlo, scrapearlo o inspeccionarlo. Flujo típico: `CdpListFrames` → elegir frame por URL → `CdpGetFrameHTML` → parsear con `golang.org/x/net/html` o regexp.
## Gotchas
- El mundo aislado ve el DOM pero NO las variables JS del page-world del iframe; suficiente para leer `outerHTML` y hacer scraping estructural.
- `frameID` debe obtenerse de `CdpListFrames`; un ID obsoleto (frame recargado) provoca error en `CdpEvalInFrame`.
- Para iframes con contenido dinámico (renderizado por JS), espera a que el iframe termine de cargar antes de llamar a esta función; de lo contrario el HTML puede estar incompleto.
- En páginas con muchos iframes pesados, el outerHTML puede ser muy grande (MBs); considera evaluar selectores más específicos con `CdpEvalInFrame` si solo necesitas parte del DOM.
+54
View File
@@ -0,0 +1,54 @@
package browser
import (
"encoding/json"
"fmt"
"unicode/utf8"
)
// CdpGetText retorna el texto visible (innerText) de la pagina o de un elemento.
// Si selector es "" lee document.body.innerText completo.
// Si selector no matchea ningun elemento retorna error.
// Si maxBytes > 0 trunca al limite dado (corte rune-safe) y añade sufijo con total original.
// Si maxBytes <= 0 no hay limite.
func CdpGetText(c *CDPConn, selector string, maxBytes int) (string, error) {
if c == nil {
return "", fmt.Errorf("cdp get text: conexion nula")
}
var expr string
if selector == "" {
expr = `document.body ? document.body.innerText : ""`
} else {
// Escapa el selector como string JSON para evitar inyeccion via comillas/backslash.
selectorJSON, err := json.Marshal(selector)
if err != nil {
return "", fmt.Errorf("cdp get text: escapar selector: %w", err)
}
expr = fmt.Sprintf(
`(function(){var e=document.querySelector(%s); return e ? e.innerText : "__FN_GET_TEXT_NOTFOUND__";})()`,
string(selectorJSON),
)
}
text, err := CdpEvaluate(c, expr)
if err != nil {
return "", fmt.Errorf("cdp get text: %w", err)
}
if selector != "" && text == "__FN_GET_TEXT_NOTFOUND__" {
return "", fmt.Errorf("cdp get text: elemento no encontrado: %s", selector)
}
if maxBytes > 0 && len(text) > maxBytes {
total := len(text)
// Corte rune-safe: retrocede hasta encontrar un rune valido completo.
cut := maxBytes
for cut > 0 && !utf8.RuneStart(text[cut]) {
cut--
}
text = text[:cut] + fmt.Sprintf("\n…[truncado, total %d bytes]", total)
}
return text, nil
}
+59
View File
@@ -0,0 +1,59 @@
---
name: cdp_get_text
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpGetText(c *CDPConn, selector string, maxBytes int) (string, error)"
description: "Retorna el texto visible (innerText) de la pagina o de un elemento CSS, con truncado opcional. Alternativa compacta a cdp_get_html cuando solo se necesita el texto legible."
tags: [cdp, browser, read, perception, navegator]
uses_functions: [cdp_evaluate_go_browser]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, unicode/utf8]
params:
- name: c
desc: "Conexion CDP activa a una tab de Chrome. Debe estar conectada a una tab tipo 'page'."
- name: selector
desc: "Selector CSS del elemento del que leer el innerText. Si es cadena vacia, lee document.body.innerText (toda la pagina)."
- name: maxBytes
desc: "Limite maximo de bytes del texto retornado. Si es <= 0 no hay limite. Si el texto supera el limite, se trunca con corte rune-safe y se añade un sufijo con el total original."
output: "Texto visible del elemento o de toda la pagina. Si maxBytes > 0 y el texto supera el limite, retorna el texto truncado con sufijo '…[truncado, total N bytes]'. Error si el selector no matchea ningun elemento o si la conexion falla."
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_get_text.go"
---
## Ejemplo
```go
// Leer todo el body con limite de 20000 bytes (apto para LLM)
text, err := CdpGetText(conn, "", 20000)
if err != nil {
log.Fatal(err)
}
fmt.Println(text)
// Leer un elemento concreto sin limite
price, err := CdpGetText(conn, ".product-price", 0)
if err != nil {
// err contiene "elemento no encontrado: .product-price" si no existe en el DOM
log.Fatal(err)
}
fmt.Println(price)
```
## Cuando usarla
Para que un LLM lea el contenido de una pagina sin reventar su ventana de contexto. Preferir sobre `cdp_get_html` cuando solo necesitas el texto — innerText es 5-50x mas compacto que el HTML crudo. Usar `selector` para acotar a la seccion relevante (articulo, tabla, formulario) y `maxBytes` para garantizar el presupuesto de tokens.
## Gotchas
- `innerText` solo devuelve el texto de nodos visibles: elementos con `display:none` o `visibility:hidden` quedan excluidos. Si necesitas leer contenido oculto usa `cdp_get_html` y parsea.
- El truncado corta en boundary de rune pero puede partir a media frase o a medio parrafo. Si necesitas preservar estructura semantica, ajusta `maxBytes` con margen o usa el selector para acotar la region.
- Requiere conexion activa a una tab de tipo `page` (no `background_page`, no `service_worker`). Tabs en estado de carga pueden devolver texto parcial; esperar con `cdp_wait_load` si el contenido es dinamico.
- El selector se escapa via `json.Marshal` — caracteres especiales como comillas simples, backslash o comillas dobles en el selector CSS son seguros.
+35
View File
@@ -0,0 +1,35 @@
package browser
import "fmt"
// CdpHandleDialog instala un auto-handler que responde automaticamente a todos
// los dialogos JS (alert, confirm, prompt, beforeunload) hasta que se llame
// la funcion cancel devuelta. Usa el evento Page.javascriptDialogOpening y
// Page.handleJavaScriptDialog del protocolo CDP.
//
// IMPORTANTE: el handler interno despacha la respuesta en una goroutine nueva
// para evitar deadlock — el evento llega en la goroutine de lectura del
// WebSocket, y sendCDP bloquea esperando una respuesta que leeria esa misma
// goroutine si se llamara de forma sincrona.
func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error) {
if c == nil {
return nil, fmt.Errorf("cdp handle dialog: conexion nula")
}
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return nil, fmt.Errorf("cdp handle dialog: %w", err)
}
cancel := c.OnEvent("Page.javascriptDialogOpening", func(method string, params map[string]any) {
p := map[string]any{"accept": accept}
if promptText != "" {
p["promptText"] = promptText
}
// go es OBLIGATORIO: el handler corre en la goroutine de lectura del
// WebSocket. Llamar sendCDP aqui directamente provoca deadlock porque
// sendCDP espera una respuesta que la misma goroutine deberia leer.
go c.sendCDP("Page.handleJavaScriptDialog", p) //nolint:errcheck
})
return cancel, nil
}
+74
View File
@@ -0,0 +1,74 @@
---
id: cdp_handle_dialog_go_browser
name: cdp_handle_dialog
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
tests: []
test_file_path: ""
description: "Instala un auto-handler que responde automaticamente a dialogos JS (alert/confirm/prompt/beforeunload) via Page.javascriptDialogOpening CDP hasta que se llame el cancel devuelto."
tags: [cdp, browser, dialog, input, navegator]
signature: "func CdpHandleDialog(c *CDPConn, accept bool, promptText string) (func(), error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_handle_dialog.go"
example: |
// Aceptar automaticamente confirm() antes de navegar
cancel, _ := CdpHandleDialog(c, true, "")
defer cancel()
_ = CdpClick(c, "#delete-account-btn")
_ = CdpWaitIdle(c, 2000)
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: accept
desc: "true para aceptar/OK el dialogo; false para rechazar/Cancel. Para alert() el valor no importa (siempre se cierra), para confirm() determina el valor de retorno, para prompt() determina si se devuelve el texto o null."
- name: promptText
desc: "Texto a inyectar en dialogos prompt(). Vacio string para no inyectar texto. Ignorado en alert() y confirm()."
output: "cancel func() para des-registrar el handler cuando ya no se necesite, y error si la conexion es nula o Page.enable falla. El cancel devuelto es seguro llamarlo multiples veces."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
_ = CdpNavigate(conn, "https://example.com/admin")
_ = CdpWaitLoad(conn, 3000)
// Instalar handler antes de la accion que dispara el dialogo
cancel, err := CdpHandleDialog(conn, true, "")
if err != nil {
log.Fatal(err)
}
defer cancel()
// Este boton dispara confirm("¿Seguro que quieres borrar?")
// El handler lo acepta automaticamente sin bloquear
_ = CdpClick(conn, "#btn-delete-all")
_ = CdpWaitIdle(conn, 2000)
// Ejemplo con prompt(): responder con texto especifico
cancelPrompt, _ := CdpHandleDialog(conn, true, "mi-respuesta-secreta")
defer cancelPrompt()
_ = CdpClick(conn, "#btn-ask-password")
_ = CdpWaitIdle(conn, 1000)
```
## Cuando usarla
Instalar antes de cualquier accion que pueda disparar `alert()`, `confirm()`, `prompt()` o `beforeunload` en la pagina. Sin este handler, el dialogo bloquea el tab del navegador indefinidamente y todas las llamadas CDP siguientes se quedan colgadas esperando. Imprescindible en scraping de paneles de administracion, flujos de borrado con confirmacion, y paginas con `beforeunload` que pregunta si quieres salir.
## Gotchas
- DEADLOCK GARANTIZADO si se llama `sendCDP` de forma sincrona dentro del handler de evento. El handler corre en la goroutine de lectura del WebSocket; `sendCDP` espera una respuesta que esa misma goroutine deberia leer. La implementacion ya usa `go c.sendCDP(...)` para evitarlo — no modificar este patron.
- El handler se instala de forma permanente hasta que se llame `cancel()`. Si la pagina dispara multiples dialogos, todos seran respondidos con los mismos parametros `accept` y `promptText`.
- `Page.enable` es idempotente pero tiene coste de red; no llamar CdpHandleDialog en bucles tight.
- Para `beforeunload` (cuando el usuario cierra/navega fuera), `accept: true` permite la navegacion y `accept: false` la bloquea.
- Llamar `cancel()` no cierra dialogos ya abiertos; solo evita que los futuros sean respondidos automaticamente.
+19
View File
@@ -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)
}
+53
View File
@@ -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`.
+73
View File
@@ -0,0 +1,73 @@
package browser
import (
"fmt"
)
// CdpFrame representa un frame/iframe del arbol de navegacion.
type CdpFrame struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
URL string `json:"url"`
Name string `json:"name"`
}
// CdpListFrames lista todos los frames de la pagina actual (frame raiz + iframes anidados)
// usando Page.getFrameTree. Retorna el arbol aplanado con cada frame y su parentId.
func CdpListFrames(c *CDPConn) ([]CdpFrame, error) {
if c == nil {
return nil, fmt.Errorf("cdp list frames: conexion nula")
}
// Page.enable es idempotente; necesario para que Page.getFrameTree funcione
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return nil, fmt.Errorf("cdp list frames: Page.enable: %w", err)
}
result, err := c.sendCDP("Page.getFrameTree", nil)
if err != nil {
return nil, fmt.Errorf("cdp list frames: Page.getFrameTree: %w", err)
}
frameTree, ok := result["frameTree"].(map[string]any)
if !ok {
return nil, fmt.Errorf("cdp list frames: frameTree no encontrado en respuesta")
}
var frames []CdpFrame
frameFlatten(frameTree, "", &frames)
return frames, nil
}
// frameFlatten recorre recursivamente el arbol de frames CDP y acumula CdpFrame.
// parentID es el ID del nodo padre; el frame raiz lo recibe vacio.
func frameFlatten(node map[string]any, parentID string, acc *[]CdpFrame) {
frameData, ok := node["frame"].(map[string]any)
if !ok {
return
}
f := CdpFrame{
ID: stringField(frameData, "id"),
ParentID: parentID,
URL: stringField(frameData, "url"),
Name: stringField(frameData, "name"),
}
*acc = append(*acc, f)
// Recorrer hijos
children, _ := node["childFrames"].([]any)
for _, child := range children {
childNode, ok := child.(map[string]any)
if !ok {
continue
}
frameFlatten(childNode, f.ID, acc)
}
}
// stringField extrae un campo string de un map[string]any de forma segura.
func stringField(m map[string]any, key string) string {
v, _ := m[key].(string)
return v
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_list_frames_go_browser
name: cdp_list_frames
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Lista todos los frames/iframes de la pestaña activa usando Page.getFrameTree y devuelve el árbol aplanado con ID, parentID, URL y nombre de cada frame."
tags: [cdp, browser, iframe, frames, page, navegator]
signature: "func CdpListFrames(c *CDPConn) ([]CdpFrame, error)"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_list_frames.go"
example: |
conn, _ := CdpConnect("localhost", 9222, "")
frames, err := CdpListFrames(conn)
for _, f := range frames {
fmt.Printf("frame %s parent=%s url=%s\n", f.ID, f.ParentID, f.URL)
}
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect; apunta a la pestaña cuyo árbol de frames se quiere inspeccionar."
output: "Slice de CdpFrame con ID, ParentID, URL y Name de cada frame aplanado; error si la conexión es nula, Page.enable falla o la respuesta CDP es inesperada."
---
## Ejemplo
```go
conn, err := CdpConnect("localhost", 9222, "")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
frames, err := CdpListFrames(conn)
if err != nil {
log.Fatal(err)
}
for _, f := range frames {
fmt.Printf("id=%-40s parent=%-40s url=%s\n", f.ID, f.ParentID, f.URL)
}
// Salida ejemplo:
// id=ABCD1234 parent= url=https://example.com
// id=EFGH5678 parent=ABCD1234 url=https://ads.example.com/iframe
```
## Cuando usarla
Antes de evaluar JS en un iframe con `CdpEvalInFrame`: necesitas el `frameID` exacto que usa CDP, no el `src` del iframe. También útil para auditar la estructura de frames de una página o detectar iframes de terceros.
## Gotchas
- Requiere que la pestaña ya esté cargada; si se llama justo tras `CdpNavigate` en páginas con lazy-load de iframes, puede devolver un listado incompleto — espera a `Page.loadEventFired` o usa un breve delay.
- `Page.enable` se llama internamente (idempotente); no hace falta llamarlo manualmente antes.
- El frame raíz tiene `ParentID` vacío. Los iframes anidados tienen como `ParentID` el `ID` del frame contenedor.
- `Name` puede ser vacío si el `<iframe>` no tiene atributo `name`.
@@ -0,0 +1,98 @@
package browser
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// jsQuote serializa s como literal string JavaScript con comillas dobles y
// caracteres escapados correctamente. Usa json.Marshal internamente para
// reutilizar el mismo escapado que JSON (compatible con JS).
func jsQuote(s string) string {
b, err := json.Marshal(s)
if err != nil {
// Fallback seguro: comillas dobles escapando backslash y comilla doble
return fmt.Sprintf("%q", s)
}
return string(b)
}
// CdpLoadStorageState lee el JSON generado por CdpSaveStorageState y restaura
// cookies y localStorage en la pestaña activa. Permite retomar una sesion
// autenticada sin repetir el login.
//
// CRITICO: el localStorage es por-origen. Antes de llamar a esta funcion hay
// que haber navegado al origen correcto (CdpNavigate al dominio). Orden
// correcto: navegar -> CdpLoadStorageState -> recargar pagina.
func CdpLoadStorageState(c *CDPConn, inPath string) error {
if c == nil {
return fmt.Errorf("cdp load storage state: conexion nula")
}
if inPath == "" {
return fmt.Errorf("cdp load storage state: inPath vacio")
}
data, err := os.ReadFile(inPath)
if err != nil {
return fmt.Errorf("cdp load storage state: leer archivo: %w", err)
}
var state CdpStorageState
if err := json.Unmarshal(data, &state); err != nil {
return fmt.Errorf("cdp load storage state: unmarshal: %w", err)
}
// Habilitar dominio Network para manipular cookies
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return fmt.Errorf("cdp load storage state: Network.enable: %w", err)
}
// Restaurar cookies. Network.setCookies aplica de forma fiable las cookies
// (sobre todo httpOnly y de sesión) cuando cada una lleva el campo `url`: de
// ahí deriva scheme y scope. getAllCookies no lo incluye, así que lo
// sintetizamos a partir de domain/secure/path cuando falta.
if len(state.Cookies) > 0 {
for _, ck := range state.Cookies {
if _, has := ck["url"]; has {
continue
}
dom, _ := ck["domain"].(string)
dom = strings.TrimPrefix(dom, ".")
if dom == "" {
continue
}
scheme := "http"
if sec, _ := ck["secure"].(bool); sec {
scheme = "https"
}
path, _ := ck["path"].(string)
if path == "" {
path = "/"
}
ck["url"] = scheme + "://" + dom + path
}
if _, err := c.sendCDP("Network.setCookies", map[string]any{
"cookies": state.Cookies,
}); err != nil {
return fmt.Errorf("cdp load storage state: setCookies: %w", err)
}
}
// Restaurar localStorage y sessionStorage — setItem por cada par clave/valor
for k, v := range state.LocalStorage {
expr := fmt.Sprintf("window.localStorage.setItem(%s, %s)", jsQuote(k), jsQuote(v))
if _, err := CdpEvaluate(c, expr); err != nil {
return fmt.Errorf("cdp load storage state: localStorage setItem %q: %w", k, err)
}
}
for k, v := range state.SessionStorage {
expr := fmt.Sprintf("window.sessionStorage.setItem(%s, %s)", jsQuote(k), jsQuote(v))
if _, err := CdpEvaluate(c, expr); err != nil {
return fmt.Errorf("cdp load storage state: sessionStorage setItem %q: %w", k, err)
}
}
return nil
}
@@ -0,0 +1,67 @@
---
id: cdp_load_storage_state_go_browser
name: cdp_load_storage_state
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Restaura cookies y localStorage desde un archivo JSON (generado por CdpSaveStorageState) en la pestaña activa, reanudando una sesión autenticada sin repetir el login."
tags: [cdp, browser, storage, session, cookies, localStorage, auth, navegator]
signature: "func CdpLoadStorageState(c *CDPConn, inPath string) error"
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_load_storage_state.go"
example: |
conn, _ := CdpConnect(9222)
defer CdpClose(conn)
CdpNavigate(conn, "https://app.example.com")
CdpLoadStorageState(conn, "/tmp/session.json")
CdpNavigate(conn, "https://app.example.com") // reload para que la app lea el localStorage restaurado
params:
- name: c
desc: "Conexión CDP activa apuntando a la pestaña donde se restaurará el estado."
- name: inPath
desc: "Ruta del archivo JSON producido previamente por CdpSaveStorageState."
output: "nil si cookies y localStorage se restauraron correctamente; error con contexto si el archivo no existe, el JSON es inválido o falla algún comando CDP."
---
## Ejemplo
```go
conn, err := CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer CdpClose(conn)
// 1. Navegar al origen correcto ANTES de restaurar
CdpNavigate(conn, "https://app.example.com")
// 2. Restaurar cookies + localStorage
if err := CdpLoadStorageState(conn, "/tmp/session.json"); err != nil {
log.Fatal(err)
}
// 3. Recargar para que la app lea el localStorage restaurado
CdpNavigate(conn, "https://app.example.com")
// A partir de aquí la sesión está activa — no se necesitó login
```
## Cuando usarla
Al inicio de un script de scraping autenticado, después de `CdpNavigate` al dominio objetivo y antes de cualquier interacción. Sustituye el flujo de login cuando ya existe un archivo de estado guardado con `CdpSaveStorageState`.
## Gotchas
- **Orden obligatorio: navegar → load → reload**. El localStorage es por-origen: si llamas a esta función antes de navegar al dominio correcto, los `setItem` escriben en el origen equivocado (p.ej. `about:blank`) y la app no los ve. Secuencia correcta: `CdpNavigate(dominio)``CdpLoadStorageState(...)``CdpNavigate(dominio)` de nuevo.
- **Cookies globales del perfil**: `Network.setCookies` restaura todas las cookies del archivo, que pueden ser de múltiples dominios. Esto es el comportamiento esperado y compatible con cómo las guardó `CdpSaveStorageState`.
- **Archivo inexistente o corrupto**: la función devuelve error explícito; comprueba que el archivo existe antes de llamarla (por ejemplo con `os.Stat`) si quieres un fallback a login completo.
- **Sesión expirada**: restaurar el estado no renueva tokens del servidor. Si la sesión expiró (cookies caducadas, JWT vencido), la app redirigirá a login igualmente. En ese caso re-autentícate y vuelve a guardar el estado.
+67
View File
@@ -0,0 +1,67 @@
package browser
import (
"fmt"
)
// CdpNavBack retrocede una entrada en el historial de navegacion de la pestana activa.
// Obtiene el historial via Page.getNavigationHistory, calcula el indice anterior y
// navega a esa entrada via Page.navigateToHistoryEntry.
// Retorna error si ya estamos al inicio del historial.
func CdpNavBack(c *CDPConn) error {
if c == nil {
return fmt.Errorf("cdp nav back: conexion nula")
}
result, err := c.sendCDP("Page.getNavigationHistory", nil)
if err != nil {
return fmt.Errorf("cdp nav back: obtener historial: %w", err)
}
currentIndexRaw, ok := result["currentIndex"]
if !ok {
return fmt.Errorf("cdp nav back: respuesta sin currentIndex")
}
currentIndex, ok := currentIndexRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav back: currentIndex tipo inesperado: %T", currentIndexRaw)
}
entriesRaw, ok := result["entries"]
if !ok {
return fmt.Errorf("cdp nav back: respuesta sin entries")
}
entries, ok := entriesRaw.([]any)
if !ok {
return fmt.Errorf("cdp nav back: entries tipo inesperado: %T", entriesRaw)
}
idx := int(currentIndex) - 1
if idx < 0 {
return fmt.Errorf("cdp nav back: ya en el inicio del historial")
}
if idx >= len(entries) {
return fmt.Errorf("cdp nav back: indice %d fuera de rango (len=%d)", idx, len(entries))
}
entry, ok := entries[idx].(map[string]any)
if !ok {
return fmt.Errorf("cdp nav back: entrada[%d] tipo inesperado: %T", idx, entries[idx])
}
entryIDRaw, ok := entry["id"]
if !ok {
return fmt.Errorf("cdp nav back: entrada sin campo id")
}
entryIDFloat, ok := entryIDRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav back: entry id tipo inesperado: %T", entryIDRaw)
}
entryID := int(entryIDFloat)
_, err = c.sendCDP("Page.navigateToHistoryEntry", map[string]any{"entryId": entryID})
if err != nil {
return fmt.Errorf("cdp nav back: navegar a entrada %d: %w", entryID, err)
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
---
id: cdp_nav_back_go_browser
name: cdp_nav_back
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Retrocede una entrada en el historial de navegación de la pestaña activa via Page.getNavigationHistory + Page.navigateToHistoryEntry. Equivalente a pulsar el botón Atrás del navegador."
tags: [cdp, browser, navigation, navegator]
signature: "func CdpNavBack(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_nav_back.go"
example: |
conn, _ := browser.CdpConnect(9222)
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://example.com/paso1")
_ = browser.CdpNavigate(conn, "https://example.com/paso2")
// Volver a /paso1
if err := browser.CdpNavBack(conn); err != nil {
log.Println(err)
}
params:
- name: c
desc: "Conexión CDP activa obtenida de CdpConnect, apuntando a la pestaña que se quiere retroceder"
output: "nil si navegó correctamente a la entrada anterior; error si ya estamos al inicio del historial, la conexión es nula o el cast de tipos falla (respuesta CDP malformada)"
---
## Ejemplo
```go
conn, err := browser.CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://metabase.local/dashboard/1")
_ = browser.CdpNavigate(conn, "https://metabase.local/question/42")
// Volver al dashboard
if err := browser.CdpNavBack(conn); err != nil {
log.Printf("no se pudo retroceder: %v", err)
}
```
## Cuando usarla
Cuando un flujo de automatización navega por varias páginas y necesita volver atrás sin conocer la URL anterior. Útil en scraping de paginaciones o en flujos de formularios multipaso donde la URL destino no es predecible.
## Gotchas
- Navega dentro del historial de ESA pestaña CDP (sesión WS), no del perfil en disco ni del historial global de Chrome.
- Si `currentIndex == 0` (primer elemento del historial), retorna error "ya en el inicio del historial" — no es un fallo de red, es estado válido.
- Requiere que `Page` esté habilitado en la sesión; Chrome lo activa automáticamente con la mayoría de conexiones CDP, pero si usas una sesión muy restrictiva puede fallar.
- No espera a que la página de destino cargue; encadenar con `CdpWaitLoad` o `CdpWaitIdle` si necesitas esperar la carga completa.
+64
View File
@@ -0,0 +1,64 @@
package browser
import (
"fmt"
)
// CdpNavForward avanza una entrada en el historial de navegacion de la pestana activa.
// Obtiene el historial via Page.getNavigationHistory, calcula el indice siguiente y
// navega a esa entrada via Page.navigateToHistoryEntry.
// Retorna error si ya estamos al final del historial (no hay entradas adelante).
func CdpNavForward(c *CDPConn) error {
if c == nil {
return fmt.Errorf("cdp nav forward: conexion nula")
}
result, err := c.sendCDP("Page.getNavigationHistory", nil)
if err != nil {
return fmt.Errorf("cdp nav forward: obtener historial: %w", err)
}
currentIndexRaw, ok := result["currentIndex"]
if !ok {
return fmt.Errorf("cdp nav forward: respuesta sin currentIndex")
}
currentIndex, ok := currentIndexRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav forward: currentIndex tipo inesperado: %T", currentIndexRaw)
}
entriesRaw, ok := result["entries"]
if !ok {
return fmt.Errorf("cdp nav forward: respuesta sin entries")
}
entries, ok := entriesRaw.([]any)
if !ok {
return fmt.Errorf("cdp nav forward: entries tipo inesperado: %T", entriesRaw)
}
idx := int(currentIndex) + 1
if idx >= len(entries) {
return fmt.Errorf("cdp nav forward: ya en el final del historial")
}
entry, ok := entries[idx].(map[string]any)
if !ok {
return fmt.Errorf("cdp nav forward: entrada[%d] tipo inesperado: %T", idx, entries[idx])
}
entryIDRaw, ok := entry["id"]
if !ok {
return fmt.Errorf("cdp nav forward: entrada sin campo id")
}
entryIDFloat, ok := entryIDRaw.(float64)
if !ok {
return fmt.Errorf("cdp nav forward: entry id tipo inesperado: %T", entryIDRaw)
}
entryID := int(entryIDFloat)
_, err = c.sendCDP("Page.navigateToHistoryEntry", map[string]any{"entryId": entryID})
if err != nil {
return fmt.Errorf("cdp nav forward: navegar a entrada %d: %w", entryID, err)
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
---
id: cdp_nav_forward_go_browser
name: cdp_nav_forward
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Avanza una entrada en el historial de navegación de la pestaña activa via Page.getNavigationHistory + Page.navigateToHistoryEntry. Equivalente a pulsar el botón Adelante del navegador."
tags: [cdp, browser, navigation, navegator]
signature: "func CdpNavForward(c *CDPConn) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_nav_forward.go"
example: |
conn, _ := browser.CdpConnect(9222)
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://example.com/paso1")
_ = browser.CdpNavigate(conn, "https://example.com/paso2")
_ = browser.CdpNavBack(conn) // volver a /paso1
// Avanzar de nuevo a /paso2
if err := browser.CdpNavForward(conn); err != nil {
log.Println(err)
}
params:
- name: c
desc: "Conexión CDP activa obtenida de CdpConnect, apuntando a la pestaña que se quiere avanzar"
output: "nil si navegó correctamente a la entrada siguiente; error si ya estamos al final del historial, la conexión es nula o el cast de tipos falla (respuesta CDP malformada)"
---
## Ejemplo
```go
conn, err := browser.CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer browser.CdpClose(conn, 0)
_ = browser.CdpNavigate(conn, "https://metabase.local/dashboard/1")
_ = browser.CdpNavigate(conn, "https://metabase.local/question/42")
_ = browser.CdpNavBack(conn) // vuelve a /dashboard/1
// Avanzar de nuevo a /question/42
if err := browser.CdpNavForward(conn); err != nil {
log.Printf("no se pudo avanzar: %v", err)
}
```
## Cuando usarla
Cuando un flujo de automatización ha retrocedido con `CdpNavBack` y necesita volver a avanzar sin conocer la URL destino. Útil para recorrer un historial de páginas hacia adelante y hacia atrás de forma programática, por ejemplo en herramientas de replay de sesiones.
## Gotchas
- Navega dentro del historial de ESA pestaña CDP (sesión WS), no del perfil en disco ni del historial global de Chrome.
- Si `currentIndex` es el último elemento del historial (`currentIndex == len(entries) - 1`), retorna error "ya en el final del historial" — no es un fallo de red, es estado válido.
- El historial se trunca cuando se navega a una URL nueva estando en una entrada intermedia: las entradas "adelante" desaparecen, igual que en un navegador real.
- No espera a que la página de destino cargue; encadenar con `CdpWaitLoad` o `CdpWaitIdle` si necesitas esperar la carga completa.
+3 -2
View File
@@ -5,8 +5,9 @@ import (
)
// CdpNavigate navega a la URL indicada usando Page.navigate.
// Espera a que la carga este confirmada via Page.loadEventFired antes de retornar.
// El timeout de la navegacion es gestionado por Chrome internamente.
// NO espera a que la pagina cargue: retorna en cuanto Chrome acepta la navegacion
// (solo verifica que no haya errorText). Para esperar la carga real encadena
// despues CdpWaitLoad (document.readyState) o CdpWaitIdle (red en reposo).
func CdpNavigate(c *CDPConn, targetURL string) error {
if c == nil {
return fmt.Errorf("cdp navigate: conexion nula")
+69
View File
@@ -0,0 +1,69 @@
package browser
import "fmt"
// pressKeyEntry define los atributos CDP de una tecla especial.
type pressKeyEntry struct {
vk int
key string
code string
text string
}
// pressKeyTable mapea nombres de tecla a sus atributos CDP.
var pressKeyTable = map[string]pressKeyEntry{
"Enter": {vk: 13, key: "Enter", code: "Enter", text: "\r"},
"Tab": {vk: 9, key: "Tab", code: "Tab"},
"Escape": {vk: 27, key: "Escape", code: "Escape"},
"Backspace": {vk: 8, key: "Backspace", code: "Backspace"},
"Delete": {vk: 46, key: "Delete", code: "Delete"},
"ArrowUp": {vk: 38, key: "ArrowUp", code: "ArrowUp"},
"ArrowDown": {vk: 40, key: "ArrowDown", code: "ArrowDown"},
"ArrowLeft": {vk: 37, key: "ArrowLeft", code: "ArrowLeft"},
"ArrowRight": {vk: 39, key: "ArrowRight", code: "ArrowRight"},
"Home": {vk: 36, key: "Home", code: "Home"},
"End": {vk: 35, key: "End", code: "End"},
"PageUp": {vk: 33, key: "PageUp", code: "PageUp"},
"PageDown": {vk: 34, key: "PageDown", code: "PageDown"},
"Space": {vk: 32, key: " ", code: "Space", text: " "},
}
// CdpPressKey pulsa una tecla especial por nombre usando Input.dispatchKeyEvent.
// Soporta: Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft,
// ArrowRight, Home, End, PageUp, PageDown, Space.
// Actua sobre el elemento con foco activo en la pagina.
func CdpPressKey(c *CDPConn, keyName string) error {
if c == nil {
return fmt.Errorf("cdp press key: conexion nula")
}
entry, ok := pressKeyTable[keyName]
if !ok {
return fmt.Errorf("cdp press key: tecla no soportada: %s", keyName)
}
down := map[string]any{
"type": "keyDown",
"windowsVirtualKeyCode": entry.vk,
"key": entry.key,
"code": entry.code,
}
if entry.text != "" {
down["text"] = entry.text
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", down); err != nil {
return fmt.Errorf("cdp press key: keyDown %q: %w", keyName, err)
}
up := map[string]any{
"type": "keyUp",
"windowsVirtualKeyCode": entry.vk,
"key": entry.key,
"code": entry.code,
}
if _, err := c.sendCDP("Input.dispatchKeyEvent", up); err != nil {
return fmt.Errorf("cdp press key: keyUp %q: %w", keyName, err)
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
---
id: cdp_press_key_go_browser
name: cdp_press_key
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
tests: []
test_file_path: ""
description: "Pulsa una tecla especial por nombre via Input.dispatchKeyEvent CDP (Enter, Tab, Escape, flechas, etc.) sobre el elemento con foco activo."
tags: [cdp, browser, input, keyboard, navegator]
signature: "func CdpPressKey(c *CDPConn, keyName string) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_press_key.go"
example: |
// Enfocar un input y pulsar Enter para enviar el formulario
_ = CdpClick(c, "input[name='q']")
_ = CdpTypeText(c, "golang")
_ = CdpPressKey(c, "Enter")
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: keyName
desc: "Nombre de la tecla a pulsar. Valores soportados: Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, PageUp, PageDown, Space."
output: "nil si la tecla se despacho correctamente. Error si la conexion es nula, la tecla no esta en la tabla soportada, o CDP rechaza el evento."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
// Enfocar campo de busqueda, escribir y enviar con Enter
_ = CdpClick(conn, "input[name='q']")
_ = CdpTypeText(conn, "golang generics")
if err := CdpPressKey(conn, "Enter"); err != nil {
log.Fatal(err)
}
// Navegar en un desplegable con flechas
_ = CdpClick(conn, "#dropdown")
_ = CdpPressKey(conn, "ArrowDown")
_ = CdpPressKey(conn, "ArrowDown")
_ = CdpPressKey(conn, "Enter")
// Cerrar un modal con Escape
_ = CdpPressKey(conn, "Escape")
```
## Cuando usarla
Usar cuando necesites simular pulsaciones de teclas especiales sobre el elemento con foco: enviar formularios con Enter, navegar opciones con flechas, limpiar campos con Backspace/Delete, cerrar modales con Escape, o desplazarse con PageUp/PageDown. Para escribir texto normal usa CdpTypeText.
## Gotchas
- La tecla actua sobre el elemento con foco activo. Llama a CdpClick primero para enfocar el elemento objetivo.
- Teclas sin caracter imprimible (Tab, Escape, flechas, Home, End, PageUp, PageDown) no envian el campo "text" — Chrome lo requiere asi para distinguir navegacion de insercion.
- Enter envia `text: "\r"` que es lo que Chrome espera para confirmar formularios y autocompletados.
- Space envia `key: " "` y `text: " "` — funciona como barra espaciadora y como insercion de espacio en inputs.
- Si la tecla que necesitas no esta en la tabla, la funcion retorna error explicito en vez de silencio.
+120
View File
@@ -0,0 +1,120 @@
package browser
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// CdpStorageState agrupa cookies, localStorage y sessionStorage capturados de una
// pestaña activa.
type CdpStorageState struct {
Cookies []map[string]any `json:"cookies"`
LocalStorage map[string]string `json:"localStorage"`
SessionStorage map[string]string `json:"sessionStorage"`
}
// readWebStorage lee window.<store> (localStorage|sessionStorage) como mapa. Si el
// origen no permite acceso (about:blank, chrome://) devuelve un mapa vacío.
func readWebStorage(c *CDPConn, store string) map[string]string {
raw, err := CdpEvaluate(c, "JSON.stringify(Object.assign({}, window."+store+"))")
if err != nil {
return map[string]string{}
}
if raw == "" || raw == "undefined" || raw == "null" {
return map[string]string{}
}
var m map[string]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return map[string]string{}
}
return m
}
// cookieDomainMatchesHost indica si una cookie con `domain` aplica al `host` dado.
// Cubre el caso de dominios con punto inicial (".example.com") y subdominios.
func cookieDomainMatchesHost(domain, host string) bool {
if domain == "" || host == "" {
return false
}
d := strings.TrimPrefix(domain, ".")
return host == d || strings.HasSuffix(host, "."+d)
}
// storageStateToMaps convierte []any (respuesta CDP) a []map[string]any.
func storageStateToMaps(raw []any) []map[string]any {
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// CdpSaveStorageState captura cookies y localStorage de la pagina actual y los
// escribe como JSON a outPath. Permite restaurar la sesion autenticada en
// ejecuciones posteriores sin repetir el login.
func CdpSaveStorageState(c *CDPConn, outPath string) error {
if c == nil {
return fmt.Errorf("cdp save storage state: conexion nula")
}
if outPath == "" {
return fmt.Errorf("cdp save storage state: outPath vacio")
}
// Habilitar dominio Network para acceder a las cookies
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return fmt.Errorf("cdp save storage state: Network.enable: %w", err)
}
// Obtener todas las cookies del perfil
res, err := c.sendCDP("Network.getAllCookies", nil)
if err != nil {
return fmt.Errorf("cdp save storage state: getAllCookies: %w", err)
}
var cookies []map[string]any
if rawCookies, ok := res["cookies"].([]any); ok {
cookies = storageStateToMaps(rawCookies)
} else {
cookies = []map[string]any{}
}
// Filtrar al origen actual: Network.getAllCookies devuelve cookies de TODOS
// los dominios del perfil. Para guardar "la sesión de ESTE sitio" solo
// conservamos las que aplican al host cargado, evitando arrastrar cookies de
// otros sitios visitados en la misma sesión del navegador.
if host, herr := CdpEvaluate(c, "location.hostname"); herr == nil {
host = strings.TrimSpace(host)
if host != "" && host != "undefined" {
filtered := make([]map[string]any, 0, len(cookies))
for _, ck := range cookies {
dom, _ := ck["domain"].(string)
if cookieDomainMatchesHost(dom, host) {
filtered = append(filtered, ck)
}
}
cookies = filtered
}
}
// Capturar localStorage y sessionStorage del origen actualmente cargado.
state := CdpStorageState{
Cookies: cookies,
LocalStorage: readWebStorage(c, "localStorage"),
SessionStorage: readWebStorage(c, "sessionStorage"),
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("cdp save storage state: marshal: %w", err)
}
if err := os.WriteFile(outPath, data, 0644); err != nil {
return fmt.Errorf("cdp save storage state: escribir archivo: %w", err)
}
return nil
}
@@ -0,0 +1,62 @@
---
id: cdp_save_storage_state_go_browser
name: cdp_save_storage_state
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
description: "Captura cookies y localStorage de la página activa y los serializa a un archivo JSON para restaurar la sesión sin repetir el login."
tags: [cdp, browser, storage, session, cookies, localStorage, auth, navegator]
signature: "func CdpSaveStorageState(c *CDPConn, outPath string) error"
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_save_storage_state.go"
example: |
conn, _ := CdpConnect(9222)
defer CdpClose(conn)
CdpNavigate(conn, "https://app.example.com")
err := CdpSaveStorageState(conn, "/tmp/session.json")
params:
- name: c
desc: "Conexión CDP activa apuntando a la pestaña con la sesión autenticada."
- name: outPath
desc: "Ruta del archivo JSON de salida donde se escribirá el estado (cookies + localStorage)."
output: "nil si el archivo se escribió correctamente; error con contexto en caso de fallo de red, CDP o escritura."
---
## Ejemplo
```go
conn, err := CdpConnect(9222)
if err != nil {
log.Fatal(err)
}
defer CdpClose(conn)
// Navegar y autenticarse manualmente o con scraping
CdpNavigate(conn, "https://app.example.com/dashboard")
// Guardar estado de la sesión
if err := CdpSaveStorageState(conn, "/tmp/session.json"); err != nil {
log.Fatal(err)
}
// /tmp/session.json contiene cookies + localStorage listos para restaurar
```
## Cuando usarla
Tras completar un login en el browser (manual o automatizado), antes de cerrar la sesión o como paso final del script de autenticación. En la próxima ejecución, llama a `CdpLoadStorageState` en vez de repetir el flujo de login.
## Gotchas
- **localStorage es por-origen**: solo captura el localStorage del origen actualmente cargado en la pestaña. Si necesitas preservar localStorage de múltiples dominios, guarda un estado por cada dominio navegado.
- **Cookies globales del perfil**: `Network.getAllCookies` devuelve todas las cookies del perfil de Chrome, no solo las del origen activo. El JSON puede ser grande si el perfil tiene muchas cookies.
- **Páginas especiales** (`about:blank`, `chrome://`, extensiones): `CdpEvaluate` sobre localStorage fallará; la función lo maneja devolviendo un mapa vacío de forma defensiva, así que no romperá — pero el localStorage quedará vacío en el JSON.
- **Permisos**: el archivo se escribe con `0644`; asegúrate de que el directorio de destino existe antes de llamar a la función.
+26
View File
@@ -0,0 +1,26 @@
package browser
import "fmt"
// CdpScroll desplaza la pagina via rueda del raton usando Input.dispatchMouseEvent.
// deltaY positivo desplaza hacia abajo; deltaX positivo desplaza hacia la derecha.
// El evento se despacha en las coordenadas (100, 100) del viewport, que
// generalmente cae sobre el contenido principal de la pagina.
func CdpScroll(c *CDPConn, deltaX, deltaY float64) error {
if c == nil {
return fmt.Errorf("cdp scroll: conexion nula")
}
params := map[string]any{
"type": "mouseWheel",
"x": 100.0,
"y": 100.0,
"deltaX": deltaX,
"deltaY": deltaY,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", params); err != nil {
return fmt.Errorf("cdp scroll: %w", err)
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
---
id: cdp_scroll_go_browser
name: cdp_scroll
kind: function
lang: go
domain: browser
purity: impure
version: 1.0.0
tested: false
tests: []
test_file_path: ""
description: "Desplaza la pagina via rueda del raton con Input.dispatchMouseEvent type mouseWheel; imprescindible para scroll infinito en SPAs."
tags: [cdp, browser, input, scroll, navegator]
signature: "func CdpScroll(c *CDPConn, deltaX, deltaY float64) error"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
file_path: "functions/browser/cdp_scroll.go"
example: |
// Scroll hacia abajo 800px en una SPA con feed infinito
for i := 0; i < 5; i++ {
_ = CdpScroll(c, 0, 800)
_ = CdpWaitIdle(c, 1500)
}
params:
- name: c
desc: "Conexion CDP activa obtenida con CdpConnect."
- name: deltaX
desc: "Desplazamiento horizontal en pixeles. Positivo = derecha, negativo = izquierda. 0 para scroll solo vertical."
- name: deltaY
desc: "Desplazamiento vertical en pixeles. Positivo = hacia abajo, negativo = hacia arriba. Valores tipicos: 300-800 por paso."
output: "nil si el evento de scroll se despacho correctamente. Error si la conexion es nula o CDP rechaza el evento."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
_ = CdpNavigate(conn, "https://news.ycombinator.com")
_ = CdpWaitLoad(conn, 3000)
// Scroll hacia abajo en 5 pasos con pausa entre cada uno
for i := 0; i < 5; i++ {
if err := CdpScroll(conn, 0, 600); err != nil {
log.Fatal(err)
}
// Esperar que la SPA cargue nuevo contenido
_ = CdpWaitIdle(conn, 1500)
}
// Volver al inicio
_ = CdpScroll(conn, 0, -99999)
```
## Cuando usarla
Usar para cargar contenido de scroll infinito en SPAs (Twitter, LinkedIn, feeds), para desplazarse hasta elementos fuera del viewport antes de interactuar con ellos, o para simular lectura humana de una pagina. Combinar con CdpWaitIdle entre scrolls para dar tiempo a que el framework cargue nuevo contenido.
## Gotchas
- El evento se despacha en las coordenadas fijas (100, 100) del viewport. Si la pagina tiene un panel lateral o header que ocupa esa zona, el scroll puede no afectar al contenedor principal. En ese caso, evaluar `window.scrollBy(deltaX, deltaY)` via CdpEvaluate como alternativa.
- deltaY positivo = hacia abajo (igual que WheelEvent nativo del navegador).
- Para SPAs con scroll infinito es imprescindible llamar CdpWaitIdle despues de cada CdpScroll; sin la pausa, los scrolls consecutivos llegan antes de que el framework procese el primero.
- No hay garantia de que el scroll llegue al valor exacto de deltaY: el navegador puede aplicar aceleracion o limitar el desplazamiento al final del contenido.
+16
View File
@@ -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)
}
+51
View File
@@ -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.
+12
View File
@@ -2,6 +2,7 @@ package browser
import (
"fmt"
"strings"
"time"
)
@@ -13,6 +14,17 @@ func CdpTypeText(c *CDPConn, text string) error {
return fmt.Errorf("cdp type text: conexion nula")
}
// Verificar que hay un campo editable enfocado. Sin foco, los caracteres se
// pierden silenciosamente (van a document.body). Devolvemos error claro en vez
// de "escribir a la nada".
focus, ferr := CdpEvaluate(c, `(function(){var a=document.activeElement;if(!a)return 'none';var t=a.tagName.toLowerCase();return (t==='input'||t==='textarea'||t==='select'||a.isContentEditable)?'ok':t;})()`)
if ferr != nil {
return fmt.Errorf("cdp type text: verificar foco: %w", ferr)
}
if strings.TrimSpace(focus) != "ok" {
return fmt.Errorf("cdp type text: no hay campo de texto enfocado (activeElement: %s); usa CdpClick sobre el input primero", strings.TrimSpace(focus))
}
// keyDown (con `text`) ya inserta el caracter en el elemento focado en
// Chrome — enviar ademas un evento "char" lo duplicaba en sitios que
// reaccionan a `input` events (DuckDuckGo, Google, etc.). Patron
+8
View File
@@ -83,7 +83,15 @@ func defaultWindowsUserDataDir() (string, error) {
}
// chromePathsLinux lista los binarios Linux-nativos de Chrome en orden de preferencia.
// Las rutas absolutas a los binarios REALES van primero: saltan el wrapper
// /usr/bin/chromium (un script que inyecta los flags de /etc/chromium.d/*, p.ej.
// --user-data-dir y --remote-debugging-port globales que pisarian el aislamiento
// del navegador del agente). Si no existen, se cae a los nombres de PATH — que
// pueden resolver al wrapper, en cuyo caso el aislamiento depende de que nuestros
// flags vayan al final (Chrome usa el ultimo --user-data-dir duplicado).
var chromePathsLinux = []string{
"/usr/lib/chromium/chromium",
"/usr/lib/chromium-browser/chromium-browser",
"chromium",
"chromium-browser",
"google-chrome",
@@ -0,0 +1,165 @@
package browser
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// ProfileExtension holds metadata about a single Chrome/Chromium extension
// installed in a profile.
type ProfileExtension struct {
ID string
Name string
Version string
Location string // "unpacked" | "internal" | "component" | "external_policy" | "unknown"
Enabled bool
FromPolicy bool
}
// prefExtensionEntry mirrors the relevant fields of each entry in
// extensions.settings inside a Chromium Preferences file.
type prefExtensionEntry struct {
Manifest *struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"manifest"`
Location int `json:"location"`
State int `json:"state"`
}
// locationLabel maps Chromium location integers to human-readable strings.
func locationLabel(loc int) string {
switch loc {
case 1:
return "internal"
case 4:
return "unpacked"
case 5:
return "component"
case 7:
return "external_policy_download"
case 10:
return "external_policy"
default:
return "unknown"
}
}
// isFromPolicy returns true when the location integer indicates
// the extension was installed via enterprise policy.
func isFromPolicy(loc int) bool {
return loc == 5 || loc == 7 || loc == 10
}
// fallbackManifest attempts to read Name and Version from the on-disk
// Extensions/<id>/<version>/manifest.json file. Both return values may be
// empty strings if the file cannot be read or parsed.
func fallbackManifest(extDir, id string) (name, version string) {
idDir := filepath.Join(extDir, id)
vers, err := os.ReadDir(idDir)
if err != nil {
return "", ""
}
// There may be several version directories; use the first one found.
for _, v := range vers {
if !v.IsDir() {
continue
}
mPath := filepath.Join(idDir, v.Name(), "manifest.json")
data, err := os.ReadFile(mPath)
if err != nil {
continue
}
var m struct {
Name string `json:"name"`
Version string `json:"version"`
}
if json.Unmarshal(data, &m) == nil {
return m.Name, m.Version
}
}
return "", ""
}
// ListChromeProfileExtensions reads the Preferences file of a Chrome/Chromium
// profile and returns one ProfileExtension per entry found in
// extensions.settings.
//
// userDataDir is the user-data-dir of the browser (e.g. ~/.config/chromium).
// An empty string defaults to ~/.config/chromium.
//
// profileDir is the subdirectory name of the profile inside userDataDir
// (e.g. "Default", "Profile 1").
//
// The returned slice is sorted by ID (deterministic order).
// Returns an error if the Preferences file is missing or contains invalid JSON.
func ListChromeProfileExtensions(userDataDir, profileDir string) ([]ProfileExtension, error) {
if userDataDir == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
userDataDir = filepath.Join(home, ".config", "chromium")
}
prefPath := filepath.Join(userDataDir, profileDir, "Preferences")
data, err := os.ReadFile(prefPath)
if err != nil {
return nil, fmt.Errorf("list_chrome_profile_extensions: cannot read Preferences for profile %q: %w", profileDir, err)
}
// We only need extensions.settings; unmarshal into a minimal shape.
var prefs struct {
Extensions struct {
Settings map[string]prefExtensionEntry `json:"settings"`
} `json:"extensions"`
}
if err := json.Unmarshal(data, &prefs); err != nil {
return nil, fmt.Errorf("list_chrome_profile_extensions: invalid JSON in Preferences for profile %q: %w", profileDir, err)
}
extDir := filepath.Join(userDataDir, profileDir, "Extensions")
var result []ProfileExtension
for id, entry := range prefs.Extensions.Settings {
name := ""
version := ""
if entry.Manifest != nil {
name = entry.Manifest.Name
version = entry.Manifest.Version
}
// Fallback: try to read from the on-disk manifest.json.
if name == "" || version == "" {
fbName, fbVer := fallbackManifest(extDir, id)
if name == "" {
name = fbName
}
if version == "" {
version = fbVer
}
}
// state 1 = enabled, 0 = disabled; absent field defaults to enabled.
enabled := entry.State != 0
result = append(result, ProfileExtension{
ID: id,
Name: name,
Version: version,
Location: locationLabel(entry.Location),
Enabled: enabled,
FromPolicy: isFromPolicy(entry.Location),
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID
})
return result, nil
}
@@ -0,0 +1,75 @@
---
name: list_chrome_profile_extensions
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func ListChromeProfileExtensions(userDataDir, profileDir string) ([]ProfileExtension, error)"
description: "Lee las extensiones instaladas en un perfil de Chrome/Chromium parseando extensions.settings del archivo Preferences. Devuelve ID, Name, Version, Location (string legible), Enabled y FromPolicy para cada extensión. Si userDataDir es vacío usa ~/.config/chromium. Cuando falta el campo manifest en Preferences intenta leer el manifest.json desde el disco (Extensions/<id>/<ver>/manifest.json)."
tags: [chrome, chromium, browser, profile, extensions, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["encoding/json", "fmt", "os", "path/filepath", "sort"]
params:
- name: userDataDir
desc: "Ruta al user-data-dir de Chrome/Chromium (ej. ~/.config/chromium, ~/.config/google-chrome). Vacío = ~/.config/chromium."
- name: profileDir
desc: "Nombre del subdirectorio del perfil dentro de userDataDir (ej. 'Default', 'Profile 1'). Debe coincidir con el valor de --profile-directory del proceso Chrome."
output: "Slice de ProfileExtension ordenado por ID (orden determinista). Error si Preferences no existe o contiene JSON inválido. Slice vacío sin error si el perfil no tiene ninguna extensión registrada."
tested: true
tests:
- "dos extensiones con IDs ordenados y campos correctos"
- "extension con state 0 tiene Enabled false"
- "perfil sin Preferences devuelve error"
- "Preferences sin extensions.settings devuelve slice vacío sin error"
- "fallback a Extensions/<id>/<ver>/manifest.json cuando falta manifest en Preferences"
- "location 5 y 10 también son FromPolicy true"
- "Preferences con JSON inválido devuelve error"
test_file_path: "functions/browser/list_chrome_profile_extensions_test.go"
file_path: "functions/browser/list_chrome_profile_extensions.go"
---
## Ejemplo
```go
// Listar extensiones del perfil Default del Chromium del usuario
exts, err := browser.ListChromeProfileExtensions("", "Default")
if err != nil {
log.Fatal(err)
}
for _, e := range exts {
policy := ""
if e.FromPolicy {
policy = " [policy]"
}
enabled := "off"
if e.Enabled {
enabled = "on"
}
fmt.Printf("%s %-40s v%-12s %-24s %s%s\n",
e.ID, e.Name, e.Version, e.Location, enabled, policy)
}
// Output (ejemplo):
// dddbmnkl uBlock Origin Lite v1.0.2 external_policy_download on [policy]
// hklob123 My Dev Extension v0.1.0 unpacked on
// Con ruta explícita (Google Chrome)
exts, err = browser.ListChromeProfileExtensions("/home/user/.config/google-chrome", "Default")
```
## Cuando usarla
Antes de automatizar un perfil de Chrome/Chromium con CDP para auditar qué extensiones están activas, detectar extensiones instaladas por política (FromPolicy) o verificar que una extensión concreta está habilitada. También útil para depurar comportamientos inesperados del navegador causados por extensiones desconocidas.
## Gotchas
- **Preferences puede estar bloqueado mientras Chrome está abierto.** En la práctica Chrome escribe atómicamente el archivo y el bloqueo es brevísimo, pero si el proceso está escribiendo en ese instante `os.ReadFile` puede devolver datos parciales. Usar cuando Chrome no esté activo o tolerar reintento.
- **manifest.name puede ser una clave i18n** (`__MSG_appName__`). En ese caso el `Name` devuelto será esa clave, no el string localizado. Las extensiones empaquetadas en el repositorio de Chrome suelen tener el nombre resuelto directamente en el JSON, pero las extensiones no publicadas pueden usar i18n.
- **Extensions del sistema (location 5 = component) siempre tienen FromPolicy = true** aunque no vengan de una política de empresa; son extensiones internas del propio Chromium (PDF viewer, etc.).
- **Extensiones desinstaladas con estado de caché** pueden aparecer en `extensions.settings` con `state: 0` pero sin directorio en `Extensions/`. Esto es normal; `ListChromeProfileExtensions` las devuelve con `Enabled: false`.
- **Profile Directory ≠ Profile Name.** El parámetro `profileDir` debe ser el nombre del directorio (ej. `"Profile 1"`), que corresponde al `Dir` de `ChromeProfile` devuelto por `list_chrome_profiles_go_browser`.
- En Chrome (Google) el user-data-dir por defecto suele ser `~/.config/google-chrome`; en Chromium `~/.config/chromium`. Pasar ruta explícita si no usas Chromium.
@@ -0,0 +1,252 @@
package browser
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// writePreferences writes a synthetic Preferences JSON file into profileDir.
func writePreferences(t *testing.T, profileDir string, settings map[string]any) {
t.Helper()
prefs := map[string]any{
"extensions": map[string]any{
"settings": settings,
},
}
data, err := json.Marshal(prefs)
if err != nil {
t.Fatalf("json.Marshal Preferences: %v", err)
}
if err := os.WriteFile(filepath.Join(profileDir, "Preferences"), data, 0o600); err != nil {
t.Fatalf("WriteFile Preferences: %v", err)
}
}
func TestListChromeProfileExtensions(t *testing.T) {
t.Run("dos extensiones con IDs ordenados y campos correctos", func(t *testing.T) {
tmpDir := t.TempDir()
profilePath := filepath.Join(tmpDir, "Default")
os.MkdirAll(profilePath, 0o755)
settings := map[string]any{
// location 7 = external_policy_download, state 1 = enabled
"dddbmnkl": map[string]any{
"manifest": map[string]any{
"name": "uBlock Origin Lite",
"version": "1.0.2",
},
"location": 7,
"state": 1,
},
// location 4 = unpacked, state 1 = enabled
"aaabcdef": map[string]any{
"manifest": map[string]any{
"name": "My Dev Extension",
"version": "0.1.0",
},
"location": 4,
"state": 1,
},
}
writePreferences(t, profilePath, settings)
exts, err := ListChromeProfileExtensions(tmpDir, "Default")
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if len(exts) != 2 {
t.Fatalf("esperaba 2 extensiones, got %d", len(exts))
}
// Sorted by ID: "aaabcdef" < "dddbmnkl"
if exts[0].ID != "aaabcdef" {
t.Errorf("exts[0].ID = %q, want %q", exts[0].ID, "aaabcdef")
}
if exts[1].ID != "dddbmnkl" {
t.Errorf("exts[1].ID = %q, want %q", exts[1].ID, "dddbmnkl")
}
// Check names and versions
if exts[0].Name != "My Dev Extension" {
t.Errorf("exts[0].Name = %q, want %q", exts[0].Name, "My Dev Extension")
}
if exts[1].Name != "uBlock Origin Lite" {
t.Errorf("exts[1].Name = %q, want %q", exts[1].Name, "uBlock Origin Lite")
}
if exts[0].Version != "0.1.0" {
t.Errorf("exts[0].Version = %q, want %q", exts[0].Version, "0.1.0")
}
if exts[1].Version != "1.0.2" {
t.Errorf("exts[1].Version = %q, want %q", exts[1].Version, "1.0.2")
}
// FromPolicy: location 7 → true; location 4 → false
if exts[0].FromPolicy {
t.Errorf("exts[0] (unpacked): FromPolicy debe ser false")
}
if !exts[1].FromPolicy {
t.Errorf("exts[1] (external_policy_download): FromPolicy debe ser true")
}
// Location strings
if exts[0].Location != "unpacked" {
t.Errorf("exts[0].Location = %q, want %q", exts[0].Location, "unpacked")
}
if exts[1].Location != "external_policy_download" {
t.Errorf("exts[1].Location = %q, want %q", exts[1].Location, "external_policy_download")
}
// Both enabled
if !exts[0].Enabled {
t.Error("exts[0]: Enabled debe ser true")
}
if !exts[1].Enabled {
t.Error("exts[1]: Enabled debe ser true")
}
})
t.Run("extension con state 0 tiene Enabled false", func(t *testing.T) {
tmpDir := t.TempDir()
profilePath := filepath.Join(tmpDir, "Default")
os.MkdirAll(profilePath, 0o755)
settings := map[string]any{
"extdisabled": map[string]any{
"manifest": map[string]any{
"name": "Disabled Ext",
"version": "2.0.0",
},
"location": 1,
"state": 0,
},
}
writePreferences(t, profilePath, settings)
exts, err := ListChromeProfileExtensions(tmpDir, "Default")
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if len(exts) != 1 {
t.Fatalf("esperaba 1 extensión, got %d", len(exts))
}
if exts[0].Enabled {
t.Error("Enabled debe ser false cuando state=0")
}
})
t.Run("perfil sin Preferences devuelve error", func(t *testing.T) {
tmpDir := t.TempDir()
os.MkdirAll(filepath.Join(tmpDir, "Default"), 0o755)
// No Preferences file created.
_, err := ListChromeProfileExtensions(tmpDir, "Default")
if err == nil {
t.Error("esperaba error al faltar Preferences, got nil")
}
})
t.Run("Preferences sin extensions.settings devuelve slice vacío sin error", func(t *testing.T) {
tmpDir := t.TempDir()
profilePath := filepath.Join(tmpDir, "Default")
os.MkdirAll(profilePath, 0o755)
// Write a Preferences with no extensions key at all.
if err := os.WriteFile(filepath.Join(profilePath, "Preferences"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
exts, err := ListChromeProfileExtensions(tmpDir, "Default")
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if len(exts) != 0 {
t.Errorf("esperaba slice vacío, got %d elementos", len(exts))
}
})
t.Run("fallback a Extensions/<id>/<ver>/manifest.json cuando falta manifest en Preferences", func(t *testing.T) {
tmpDir := t.TempDir()
profilePath := filepath.Join(tmpDir, "Default")
os.MkdirAll(profilePath, 0o755)
const extID = "fallbackext"
const extVer = "3.1.0"
// Preferences entry without a manifest field.
settings := map[string]any{
extID: map[string]any{
"location": 4,
"state": 1,
// no "manifest" key
},
}
writePreferences(t, profilePath, settings)
// Create the on-disk manifest.json.
manifestDir := filepath.Join(profilePath, "Extensions", extID, extVer)
os.MkdirAll(manifestDir, 0o755)
manifestData, _ := json.Marshal(map[string]any{
"name": "Fallback Extension",
"version": extVer,
})
os.WriteFile(filepath.Join(manifestDir, "manifest.json"), manifestData, 0o600)
exts, err := ListChromeProfileExtensions(tmpDir, "Default")
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if len(exts) != 1 {
t.Fatalf("esperaba 1 extensión, got %d", len(exts))
}
if exts[0].Name != "Fallback Extension" {
t.Errorf("Name = %q, want %q", exts[0].Name, "Fallback Extension")
}
if exts[0].Version != extVer {
t.Errorf("Version = %q, want %q", exts[0].Version, extVer)
}
})
t.Run("location 5 y 10 también son FromPolicy true", func(t *testing.T) {
tmpDir := t.TempDir()
profilePath := filepath.Join(tmpDir, "Default")
os.MkdirAll(profilePath, 0o755)
settings := map[string]any{
"comp0000": map[string]any{
"manifest": map[string]any{"name": "Component Ext", "version": "1.0"},
"location": 5,
"state": 1,
},
"poli0000": map[string]any{
"manifest": map[string]any{"name": "Policy Ext", "version": "1.0"},
"location": 10,
"state": 1,
},
}
writePreferences(t, profilePath, settings)
exts, err := ListChromeProfileExtensions(tmpDir, "Default")
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
for _, ext := range exts {
if !ext.FromPolicy {
t.Errorf("extensión %q (location component/policy) debe tener FromPolicy=true", ext.ID)
}
}
})
t.Run("Preferences con JSON inválido devuelve error", func(t *testing.T) {
tmpDir := t.TempDir()
profilePath := filepath.Join(tmpDir, "Default")
os.MkdirAll(profilePath, 0o755)
os.WriteFile(filepath.Join(profilePath, "Preferences"), []byte(`{invalid json`), 0o600)
_, err := ListChromeProfileExtensions(tmpDir, "Default")
if err == nil {
t.Error("esperaba error con JSON inválido, got nil")
}
})
}
@@ -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`.
+173
View File
@@ -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)