From 216cad4c12215ca4627306b63529f739a7d04343 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 14:27:10 +0200 Subject: [PATCH] =?UTF-8?q?perf(browser):=20acelera=20CDP=20=E2=80=94=20en?= =?UTF-8?q?able=20cacheado,=20wait=5Fload=20por=20evento,=20timeout=20en?= =?UTF-8?q?=20sendCDP,=20escritura=20insertText?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- functions/browser/cdp_click_xy_human.go | 4 +- functions/browser/cdp_conn.go | 75 +++++++++++++++++++++-- functions/browser/cdp_get_ax_outline.go | 6 +- functions/browser/cdp_move_mouse_human.go | 15 ++--- functions/browser/cdp_type_ref.go | 14 +++++ functions/browser/cdp_type_text.go | 60 +++++++++++++----- functions/browser/cdp_wait_idle.go | 9 ++- functions/browser/cdp_wait_load.go | 50 ++++++++++----- 8 files changed, 183 insertions(+), 50 deletions(-) diff --git a/functions/browser/cdp_click_xy_human.go b/functions/browser/cdp_click_xy_human.go index 30afbe4a..a52d7250 100644 --- a/functions/browser/cdp_click_xy_human.go +++ b/functions/browser/cdp_click_xy_human.go @@ -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 diff --git a/functions/browser/cdp_conn.go b/functions/browser/cdp_conn.go index e5eaffff..56dcee6c 100644 --- a/functions/browser/cdp_conn.go +++ b/functions/browser/cdp_conn.go @@ -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 diff --git a/functions/browser/cdp_get_ax_outline.go b/functions/browser/cdp_get_ax_outline.go index a9f5f61c..fb99ac90 100644 --- a/functions/browser/cdp_get_ax_outline.go +++ b/functions/browser/cdp_get_ax_outline.go @@ -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) } diff --git a/functions/browser/cdp_move_mouse_human.go b/functions/browser/cdp_move_mouse_human.go index 243b7811..f07e508e 100644 --- a/functions/browser/cdp_move_mouse_human.go +++ b/functions/browser/cdp_move_mouse_human.go @@ -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 diff --git a/functions/browser/cdp_type_ref.go b/functions/browser/cdp_type_ref.go index aa6ed59d..aa8eb84d 100644 --- a/functions/browser/cdp_type_ref.go +++ b/functions/browser/cdp_type_ref.go @@ -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) +} diff --git a/functions/browser/cdp_type_text.go b/functions/browser/cdp_type_text.go index 0be2f30f..b97c4d33 100644 --- a/functions/browser/cdp_type_text.go +++ b/functions/browser/cdp_type_text.go @@ -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 +} diff --git a/functions/browser/cdp_wait_idle.go b/functions/browser/cdp_wait_idle.go index 8813ca80..fd8d190f 100644 --- a/functions/browser/cdp_wait_idle.go +++ b/functions/browser/cdp_wait_idle.go @@ -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 diff --git a/functions/browser/cdp_wait_load.go b/functions/browser/cdp_wait_load.go index 4d0c0d17..d88b75e9 100644 --- a/functions/browser/cdp_wait_load.go +++ b/functions/browser/cdp_wait_load.go @@ -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) + } }