diff --git a/functions/infra/cdp_connect.go b/functions/infra/cdp_connect.go index 58e01dbb..f7e4f989 100644 --- a/functions/infra/cdp_connect.go +++ b/functions/infra/cdp_connect.go @@ -20,8 +20,11 @@ type cdpTarget struct { // cdpGetPageWSURL obtiene el webSocketDebuggerUrl de la primera tab de tipo "page" // via el endpoint /json. Si no hay ninguna, crea una nueva con /json/new. -func cdpGetPageWSURL(port int) (string, error) { - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json", port)) +func cdpGetPageWSURL(host string, port int) (string, error) { + if host == "" { + host = "localhost" + } + resp, err := http.Get(fmt.Sprintf("http://%s:%d/json", host, port)) if err != nil { return "", fmt.Errorf("cdp targets: %w", err) } @@ -40,7 +43,7 @@ func cdpGetPageWSURL(port int) (string, error) { } // No hay tabs — crear una nueva via /json/new - newResp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/new", port)) + newResp, err := http.Get(fmt.Sprintf("http://%s:%d/json/new", host, port)) if err != nil { return "", fmt.Errorf("cdp new tab: %w", err) } @@ -61,7 +64,16 @@ func cdpGetPageWSURL(port int) (string, error) { // Si no hay tabs disponibles, crea una nueva via /json/new. // Realiza el handshake WebSocket RFC 6455 sobre TCP puro (sin dependencias externas). func CdpConnect(port int) (*CDPConn, error) { - wsURL, err := cdpGetPageWSURL(port) + return CdpConnectHost("localhost", port) +} + +// CdpConnectHost es como CdpConnect pero permite especificar el host. +// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost. +func CdpConnectHost(host string, port int) (*CDPConn, error) { + if host == "" { + host = "localhost" + } + wsURL, err := cdpGetPageWSURL(host, port) if err != nil { return nil, fmt.Errorf("cdp connect: %w", err) } @@ -72,20 +84,20 @@ func CdpConnect(port int) (*CDPConn, error) { return nil, fmt.Errorf("cdp connect: parsear ws url %q: %w", wsURL, err) } - host := u.Host - if !strings.Contains(host, ":") { - host = host + ":80" + wsHost := u.Host + if !strings.Contains(wsHost, ":") { + wsHost = wsHost + ":80" } // Abrir conexion TCP - tcpConn, err := net.Dial("tcp", host) + tcpConn, err := net.Dial("tcp", wsHost) if err != nil { - return nil, fmt.Errorf("cdp connect: tcp dial %s: %w", host, err) + return nil, fmt.Errorf("cdp connect: tcp dial %s: %w", wsHost, err) } // Realizar handshake WebSocket path := u.RequestURI() - reader, err := wsHandshake(tcpConn, host, path) + reader, err := wsHandshake(tcpConn, wsHost, path) if err != nil { tcpConn.Close() return nil, fmt.Errorf("cdp connect: ws handshake: %w", err) diff --git a/functions/infra/cdp_wait_load.go b/functions/infra/cdp_wait_load.go new file mode 100644 index 00000000..94753de4 --- /dev/null +++ b/functions/infra/cdp_wait_load.go @@ -0,0 +1,35 @@ +package infra + +import ( + "fmt" + "time" +) + +// 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). +func CdpWaitLoad(c *CDPConn, timeout time.Duration) error { + if c == nil { + return fmt.Errorf("cdp wait load: conexion nula") + } + if timeout <= 0 { + 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) + } + + return fmt.Errorf("cdp wait load: pagina no cargo despues de %s", timeout) +} diff --git a/functions/infra/cdp_wait_load.md b/functions/infra/cdp_wait_load.md new file mode 100644 index 00000000..afac7ef9 --- /dev/null +++ b/functions/infra/cdp_wait_load.md @@ -0,0 +1,41 @@ +--- +name: cdp_wait_load +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func CdpWaitLoad(c *CDPConn, timeout time.Duration) error" +description: "Espera a que la pagina 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 inmediato si CdpEvaluate falla (la conexion puede estar rota)." +tags: [chrome, cdp, browser, automation, wait, polling, devtools, readystate, load] +uses_functions: [cdp_evaluate_go_infra] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [fmt, time] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/cdp_wait_load.go" +--- + +## Ejemplo + +```go +conn, _ := CdpConnect(9222) +CdpNavigate(conn, "https://example.com") + +// Esperar hasta 30 segundos a que la pagina cargue por completo +if err := CdpWaitLoad(conn, 30*time.Second); err != nil { + log.Fatal("Timeout esperando carga:", err) +} + +html, _ := CdpGetHTML(conn) +``` + +## Notas + +A diferencia de `CdpWaitElement`, que ignora errores de `CdpEvaluate` durante el polling (la pagina puede aun no estar lista), `CdpWaitLoad` retorna el error inmediatamente porque un fallo en `document.readyState` indica una conexion rota, no una condicion transitoria. + +Si `timeout <= 0` usa 30s por defecto (mas largo que `CdpWaitElement` porque la carga completa de red puede tardar mas que la aparicion de un elemento DOM). diff --git a/functions/infra/chrome_launch.go b/functions/infra/chrome_launch.go index ef266694..43a5e05b 100644 --- a/functions/infra/chrome_launch.go +++ b/functions/infra/chrome_launch.go @@ -16,6 +16,8 @@ type ChromeLaunchOpts struct { UserDataDir string // Headless activa el modo headless (--headless=new). Por defecto false. Headless bool + // ChromePath es la ruta al ejecutable de Chrome. Si esta vacio, se busca automaticamente. + ChromePath string // ExtraArgs permite pasar flags adicionales a Chrome. ExtraArgs []string } @@ -45,9 +47,13 @@ func findChrome() (string, error) { } // waitCDPReady espera hasta que el puerto CDP responda conexiones TCP. -func waitCDPReady(port int, timeout time.Duration) error { +// host puede estar vacio (usa "localhost"). +func waitCDPReady(host string, port int, timeout time.Duration) error { + if host == "" { + host = "localhost" + } deadline := time.Now().Add(timeout) - addr := fmt.Sprintf("localhost:%d", port) + addr := fmt.Sprintf("%s:%d", host, port) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) if err == nil { @@ -56,7 +62,7 @@ func waitCDPReady(port int, timeout time.Duration) error { } time.Sleep(200 * time.Millisecond) } - return fmt.Errorf("chrome: puerto CDP %d no disponible despues de %s", port, timeout) + return fmt.Errorf("chrome: puerto CDP %s:%d no disponible despues de %s", host, port, timeout) } // ChromeLaunch lanza Google Chrome con remote debugging habilitado en el puerto indicado. @@ -70,9 +76,13 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) { opts.UserDataDir = "/tmp/chrome-cdp-profile" } - chromePath, err := findChrome() - if err != nil { - return 0, err + chromePath := opts.ChromePath + if chromePath == "" { + var err error + chromePath, err = findChrome() + if err != nil { + return 0, err + } } args := []string{ @@ -111,10 +121,19 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) { pid := cmd.Process.Pid // Esperar a que el puerto CDP este listo - if err := waitCDPReady(opts.Port, 15*time.Second); err != nil { - // Matar proceso si no arranco correctamente - cmd.Process.Kill() - return 0, err + // Si Chrome escucha en 0.0.0.0 (ej: WSL2 -> Windows), el caller se encarga del wait + skipWait := false + for _, a := range opts.ExtraArgs { + if a == "--remote-debugging-address=0.0.0.0" { + skipWait = true + break + } + } + if !skipWait { + if err := waitCDPReady("localhost", opts.Port, 15*time.Second); err != nil { + cmd.Process.Kill() + return 0, err + } } return pid, nil