Files
fn_registry/functions/browser/cdp_conn.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

424 lines
12 KiB
Go

package browser
import (
"bufio"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"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.
type EventHandler func(method string, params map[string]any)
// CDPConn es una conexion activa al Chrome DevTools Protocol.
// Gestiona el WebSocket raw y el protocolo JSON-RPC de CDP.
type CDPConn struct {
conn net.Conn
reader *bufio.Reader
mu sync.Mutex
nextID atomic.Int64
port int
pid int
pending map[int64]chan cdpResponse
pendMu sync.Mutex
closed bool
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).
frameCtx *frameCtxCache
frameCtxMu sync.Mutex
}
type cdpRequest struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params,omitempty"`
}
type cdpResponse struct {
ID int64 `json:"id"`
Result map[string]any `json:"result"`
Error *cdpError `json:"error"`
Method string `json:"method"` // para eventos
Params map[string]any `json:"params"` // para eventos
}
type cdpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// cdpVersionResponse es la respuesta de /json/version del endpoint CDP.
type cdpVersionResponse struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
Browser string `json:"Browser"`
}
// wsHandshake realiza el handshake WebSocket RFC 6455 sobre una conexion TCP ya abierta.
func wsHandshake(conn net.Conn, host, path string) (*bufio.Reader, error) {
// Generar clave aleatoria de 16 bytes en base64
keyBytes := make([]byte, 16)
if _, err := rand.Read(keyBytes); err != nil {
return nil, fmt.Errorf("ws handshake: generar clave: %w", err)
}
key := base64.StdEncoding.EncodeToString(keyBytes)
// Enviar request HTTP upgrade
req := fmt.Sprintf(
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n",
path, host, key,
)
if _, err := fmt.Fprint(conn, req); err != nil {
return nil, fmt.Errorf("ws handshake: enviar upgrade: %w", err)
}
// Leer respuesta HTTP
reader := bufio.NewReaderSize(conn, 65536)
status, err := reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("ws handshake: leer status: %w", err)
}
if !strings.Contains(status, "101") {
return nil, fmt.Errorf("ws handshake: status inesperado: %s", strings.TrimSpace(status))
}
// Consumir headers hasta linea vacia
var acceptKey string
for {
line, err := reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("ws handshake: leer headers: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if strings.HasPrefix(strings.ToLower(line), "sec-websocket-accept:") {
acceptKey = strings.TrimSpace(line[len("sec-websocket-accept:"):])
}
}
// Verificar Sec-WebSocket-Accept
magic := "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
h := sha1.New()
h.Write([]byte(key + magic))
expected := base64.StdEncoding.EncodeToString(h.Sum(nil))
if acceptKey != expected {
return nil, fmt.Errorf("ws handshake: accept key invalida: got %q, want %q", acceptKey, expected)
}
return reader, nil
}
// wsReadMessage lee un frame WebSocket y retorna el payload.
// Solo soporta frames de texto/binario no fragmentados (suficiente para CDP).
func wsReadMessage(reader *bufio.Reader) ([]byte, error) {
// Leer primeros 2 bytes del frame
header := make([]byte, 2)
if _, err := io.ReadFull(reader, header); err != nil {
return nil, fmt.Errorf("ws read: header: %w", err)
}
// fin := (header[0] & 0x80) != 0 // ignoramos fragmentacion
opcode := header[0] & 0x0F
masked := (header[1] & 0x80) != 0
payloadLen := int64(header[1] & 0x7F)
if opcode == 8 {
return nil, fmt.Errorf("ws read: connection close frame")
}
// Leer longitud extendida
switch payloadLen {
case 126:
var ext uint16
if err := binary.Read(reader, binary.BigEndian, &ext); err != nil {
return nil, fmt.Errorf("ws read: extended len 16: %w", err)
}
payloadLen = int64(ext)
case 127:
var ext uint64
if err := binary.Read(reader, binary.BigEndian, &ext); err != nil {
return nil, fmt.Errorf("ws read: extended len 64: %w", err)
}
payloadLen = int64(ext)
}
// Leer mascara si aplica (servidor->cliente normalmente no tiene mascara)
var mask [4]byte
if masked {
if _, err := io.ReadFull(reader, mask[:]); err != nil {
return nil, fmt.Errorf("ws read: mask: %w", err)
}
}
// Leer payload
payload := make([]byte, payloadLen)
if _, err := io.ReadFull(reader, payload); err != nil {
return nil, fmt.Errorf("ws read: payload: %w", err)
}
// Aplicar mascara si hay
if masked {
for i := range payload {
payload[i] ^= mask[i%4]
}
}
return payload, nil
}
// wsWriteMessage escribe un frame WebSocket enmascarado (cliente->servidor requiere mascara).
func wsWriteMessage(conn net.Conn, data []byte) error {
// Generar mascara aleatoria
var mask [4]byte
if _, err := rand.Read(mask[:]); err != nil {
return fmt.Errorf("ws write: generar mascara: %w", err)
}
// Construir frame
payloadLen := len(data)
var header []byte
header = append(header, 0x81) // FIN + opcode text
if payloadLen < 126 {
header = append(header, byte(payloadLen)|0x80) // masked
} else if payloadLen < 65536 {
header = append(header, 126|0x80)
header = append(header, byte(payloadLen>>8), byte(payloadLen))
} else {
header = append(header, 127|0x80)
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(payloadLen))
header = append(header, b...)
}
header = append(header, mask[:]...)
// Aplicar mascara al payload
masked := make([]byte, payloadLen)
for i, b := range data {
masked[i] = b ^ mask[i%4]
}
frame := append(header, masked...)
if _, err := conn.Write(frame); err != nil {
return fmt.Errorf("ws write: %w", err)
}
return nil
}
// sendCDP envia un comando CDP y espera la respuesta con el mismo ID.
func (c *CDPConn) sendCDP(method string, params map[string]any) (map[string]any, error) {
id := c.nextID.Add(1)
req := cdpRequest{ID: id, Method: method, Params: params}
data, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("cdp send: marshal: %w", err)
}
// Registrar canal para la respuesta
ch := make(chan cdpResponse, 1)
c.pendMu.Lock()
c.pending[id] = ch
c.pendMu.Unlock()
// Enviar frame WebSocket
c.mu.Lock()
err = wsWriteMessage(c.conn, data)
c.mu.Unlock()
if err != nil {
c.pendMu.Lock()
delete(c.pending, id)
c.pendMu.Unlock()
return nil, fmt.Errorf("cdp send %s: %w", method, err)
}
// 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)
}
}
// 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
// (respuestas a comandos) o a los handlers registrados (eventos CDP).
// Debe ejecutarse en una goroutine.
func (c *CDPConn) readLoop() {
for {
data, err := wsReadMessage(c.reader)
if err != nil {
// Conexion cerrada o error — notificar a todos los pendientes
c.pendMu.Lock()
for _, ch := range c.pending {
ch <- cdpResponse{Error: &cdpError{Message: err.Error()}}
}
c.pending = map[int64]chan cdpResponse{}
c.pendMu.Unlock()
return
}
var resp cdpResponse
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
// Si tiene ID, es respuesta a un comando
if resp.ID > 0 {
c.pendMu.Lock()
ch, ok := c.pending[resp.ID]
if ok {
delete(c.pending, resp.ID)
}
c.pendMu.Unlock()
if ok {
ch <- resp
}
continue
}
// Sin ID = evento CDP. Llamar handlers registrados para ese metodo.
if resp.Method != "" {
c.hMu.Lock()
hs := append([]EventHandler(nil), c.handlers[resp.Method]...)
c.hMu.Unlock()
for _, h := range hs {
// Aislamos panics de handlers ajenos para que un handler
// roto no mate la conexion entera.
func(h EventHandler) {
defer func() { _ = recover() }()
h(resp.Method, resp.Params)
}(h)
}
}
}
}
// OnEvent registra un handler para un metodo CDP (ej "Network.requestWillBeSent").
// Devuelve una funcion `cancel` que des-registra el handler. Multiples handlers
// para el mismo metodo se invocan en orden de registro.
//
// El handler corre en la goroutine de lectura — mantenlo rapido. Para trabajo
// pesado, despacha a un canal/goroutine propios.
func (c *CDPConn) OnEvent(method string, h EventHandler) (cancel func()) {
if c == nil || h == nil || method == "" {
return func() {}
}
c.hMu.Lock()
if c.handlers == nil {
c.handlers = make(map[string][]EventHandler)
}
c.handlers[method] = append(c.handlers[method], h)
idx := len(c.handlers[method]) - 1
c.hMu.Unlock()
return func() {
c.hMu.Lock()
defer c.hMu.Unlock()
hs := c.handlers[method]
if idx < len(hs) {
c.handlers[method] = append(hs[:idx], hs[idx+1:]...)
}
}
}
// cdpGetWSURL obtiene el webSocketDebuggerUrl del endpoint HTTP de CDP.
func cdpGetWSURL(port int) (string, error) {
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/version", port))
if err != nil {
return "", fmt.Errorf("cdp version: %w", err)
}
defer resp.Body.Close()
var info cdpVersionResponse
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return "", fmt.Errorf("cdp version: decode: %w", err)
}
if info.WebSocketDebuggerURL == "" {
return "", fmt.Errorf("cdp version: webSocketDebuggerUrl vacio")
}
return info.WebSocketDebuggerURL, nil
}