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