216cad4c12
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).
173 lines
5.8 KiB
Go
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())
|
|
}
|