perf(browser): acelera CDP — enable cacheado, wait_load por evento, timeout en sendCDP, escritura insertText

Optimiza el dominio browser para que el manejo del navegador via CDP sea mucho más rápido en automatización propia, manteniendo el camino sigiloso disponible.

- CDPConn cachea los enable de Accessibility/Network/Page por conexión (ensureAX/ensureNetwork/ensurePage): elimina un round-trip redundante en cada percepción y espera, que son las operaciones más frecuentes del bucle percibir->actuar del agente.
- sendCDP adquiere timeout (cdpCmdTimeout 30s): antes una respuesta que Chrome nunca enviaba colgaba la goroutine del tool indefinidamente; ahora falla limpio y el retry puede reconectar.
- CdpWaitLoad pasa de polling de document.readyState cada 200ms a esperar el evento Page.loadEventFired, con fast path inicial de readyState y re-chequeo anti-carrera tras suscribir. Si la página ya está cargada retorna en microsegundos.
- cdp_wait_idle usa ensureNetwork y deja de hacer Network.disable al salir (borraba el estado y forzaba el enable de nuevo).
- Nuevas funciones de escritura rápida: CdpInsertText (todo el texto en un solo Input.insertText) y CdpTypeRefFast (focus + insertText). El chequeo de foco se extrajo a assertEditableFocus, compartido con CdpTypeText.
- CdpTypeText pasa su pausa entre caracteres de 10ms fija a aleatoria 15-65ms (ritmo humano irregular).
- El modo 'auto' se añade al perfil de ratón (MouseProfileForMode, mouseHumanDefaults, clickPauseMs) como alias rápido de 'fast'.

No se tocan las firmas públicas existentes; CdpTypeRef y CdpTypeText conservan su comportamiento (camino human).
This commit is contained in:
2026-06-13 14:27:10 +02:00
parent 167a7e5eb7
commit 216cad4c12
8 changed files with 183 additions and 50 deletions
+2 -2
View File
@@ -51,12 +51,12 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error {
}
// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de
// velocidad: human 30-90, fast 5-15, instant 0.
// velocidad: human 30-90, auto/fast 5-15, instant 0.
func clickPauseMs(mode string) int {
switch mode {
case "instant":
return 0
case "fast":
case "fast", "auto":
return 5 + rand.Intn(11) // 5..15
default: // "human" o ""
return 30 + rand.Intn(61) // 30..90
+70 -5
View File
@@ -14,8 +14,16 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
)
// cdpCmdTimeout es el tope que sendCDP espera por la respuesta a un comando antes
// de rendirse. Sin el, una respuesta que Chrome nunca envia (tab cerrada a media
// peticion, proceso colgado) bloquearia la goroutine del tool para siempre — el
// agente lo percibe como "lentitud infinita". Con el timeout, el tool falla limpio
// y el retry de withConn puede reconectar.
const cdpCmdTimeout = 30 * time.Second
// EventHandler es invocado cuando llega un evento CDP del metodo subscrito.
// El handler corre en la goroutine del readLoop — debe ser rapido o despachar
// a un canal/goroutine propio. params puede ser nil si Chrome no envia.
@@ -36,6 +44,15 @@ type CDPConn struct {
handlers map[string][]EventHandler
hMu sync.Mutex
// axEnabled/netEnabled/pageEnabled cachean si ya enviamos el enable de cada
// dominio CDP en esta conexion. enable/disable es idempotente pero cuesta un
// round-trip; en el hot path del agente (percibir->actuar repetido) re-enviar
// Accessibility.enable / Network.enable en cada llamada duplica los RTT.
// Habilitar una vez y cachear el flag elimina ese coste por percepcion/espera.
axEnabled atomic.Bool
netEnabled atomic.Bool
pageEnabled atomic.Bool
// frameCtx cachea el executionContextId del isolated world por frameID, para
// que CdpEvalInFrame no cree un mundo aislado nuevo en cada llamada.
// frameCtxMu protege solo el lazy-init del puntero (el cache tiene su mutex).
@@ -250,12 +267,60 @@ func (c *CDPConn) sendCDP(method string, params map[string]any) (map[string]any,
return nil, fmt.Errorf("cdp send %s: %w", method, err)
}
// Esperar respuesta
resp := <-ch
if resp.Error != nil {
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
// Esperar respuesta (con timeout para no colgar el tool indefinidamente).
select {
case resp := <-ch:
if resp.Error != nil {
return nil, fmt.Errorf("cdp %s: error %d: %s", method, resp.Error.Code, resp.Error.Message)
}
return resp.Result, nil
case <-time.After(cdpCmdTimeout):
c.pendMu.Lock()
delete(c.pending, id)
c.pendMu.Unlock()
return nil, fmt.Errorf("cdp %s: sin respuesta tras %s (conexion colgada?)", method, cdpCmdTimeout)
}
return resp.Result, nil
}
// ensureAX habilita el dominio Accessibility una sola vez por conexion (necesario
// antes de Accessibility.getFullAXTree). Idempotente y cacheado: la segunda y
// sucesivas llamadas son no-op, evitando un round-trip por percepcion.
func (c *CDPConn) ensureAX() error {
if c.axEnabled.Load() {
return nil
}
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
return err
}
c.axEnabled.Store(true)
return nil
}
// ensureNetwork habilita el dominio Network una sola vez por conexion. Cacheado:
// no lo deshabilitamos al terminar una espera (eso borraria el estado y forzaria
// el enable de nuevo); los handlers de eventos se desregistran por su cancel().
func (c *CDPConn) ensureNetwork() error {
if c.netEnabled.Load() {
return nil
}
if _, err := c.sendCDP("Network.enable", nil); err != nil {
return err
}
c.netEnabled.Store(true)
return nil
}
// ensurePage habilita el dominio Page una sola vez por conexion (necesario para
// recibir Page.loadEventFired y demas eventos de ciclo de vida de la pagina).
func (c *CDPConn) ensurePage() error {
if c.pageEnabled.Load() {
return nil
}
if _, err := c.sendCDP("Page.enable", nil); err != nil {
return err
}
c.pageEnabled.Store(true)
return nil
}
// readLoop lee mensajes del WebSocket y los enruta a los canales pendientes
+4 -2
View File
@@ -72,8 +72,10 @@ func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error) {
return "", fmt.Errorf("cdp get ax outline: conexion nula")
}
// Accessibility.enable es idempotente; necesario antes de getFullAXTree.
if _, err := c.sendCDP("Accessibility.enable", nil); err != nil {
// Accessibility.enable (idempotente, cacheado por conexion): necesario antes de
// getFullAXTree. Cachear el flag evita un round-trip extra en cada percepcion,
// que es la operacion mas frecuente del bucle percibir->actuar del agente.
if err := c.ensureAX(); err != nil {
return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err)
}
+8 -7
View File
@@ -9,11 +9,12 @@ import (
// MouseHumanOpts configura el movimiento humano del ratón.
type MouseHumanOpts struct {
// Mode es la política de velocidad: "human" (default, ""), "fast" o "instant".
// Controla los defaults de Steps/DurationMs/JitterPx y la pausa press/release:
// Mode es la política de velocidad: "auto"/"fast" (rápido), "human" (sigiloso,
// también "") o "instant". Controla los defaults de Steps/DurationMs/JitterPx y
// la pausa press/release:
// - auto/fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
// rápido — modo por defecto del MCP para automatización propia).
// - human: Bézier ~25 pts, 350-800ms, jitter 2px (sigilo anti-bot alto).
// - fast: recta ~5 pts, 40-80ms, jitter mínimo (eventos de ratón reales,
// para scraping masivo propio).
// - instant: sin movimiento de ratón (CdpMoveMouseHuman es no-op); el click
// por #ref usa element.click() JS. Para tests y fallback sin bbox.
// Los valores explícitos (Steps/DurationMs/JitterPx != 0) ganan al preset del modo.
@@ -37,7 +38,7 @@ type MouseHumanOpts struct {
// Un modo desconocido se trata como "human" (el más seguro).
func MouseProfileForMode(mode string) MouseHumanOpts {
switch mode {
case "fast", "instant", "human", "":
case "auto", "fast", "instant", "human", "":
return MouseHumanOpts{Mode: mode, FromX: -1, FromY: -1}
default:
return MouseHumanOpts{Mode: "human", FromX: -1, FromY: -1}
@@ -56,14 +57,14 @@ func mouseHumanDefaults(opts MouseHumanOpts) MouseHumanOpts {
opts.DurationMs = 1
}
// JitterPx se queda en 0.
case "fast":
case "fast", "auto":
if opts.Steps <= 0 {
opts.Steps = 5
}
if opts.DurationMs <= 0 {
opts.DurationMs = 40 + rand.Intn(41) // 40..80
}
// JitterPx se queda en lo recibido (0 por defecto, sin jitter en fast).
// JitterPx se queda en lo recibido (0 por defecto, sin jitter en fast/auto).
default: // "human" o ""
if opts.Steps <= 0 {
opts.Steps = 25
+14
View File
@@ -14,3 +14,17 @@ func CdpTypeRef(c *CDPConn, backendNodeID int, text string) error {
}
return CdpTypeText(c, text)
}
// CdpTypeRefFast enfoca el elemento del #ref e inserta el texto en UN solo
// round-trip (Input.insertText), sin teclear caracter por caracter. Es el camino
// rápido del modo automático: equivale a focus(ref) → CdpInsertText. Para sitios
// con detección por pulsación usa CdpTypeRef (modo human, char por char).
func CdpTypeRefFast(c *CDPConn, backendNodeID int, text string) error {
if c == nil {
return fmt.Errorf("cdp type ref fast: conexión nil")
}
if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil {
return fmt.Errorf("cdp type ref fast: focus ref %d: %w", backendNodeID, err)
}
return CdpInsertText(c, text)
}
+45 -15
View File
@@ -2,27 +2,38 @@ package browser
import (
"fmt"
"math/rand"
"strings"
"time"
)
// CdpTypeText escribe texto en el elemento activo de la pagina caracter por caracter.
// Usa Input.dispatchKeyEvent para simular pulsaciones de teclado reales.
// Recomienda usar CdpClick primero para enfocar el elemento objetivo.
// assertEditableFocus verifica que el activeElement de la pagina acepta texto
// (input/textarea/select/contentEditable). Sin foco, los caracteres se pierden
// silenciosamente (van a document.body); devolvemos un error claro en vez de
// "escribir a la nada". Compartido por CdpTypeText (camino human) y CdpInsertText
// (camino rapido).
func assertEditableFocus(c *CDPConn) error {
focus, ferr := CdpEvaluate(c, `(function(){var a=document.activeElement;if(!a)return 'none';var t=a.tagName.toLowerCase();return (t==='input'||t==='textarea'||t==='select'||a.isContentEditable)?'ok':t;})()`)
if ferr != nil {
return fmt.Errorf("verificar foco: %w", ferr)
}
if strings.TrimSpace(focus) != "ok" {
return fmt.Errorf("no hay campo de texto enfocado (activeElement: %s); enfoca el input primero", strings.TrimSpace(focus))
}
return nil
}
// CdpTypeText escribe texto en el elemento activo de la pagina caracter por
// caracter, con una pausa ALEATORIA entre teclas. Es el camino "human": emite
// keyDown/keyUp reales por tecla (sitios que validan pulsacion a pulsacion
// reaccionan) y el ritmo irregular reduce la deteccion de automatizacion. Para el
// camino rapido (modo auto) usa CdpInsertText: un solo round-trip, sin teclear.
func CdpTypeText(c *CDPConn, text string) error {
if c == nil {
return fmt.Errorf("cdp type text: conexion nula")
}
// Verificar que hay un campo editable enfocado. Sin foco, los caracteres se
// pierden silenciosamente (van a document.body). Devolvemos error claro en vez
// de "escribir a la nada".
focus, ferr := CdpEvaluate(c, `(function(){var a=document.activeElement;if(!a)return 'none';var t=a.tagName.toLowerCase();return (t==='input'||t==='textarea'||t==='select'||a.isContentEditable)?'ok':t;})()`)
if ferr != nil {
return fmt.Errorf("cdp type text: verificar foco: %w", ferr)
}
if strings.TrimSpace(focus) != "ok" {
return fmt.Errorf("cdp type text: no hay campo de texto enfocado (activeElement: %s); usa CdpClick sobre el input primero", strings.TrimSpace(focus))
if err := assertEditableFocus(c); err != nil {
return fmt.Errorf("cdp type text: %w", err)
}
// keyDown (con `text`) ya inserta el caracter en el elemento focado en
@@ -49,9 +60,28 @@ func CdpTypeText(c *CDPConn, text string) error {
return fmt.Errorf("cdp type text: keyUp %q: %w", charStr, err)
}
// Pequena pausa entre caracteres para simular escritura humana.
time.Sleep(10 * time.Millisecond)
// Pausa ALEATORIA entre caracteres (15-65 ms) para imitar el ritmo
// irregular de un humano escribiendo, en vez de un intervalo de maquina fijo.
time.Sleep(time.Duration(15+rand.Intn(51)) * time.Millisecond)
}
return nil
}
// CdpInsertText inserta todo el texto en el elemento enfocado en UN solo
// round-trip via Input.insertText. Es el camino rapido del modo automatico: no
// emite keyDown/keyUp por tecla, por lo que sitios que validan pulsacion a
// pulsacion (autocompletes muy estrictos) pueden no reaccionar — para esos casos
// usa CdpTypeText (modo human). Requiere un campo editable enfocado.
func CdpInsertText(c *CDPConn, text string) error {
if c == nil {
return fmt.Errorf("cdp insert text: conexion nula")
}
if err := assertEditableFocus(c); err != nil {
return fmt.Errorf("cdp insert text: %w", err)
}
if _, err := c.sendCDP("Input.insertText", map[string]any{"text": text}); err != nil {
return fmt.Errorf("cdp insert text: %w", err)
}
return nil
}
+6 -3
View File
@@ -136,11 +136,14 @@ func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error {
})
defer cancel3()
// Habilitar dominio Network (igual que cdp_har_record).
if _, err := c.sendCDP("Network.enable", nil); err != nil {
// Habilitar dominio Network (idempotente, cacheado por conexion). NO lo
// deshabilitamos al salir: Network.disable borraria el estado y el siguiente
// wait_idle pagaria el enable de nuevo (round-trip extra). Los handlers de
// eventos se desregistran por sus cancel() de defer, que es lo unico necesario
// para dejar de contar.
if err := c.ensureNetwork(); 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
+34 -16
View File
@@ -6,9 +6,11 @@ import (
)
// CdpWaitLoad espera a que la página actual termine de cargar completamente.
// Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta
// que sea "complete", o hasta que se agote el timeout.
// Retorna error si el timeout se agota o si CdpEvaluate falla (conexion rota).
// Bloquea hasta recibir el evento CDP Page.loadEventFired (sin polling): suscribe
// el evento via OnEvent y espera en un canal con timeout. Antes de esperar hace un
// fast path comprobando document.readyState — si la página ya está "complete",
// retorna de inmediato sin armar el handler.
// Retorna error si el timeout se agota o si no logra habilitar el dominio Page.
func CdpWaitLoad(c *CDPConn, timeout time.Duration) error {
if c == nil {
return fmt.Errorf("cdp wait load: conexion nula")
@@ -17,19 +19,35 @@ func CdpWaitLoad(c *CDPConn, timeout time.Duration) error {
timeout = 30 * time.Second
}
deadline := time.Now().Add(timeout)
interval := 200 * time.Millisecond
for time.Now().Before(deadline) {
result, err := CdpEvaluate(c, "document.readyState")
if err != nil {
return fmt.Errorf("cdp wait load: error evaluando readyState: %w", err)
}
if result == "complete" {
return nil
}
time.Sleep(interval)
// Fast path: si el documento ya terminó de cargar, no esperamos eventos.
if rs, err := CdpEvaluate(c, "document.readyState"); err == nil && rs == "complete" {
return nil
}
return fmt.Errorf("cdp wait load: pagina no cargo despues de %s", timeout)
// Habilitar Page (idempotente, cacheado) y suscribir el evento de carga.
if err := c.ensurePage(); err != nil {
return fmt.Errorf("cdp wait load: Page.enable: %w", err)
}
loaded := make(chan struct{}, 1)
cancel := c.OnEvent("Page.loadEventFired", func(_ string, _ map[string]any) {
select {
case loaded <- struct{}{}:
default:
}
})
defer cancel()
// Re-chequear readyState tras suscribir: si la carga terminó entre el fast
// path y el registro del handler, ya no llegaría el evento (carrera) — lo
// captamos aquí en vez de colgarnos hasta el timeout.
if rs, err := CdpEvaluate(c, "document.readyState"); err == nil && rs == "complete" {
return nil
}
select {
case <-loaded:
return nil
case <-time.After(timeout):
return fmt.Errorf("cdp wait load: pagina no cargo despues de %s", timeout)
}
}