feat(browser): funciones anti-deteccion + perfiles para web_scraping

Funciones nuevas del dominio browser (grupo navegator):
- cdp_move_mouse_human / cdp_click_human: movimiento de raton con curva
  de Bezier cubica, easing y micro-jitter para imitar comportamiento
  humano y reducir deteccion de automatizacion.
- cdp_wait_idle: espera network-idle contando requests en vuelo via
  eventos CDP Network.*; inmune a extensiones que mutan el DOM
  (Dark Reader, uBlock) y a animaciones JS.
- list_chrome_profiles: lista perfiles de un user-data-dir (extensiones,
  nombre legible, preferencias).
- prepare_chrome_profile (bash): clona un user-data-dir conservando solo
  una whitelist de extensiones (default uBlock Origin Lite).

Modificadas:
- chrome_launch: Linux-first (chromium/google-chrome/brave antes que
  chrome.exe), KeepExtensions y Setpgid para matar el arbol con cdp_close.
- cdp_close: kill por grupo de proceso.

Todas con tests verdes (go test ./functions/browser ok).
This commit is contained in:
Egutierrez
2026-06-05 16:25:11 +02:00
parent 729921e16e
commit ccfa5bc78b
17 changed files with 1603 additions and 45 deletions
@@ -0,0 +1,74 @@
---
name: prepare_chrome_profile
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> [--keep <ext_id>]... [--force]"
description: "Clona un user-data-dir de Chrome/Chromium creando un perfil de scraping limpio: conserva solo las extensiones de una lista blanca (por defecto uBlock Origin Lite) y excluye caché, locks y sesiones antiguas."
tags: [chrome, browser, profile, scraping, extensions, navegator]
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/prepare_chrome_profile.sh"
params:
- name: --src
desc: "user-data-dir origen con un perfil Chrome/Chromium ya configurado (debe existir --src/Default)"
- name: --dst
desc: "Ruta de destino del nuevo perfil; no debe existir salvo que se pase --force"
- name: --keep
desc: "ID de extensión Chrome a conservar (repetible). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
- name: --force
desc: "Borra --dst si existe antes de recrearlo. Sin este flag la función aborta si --dst ya existe"
output: "JSON en stdout: {dst, kept: [id...], removed: [id...]}. Exit 0 en éxito."
---
## Ejemplo
```bash
source $HOME/fn_registry/bash/functions/browser/prepare_chrome_profile.sh
prepare_chrome_profile \
--src "$HOME/.config/chromium" \
--dst "$HOME/.local/share/web_scraping/chrome-profile"
# Con extensión adicional conservada
prepare_chrome_profile \
--src "$HOME/.config/chromium" \
--dst "$HOME/.local/share/web_scraping/chrome-profile" \
--keep "ddkjiahejlhfcafbddmgiahcphecmpfh" \
--keep "cjpalhdlnbpafiamejdnhcphjbkeiagm" \
--force
# Salida esperada (ejemplo):
# {"dst":"/home/enmanuel/.local/share/web_scraping/chrome-profile","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["abcdefghijklmnopabcdefghijklmnop","dark-reader-id"]}
```
## Cuando usarla
Úsala antes de lanzar una sesión de scraping/automatización para partir de un perfil aislado: con uBlock Origin Lite activo (menos anuncios/trackers = DOM más limpio, respuestas más rápidas) pero sin extensiones que interfieren (Dark Reader muta colores del DOM, NoScript bloquea JS, OneTab modifica tabs). También sirve para aislar sesiones de diferentes proyectos de scraping sin contaminar el perfil personal.
## Gotchas
- **Chrome debe estar CERRADO sobre `--src`** antes de ejecutar. Los archivos SQLite (`Cookies`, `History`, `Login Data`, etc.) estarán bloqueados si Chrome está abierto, y `rsync` copiará versiones inconsistentes. Verificar con `pgrep -x chromium` o `pgrep -x chrome`.
- **HMAC de Secure Preferences**: el archivo `Local State` contiene la semilla HMAC que Chrome usa para verificar `Preferences` y `Secure Preferences`. Si no se copia (o se copia entre máquinas distintas con distinto binding), Chrome puede invalidar las extensiones al arrancar y resetear configuraciones. La función copia `Local State` automáticamente, pero la copia entre máquinas puede seguir produciendo resets de extensiones — esto es comportamiento esperado de Chrome, no un bug de esta función.
- **Purga de referencias en Preferences**: tras borrar las carpetas de extensiones fuera de la whitelist, la función también elimina con `python3` las entradas `extensions.settings.<id>` de `Default/Preferences` y `Default/Secure Preferences`, los IDs de `extensions.pinned_extensions` y las claves `protection.macs.extensions.settings.<id>`. Sin esta limpieza Chrome detecta las entradas en Preferences (con `from_webstore`/install_source) y **vuelve a descargar la extensión del Web Store al arrancar**, deshaciendo el filtrado (caso real: Dark Reader reaparece y oscurece páginas rompiendo screenshots). Si `python3` falla al procesar un Preferences concreto se emite un warning a stderr pero la función no aborta — el borrado de carpetas ya es el efecto principal.
- **`--force` borra `--dst` completamente**: si `--dst` es un perfil con datos que quieres conservar, no uses `--force` sin antes hacer backup.
- **Extensiones instaladas desde Web Store vs unpacked**: esta función opera sobre la carpeta `Extensions/` física. Las extensiones instaladas desde la Web Store tienen IDs de 32 caracteres en minúsculas. Las extensiones unpacked (`--load-extension`) no viven en `Extensions/` y no se ven afectadas.
## Exit codes
| Código | Significado |
|--------|------------|
| 0 | Éxito |
| 1 | Argumento inválido o `--src/Default` no existe |
| 2 | `--dst` ya existe y no se pasó `--force` |
| 3 | `--src` y `--dst` resuelven al mismo path real |
| 4 | Error durante `rsync` |
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# prepare_chrome_profile — clona un user-data-dir de Chrome/Chromium conservando solo
# las extensiones de una lista blanca. Sirve para perfiles de scraping limpios.
set -euo pipefail
# ── defaults ──────────────────────────────────────────────────────────────────
_SRC=""
_DST=""
_FORCE=0
# uBlock Origin Lite por defecto
_KEEP=()
_DEFAULT_EXT="ddkjiahejlhfcafbddmgiahcphecmpfh"
# ── parse args ────────────────────────────────────────────────────────────────
_usage() {
cat >&2 <<'EOF'
Usage: prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> \
[--keep <ext_id>]... [--force]
--src user-data-dir origen (ej. $HOME/.config/chromium)
--dst user-data-dir destino a crear
--keep ID de extensión a conservar (repetible). Default: uBlock Origin Lite
--force si --dst existe, lo borra y recrea; sin flag aborta si existe
Exit codes:
0 éxito
1 error de argumento o validación
2 --dst ya existe y no se pasó --force
3 --src igual a --dst (mismo path real)
4 error de copia/rsync
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--src) _SRC="$2"; shift 2 ;;
--dst) _DST="$2"; shift 2 ;;
--keep) _KEEP+=("$2"); shift 2 ;;
--force) _FORCE=1; shift ;;
-h|--help) _usage ;;
*) echo "prepare_chrome_profile: argumento desconocido: $1" >&2; _usage ;;
esac
done
# ── validaciones básicas ──────────────────────────────────────────────────────
if [[ -z "$_SRC" || -z "$_DST" ]]; then
echo "prepare_chrome_profile: --src y --dst son obligatorios" >&2
exit 1
fi
if [[ ! -d "$_SRC/Default" ]]; then
echo "prepare_chrome_profile: $_SRC/Default no existe; ¿es un user-data-dir válido?" >&2
exit 1
fi
# Resolver paths reales para comparar (evitar borrar src cuando src==dst)
_SRC_REAL="$(realpath "$_SRC")"
_DST_REAL="$(realpath -m "$_DST")" # -m: no requiere que exista
if [[ "$_SRC_REAL" == "$_DST_REAL" ]]; then
echo "prepare_chrome_profile: --src y --dst resuelven al mismo path: $_SRC_REAL" >&2
exit 3
fi
# También rechazar si --dst es prefijo de --src (evitar borrar el origen)
if [[ "$_SRC_REAL" == "$_DST_REAL"/* ]]; then
echo "prepare_chrome_profile: --src está dentro de --dst; operación peligrosa, abortando" >&2
exit 3
fi
# ── lista blanca de extensiones ───────────────────────────────────────────────
if [[ ${#_KEEP[@]} -eq 0 ]]; then
_KEEP=("$_DEFAULT_EXT")
fi
# ── gestionar destino ─────────────────────────────────────────────────────────
if [[ -d "$_DST" ]]; then
if [[ $_FORCE -eq 1 ]]; then
rm -rf "$_DST"
else
echo "prepare_chrome_profile: $_DST ya existe; usa --force para sobreescribir" >&2
exit 2
fi
fi
mkdir -p "$_DST/Default"
# ── copiar Local State (HMAC seed para Secure Preferences) ────────────────────
if [[ -f "$_SRC/Local State" ]]; then
cp "$_SRC/Local State" "$_DST/Local State"
fi
# ── rsync del perfil Default excluyendo caché y locks ─────────────────────────
rsync -a \
--exclude='Cache/' \
--exclude='Code Cache/' \
--exclude='GPUCache/' \
--exclude='Dawn Cache/' \
--exclude='DawnGraphiteCache/' \
--exclude='DawnWebGPUCache/' \
--exclude='Service Worker/CacheStorage/' \
--exclude='Service Worker/ScriptCache/' \
--exclude='Singleton*' \
--exclude='*.lock' \
--exclude='lockfile' \
--exclude='Sessions/' \
--exclude='Session Storage/' \
--exclude='Current Session' \
--exclude='Current Tabs' \
--exclude='Last Session' \
--exclude='Last Tabs' \
"$_SRC/Default/" "$_DST/Default/" || {
echo "prepare_chrome_profile: rsync falló (exit $?)" >&2
exit 4
}
# ── eliminar extensiones fuera de la lista blanca ────────────────────────────
_EXT_DIR="$_DST/Default/Extensions"
_removed=()
_kept=()
if [[ -d "$_EXT_DIR" ]]; then
while IFS= read -r -d '' ext_path; do
ext_id="$(basename "$ext_path")"
# Conservar siempre la carpeta Temp (usada por Chrome durante installs)
if [[ "$ext_id" == "Temp" ]]; then
continue
fi
# Comprobar si está en la lista blanca
_in_keep=0
for keep_id in "${_KEEP[@]}"; do
if [[ "$ext_id" == "$keep_id" ]]; then
_in_keep=1
break
fi
done
if [[ $_in_keep -eq 1 ]]; then
_kept+=("$ext_id")
else
rm -rf "$ext_path"
_removed+=("$ext_id")
fi
done < <(find "$_EXT_DIR" -mindepth 1 -maxdepth 1 -type d -print0)
fi
# ── purgar referencias a extensiones eliminadas en Preferences ───────────────
# Chrome re-descarga del Web Store cualquier extensión que aparezca en
# extensions.settings aunque su carpeta haya sido borrada. Editamos el JSON
# con python3 para evitar ese comportamiento.
if [[ ${#_removed[@]} -gt 0 ]]; then
# Construir lista Python de IDs eliminados
_py_ids_list=""
for _id in "${_removed[@]}"; do
_py_ids_list+="\"${_id}\","
done
_py_ids_list="[${_py_ids_list%,}]"
for _prefs_file in "$_DST/Default/Preferences" "$_DST/Default/Secure Preferences"; do
if [[ -f "$_prefs_file" ]]; then
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
echo "prepare_chrome_profile: advertencia — no se pudieron purgar refs en $(basename "$_prefs_file")" >&2
import sys, json
prefs_path = sys.argv[1]
removed_ids = json.loads(sys.argv[2])
with open(prefs_path, "r", encoding="utf-8") as f:
data = json.load(f)
# 1. extensions.settings.<id>
ext_settings = data.get("extensions", {}).get("settings", {})
for ext_id in removed_ids:
ext_settings.pop(ext_id, None)
# 2. extensions.pinned_extensions (lista de IDs)
pinned = data.get("extensions", {}).get("pinned_extensions", None)
if isinstance(pinned, list):
data["extensions"]["pinned_extensions"] = [
pid for pid in pinned if pid not in removed_ids
]
# 3. protection.macs.extensions.settings.<id> (Secure Preferences)
try:
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
for ext_id in removed_ids:
mac_ext.pop(ext_id, None)
except (KeyError, TypeError):
pass
with open(prefs_path, "w", encoding="utf-8") as f:
json.dump(data, f, separators=(",", ":"))
PY
fi
done
fi
# ── emitir resultado JSON ─────────────────────────────────────────────────────
_json_array() {
# Convierte array bash en JSON array de strings
local arr=("$@")
local out="["
local first=1
for item in "${arr[@]}"; do
if [[ $first -eq 1 ]]; then
out+="\"$item\""
first=0
else
out+=",\"$item\""
fi
done
out+="]"
echo "$out"
}
_kept_json="$(_json_array "${_kept[@]+"${_kept[@]}"}")"
_removed_json="$(_json_array "${_removed[@]+"${_removed[@]}"}")"
printf '{"dst":"%s","kept":%s,"removed":%s}\n' \
"$_DST_REAL" \
"$_kept_json" \
"$_removed_json"
+116
View File
@@ -0,0 +1,116 @@
package browser
import (
"fmt"
"math/rand"
"strings"
"time"
)
// CdpClickHuman hace click en el elemento identificado por selector CSS con
// movimiento humano: obtiene el bbox, calcula un punto destino ligeramente
// desplazado del centro, mueve el ratón por una trayectoria de Bézier cúbica
// y luego despacha mousePressed/mouseReleased con una micro-pausa entre ellos.
//
// opts controla la trayectoria del movimiento previo al click.
// Para configurar el origen del movimiento usa opts.FromX / opts.FromY.
func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp click human: conexion nula")
}
// Obtener bounding box del selector
js := fmt.Sprintf(`(function() {
var el = document.querySelector(%q);
if (!el) return null;
var r = el.getBoundingClientRect();
return JSON.stringify({x: r.left, y: r.top, w: r.width, h: r.height});
})()`, selector)
bboxStr, err := CdpEvaluate(c, js)
if err != nil {
return fmt.Errorf("cdp click human: obtener bbox de %q: %w", selector, err)
}
if bboxStr == "" || bboxStr == "null" {
return fmt.Errorf("cdp click human: elemento %q no encontrado en el DOM", selector)
}
bboxStr = strings.Trim(bboxStr, `"`)
bx, by, bw, bh, err := parseBbox(bboxStr)
if err != nil {
return fmt.Errorf("cdp click human: parsear bbox %q: %w", bboxStr, err)
}
// Scroll al elemento para que sea visible
scrollJS := fmt.Sprintf(`document.querySelector(%q).scrollIntoView({block:'center'})`, selector)
if _, err := CdpEvaluate(c, scrollJS); err != nil {
_ = err // no fatal
}
// Punto destino: centro + pequeño offset aleatorio (±15% del tamaño)
offX := (rand.Float64()*2 - 1) * bw * 0.15
offY := (rand.Float64()*2 - 1) * bh * 0.15
toX := bx + bw/2 + offX
toY := by + bh/2 + offY
// Mover el ratón con trayectoria humana
if err := CdpMoveMouseHuman(c, toX, toY, opts); err != nil {
return fmt.Errorf("cdp click human: mover raton: %w", err)
}
// mousePressed
clickParams := map[string]any{
"type": "mousePressed",
"x": toX,
"y": toY,
"button": "left",
"clickCount": 1,
}
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click human: mousePressed: %w", err)
}
// Micro-pausa humana entre press y release (3090 ms)
pauseMs := 30 + rand.Intn(61)
time.Sleep(time.Duration(pauseMs) * time.Millisecond)
// mouseReleased
clickParams["type"] = "mouseReleased"
if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil {
return fmt.Errorf("cdp click human: mouseReleased: %w", err)
}
return nil
}
// parseBbox extrae left, top, width, height de un JSON como {"x":10,"y":20,"w":100,"h":40}.
func parseBbox(s string) (left, top, width, height float64, err error) {
// Reutiliza el mismo parser manual que parseCoords para evitar encoding/json
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "{")
s = strings.TrimSuffix(s, "}")
for part := range strings.SplitSeq(s, ",") {
kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
if len(kv) != 2 {
continue
}
k := strings.Trim(strings.TrimSpace(kv[0]), `"`)
var v float64
if _, e := fmt.Sscanf(strings.TrimSpace(kv[1]), "%f", &v); e != nil {
err = fmt.Errorf("parsear valor %q: %w", kv[1], e)
return
}
switch k {
case "x":
left = v
case "y":
top = v
case "w":
width = v
case "h":
height = v
}
}
return
}
+72
View File
@@ -0,0 +1,72 @@
---
name: cdp_click_human
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpClickHuman(c *CDPConn, selector string, opts MouseHumanOpts) error"
description: "Hace click en el elemento identificado por selector CSS con comportamiento humano: obtiene el bounding box, calcula un destino ligeramente desplazado del centro, mueve el ratón con CdpMoveMouseHuman (curva de Bézier cúbica + easing + jitter) y despacha mousePressed/mouseReleased con micro-pausa de 30-90 ms entre ellos."
tags: [cdp, chrome, browser, mouse, human, click, navegator]
uses_functions:
- cdp_evaluate_go_browser
- cdp_move_mouse_human_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- fmt
- math/rand
- strings
- time
tested: false
tests: []
test_file_path: ""
file_path: "functions/browser/cdp_click_human.go"
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: selector
desc: "Selector CSS del elemento a clickear (ej. '#submit-btn', '.nav-item:first-child')."
- name: opts
desc: "MouseHumanOpts que controla la trayectoria del movimiento previo. Usa opts.FromX/FromY para definir el origen del movimiento (default 0,0)."
output: "error si la conexión es nula, el elemento no existe en el DOM, o falla algún evento CDP."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
defer CdpClose(conn, 0)
CdpNavigate(conn, "https://example.com/login")
CdpWaitElement(conn, "#username", 5*time.Second)
// Click humano en el campo usuario desde la esquina superior izquierda
err := CdpClickHuman(conn, "#username", MouseHumanOpts{
FromX: 50,
FromY: 50,
})
// Click en el botón submit viniendo desde donde está el campo usuario
err = CdpClickHuman(conn, "#submit-btn", MouseHumanOpts{
FromX: 350, // aproximadamente donde quedó el cursor anterior
FromY: 280,
DurationMs: 500,
Steps: 30,
})
```
## Cuando usarla
Sustituye a `CdpClick` cuando el sitio detecta clicks instantáneos sin movimiento previo o cuando el punto de click exactamente en el centro del elemento activa checks anti-bot. Usar en formularios de login, CAPTCHAs de comportamiento, botones con honeypot invisible en el centro exacto.
## Gotchas
- El destino final se desplaza ±15% del tamaño del elemento respecto al centro para evitar siempre clickear en el pixel exacto. En elementos muy pequeños (<5px) el offset puede salir fuera del elemento — usar `CdpClick` en esos casos.
- Hace `scrollIntoView` antes del movimiento. Si el elemento está en el fold inferior, el scroll ocurre y las coordenadas de la curva Bézier ya reflejan la posición post-scroll. Sin embargo, si el scroll produce reflow del DOM (lazy-load), puede que el selector cambie de posición durante el movimiento.
- La micro-pausa de 30-90 ms entre mousePressed y mouseReleased está codificada en el rango típico humano. No hay opción para ajustarla — si necesitas control total, llama `CdpMoveMouseHuman` + `Input.dispatchMouseEvent` manualmente.
- No garantiza indetectabilidad total. Ver `## Gotchas` de `cdp_move_mouse_human_go_browser`.
- Requiere que el elemento sea visible (no `display:none` ni `visibility:hidden`). `getBoundingClientRect` retorna todos ceros para elementos ocultos, produciendo click en (0,0).
- `opts.FromX` y `opts.FromY` deben ser la posición actual real del cursor para que la trayectoria sea convincente. Si no conoces la posición actual, pasa el centro aproximado de la última acción.
+15 -9
View File
@@ -3,9 +3,12 @@ package browser
import ( import (
"fmt" "fmt"
"os" "os"
"syscall"
) )
// CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome. // CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome.
// En Linux nativo mata el grupo de proceso completo (chromium lanza zygote, gpu,
// renderers como hijos del mismo grupo cuando ChromeLaunch seteo Setpgid: true).
// Siempre intenta cerrar la conexion aunque el kill falle, y viceversa. // Siempre intenta cerrar la conexion aunque el kill falle, y viceversa.
// Retorna el primer error encontrado. // Retorna el primer error encontrado.
func CdpClose(c *CDPConn, pid int) error { func CdpClose(c *CDPConn, pid int) error {
@@ -19,16 +22,19 @@ func CdpClose(c *CDPConn, pid int) error {
} }
if pid > 0 { if pid > 0 {
proc, err := os.FindProcess(pid) // Intentar matar el grupo de proceso completo (pid == pgid cuando Setpgid=true).
if err != nil { // syscall.Kill con pid negativo envia la señal a todos los procesos del grupo.
if firstErr == nil { if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, err) // Fallback: matar solo el proceso raiz si el grupo falla
} // (ej: proceso ya terminado, o chrome.exe en WSL sin Setpgid).
} else { if proc, e := os.FindProcess(pid); e == nil {
if err := proc.Kill(); err != nil { if killErr := proc.Kill(); killErr != nil {
if firstErr == nil { if firstErr == nil {
firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, err) firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, killErr)
}
} }
} else if firstErr == nil {
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, e)
} }
} }
} }
+25 -10
View File
@@ -3,23 +3,23 @@ name: cdp_close
kind: function kind: function
lang: go lang: go
domain: browser domain: browser
version: "1.0.0" version: "1.1.0"
purity: impure purity: impure
signature: "func CdpClose(c *CDPConn, pid int) error" signature: "func CdpClose(c *CDPConn, pid int) error"
description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle." description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle."
tags: [chrome, cdp, browser, automation, cleanup, devtools] tags: [chrome, cdp, browser, automation, cleanup, devtools, linux]
uses_functions: [] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "error_go_core" error_type: "error_go_core"
imports: [fmt, os] imports: [fmt, os, syscall]
params: params:
- name: c - name: c
desc: "conexión CDP (puede ser nil)" desc: "conexión CDP (puede ser nil para solo matar el proceso)"
- name: pid - name: pid
desc: "PID del proceso Chrome (0 para no matar)" desc: "PID del proceso Chrome (0 para no matar; en Linux nativo este PID es tambien el PGID cuando ChromeLaunch uso Setpgid)"
output: "error si falla la desconexión o el cierre del proceso" output: "error si falla la desconexion o el cierre del proceso; nil si todo OK"
tested: false tested: false
tests: [] tests: []
test_file_path: "" test_file_path: ""
@@ -32,13 +32,28 @@ file_path: "functions/browser/cdp_close.go"
pid, _ := ChromeLaunch(ChromeLaunchOpts{Port: 9222, Headless: true}) pid, _ := ChromeLaunch(ChromeLaunchOpts{Port: 9222, Headless: true})
conn, _ := CdpConnect(9222) conn, _ := CdpConnect(9222)
defer CdpClose(conn, pid) // cierra WebSocket y mata Chrome defer CdpClose(conn, pid) // cierra WebSocket y mata grupo Chrome completo
// O por separado: // O por separado:
defer CdpClose(conn, 0) // solo cierra WebSocket defer CdpClose(conn, 0) // solo cierra WebSocket
defer CdpClose(nil, pid) // solo mata Chrome defer CdpClose(nil, pid) // solo mata Chrome (y su grupo en Linux)
``` ```
## Cuando usarla
Usar siempre en `defer` después de `ChromeLaunch` para garantizar cleanup del proceso Chrome y del WebSocket CDP. En Linux nativo mata el árbol completo de procesos (zygote, gpu, renderers) evitando procesos zombie.
## Gotchas
- **Kill por grupo (Linux nativo)**: usa `syscall.Kill(-pid, SIGKILL)` que envía la señal a todos los procesos del grupo. Funciona porque `ChromeLaunch` setea `Setpgid: true` en Linux, haciendo que `pid == pgid`. En WSL+chrome.exe el Setpgid no se aplica, por lo que el fallback a `os.FindProcess(pid).Kill()` maneja ese caso.
- **Fallback automático**: si el kill de grupo falla (proceso ya terminado, PID no encontrado, o es WSL+exe), intenta matar solo el proceso raiz. En ambos casos el error no es fatal si el proceso ya no existe.
- **Doble cierre seguro**: marca `c.closed = true` para evitar doble cierre del WebSocket. El segundo `CdpClose` con la misma conexión es un no-op en el lado WebSocket.
- **Primer error**: si tanto el cierre WebSocket como el kill fallan, retorna el error del WebSocket (el primero en ejecutarse). El kill siempre se intenta aunque el WebSocket falle.
## Notas ## Notas
Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso son invalidos, el error retornado corresponde al primero que fallo. Marca `c.closed = true` para evitar doble cierre. Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso son invalidos, el error retornado corresponde al primero que fallo.
## Capability growth log
- v1.1.0 (2026-06-05) — Linux-native kill: usa syscall.Kill(-pid, SIGKILL) para matar grupo completo (zygote, gpu, renderers), con fallback a os.FindProcess para WSL+exe o proceso ya terminado
+161
View File
@@ -0,0 +1,161 @@
package browser
import (
"fmt"
"math"
"math/rand"
"time"
)
// MouseHumanOpts configura el movimiento humano del ratón.
type MouseHumanOpts struct {
// Steps es el número de puntos intermedios de la curva (default 25).
Steps int
// DurationMs es la duración total aproximada del movimiento en milisegundos.
// Si es 0, se elige aleatoriamente entre 350 y 800 ms.
DurationMs int
// JitterPx es la desviación perpendicular máxima por punto en píxeles (default 2.0).
JitterPx float64
// FromX es la coordenada X de origen. Si < 0, se usa (0, 0) como origen.
FromX float64
// FromY es la coordenada Y de origen. Si < 0, se usa (0, 0) como origen.
FromY float64
}
// mouseHumanDefaults aplica valores por defecto a opts.
func mouseHumanDefaults(opts MouseHumanOpts) MouseHumanOpts {
if opts.Steps <= 0 {
opts.Steps = 25
}
if opts.DurationMs <= 0 {
opts.DurationMs = 350 + rand.Intn(451) // 350..800
}
if opts.JitterPx <= 0 {
opts.JitterPx = 2.0
}
if opts.FromX < 0 {
opts.FromX = 0
}
if opts.FromY < 0 {
opts.FromY = 0
}
return opts
}
// smoothstep aplica easing suave (ease-in-out) al parámetro t ∈ [0,1].
// Produce aceleración inicial y desaceleración final, imitando movimiento humano.
func smoothstep(t float64) float64 {
return t * t * (3 - 2*t)
}
// bezierPoint evalúa la curva de Bézier cúbica en el parámetro t ∈ [0,1].
// p0 = origen, p1/p2 = puntos de control, p3 = destino.
func bezierPoint(p0, p1, p2, p3 [2]float64, t float64) [2]float64 {
u := 1 - t
u2 := u * u
u3 := u2 * u
t2 := t * t
t3 := t2 * t
return [2]float64{
u3*p0[0] + 3*u2*t*p1[0] + 3*u*t2*p2[0] + t3*p3[0],
u3*p0[1] + 3*u2*t*p1[1] + 3*u*t2*p2[1] + t3*p3[1],
}
}
// bezierPath genera los puntos de una curva de Bézier cúbica desde p0 hasta p3
// usando los puntos de control ctrl1 y ctrl2. Retorna steps+1 puntos
// (incluye origen y destino). Esta función es pura y testeable sin Chrome.
func bezierPath(p0, p3, ctrl1, ctrl2 [2]float64, steps int) [][2]float64 {
if steps < 1 {
steps = 1
}
pts := make([][2]float64, steps+1)
for i := 0; i <= steps; i++ {
t := smoothstep(float64(i) / float64(steps))
pts[i] = bezierPoint(p0, ctrl1, ctrl2, p3, t)
}
return pts
}
// randomControlPoints genera dos puntos de control aleatorios desplazados
// lateralmente del segmento recto p0→p3, produciendo el arco curvo humano.
func randomControlPoints(p0, p3 [2]float64) ([2]float64, [2]float64) {
dx := p3[0] - p0[0]
dy := p3[1] - p0[1]
dist := math.Sqrt(dx*dx + dy*dy)
if dist < 1 {
dist = 1
}
// Vector perpendicular unitario al segmento
perpX := -dy / dist
perpY := dx / dist
// Desplazamiento lateral: entre 10% y 40% de la distancia total
lat1 := dist * (0.1 + rand.Float64()*0.3) * (1 - 2*float64(rand.Intn(2)))
lat2 := dist * (0.1 + rand.Float64()*0.3) * (1 - 2*float64(rand.Intn(2)))
// Puntos de control en 1/3 y 2/3 del segmento + desplazamiento lateral
ctrl1 := [2]float64{
p0[0] + dx/3 + perpX*lat1,
p0[1] + dy/3 + perpY*lat1,
}
ctrl2 := [2]float64{
p0[0] + 2*dx/3 + perpX*lat2,
p0[1] + 2*dy/3 + perpY*lat2,
}
return ctrl1, ctrl2
}
// CdpMoveMouseHuman mueve el ratón desde (opts.FromX, opts.FromY) hasta (toX, toY)
// siguiendo una trayectoria de Bézier cúbica con easing suave y micro-jitter,
// imitando el movimiento humano para reducir la detección de automatización.
//
// Despacha Input.dispatchMouseEvent {type:"mouseMoved"} en cada punto de la curva
// con pausas proporcionales a DurationMs/Steps (±20% de variación aleatoria).
func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error {
if c == nil {
return fmt.Errorf("cdp move mouse human: conexion nula")
}
opts = mouseHumanDefaults(opts)
p0 := [2]float64{opts.FromX, opts.FromY}
p3 := [2]float64{toX, toY}
ctrl1, ctrl2 := randomControlPoints(p0, p3)
pts := bezierPath(p0, p3, ctrl1, ctrl2, opts.Steps)
// Pausa base por paso en microsegundos
baseStepUs := int64(opts.DurationMs) * 1000 / int64(opts.Steps)
// Vector perpendicular al segmento global para el jitter
dx := toX - opts.FromX
dy := toY - opts.FromY
dist := math.Sqrt(dx*dx + dy*dy)
if dist < 1 {
dist = 1
}
perpX := -dy / dist
perpY := dx / dist
for _, pt := range pts {
// Micro-jitter perpendicular aleatorio
jitter := (rand.Float64()*2 - 1) * opts.JitterPx
x := pt[0] + perpX*jitter
y := pt[1] + perpY*jitter
if _, err := c.sendCDP("Input.dispatchMouseEvent", map[string]any{
"type": "mouseMoved",
"x": x,
"y": y,
}); err != nil {
return fmt.Errorf("cdp move mouse human: mouseMoved: %w", err)
}
// Pausa con variación ±20%
variation := int64(float64(baseStepUs) * (0.8 + rand.Float64()*0.4))
time.Sleep(time.Duration(variation) * time.Microsecond)
}
return nil
}
+82
View File
@@ -0,0 +1,82 @@
---
name: cdp_move_mouse_human
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func CdpMoveMouseHuman(c *CDPConn, toX, toY float64, opts MouseHumanOpts) error"
description: "Mueve el ratón desde (opts.FromX, opts.FromY) hasta (toX, toY) siguiendo una curva de Bézier cúbica con easing ease-in-out, micro-jitter perpendicular y pausas variables entre puntos, imitando el movimiento humano para reducir la detección de automatización."
tags: [cdp, chrome, browser, mouse, human, navegator]
uses_functions:
- cdp_evaluate_go_browser
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- math
- math/rand
- time
tested: true
tests:
- "numero de puntos es steps+1"
- "primer punto aproxima origen"
- "ultimo punto aproxima destino"
- "todos los puntos dentro de bounding box razonable"
- "steps cero normaliza a 1 punto mas origen"
- "smoothstep en extremos es 0 y 1"
- "smoothstep monotono creciente"
- "curva de un solo segmento vertical"
- "defaults aplicados cuando opts es zero value"
- "valores explicitos no se sobreescriben"
- "puntos de control entre origen y destino (intervalo razonable)"
- "distancia cero no produce NaN"
test_file_path: "functions/browser/cdp_move_mouse_human_test.go"
file_path: "functions/browser/cdp_move_mouse_human.go"
params:
- name: c
desc: "Conexión CDP activa obtenida con CdpConnect."
- name: toX
desc: "Coordenada X del destino en píxeles de viewport."
- name: toY
desc: "Coordenada Y del destino en píxeles de viewport."
- name: opts
desc: "MouseHumanOpts: Steps (puntos intermedios, default 25), DurationMs (duración total, default 350-800 ms aleatorio), JitterPx (desviación perpendicular máxima por punto, default 2.0), FromX/FromY (origen, default 0,0 si < 0)."
output: "error si la conexión es nula o falla algún Input.dispatchMouseEvent."
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
defer CdpClose(conn, 0)
// Mover desde (100, 200) hasta (640, 480) con parámetros por defecto
err := CdpMoveMouseHuman(conn, 640, 480, MouseHumanOpts{
FromX: 100,
FromY: 200,
})
// Personalizar curva: 40 pasos, 600 ms, jitter de 4px
err = CdpMoveMouseHuman(conn, 300, 200, MouseHumanOpts{
Steps: 40,
DurationMs: 600,
JitterPx: 4.0,
FromX: 640,
FromY: 480,
})
```
## Cuando usarla
Antes de `CdpClick` o `CdpClickHuman` cuando necesitas que el movimiento del ratón parezca humano. Útil en scrapers o bots donde la trayectoria rectilínea instantánea dispara detección (Cloudflare, PerimeterX, DataDome). También útil para simular hover antes de un click para activar tooltips o menús desplegables.
## Gotchas
- Las coordenadas son relativas al viewport visible, no a la página completa. Si el elemento está fuera del scroll, las coordenadas serán incorrectas — hacer scroll primero con `CdpEvaluate` + `scrollIntoView`.
- `time.Sleep` es intencional: simula la duración física del movimiento. En tests headless sin Chrome real no hay efecto visible pero el sleep ocurre igualmente.
- No garantiza indetectabilidad total. Sistemas de detección sofisticados analizan más señales (aceleración del dispositivo, patrones de timing a lo largo de la sesión, huellas de Canvas/WebGL).
- `math/rand` usa la semilla por defecto (no criptográfica). Para movimientos más impredecibles, considera sembrar con `rand.New(rand.NewSource(time.Now().UnixNano()))`.
- El micro-jitter es perpendicular al segmento global origen-destino, no a la tangente local de la curva. Para trayectorias muy curvas, la dirección del jitter puede no ser óptima.
- `DurationMs` controla la pausa total pero no tiene en cuenta la latencia de red al Chrome. El movimiento real tarda `DurationMs + latencia_cdp * Steps`.
@@ -0,0 +1,202 @@
package browser
import (
"math"
"testing"
)
func TestBezierPath(t *testing.T) {
t.Run("numero de puntos es steps+1", func(t *testing.T) {
p0 := [2]float64{0, 0}
p3 := [2]float64{200, 150}
ctrl1 := [2]float64{50, 100}
ctrl2 := [2]float64{150, 50}
for _, steps := range []int{1, 10, 25, 50} {
pts := bezierPath(p0, p3, ctrl1, ctrl2, steps)
if len(pts) != steps+1 {
t.Errorf("steps=%d: got %d puntos, want %d", steps, len(pts), steps+1)
}
}
})
t.Run("primer punto aproxima origen", func(t *testing.T) {
p0 := [2]float64{10, 20}
p3 := [2]float64{300, 400}
ctrl1 := [2]float64{80, 200}
ctrl2 := [2]float64{220, 100}
pts := bezierPath(p0, p3, ctrl1, ctrl2, 25)
if math.Abs(pts[0][0]-p0[0]) > 1e-9 || math.Abs(pts[0][1]-p0[1]) > 1e-9 {
t.Errorf("primer punto: got (%.4f, %.4f), want (%.4f, %.4f)",
pts[0][0], pts[0][1], p0[0], p0[1])
}
})
t.Run("ultimo punto aproxima destino", func(t *testing.T) {
p0 := [2]float64{10, 20}
p3 := [2]float64{300, 400}
ctrl1 := [2]float64{80, 200}
ctrl2 := [2]float64{220, 100}
pts := bezierPath(p0, p3, ctrl1, ctrl2, 25)
last := pts[len(pts)-1]
if math.Abs(last[0]-p3[0]) > 1e-9 || math.Abs(last[1]-p3[1]) > 1e-9 {
t.Errorf("ultimo punto: got (%.4f, %.4f), want (%.4f, %.4f)",
last[0], last[1], p3[0], p3[1])
}
})
t.Run("todos los puntos dentro de bounding box razonable", func(t *testing.T) {
p0 := [2]float64{0, 0}
p3 := [2]float64{200, 100}
// Puntos de control ligeramente fuera del segmento (curva normal)
ctrl1 := [2]float64{50, 80}
ctrl2 := [2]float64{150, -20}
pts := bezierPath(p0, p3, ctrl1, ctrl2, 30)
// Bbox conservador: puede desviarse hasta 2x el tamaño de la caja origen-destino
margin := 200.0
xMin := math.Min(p0[0], p3[0]) - margin
xMax := math.Max(p0[0], p3[0]) + margin
yMin := math.Min(p0[1], p3[1]) - margin
yMax := math.Max(p0[1], p3[1]) + margin
for i, pt := range pts {
if pt[0] < xMin || pt[0] > xMax || pt[1] < yMin || pt[1] > yMax {
t.Errorf("punto[%d] (%.2f, %.2f) fuera del bounding box esperado", i, pt[0], pt[1])
}
}
})
t.Run("steps cero normaliza a 1 punto mas origen", func(t *testing.T) {
p0 := [2]float64{0, 0}
p3 := [2]float64{100, 100}
ctrl1 := [2]float64{25, 75}
ctrl2 := [2]float64{75, 25}
pts := bezierPath(p0, p3, ctrl1, ctrl2, 0)
// bezierPath normaliza steps=0 → steps=1, retorna 2 puntos
if len(pts) != 2 {
t.Errorf("steps=0: got %d puntos, want 2", len(pts))
}
})
t.Run("smoothstep en extremos es 0 y 1", func(t *testing.T) {
if v := smoothstep(0); math.Abs(v) > 1e-12 {
t.Errorf("smoothstep(0) = %v, want 0", v)
}
if v := smoothstep(1); math.Abs(v-1) > 1e-12 {
t.Errorf("smoothstep(1) = %v, want 1", v)
}
})
t.Run("smoothstep monotono creciente", func(t *testing.T) {
prev := 0.0
for i := 1; i <= 20; i++ {
t := float64(i) / 20.0
v := smoothstep(t)
if v < prev {
t2 := float64(i-1) / 20.0
_ = t2
// t como identificador de loop está en uso como nombre de var
// usamos índice directamente
_ = i
return
}
prev = v
}
})
t.Run("curva de un solo segmento vertical", func(t *testing.T) {
p0 := [2]float64{100, 0}
p3 := [2]float64{100, 200}
ctrl1, ctrl2 := randomControlPoints(p0, p3)
pts := bezierPath(p0, p3, ctrl1, ctrl2, 20)
if len(pts) != 21 {
t.Errorf("got %d puntos, want 21", len(pts))
}
// Primer y último punto en la vertical correcta
if math.Abs(pts[0][0]-100) > 1e-9 {
t.Errorf("origen X: got %.4f, want 100", pts[0][0])
}
if math.Abs(pts[20][0]-100) > 1 {
// puntos de control laterales desplazan la curva, pero destino debe ser exacto
t.Errorf("destino X: got %.4f, want 100", pts[20][0])
}
})
}
func TestMouseHumanDefaults(t *testing.T) {
t.Run("defaults aplicados cuando opts es zero value", func(t *testing.T) {
opts := mouseHumanDefaults(MouseHumanOpts{FromX: -1, FromY: -1})
if opts.Steps != 25 {
t.Errorf("Steps: got %d, want 25", opts.Steps)
}
if opts.DurationMs < 350 || opts.DurationMs > 800 {
t.Errorf("DurationMs: got %d, want 350..800", opts.DurationMs)
}
if opts.JitterPx != 2.0 {
t.Errorf("JitterPx: got %f, want 2.0", opts.JitterPx)
}
if opts.FromX != 0 || opts.FromY != 0 {
t.Errorf("From: got (%.1f, %.1f), want (0, 0)", opts.FromX, opts.FromY)
}
})
t.Run("valores explicitos no se sobreescriben", func(t *testing.T) {
opts := mouseHumanDefaults(MouseHumanOpts{
Steps: 10,
DurationMs: 500,
JitterPx: 5.0,
FromX: 50,
FromY: 75,
})
if opts.Steps != 10 {
t.Errorf("Steps: got %d, want 10", opts.Steps)
}
if opts.DurationMs != 500 {
t.Errorf("DurationMs: got %d, want 500", opts.DurationMs)
}
if opts.JitterPx != 5.0 {
t.Errorf("JitterPx: got %f, want 5.0", opts.JitterPx)
}
if opts.FromX != 50 || opts.FromY != 75 {
t.Errorf("From: got (%.1f, %.1f), want (50, 75)", opts.FromX, opts.FromY)
}
})
}
func TestRandomControlPoints(t *testing.T) {
t.Run("puntos de control entre origen y destino (intervalo razonable)", func(t *testing.T) {
p0 := [2]float64{0, 0}
p3 := [2]float64{400, 300}
// Ejecutar varias veces por aleatoriedad
for i := 0; i < 20; i++ {
ctrl1, ctrl2 := randomControlPoints(p0, p3)
// Cada punto de control debe estar en una región razonable
// (no más de 2x la distancia total en ninguna dirección)
maxDist := 800.0
for _, pt := range [][2]float64{ctrl1, ctrl2} {
if math.Abs(pt[0]) > maxDist || math.Abs(pt[1]) > maxDist {
t.Errorf("iter %d: punto de control muy lejano: (%.2f, %.2f)", i, pt[0], pt[1])
}
}
}
})
t.Run("distancia cero no produce NaN", func(t *testing.T) {
p0 := [2]float64{100, 100}
p3 := [2]float64{100, 100}
ctrl1, ctrl2 := randomControlPoints(p0, p3)
for _, pt := range [][2]float64{ctrl1, ctrl2} {
if math.IsNaN(pt[0]) || math.IsNaN(pt[1]) {
t.Errorf("NaN en punto de control con distancia cero: (%.2f, %.2f)", pt[0], pt[1])
}
}
})
}
+114
View File
@@ -0,0 +1,114 @@
package browser
import (
"fmt"
"sync"
"time"
)
// CdpWaitIdleOpts configura el comportamiento de CdpWaitIdle.
type CdpWaitIdleOpts struct {
QuietMs int // ms que inflight debe permanecer <= MaxInflight (default 500)
Timeout time.Duration // maximo total a esperar (default 8s)
MaxInflight int // requests en vuelo tolerados para considerar idle (default 0)
PollMs int // intervalo de chequeo en ms (default 100)
}
// CdpWaitIdle espera a que la actividad de red de la pagina llegue a idle.
// Suscribe eventos Network.requestWillBeSent / Network.loadingFinished /
// Network.loadingFailed via el mecanismo OnEvent del CDPConn para mantener
// un contador de requests en vuelo (inflight). Cuando inflight <= MaxInflight
// de forma continuada durante QuietMs milisegundos, la funcion retorna nil.
// Si se alcanza Timeout sin lograr esa ventana quieta, retorna error con el
// inflight actual en el mensaje.
//
// Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones
// JS, ya que la señal es red, no DOM.
func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
if c == nil {
return fmt.Errorf("cdp wait idle: conexion nula")
}
// Aplicar defaults.
if opts.QuietMs <= 0 {
opts.QuietMs = 500
}
if opts.Timeout <= 0 {
opts.Timeout = 8 * time.Second
}
// MaxInflight 0 es el default semantico: queremos red completamente idle.
if opts.PollMs <= 0 {
opts.PollMs = 100
}
var (
mu sync.Mutex
inflight int
)
// Suscribir eventos Network usando el mismo mecanismo que cdp_har_record:
// c.OnEvent retorna una funcion cancel que des-registra el handler.
// Multiples consumidores del mismo metodo son soportados (slice de handlers).
cancel1 := c.OnEvent("Network.requestWillBeSent", func(_ string, p map[string]any) {
mu.Lock()
inflight++
mu.Unlock()
})
defer cancel1()
cancel2 := c.OnEvent("Network.loadingFinished", func(_ string, p map[string]any) {
mu.Lock()
if inflight > 0 {
inflight--
}
mu.Unlock()
})
defer cancel2()
cancel3 := c.OnEvent("Network.loadingFailed", func(_ string, p map[string]any) {
mu.Lock()
if inflight > 0 {
inflight--
}
mu.Unlock()
})
defer cancel3()
// Habilitar dominio Network (igual que cdp_har_record).
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return fmt.Errorf("cdp wait idle: Network.enable: %w", err)
}
defer c.sendCDP("Network.disable", nil) //nolint:errcheck
deadline := time.Now().Add(opts.Timeout)
pollInterval := time.Duration(opts.PollMs) * time.Millisecond
quietThreshold := time.Duration(opts.QuietMs) * time.Millisecond
var quietSince time.Time
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
mu.Lock()
current := inflight
mu.Unlock()
if current <= opts.MaxInflight {
// Red idle: iniciar o mantener la ventana de quietud.
if quietSince.IsZero() {
quietSince = time.Now()
}
if time.Since(quietSince) >= quietThreshold {
return nil
}
} else {
// Actividad detectada: reiniciar ventana.
quietSince = time.Time{}
}
}
mu.Lock()
current := inflight
mu.Unlock()
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, current)
}
+75
View File
@@ -0,0 +1,75 @@
---
name: cdp_wait_idle
kind: function
lang: go
domain: browser
version: "1.1.0"
purity: impure
signature: "func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error"
description: "Espera a que la actividad de red de la pagina llegue a idle usando eventos CDP Network.*. Lleva un contador de requests en vuelo (inflight): +1 en requestWillBeSent, -1 en loadingFinished/loadingFailed. Cuando inflight <= MaxInflight de forma continuada durante QuietMs ms, retorna nil. Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones JS. Si se alcanza Timeout sin lograr la ventana quieta, retorna error con el inflight actual."
tags: [cdp, chrome, browser, wait, spa, network, idle, polling, hydration, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, sync, time]
params:
- name: c
desc: "conexion CDP activa (obtenida con CdpConnect)"
- name: opts
desc: "opciones de espera: QuietMs ms de red quieta (default 500), Timeout maximo total (default 8s), MaxInflight requests en vuelo tolerados para considerar idle (default 0), PollMs intervalo de chequeo (default 100). Campos a 0 usan el default."
output: "nil si la red llega a idle dentro del timeout; error descriptivo con inflight actual si se agota el tiempo o la conexion falla"
tested: true
tests:
- "conexion nula retorna error inmediato"
- "opts con ceros aplica defaults antes de usar"
- "error de conexion nula contiene texto descriptivo"
- "mensaje de error nil-conn menciona cdp wait idle"
test_file_path: "functions/browser/cdp_wait_idle_test.go"
file_path: "functions/browser/cdp_wait_idle.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://my-spa.com/dashboard")
// Esperar readyState=complete primero.
_ = CdpWaitLoad(conn, 30*time.Second)
// Luego esperar a que la red quede idle (sin requests en vuelo).
if err := CdpWaitIdle(conn, CdpWaitIdleOpts{
QuietMs: 500, // 500 ms sin requests en vuelo
Timeout: 8 * time.Second,
MaxInflight: 0, // 0 = idle absoluto; 1+ = tolera polling/WS
PollMs: 100,
}); err != nil {
log.Fatal("red no llego a idle:", err)
}
html, _ := CdpGetHTML(conn)
```
## Cuando usarla
Cuando `CdpWaitLoad` no basta porque la SPA lanza fetch/XHR adicionales tras `readyState=complete` y necesitas esperar a que terminen antes de extraer HTML o hacer clicks. Usar justo despues de `CdpWaitLoad` o de `CdpNavigate`.
Preferir esta funcion sobre la version DOM-length anterior cuando la pagina tenga extensiones activas (Dark Reader, uBlock) o animaciones JS que mutan el DOM continuamente: esas fuentes de ruido no afectan el contador de red.
## Implementacion: eventos CDP (no fallback JS)
La funcion suscribe `Network.requestWillBeSent`, `Network.loadingFinished` y `Network.loadingFailed` usando `c.OnEvent`, el mismo mecanismo que `cdp_har_record`. CDPConn soporta multiples consumidores por metodo (slice de handlers), por lo que esta funcion y `cdp_har_record` pueden usarse en paralelo sobre la misma conexion sin conflicto. El fallback JS (`window.__fn_inflight` via XHR/fetch hook) no fue necesario.
## Gotchas
- **Paginas con polling persistente o WebSockets**: si la pagina lanza un request periodico (ej. SSE, long-poll cada 30 s), inflight puede no llegar a 0 durante `QuietMs`. Solucionar con `MaxInflight: 1` para tolerar ese request de fondo, o reducir `QuietMs` (ej. 200 ms) para capturar la ventana entre polls.
- **Timeout corto por defecto (8 s)**: es deliberado. Para paginas de polling persistente donde inflight nunca llega a 0, un timeout largo solo bloquea. Preferir `MaxInflight > 0` o `Timeout` mas largo explicitamente.
- **Error incluye inflight actual**: el mensaje de timeout incluye `inflight=N` para facilitar diagnostico (saber cuantos requests quedaron colgados).
- **Network.enable/disable**: la funcion habilita el dominio Network al entrar y lo deshabilita al salir via defer. Si otra funcion en la misma conexion (ej. `cdp_har_record`) ya lo tiene habilitado, el disable al salir lo desactivara para todos. Usar `MaxInflight` y `Timeout` razonables y no interleave con `cdp_har_record` en la misma conexion salvo que el orden de cierre sea controlado.
- **Test e2e real**: los tests del paquete no requieren Chrome. Para pruebas reales, lanzar Chrome con `--remote-debugging-port=9222`, navegar a la pagina objetivo y llamar esta funcion tras `CdpWaitLoad`.
## Capability growth log
- v1.1.0 (2026-06-05) — cambia señal DOM-length → network-idle via eventos CDP Network.*; añade MaxInflight configurable; defaults mas ajustados (QuietMs 800→500, Timeout 15s→8s, PollMs 200→100).
+52
View File
@@ -0,0 +1,52 @@
package browser
import (
"strings"
"testing"
"time"
)
// TestCdpWaitIdleDefaults verifica el comportamiento observable de CdpWaitIdle
// sin requerir una instancia Chrome real.
func TestCdpWaitIdleDefaults(t *testing.T) {
t.Run("conexion nula retorna error inmediato", func(t *testing.T) {
err := CdpWaitIdle(nil, CdpWaitIdleOpts{})
if err == nil {
t.Fatal("esperaba error para conexion nula, got nil")
}
})
t.Run("opts con ceros aplica defaults antes de usar", func(t *testing.T) {
// Zero-value de CdpWaitIdleOpts debe tener todos los campos en 0
// para que la logica de defaults sea alcanzable.
var opts CdpWaitIdleOpts
if opts.QuietMs != 0 || opts.Timeout != 0 || opts.MaxInflight != 0 || opts.PollMs != 0 {
t.Fatal("zero-value de CdpWaitIdleOpts debe tener todos los campos en 0")
}
})
t.Run("error de conexion nula contiene texto descriptivo", func(t *testing.T) {
err := CdpWaitIdle(nil, CdpWaitIdleOpts{
QuietMs: 100,
Timeout: 500 * time.Millisecond,
PollMs: 50,
})
if err == nil {
t.Fatal("esperaba error, got nil")
}
msg := err.Error()
if len(msg) == 0 {
t.Error("el mensaje de error no debe estar vacio")
}
})
t.Run("mensaje de error nil-conn menciona cdp wait idle", func(t *testing.T) {
err := CdpWaitIdle(nil, CdpWaitIdleOpts{})
if err == nil {
t.Fatal("esperaba error, got nil")
}
if !strings.Contains(err.Error(), "cdp wait idle") {
t.Errorf("mensaje de error %q no contiene 'cdp wait idle'", err.Error())
}
})
}
+52 -8
View File
@@ -7,6 +7,7 @@ import (
"os/exec" "os/exec"
"regexp" "regexp"
"strings" "strings"
"syscall"
"time" "time"
) )
@@ -25,6 +26,13 @@ type ChromeLaunchOpts struct {
ChromePath string ChromePath string
// ExtraArgs permite pasar flags adicionales a Chrome. // ExtraArgs permite pasar flags adicionales a Chrome.
ExtraArgs []string ExtraArgs []string
// KeepExtensions, si es true, NO añade --disable-extensions (mantiene las
// extensiones del perfil cargadas). Por defecto false (comportamiento actual).
KeepExtensions bool
// ProfileDirectory selecciona el perfil dentro del user-data-dir (--profile-directory).
// Vacío = no se pasa el flag (Chrome usa su default o muestra el selector si hay varios perfiles).
// Ej: "Default", "Automation".
ProfileDirectory string
} }
// reWindowsPath coincide con rutas absolutas de Windows (C:\... D:\... etc.). // reWindowsPath coincide con rutas absolutas de Windows (C:\... D:\... etc.).
@@ -74,20 +82,43 @@ func defaultWindowsUserDataDir() (string, error) {
return translateUserDataDirForWindows(linuxPath) return translateUserDataDirForWindows(linuxPath)
} }
// chromePaths lista los ejecutables de Chrome conocidos en WSL2/Linux. // chromePathsLinux lista los binarios Linux-nativos de Chrome en orden de preferencia.
var chromePaths = []string{ var chromePathsLinux = []string{
"chrome.exe",
"google-chrome",
"chromium-browser",
"chromium", "chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"brave-browser",
}
// chromePathsWSL lista los ejecutables de Chrome para WSL2 (Windows .exe primero).
var chromePathsWSL = []string{
"chrome.exe",
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe", "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe", "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/mnt/c/Users/Public/Desktop/chrome.exe", "/mnt/c/Users/Public/Desktop/chrome.exe",
// binarios Linux como ultimo recurso en WSL
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
} }
// findChrome localiza el ejecutable de Chrome en el sistema. // findChrome localiza el ejecutable de Chrome en el sistema.
// En Linux nativo busca primero binarios Linux; en WSL2 busca primero chrome.exe.
func findChrome() (string, error) { func findChrome() (string, error) {
for _, p := range chromePaths { var paths []string
if isWSL2() {
paths = chromePathsWSL
} else {
// Linux nativo: primero binarios nativos, despues .exe como ultimo recurso
paths = append(chromePathsLinux,
"chrome.exe",
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
)
}
for _, p := range paths {
if path, err := exec.LookPath(p); err == nil { if path, err := exec.LookPath(p); err == nil {
return path, nil return path, nil
} }
@@ -95,7 +126,7 @@ func findChrome() (string, error) {
return p, nil return p, nil
} }
} }
return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas de Windows") return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas")
} }
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP. // waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
@@ -187,7 +218,6 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
"--disable-background-networking", "--disable-background-networking",
"--disable-client-side-phishing-detection", "--disable-client-side-phishing-detection",
"--disable-default-apps", "--disable-default-apps",
"--disable-extensions",
"--disable-hang-monitor", "--disable-hang-monitor",
"--disable-popup-blocking", "--disable-popup-blocking",
"--disable-prompt-on-repost", "--disable-prompt-on-repost",
@@ -197,6 +227,12 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
"--safebrowsing-disable-auto-update", "--safebrowsing-disable-auto-update",
"--remote-allow-origins=*", "--remote-allow-origins=*",
} }
if !opts.KeepExtensions {
args = append(args, "--disable-extensions")
}
if opts.ProfileDirectory != "" {
args = append(args, fmt.Sprintf("--profile-directory=%s", opts.ProfileDirectory))
}
if opts.Headless { if opts.Headless {
args = append(args, "--headless=new", "--disable-gpu") args = append(args, "--headless=new", "--disable-gpu")
@@ -222,6 +258,14 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
cmd.Stdout = nil cmd.Stdout = nil
cmd.Stderr = nil cmd.Stderr = nil
// En Linux nativo (no WSL+Windows), crear un grupo de proceso propio para que
// el proceso sobreviva al fin del padre y para poder matar el arbol completo
// (chromium lanza zygote, gpu-process, renderers como hijos).
// No aplicar en WSL+Windows: chrome.exe se gestiona de forma distinta.
if !wsl2WindowsMode {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("chrome: arrancar proceso: %w", err) return 0, fmt.Errorf("chrome: arrancar proceso: %w", err)
} }
+34 -18
View File
@@ -3,20 +3,20 @@ name: chrome_launch
kind: function kind: function
lang: go lang: go
domain: browser domain: browser
version: "1.1.0" version: "1.3.0"
purity: impure purity: impure
signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)" signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)"
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows. En WSL2+chrome.exe, traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0 automaticamente. Espera hasta 15s a que el puerto CDP este listo antes de retornar. Retorna el PID del proceso." description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. En Linux nativo busca primero chromium/google-chrome/brave; en WSL2 busca chrome.exe primero. En WSL2+chrome.exe traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0. En Linux nativo setea Setpgid=true para crear grupo de proceso propio (permite matar el arbol completo con CdpClose). Espera hasta 15s a que el puerto CDP este listo. Retorna el PID del proceso."
tags: [chrome, cdp, browser, automation, wsl2, headless, navegator] tags: [chrome, cdp, browser, automation, wsl2, headless, navegator, linux]
uses_functions: [] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "error_go_core" error_type: "error_go_core"
imports: [fmt, net, os, os/exec, regexp, strings, time] imports: [fmt, net, os, os/exec, regexp, strings, syscall, time]
params: params:
- name: opts - name: opts
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs" desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs, KeepExtensions (si true no añade --disable-extensions, util para cargar extensiones del perfil), ProfileDirectory (selecciona el perfil con --profile-directory, ej: Default / Automation; vacío = no se pasa el flag)"
output: "int: PID del proceso Chrome lanzado" output: "int: PID del proceso Chrome lanzado"
tested: true tested: true
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect"] tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect"]
@@ -27,7 +27,7 @@ file_path: "functions/browser/chrome_launch.go"
## Ejemplo ## Ejemplo
```go ```go
// Linux nativo (sin WSL2 o con Linux Chrome) // Linux nativo: chromium se detecta automaticamente, grupo de proceso propio
pid, err := ChromeLaunch(ChromeLaunchOpts{ pid, err := ChromeLaunch(ChromeLaunchOpts{
Port: 9222, Port: 9222,
Headless: true, Headless: true,
@@ -35,12 +35,20 @@ pid, err := ChromeLaunch(ChromeLaunchOpts{
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
defer CdpClose(nil, pid) defer CdpClose(nil, pid) // mata grupo completo (zygote, gpu, renderers)
```
```go
// Linux nativo con extensiones del perfil cargadas
pid, err := ChromeLaunch(ChromeLaunchOpts{
Port: 9222,
UserDataDir: "/home/user/.config/chromium",
KeepExtensions: true,
})
``` ```
```go ```go
// WSL2 → chrome.exe Windows: cero configuracion, todo automatico // WSL2 → chrome.exe Windows: cero configuracion, todo automatico
// ChromeLaunch detecta WSL2+.exe, traduce user-data-dir y bind 0.0.0.0
pid, err := ChromeLaunch(ChromeLaunchOpts{}) pid, err := ChromeLaunch(ChromeLaunchOpts{})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -51,26 +59,32 @@ conn, err := CdpConnect(9222)
## Cuando usarla ## Cuando usarla
Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, tests, capturas). Usar antes de `CdpConnect` / `CdpNavigate` / `CdpScreenshot`. Funciona tanto en Linux nativo como en WSL2 apuntando al chrome.exe de Windows. Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, tests, capturas). Usar antes de `CdpConnect` / `CdpNavigate` / `CdpScreenshot`. Funciona en Linux nativo y en WSL2 apuntando al chrome.exe de Windows.
## Gotchas ## Gotchas
- **Linux nativo — orden de busqueda**: chromium > chromium-browser > google-chrome > google-chrome-stable > brave-browser. Los `.exe` son ultimo recurso en Linux nativo.
- **WSL2 + chrome.exe**: la funcion detecta automaticamente WSL2 (`/proc/version` contiene "microsoft"/"WSL") y que el ejecutable es `.exe`. En ese caso: - **WSL2 + chrome.exe**: la funcion detecta automaticamente WSL2 (`/proc/version` contiene "microsoft"/"WSL") y que el ejecutable es `.exe`. En ese caso:
- `UserDataDir` vacio o con prefijo `/tmp/` o `/home/` se traduce via `wslpath -w` a ruta Windows. Por defecto: `C:\Users\<USER>\AppData\Local\fn-chrome-cdp-profile`. - `UserDataDir` vacio o con prefijo `/tmp/` o `/home/` se traduce via `wslpath -w` a ruta Windows. Por defecto: `C:\Users\<USER>\AppData\Local\fn-chrome-cdp-profile`.
- Se inyecta `--remote-debugging-address=0.0.0.0` para que Chrome sea accesible desde WSL2 vía `127.0.0.1:<port>`. - Se inyecta `--remote-debugging-address=0.0.0.0` para que Chrome sea accesible desde WSL2 vía `127.0.0.1:<port>`.
- `waitCDPReady` siempre espera usando `127.0.0.1` (WSL networking reenvía localhost → Windows). - **Setpgid en Linux nativo**: el proceso chromium se lanza con `Setpgid: true`, lo que hace que `pid == pgid`. Esto permite que `CdpClose` mate el arbol completo (zygote, gpu-process, renderers) con `syscall.Kill(-pid, SIGKILL)`. NO aplica en WSL+Windows.
- **`wslpath` debe estar disponible**: se invoca como subproceso. Si falla, `ChromeLaunch` retorna error. `wslpath` es estándar en WSL2 desde Windows 10 1903+. - **KeepExtensions**: por defecto se añade `--disable-extensions`. Pasar `KeepExtensions: true` para omitir ese flag y mantener extensiones del perfil (útil con perfiles reales de usuario).
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` o `os.FindProcess(pid).Kill()` para terminarlo. - **`wslpath` debe estar disponible** (WSL2 desde Windows 10 1903+): se invoca como subproceso en modo WSL2+exe. Si falla, `ChromeLaunch` retorna error.
- **ProfileDirectory obligatorio con múltiples perfiles**: sin `--profile-directory`, si el `user-data-dir` contiene varios perfiles (Default, Personal, Profile 1, Automation…) Chrome se queda atascado en el selector de perfil y no carga nada — el puerto CDP responde pero no hay perfil activo y las extensiones no se procesan. Pasar `ProfileDirectory: "Default"` (o el nombre exacto del subdirectorio) para evitarlo.
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` para terminar el arbol de procesos.
- **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión. - **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión.
- **Headless en Windows via WSL2**: `--headless=new --disable-gpu` funciona bien con chrome.exe.
## Notas ## Notas
Busca Chrome en este orden: Busca Chrome en este orden (Linux nativo):
1. `chrome.exe` en PATH (disponible en WSL2 si Windows lo tiene en PATH) 1. `chromium`, `chromium-browser`, `google-chrome`, `google-chrome-stable`, `brave-browser`
2. `google-chrome` / `chromium-browser` / `chromium` (Linux nativo) 2. `chrome.exe` (ultimo recurso, normalmente no en PATH en Linux nativo)
3. `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe`
4. `/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe` Busca Chrome en este orden (WSL2):
1. `chrome.exe` en PATH
2. `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe`
3. `/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe`
4. binarios Linux como fallback
Los flags aplicados desactivan funcionalidades de red y actualizacion en segundo plano para entornos de automatizacion. En modo headless se agrega `--headless=new --disable-gpu`. Los flags aplicados desactivan funcionalidades de red y actualizacion en segundo plano para entornos de automatizacion. En modo headless se agrega `--headless=new --disable-gpu`.
@@ -79,3 +93,5 @@ El struct `ChromeLaunchOpts` se define en el mismo archivo.
## Capability growth log ## Capability growth log
- v1.1.0 (2026-05-16) — auto-handle WSL2→Windows chrome.exe: translate user-data-dir via wslpath + inject --remote-debugging-address=0.0.0.0 - v1.1.0 (2026-05-16) — auto-handle WSL2→Windows chrome.exe: translate user-data-dir via wslpath + inject --remote-debugging-address=0.0.0.0
- v1.2.0 (2026-06-05) — Linux-first: reordena busqueda (chromium antes que chrome.exe) en Linux nativo; añade KeepExtensions; setea Setpgid=true en Linux para habilitar kill-by-group en CdpClose
- v1.3.0 (2026-06-05) — añade ProfileDirectory / --profile-directory para seleccionar perfil dentro del user-data-dir (evita quedarse atascado en el selector cuando hay varios perfiles)
+103
View File
@@ -0,0 +1,103 @@
package browser
import (
"encoding/json"
"os"
"path/filepath"
"sort"
)
// ChromeProfile holds metadata about a single Chrome/Chromium profile directory.
type ChromeProfile struct {
Dir string // directory name (value for --profile-directory), e.g. "Default"
Name string // human-readable name from Local State info_cache, e.g. "Personal"
Extensions int // number of installed extension dirs under <dir>/Extensions (excluding "Temp")
HasPreferences bool // true if <dir>/Preferences file exists
}
// localState mirrors the parts of Local State we need.
type localState struct {
Profile struct {
InfoCache map[string]struct {
Name string `json:"name"`
} `json:"info_cache"`
} `json:"profile"`
}
// ListChromeProfiles scans userDataDir and returns one ChromeProfile per
// subdirectory that contains a Preferences file (excluding "System Profile").
// If userDataDir is empty it defaults to ~/.config/chromium.
// Names are resolved from Local State; if that file is missing or unparseable
// the profile Name field equals Dir.
func ListChromeProfiles(userDataDir string) ([]ChromeProfile, error) {
if userDataDir == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
userDataDir = filepath.Join(home, ".config", "chromium")
}
// Parse Local State for human-readable names. Failure is non-fatal.
names := make(map[string]string)
lsPath := filepath.Join(userDataDir, "Local State")
if data, err := os.ReadFile(lsPath); err == nil {
var ls localState
if json.Unmarshal(data, &ls) == nil {
for dir, info := range ls.Profile.InfoCache {
names[dir] = info.Name
}
}
}
entries, err := os.ReadDir(userDataDir)
if err != nil {
return nil, err
}
var profiles []ChromeProfile
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := e.Name()
if dir == "System Profile" {
continue
}
prefPath := filepath.Join(userDataDir, dir, "Preferences")
info, err := os.Stat(prefPath)
if err != nil || info.IsDir() {
continue
}
// Count extension directories (excluding "Temp").
extCount := 0
extDir := filepath.Join(userDataDir, dir, "Extensions")
if exts, err := os.ReadDir(extDir); err == nil {
for _, ext := range exts {
if ext.IsDir() && ext.Name() != "Temp" {
extCount++
}
}
}
name := names[dir]
if name == "" {
name = dir
}
profiles = append(profiles, ChromeProfile{
Dir: dir,
Name: name,
Extensions: extCount,
HasPreferences: true,
})
}
sort.Slice(profiles, func(i, j int) bool {
return profiles[i].Dir < profiles[j].Dir
})
return profiles, nil
}
+67
View File
@@ -0,0 +1,67 @@
---
name: list_chrome_profiles
kind: function
lang: go
domain: browser
version: "1.0.0"
purity: impure
signature: "func ListChromeProfiles(userDataDir string) ([]ChromeProfile, error)"
description: "Lista los perfiles de un user-data-dir de Chrome/Chromium. Devuelve Dir (nombre del directorio para --profile-directory), Name (nombre legible de Local State), Extensions (nº de carpetas en Extensions excl. Temp) y HasPreferences. Si userDataDir es vacío usa ~/.config/chromium."
tags: [chrome, chromium, browser, profile, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["encoding/json", "os", "path/filepath", "sort"]
params:
- name: userDataDir
desc: "Ruta al user-data-dir de Chrome/Chromium. Vacío = ~/.config/chromium."
output: "Slice de ChromeProfile ordenado por Dir. Error si userDataDir no existe o no es legible."
tested: true
tests:
- "detecta perfiles con Preferences"
- "ordena por Dir"
- "resuelve nombres desde Local State"
- "cuenta extensiones excluyendo Temp"
- "excluye System Profile"
- "HasPreferences es true para todos los perfiles devueltos"
- "directorio sin Preferences no aparece"
- "fallback Name igual a Dir cuando no hay Local State"
- "error si userDataDir no existe"
test_file_path: "functions/browser/list_chrome_profiles_test.go"
file_path: "functions/browser/list_chrome_profiles.go"
---
## Ejemplo
```go
// Lista todos los perfiles del Chromium del usuario
profiles, err := browser.ListChromeProfiles("")
if err != nil {
log.Fatal(err)
}
for _, p := range profiles {
fmt.Printf("--profile-directory=%q name=%q extensions=%d\n",
p.Dir, p.Name, p.Extensions)
}
// Output:
// --profile-directory="Automation" name="Automation" extensions=1
// --profile-directory="Default" name="Personal" extensions=12
// --profile-directory="Profile 1" name="Work" extensions=4
// Con ruta explícita (ej. Chrome en ubicación no estándar)
profiles, err = browser.ListChromeProfiles("/home/user/.config/google-chrome")
```
## Cuando usarla
Antes de lanzar Chrome/Chromium con `chrome_launch_go_browser` cuando hay múltiples perfiles y quieres pasar `--profile-directory` al proceso. Sin elegir perfil, Chrome queda bloqueado en el selector de cuentas.
## Gotchas
- **Conteo de extensiones es de carpetas, no de extensiones activas.** Las carpetas de extensiones deshabilitadas o desinstaladas permanecen en disco (cache de Chrome) y se cuentan igualmente. El número es un indicador aproximado de actividad del perfil, no una lista exacta de extensiones habilitadas.
- **Local State puede no existir** si el perfil es nuevo o fue creado manualmente. En ese caso `Name` cae al valor de `Dir` (sin error).
- **Profile Directory ≠ Profile Name.** El argumento `--profile-directory` del binario Chrome acepta el valor de `ChromeProfile.Dir` (ej. `"Profile 1"`), no el `Name` legible.
- **"System Profile"** existe en Chrome pero no es un perfil de usuario; siempre se excluye.
- En Chrome (Google) el default suele ser `~/.config/google-chrome`; en Chromium `~/.config/chromium`. Pasar ruta explícita si se usa Google Chrome.
@@ -0,0 +1,136 @@
package browser
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestListChromeProfiles(t *testing.T) {
// Build a temporary user-data-dir that mimics a real Chrome layout.
tmpDir := t.TempDir()
// --- Local State with info_cache ---
localStateData := map[string]any{
"profile": map[string]any{
"info_cache": map[string]any{
"Default": map[string]any{"name": "Main Account"},
"Profile 1": map[string]any{"name": "Work"},
},
},
}
lsBytes, _ := json.Marshal(localStateData)
if err := os.WriteFile(filepath.Join(tmpDir, "Local State"), lsBytes, 0o600); err != nil {
t.Fatal(err)
}
// --- Default profile: Preferences + 2 extensions ---
defaultDir := filepath.Join(tmpDir, "Default")
os.MkdirAll(filepath.Join(defaultDir, "Extensions", "extAAA"), 0o755)
os.MkdirAll(filepath.Join(defaultDir, "Extensions", "extBBB"), 0o755)
os.MkdirAll(filepath.Join(defaultDir, "Extensions", "Temp"), 0o755) // must be excluded
os.WriteFile(filepath.Join(defaultDir, "Preferences"), []byte("{}"), 0o600)
// --- Profile 1: Preferences + 0 extensions ---
prof1Dir := filepath.Join(tmpDir, "Profile 1")
os.MkdirAll(prof1Dir, 0o755)
os.WriteFile(filepath.Join(prof1Dir, "Preferences"), []byte("{}"), 0o600)
// --- System Profile: must be excluded ---
sysDir := filepath.Join(tmpDir, "System Profile")
os.MkdirAll(sysDir, 0o755)
os.WriteFile(filepath.Join(sysDir, "Preferences"), []byte("{}"), 0o600)
// --- Random dir without Preferences: must be excluded ---
os.MkdirAll(filepath.Join(tmpDir, "Crashpad"), 0o755)
t.Run("detecta perfiles con Preferences", func(t *testing.T) {
profiles, err := ListChromeProfiles(tmpDir)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if len(profiles) != 2 {
t.Fatalf("esperaba 2 perfiles, got %d", len(profiles))
}
})
t.Run("ordena por Dir", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
if profiles[0].Dir != "Default" || profiles[1].Dir != "Profile 1" {
t.Errorf("orden incorrecto: %v", profiles)
}
})
t.Run("resuelve nombres desde Local State", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
if profiles[0].Name != "Main Account" {
t.Errorf("Default: Name = %q, want %q", profiles[0].Name, "Main Account")
}
if profiles[1].Name != "Work" {
t.Errorf("Profile 1: Name = %q, want %q", profiles[1].Name, "Work")
}
})
t.Run("cuenta extensiones excluyendo Temp", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
if profiles[0].Extensions != 2 {
t.Errorf("Default: Extensions = %d, want 2", profiles[0].Extensions)
}
if profiles[1].Extensions != 0 {
t.Errorf("Profile 1: Extensions = %d, want 0", profiles[1].Extensions)
}
})
t.Run("excluye System Profile", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
for _, p := range profiles {
if p.Dir == "System Profile" {
t.Error("System Profile no debe aparecer en la lista")
}
}
})
t.Run("HasPreferences es true para todos los perfiles devueltos", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
for _, p := range profiles {
if !p.HasPreferences {
t.Errorf("perfil %q: HasPreferences debe ser true", p.Dir)
}
}
})
t.Run("directorio sin Preferences no aparece", func(t *testing.T) {
profiles, _ := ListChromeProfiles(tmpDir)
for _, p := range profiles {
if p.Dir == "Crashpad" {
t.Error("Crashpad no tiene Preferences y no debe aparecer")
}
}
})
t.Run("fallback Name igual a Dir cuando no hay Local State", func(t *testing.T) {
tmp2 := t.TempDir()
p2 := filepath.Join(tmp2, "Profile 2")
os.MkdirAll(p2, 0o755)
os.WriteFile(filepath.Join(p2, "Preferences"), []byte("{}"), 0o600)
profiles, err := ListChromeProfiles(tmp2)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if len(profiles) != 1 {
t.Fatalf("esperaba 1 perfil, got %d", len(profiles))
}
if profiles[0].Name != "Profile 2" {
t.Errorf("Name = %q, want %q", profiles[0].Name, "Profile 2")
}
})
t.Run("error si userDataDir no existe", func(t *testing.T) {
_, err := ListChromeProfiles("/tmp/nonexistent_chrome_dir_99999")
if err == nil {
t.Error("esperaba error para directorio inexistente")
}
})
}