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
+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 (
"fmt"
"os"
"syscall"
)
// 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.
// Retorna el primer error encontrado.
func CdpClose(c *CDPConn, pid int) error {
@@ -19,16 +22,19 @@ func CdpClose(c *CDPConn, pid int) error {
}
if pid > 0 {
proc, err := os.FindProcess(pid)
if err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: encontrar proceso %d: %w", pid, err)
}
} else {
if err := proc.Kill(); err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("cdp close: matar proceso %d: %w", pid, err)
// Intentar matar el grupo de proceso completo (pid == pgid cuando Setpgid=true).
// syscall.Kill con pid negativo envia la señal a todos los procesos del grupo.
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
// Fallback: matar solo el proceso raiz si el grupo falla
// (ej: proceso ya terminado, o chrome.exe en WSL sin Setpgid).
if proc, e := os.FindProcess(pid); e == nil {
if killErr := proc.Kill(); killErr != nil {
if firstErr == nil {
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
lang: go
domain: browser
version: "1.0.0"
version: "1.1.0"
purity: impure
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."
tags: [chrome, cdp, browser, automation, cleanup, devtools]
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, linux]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os]
imports: [fmt, os, syscall]
params:
- name: c
desc: "conexión CDP (puede ser nil)"
desc: "conexión CDP (puede ser nil para solo matar el proceso)"
- name: pid
desc: "PID del proceso Chrome (0 para no matar)"
output: "error si falla la desconexión o el cierre del proceso"
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 desconexion o el cierre del proceso; nil si todo OK"
tested: false
tests: []
test_file_path: ""
@@ -32,13 +32,28 @@ file_path: "functions/browser/cdp_close.go"
pid, _ := ChromeLaunch(ChromeLaunchOpts{Port: 9222, Headless: true})
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:
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
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"
"regexp"
"strings"
"syscall"
"time"
)
@@ -25,6 +26,13 @@ type ChromeLaunchOpts struct {
ChromePath string
// ExtraArgs permite pasar flags adicionales a Chrome.
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.).
@@ -74,20 +82,43 @@ func defaultWindowsUserDataDir() (string, error) {
return translateUserDataDirForWindows(linuxPath)
}
// chromePaths lista los ejecutables de Chrome conocidos en WSL2/Linux.
var chromePaths = []string{
"chrome.exe",
"google-chrome",
"chromium-browser",
// chromePathsLinux lista los binarios Linux-nativos de Chrome en orden de preferencia.
var chromePathsLinux = []string{
"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 (x86)/Google/Chrome/Application/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.
// En Linux nativo busca primero binarios Linux; en WSL2 busca primero chrome.exe.
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 {
return path, nil
}
@@ -95,7 +126,7 @@ func findChrome() (string, error) {
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.
@@ -187,7 +218,6 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
"--disable-background-networking",
"--disable-client-side-phishing-detection",
"--disable-default-apps",
"--disable-extensions",
"--disable-hang-monitor",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
@@ -197,6 +227,12 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
"--safebrowsing-disable-auto-update",
"--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 {
args = append(args, "--headless=new", "--disable-gpu")
@@ -222,6 +258,14 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
cmd.Stdout = 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 {
return 0, fmt.Errorf("chrome: arrancar proceso: %w", err)
}
+34 -18
View File
@@ -3,20 +3,20 @@ name: chrome_launch
kind: function
lang: go
domain: browser
version: "1.1.0"
version: "1.3.0"
purity: impure
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."
tags: [chrome, cdp, browser, automation, wsl2, headless, navegator]
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, linux]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
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:
- 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"
tested: true
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect"]
@@ -27,7 +27,7 @@ file_path: "functions/browser/chrome_launch.go"
## Ejemplo
```go
// Linux nativo (sin WSL2 o con Linux Chrome)
// Linux nativo: chromium se detecta automaticamente, grupo de proceso propio
pid, err := ChromeLaunch(ChromeLaunchOpts{
Port: 9222,
Headless: true,
@@ -35,12 +35,20 @@ pid, err := ChromeLaunch(ChromeLaunchOpts{
if err != nil {
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
// 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{})
if err != nil {
log.Fatal(err)
@@ -51,26 +59,32 @@ conn, err := CdpConnect(9222)
## 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
- **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:
- `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>`.
- `waitCDPReady` siempre espera usando `127.0.0.1` (WSL networking reenvía localhost → Windows).
- **`wslpath` debe estar disponible**: se invoca como subproceso. Si falla, `ChromeLaunch` retorna error. `wslpath` es estándar en WSL2 desde Windows 10 1903+.
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` o `os.FindProcess(pid).Kill()` para terminarlo.
- **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.
- **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).
- **`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.
- **Headless en Windows via WSL2**: `--headless=new --disable-gpu` funciona bien con chrome.exe.
## Notas
Busca Chrome en este orden:
1. `chrome.exe` en PATH (disponible en WSL2 si Windows lo tiene en PATH)
2. `google-chrome` / `chromium-browser` / `chromium` (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 (Linux nativo):
1. `chromium`, `chromium-browser`, `google-chrome`, `google-chrome-stable`, `brave-browser`
2. `chrome.exe` (ultimo recurso, normalmente no en PATH en Linux nativo)
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`.
@@ -79,3 +93,5 @@ El struct `ChromeLaunchOpts` se define en el mismo archivo.
## 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.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")
}
})
}