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:
@@ -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"
|
||||||
@@ -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 (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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user