Files
fn_registry/functions/browser/cdp_wait_idle.go
T
egutierrez 216cad4c12 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).
2026-06-13 14:27:10 +02:00

173 lines
5.8 KiB
Go

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 2)
PollMs int // intervalo de chequeo en ms (default 100)
}
// isPersistentResourceType indica si un Network resourceType corresponde a una
// conexion de larga duracion que NO emite loadingFinished/loadingFailed y por
// tanto colgaria el contador inflight para siempre. La pagina abre estas
// conexiones (analytics en vivo, push, hot-reload) y nunca "terminan".
func isPersistentResourceType(resourceType string) bool {
switch resourceType {
case "WebSocket", "EventSource":
return true
default:
return false
}
}
// InflightTracker cuenta requests de red en vuelo de forma pura y testeable: no
// toca red ni CDP, solo recibe eventos ya parseados (requestId + resourceType) y
// mantiene el conjunto de requests activos. Trackea por requestId para que el
// loadingFinished/loadingFailed de un request que nunca contamos (una conexion
// persistente) sea un no-op en vez de un decremento espurio.
//
// Las conexiones persistentes (WebSocket, EventSource) se excluyen del conteo
// porque no emiten un evento de finalizacion: contarlas haria que la red nunca
// pareciera idle.
type InflightTracker struct {
mu sync.Mutex
tracked map[string]bool
}
// NewInflightTracker crea un tracker vacio listo para recibir eventos.
func NewInflightTracker() *InflightTracker {
return &InflightTracker{tracked: map[string]bool{}}
}
// OnRequest registra el inicio de un request (Network.requestWillBeSent). Ignora
// las conexiones persistentes para no contaminar el conteo.
func (t *InflightTracker) OnRequest(requestID, resourceType string) {
if isPersistentResourceType(resourceType) {
return
}
t.mu.Lock()
t.tracked[requestID] = true
t.mu.Unlock()
}
// OnFinish marca un request como completado (Network.loadingFinished).
func (t *InflightTracker) OnFinish(requestID string) { t.complete(requestID) }
// OnFail marca un request como fallido (Network.loadingFailed). A efectos de
// inflight, fallar y terminar son lo mismo: el request ya no esta en vuelo.
func (t *InflightTracker) OnFail(requestID string) { t.complete(requestID) }
func (t *InflightTracker) complete(requestID string) {
t.mu.Lock()
delete(t.tracked, requestID)
t.mu.Unlock()
}
// Inflight retorna el numero de requests actualmente en vuelo.
func (t *InflightTracker) Inflight() int {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.tracked)
}
// IsIdle indica si el numero de requests en vuelo esta dentro del umbral dado.
func (t *InflightTracker) IsIdle(maxInflight int) bool {
return t.Inflight() <= maxInflight
}
// 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 y delega el conteo
// en un InflightTracker. 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.
//
// 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 default 2: la web moderna mantiene 1-2 beacons/analytics de
// fondo que casi nunca dejan inflight en 0; exigir 0 cuelga hasta el timeout.
if opts.MaxInflight <= 0 {
opts.MaxInflight = 2
}
if opts.PollMs <= 0 {
opts.PollMs = 100
}
tracker := NewInflightTracker()
// 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) {
id, _ := p["requestId"].(string)
typ, _ := p["type"].(string)
tracker.OnRequest(id, typ)
})
defer cancel1()
cancel2 := c.OnEvent("Network.loadingFinished", func(_ string, p map[string]any) {
id, _ := p["requestId"].(string)
tracker.OnFinish(id)
})
defer cancel2()
cancel3 := c.OnEvent("Network.loadingFailed", func(_ string, p map[string]any) {
id, _ := p["requestId"].(string)
tracker.OnFail(id)
})
defer cancel3()
// 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)
}
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)
if tracker.IsIdle(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{}
}
}
return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, tracker.Inflight())
}