feat(browser): cdp_collect_console + cdp_print_pdf + cdp_select_option + cdp_set_file_input
Cuatro primitivas CDP nuevas para el dominio browser, base de nuevas tools del browser_mcp: - cdp_collect_console: snapshot temporal de console + exceptions + log entries - cdp_print_pdf: Page.printToPDF -> []byte - cdp_select_option: selecciona <option> en un <select> y dispara input/change - cdp_set_file_input: sube archivos a un <input type=file> via DOM.setFileInputFiles Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConsoleEntry es una entrada del log de consola/diagnostico capturada via CDP
|
||||
// durante una ventana temporal. Type clasifica el origen:
|
||||
// - "log"/"info"/"warn"/"error"/"debug" — Runtime.consoleAPICalled (console.*)
|
||||
// - "exception" — Runtime.exceptionThrown (errores JS no capturados)
|
||||
// - el level de Log.entryAdded ("verbose"/"info"/"warning"/"error") para
|
||||
// avisos del propio navegador (network, security, deprecaciones...)
|
||||
type ConsoleEntry struct {
|
||||
Type string `json:"type"` // log|info|warn|warning|error|debug|exception|verbose
|
||||
Text string `json:"text"` // mensaje legible (args concatenados / descripcion + stack)
|
||||
URL string `json:"url"` // URL del script o recurso, si Chrome lo informa
|
||||
Line int `json:"line"` // numero de linea (1-based), 0 si desconocido
|
||||
Timestamp float64 `json:"timestamp"` // CDP timestamp (monotonic seconds) o wall time
|
||||
}
|
||||
|
||||
// CdpCollectConsole habilita los dominios Runtime y Log en la conexion, se
|
||||
// suscribe a los eventos de consola/excepcion/log del navegador y acumula todo
|
||||
// lo que ocurra durante `durationMs` milisegundos. Es un SNAPSHOT temporal:
|
||||
// captura solo lo emitido dentro de la ventana, no el historico previo de la
|
||||
// pagina. Si durationMs <= 0 usa 1500ms por defecto.
|
||||
//
|
||||
// Eventos capturados y como se mapean a ConsoleEntry.Type:
|
||||
// - Runtime.consoleAPICalled -> el `type` del evento (log/info/warning/error/...)
|
||||
// - Runtime.exceptionThrown -> "exception" (texto = descripcion + stack)
|
||||
// - Log.entryAdded -> el `level` del entry (warning/error del browser)
|
||||
//
|
||||
// Robusta ante silencio: si no llega ningun evento devuelve un slice vacio
|
||||
// (no nil, no error). La conexion debe estar abierta; la funcion no la cierra.
|
||||
func CdpCollectConsole(c *CDPConn, durationMs int) ([]ConsoleEntry, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("cdp collect console: conexion nula")
|
||||
}
|
||||
if durationMs <= 0 {
|
||||
durationMs = 1500
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
entries = make([]ConsoleEntry, 0, 16)
|
||||
)
|
||||
|
||||
// Helpers para extraer campos de map[string]any sin pelearse con cast.
|
||||
str := func(m map[string]any, k string) string {
|
||||
if v, ok := m[k]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
num := func(m map[string]any, k string) float64 {
|
||||
if v, ok := m[k]; ok {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// argToText convierte un RemoteObject de Runtime a una representacion legible.
|
||||
// Para primitivas usa `value`; para objetos sin value cae a `description` o
|
||||
// `unserializableValue`; ultimo recurso, el `type`.
|
||||
argToText := func(arg map[string]any) string {
|
||||
if v, ok := arg["value"]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
// objetos/arrays serializados por valor -> JSON real.
|
||||
if b, err := json.Marshal(v); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
if d := str(arg, "description"); d != "" {
|
||||
return d
|
||||
}
|
||||
if u := str(arg, "unserializableValue"); u != "" {
|
||||
return u
|
||||
}
|
||||
return str(arg, "type")
|
||||
}
|
||||
|
||||
// --- Runtime.consoleAPICalled: console.log / info / warn / error / ... ---
|
||||
cancel1 := c.OnEvent("Runtime.consoleAPICalled", func(_ string, p map[string]any) {
|
||||
entry := ConsoleEntry{
|
||||
Type: str(p, "type"),
|
||||
Timestamp: num(p, "timestamp"),
|
||||
}
|
||||
// Concatenar los args a un texto legible separado por espacios.
|
||||
if rawArgs, ok := p["args"].([]any); ok {
|
||||
parts := make([]string, 0, len(rawArgs))
|
||||
for _, ra := range rawArgs {
|
||||
if am, ok := ra.(map[string]any); ok {
|
||||
parts = append(parts, argToText(am))
|
||||
}
|
||||
}
|
||||
entry.Text = strings.Join(parts, " ")
|
||||
}
|
||||
// stackTrace -> primer frame para URL/linea.
|
||||
if st, ok := p["stackTrace"].(map[string]any); ok {
|
||||
if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 {
|
||||
if f0, ok := frames[0].(map[string]any); ok {
|
||||
entry.URL = str(f0, "url")
|
||||
// lineNumber es 0-based en CDP; +1 para ser 1-based legible.
|
||||
if ln := int(num(f0, "lineNumber")); ln >= 0 {
|
||||
entry.Line = ln + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
entries = append(entries, entry)
|
||||
mu.Unlock()
|
||||
})
|
||||
defer cancel1()
|
||||
|
||||
// --- Runtime.exceptionThrown: errores JS no capturados ---
|
||||
cancel2 := c.OnEvent("Runtime.exceptionThrown", func(_ string, p map[string]any) {
|
||||
entry := ConsoleEntry{
|
||||
Type: "exception",
|
||||
Timestamp: num(p, "timestamp"),
|
||||
}
|
||||
ed, _ := p["exceptionDetails"].(map[string]any)
|
||||
if ed != nil {
|
||||
// Texto base de la excepcion.
|
||||
text := str(ed, "text")
|
||||
// Si hay un objeto de excepcion con descripcion (stack completo), preferirlo.
|
||||
if exc, ok := ed["exception"].(map[string]any); ok {
|
||||
if desc := str(exc, "description"); desc != "" {
|
||||
if text != "" && !strings.Contains(desc, text) {
|
||||
text = text + ": " + desc
|
||||
} else {
|
||||
text = desc
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.Text = text
|
||||
entry.URL = str(ed, "url")
|
||||
// lineNumber 0-based -> 1-based.
|
||||
if ln := int(num(ed, "lineNumber")); ln >= 0 {
|
||||
entry.Line = ln + 1
|
||||
}
|
||||
// stackTrace top frame como respaldo de URL/linea.
|
||||
if entry.URL == "" {
|
||||
if st, ok := ed["stackTrace"].(map[string]any); ok {
|
||||
if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 {
|
||||
if f0, ok := frames[0].(map[string]any); ok {
|
||||
entry.URL = str(f0, "url")
|
||||
if entry.Line == 0 {
|
||||
if ln := int(num(f0, "lineNumber")); ln >= 0 {
|
||||
entry.Line = ln + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if entry.Text == "" {
|
||||
entry.Text = "uncaught exception"
|
||||
}
|
||||
mu.Lock()
|
||||
entries = append(entries, entry)
|
||||
mu.Unlock()
|
||||
})
|
||||
defer cancel2()
|
||||
|
||||
// --- Log.entryAdded: avisos del propio navegador (network, security...) ---
|
||||
cancel3 := c.OnEvent("Log.entryAdded", func(_ string, p map[string]any) {
|
||||
le, _ := p["entry"].(map[string]any)
|
||||
if le == nil {
|
||||
return
|
||||
}
|
||||
entry := ConsoleEntry{
|
||||
Type: str(le, "level"), // verbose|info|warning|error
|
||||
Text: str(le, "text"),
|
||||
URL: str(le, "url"),
|
||||
Line: int(num(le, "lineNumber")),
|
||||
Timestamp: num(le, "timestamp"),
|
||||
}
|
||||
mu.Lock()
|
||||
entries = append(entries, entry)
|
||||
mu.Unlock()
|
||||
})
|
||||
defer cancel3()
|
||||
|
||||
// Habilitar dominios. Runtime.enable provoca un flush de consoleAPICalled
|
||||
// rezagados; Log.enable abre el stream de avisos del navegador.
|
||||
if _, err := c.sendCDP("Runtime.enable", nil); err != nil {
|
||||
return nil, fmt.Errorf("cdp collect console: Runtime.enable: %w", err)
|
||||
}
|
||||
if _, err := c.sendCDP("Log.enable", nil); err != nil {
|
||||
// Log.enable puede no estar disponible en algunos targets; no es fatal,
|
||||
// seguimos capturando Runtime.*. Deshabilitar Runtime no hace falta.
|
||||
_ = err
|
||||
}
|
||||
// No deshabilitamos Runtime al salir: otras funciones (ej. cdp_pick_element_js)
|
||||
// dependen de consoleAPICalled. Solo cerramos Log que abrimos aqui.
|
||||
defer c.sendCDP("Log.disable", nil)
|
||||
|
||||
// Ventana de captura.
|
||||
time.Sleep(time.Duration(durationMs) * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
out := make([]ConsoleEntry, len(entries))
|
||||
copy(out, entries)
|
||||
mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: cdp_collect_console
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpCollectConsole(c *CDPConn, durationMs int) ([]ConsoleEntry, error)"
|
||||
description: "Captura un snapshot temporal del log de consola y diagnostico de una pagina Chrome via CDP. Habilita los dominios Runtime y Log, se suscribe a Runtime.consoleAPICalled (console.log/info/warn/error con args concatenados), Runtime.exceptionThrown (errores JS no capturados, type=exception con descripcion + stack) y Log.entryAdded (avisos del propio navegador: network, security, deprecaciones) y acumula todo lo que ocurra durante durationMs ms (default 1500). Devuelve un slice de ConsoleEntry (Type, Text, URL, Line, Timestamp). Es un snapshot de la ventana, no historico previo. Robusta ante silencio: devuelve slice vacio si no llega ningun evento."
|
||||
tags: [chrome, cdp, browser, automation, console, devtools, debug, diagnostics, logs, errors, exceptions, flow-replay]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/json, fmt, strings, sync, time]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa (*CDPConn) contra una pestaña Chrome con el target abierto"
|
||||
- name: durationMs
|
||||
desc: "ventana de captura en milisegundos; si <=0 usa 1500ms. Es el tiempo durante el cual se acumulan eventos de consola/excepcion/log antes de devolver"
|
||||
output: "slice de ConsoleEntry (Type, Text, URL, Line, Timestamp) con todo lo emitido en la ventana; slice vacío (no nil, no error) si no hubo eventos; error solo si la conexión es nula o falla Runtime.enable"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_collect_console.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com")
|
||||
|
||||
// Captura todo lo que la pagina escriba en consola durante 2 segundos
|
||||
// mientras se carga / interactua.
|
||||
entries, err := CdpCollectConsole(conn, 2000)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
fmt.Printf("[%s] %s (%s:%d)\n", e.Type, e.Text, e.URL, e.Line)
|
||||
}
|
||||
// Ejemplo de salida:
|
||||
// [error] Uncaught TypeError: x is not a function (https://example.com/app.js:42)
|
||||
// [warning] Mixed Content: requested an insecure resource (https://example.com:0)
|
||||
// [log] app initialized (https://example.com/app.js:5)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas ver qué errores, warnings o mensajes de consola produce una página justo después de navegar o tras disparar una acción (click, submit). Úsala para depurar por qué un flujo web falla en silencio (excepción JS no capturada, recurso bloqueado por CSP/mixed-content, error de red que solo aparece en consola), para validar que una SPA arrancó sin errores, o como paso de diagnóstico dentro de un flow-replay antes de dar por bueno un replay. Llámala envolviendo la acción que quieres observar: navega/interactúa y deja que la ventana de captura recoja lo que emita.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: requiere Chrome vivo.** Necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador.
|
||||
- **Es un snapshot temporal, no histórico.** Solo captura eventos emitidos DURANTE la ventana `durationMs`. Los mensajes que la página imprimió antes de llamar a la función no se recuperan (excepto los que `Runtime.enable` reenvía al activarse, que Chrome flushea de forma limitada). Si quieres capturar el arranque, conéctate y llama ANTES de navegar, o navega dentro de la ventana.
|
||||
- **Bloquea durante `durationMs`.** La función duerme la goroutine la ventana completa antes de devolver — no hay early-return aunque ya tengas eventos. Elige `durationMs` acorde a lo que esperas observar (1500ms default suele bastar para el load inicial).
|
||||
- **`Type` mezcla tres taxonomías.** `consoleAPICalled` usa `log|info|warning|error|debug|...`; `exceptionThrown` siempre marca `exception`; `Log.entryAdded` usa el `level` del navegador (`verbose|info|warning|error`). Filtra por substring (`warn`, `error`) si quieres agrupar severidades; nota que console.warn produce `warning`, no `warn`.
|
||||
- **`Line` es 1-based.** CDP reporta `lineNumber` 0-based; esta función suma 1 para que coincida con lo que muestran las DevTools. Los `Log.entryAdded` se dejan tal cual los da Chrome.
|
||||
- **No deshabilita `Runtime` al salir.** Otras funciones del package (ej. `cdp_pick_element_js`) dependen de `Runtime.consoleAPICalled`; deshabilitarlo rompería sus handlers. Sí cierra el dominio `Log` que abre aquí.
|
||||
- **`Log.enable` puede no estar disponible** en algunos targets (workers, ciertos contextos). Si falla, la función NO aborta: sigue capturando `Runtime.*` y solo pierde los avisos de `Log.entryAdded`.
|
||||
|
||||
## Notas
|
||||
|
||||
`ConsoleEntry` se define como tipo simple del package `browser` en el mismo `.go` (igual que `HarEntry`/`HarHeader` en `cdp_har_record.go`), no como tipo del registry — evita import circular y mantiene la firma autosuficiente. La acumulación usa un `sync.Mutex` porque los handlers de `OnEvent` corren en la goroutine del `readLoop` de `CDPConn`, concurrente con la goroutine que duerme la ventana. La conversión de args de `consoleAPICalled` serializa objetos/arrays a JSON real (no la repr `%v` de Go) para que datos estructurados sean parseables.
|
||||
@@ -0,0 +1,77 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CdpPrintPDFOpts configura la generacion del PDF via Page.printToPDF.
|
||||
type CdpPrintPDFOpts struct {
|
||||
// Landscape orienta la pagina en horizontal cuando es true (vertical por defecto).
|
||||
Landscape bool
|
||||
// PrintBackground incluye los graficos de fondo (colores e imagenes CSS) cuando es true.
|
||||
PrintBackground bool
|
||||
// Scale es el factor de escala del renderizado (1.0 = tamano natural).
|
||||
// Si es <= 0 se usa 1.0. Chrome acepta el rango [0.1, 2].
|
||||
Scale float64
|
||||
// PaperWidthIn es el ancho del papel en pulgadas. 0 deja el default del navegador (8.5in).
|
||||
PaperWidthIn float64
|
||||
// PaperHeightIn es el alto del papel en pulgadas. 0 deja el default del navegador (11in).
|
||||
PaperHeightIn float64
|
||||
}
|
||||
|
||||
// CdpPrintPDF genera un PDF de la pagina actual via el metodo CDP Page.printToPDF
|
||||
// y devuelve los bytes del PDF ya decodificados, sin tocar el disco.
|
||||
//
|
||||
// Usa transferMode "ReturnAsBase64" (el default de CDP): Chrome devuelve el PDF
|
||||
// completo como string base64 en el campo "data" de la respuesta, que esta
|
||||
// funcion decodifica a []byte. Es robusto ante paginas grandes porque sendCDP
|
||||
// espera la respuesta completa por el WebSocket antes de decodificar.
|
||||
//
|
||||
// Las opciones se traducen a los params de Page.printToPDF: Landscape,
|
||||
// PrintBackground y Scale siempre se envian (con Scale forzado a 1.0 si opts pide
|
||||
// <= 0). PaperWidthIn/PaperHeightIn solo se envian cuando son > 0, dejando el
|
||||
// tamano de papel por defecto del navegador en caso contrario.
|
||||
//
|
||||
// Es la primitiva reutilizable de impresion a PDF: util para devolver el PDF al
|
||||
// LLM como document content (bytes) o para que un caller lo persista a disco.
|
||||
func CdpPrintPDF(c *CDPConn, opts CdpPrintPDFOpts) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("cdp print pdf: conexion nula")
|
||||
}
|
||||
|
||||
scale := opts.Scale
|
||||
if scale <= 0 {
|
||||
scale = 1.0
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"transferMode": "ReturnAsBase64",
|
||||
"landscape": opts.Landscape,
|
||||
"printBackground": opts.PrintBackground,
|
||||
"scale": scale,
|
||||
}
|
||||
if opts.PaperWidthIn > 0 {
|
||||
params["paperWidth"] = opts.PaperWidthIn
|
||||
}
|
||||
if opts.PaperHeightIn > 0 {
|
||||
params["paperHeight"] = opts.PaperHeightIn
|
||||
}
|
||||
|
||||
result, err := c.sendCDP("Page.printToPDF", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp print pdf: %w", err)
|
||||
}
|
||||
|
||||
dataStr, ok := result["data"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cdp print pdf: campo data ausente en respuesta")
|
||||
}
|
||||
|
||||
pdfData, err := base64.StdEncoding.DecodeString(dataStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp print pdf: decodificar base64: %w", err)
|
||||
}
|
||||
|
||||
return pdfData, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: cdp_print_pdf
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpPrintPDF(c *CDPConn, opts CdpPrintPDFOpts) ([]byte, error)"
|
||||
description: "Genera un PDF de la pagina actual via el metodo CDP Page.printToPDF y devuelve los bytes ya decodificados, sin tocar el disco. Usa transferMode ReturnAsBase64 (Chrome devuelve el PDF como base64 en el campo data) y lo decodifica a []byte. Aplica las opciones a los params: Landscape, PrintBackground y Scale siempre (Scale forzado a 1.0 si opts pide <= 0); PaperWidthIn/PaperHeightIn solo cuando son > 0, dejando el tamano de papel por defecto del navegador en caso contrario. Robusto ante paginas grandes. Primitiva reutilizable para devolver el PDF al LLM como document content o persistirlo a disco."
|
||||
tags: [chrome, cdp, browser, automation, pdf, print, printToPDF, devtools, document, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/base64, fmt]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa (*CDPConn) contra Chrome con el target abierto"
|
||||
- name: opts
|
||||
desc: "opciones de impresión (Landscape, PrintBackground, Scale, PaperWidthIn, PaperHeightIn en pulgadas)"
|
||||
output: "bytes del PDF decodificados desde base64, o error si falla la generación o la decodificación"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_print_pdf.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com")
|
||||
|
||||
pdfData, err := CdpPrintPDF(conn, CdpPrintPDFOpts{
|
||||
Landscape: false,
|
||||
PrintBackground: true,
|
||||
Scale: 1.0,
|
||||
PaperWidthIn: 8.27, // A4
|
||||
PaperHeightIn: 11.69, // A4
|
||||
})
|
||||
// pdfData: bytes del PDF listos para escribir a disco o devolver al LLM
|
||||
// os.WriteFile("example.pdf", pdfData, 0644)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas el PDF de la página actual en memoria: para devolverlo al LLM como document content (bytes), para archivar el render de una página (factura, informe, dashboard) o como primitiva sobre la que un caller compone la escritura a disco. Úsala tras `CdpNavigate` + espera de carga (`CdpWaitIdle`) para asegurar que el contenido está renderizado antes de imprimir.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura: requiere Chrome vivo**: necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador.
|
||||
- **Solo en modo headless completo de impresión**: `Page.printToPDF` funciona de forma fiable en Chrome headless. En modo headed (con UI), algunas builds de Chrome devuelven `PrintToPDF is not implemented`; si lo necesitas con UI, lanza Chrome con `--headless=new`.
|
||||
- **Scale fuera de rango**: Chrome acepta `scale` en `[0.1, 2]`. Esta función fuerza `1.0` cuando `opts.Scale <= 0`, pero no recorta valores válidos fuera de rango — si pasas `5.0`, Chrome puede rechazar el comando con error.
|
||||
- **Paper en pulgadas**: `PaperWidthIn`/`PaperHeightIn` son pulgadas (la unidad nativa de CDP), no mm. A4 ≈ 8.27 × 11.69 in, Letter = 8.5 × 11 in. `0` deja el default del navegador (Letter).
|
||||
- **Contenido lazy-load / dinámico**: `printToPDF` captura el DOM en el instante de la llamada. Si la página carga contenido al hacer scroll o por JS diferido, espera a que termine (scroll + `CdpWaitIdle`) antes de imprimir.
|
||||
- **PrintBackground apagado por defecto**: igual que el diálogo de impresión de Chrome, los fondos CSS (colores e imágenes) no salen salvo que pongas `PrintBackground: true`.
|
||||
|
||||
## Notas
|
||||
|
||||
Adición al dominio `browser` (estilo CDP del paquete): el `.go` vive junto a las demás funciones `cdp_*.go` en el mismo paquete `browser`. El struct `CdpPrintPDFOpts` se define en el mismo archivo. Chrome retorna el PDF como base64 (`transferMode: "ReturnAsBase64"`, el default de CDP); esta función lo decodifica a `[]byte` y lo devuelve sin escribir a disco — el caller decide el destino. Patrón gemelo de `CdpScreenshotBytes` para el caso de impresión a PDF.
|
||||
@@ -0,0 +1,87 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CdpSelectOption selecciona la <option> de un <select> (localizado por selector
|
||||
// CSS) cuyo value coincide con value; si ningun value coincide, busca por texto
|
||||
// visible de la option. Tras setear select.value despacha los eventos 'input' y
|
||||
// 'change' con bubbles:true para que frameworks (React/Vue) reaccionen al cambio.
|
||||
//
|
||||
// Devuelve error si el select no existe ("select not found") o si ninguna option
|
||||
// coincide por value ni por texto ("option not found").
|
||||
func CdpSelectOption(c *CDPConn, selector string, value string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp select option: conexion nula")
|
||||
}
|
||||
|
||||
// Script JS: localiza el select, busca la option por value y, como fallback,
|
||||
// por textContent (trim). Si encuentra, setea value, dispara input+change y
|
||||
// devuelve "__OK__". Si no, devuelve un centinela de error claro. Usamos
|
||||
// JSON.stringify de los inputs para inyectarlos de forma segura.
|
||||
js := fmt.Sprintf(`(function() {
|
||||
var sel = document.querySelector(%s);
|
||||
if (!sel) return '__NO_SELECT__';
|
||||
var want = %s;
|
||||
var opts = Array.prototype.slice.call(sel.options);
|
||||
var match = null;
|
||||
for (var i = 0; i < opts.length; i++) {
|
||||
if (opts[i].value === want) { match = opts[i]; break; }
|
||||
}
|
||||
if (!match) {
|
||||
for (var j = 0; j < opts.length; j++) {
|
||||
if ((opts[j].textContent || '').trim() === want) { match = opts[j]; break; }
|
||||
}
|
||||
}
|
||||
if (!match) return '__NO_OPTION__';
|
||||
sel.value = match.value;
|
||||
sel.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
sel.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
return '__OK__';
|
||||
})()`, jsString(selector), jsString(value))
|
||||
|
||||
res, err := CdpEvaluate(c, js)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp select option: evaluar selector %q: %w", selector, err)
|
||||
}
|
||||
|
||||
res = strings.Trim(res, `"`)
|
||||
switch res {
|
||||
case "__OK__":
|
||||
return nil
|
||||
case "__NO_SELECT__":
|
||||
return fmt.Errorf("cdp select option: select not found para selector %q", selector)
|
||||
case "__NO_OPTION__":
|
||||
return fmt.Errorf("cdp select option: option not found para value %q en select %q", value, selector)
|
||||
default:
|
||||
return fmt.Errorf("cdp select option: resultado inesperado %q para selector %q", res, selector)
|
||||
}
|
||||
}
|
||||
|
||||
// jsString convierte un string Go en un literal JS seguro (entre comillas dobles,
|
||||
// con escapes para comillas, backslashes y saltos de linea). Evita la inyeccion
|
||||
// de codigo al interpolar selectores/valores arbitrarios en el script JS.
|
||||
func jsString(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: cdp_select_option
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpSelectOption(c *CDPConn, selector string, value string) error"
|
||||
description: "Selecciona la <option> de un <select> (localizado por selector CSS) cuyo value coincide con el valor dado; si ningun value coincide, busca por texto visible de la option. Tras setear select.value despacha los eventos 'input' y 'change' con bubbles:true para que frameworks (React/Vue) reaccionen al cambio. Via Runtime.evaluate, reusa CdpEvaluate."
|
||||
tags: [chrome, cdp, browser, automation, select, dropdown, form, dom, devtools]
|
||||
uses_functions: [cdp_evaluate_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, strings]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa"
|
||||
- name: selector
|
||||
desc: "selector CSS del elemento <select> a modificar"
|
||||
- name: value
|
||||
desc: "value de la <option> a seleccionar; si no hay match por value, se busca por texto visible (textContent trimeado)"
|
||||
output: "error si el select no existe (\"select not found\") o ninguna option coincide por value ni por texto (\"option not found\"); nil si la selección y los eventos se despacharon correctamente"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_select_option.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com/form")
|
||||
|
||||
// Seleccionar por value
|
||||
if err := CdpSelectOption(conn, "#country", "ES"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Seleccionar por texto visible cuando no se conoce el value interno
|
||||
if err := CdpSelectOption(conn, "select[name=lang]", "Español"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites elegir una opcion de un `<select>` nativo en un formulario
|
||||
web y quieras que un framework (React, Vue, Angular) reaccione al cambio. Es la
|
||||
forma robusta de rellenar dropdowns durante automatizacion/scraping: a diferencia
|
||||
de un click sobre la option, setea `select.value` y dispara `input`+`change`, que
|
||||
es lo que los frameworks escuchan. Combinala con `CdpClick` para enviar el
|
||||
formulario despues.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Solo funciona con `<select>` nativos (HTML). Dropdowns custom hechos con `<div>`
|
||||
+ JS (ej. react-select, headlessui) NO son `<select>` reales: para esos hay que
|
||||
clickar y elegir la opcion del menu desplegado, no usar esta funcion.
|
||||
- El match por value es exacto (`===`); el fallback por texto compara `textContent`
|
||||
trimeado de forma exacta tras `.trim()` (no substring, no case-insensitive).
|
||||
- No hace scroll ni verifica visibilidad: opera sobre el DOM directamente. Si el
|
||||
`<select>` esta deshabilitado (`disabled`), el value se setea igual pero la UI
|
||||
puede ignorarlo segun el framework.
|
||||
- Para `<select multiple>` solo selecciona una opcion (la que coincide) y resetea
|
||||
el resto, porque setea `select.value` (no añade a `selectedOptions`).
|
||||
- Si el elemento aun no existe (carga dinamica), retorna "select not found" sin
|
||||
esperar — combinar con `CdpWaitElement` para elementos diferidos.
|
||||
@@ -0,0 +1,82 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CdpSetFileInput sube archivos a un <input type="file"> identificado por el
|
||||
// selector CSS. Resuelve el nodo via DOM.getDocument + DOM.querySelector y luego
|
||||
// asigna los archivos con DOM.setFileInputFiles. Util para automatizar formularios
|
||||
// de subida sin simular el dialogo nativo de seleccion de archivos.
|
||||
//
|
||||
// Cada path de paths se valida con os.Stat ANTES de enviar el comando: si alguno
|
||||
// no existe (o no es accesible) se devuelve error inmediato sin tocar el DOM. Los
|
||||
// paths deben ser absolutos y accesibles por el proceso de Chrome (ver Gotchas en
|
||||
// el .md): Chrome lee los archivos desde su propio contexto, no desde el de este
|
||||
// programa.
|
||||
func CdpSetFileInput(c *CDPConn, selector string, paths []string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cdp set file input: conexion nula")
|
||||
}
|
||||
if selector == "" {
|
||||
return fmt.Errorf("cdp set file input: selector vacio")
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return fmt.Errorf("cdp set file input: lista de paths vacia")
|
||||
}
|
||||
|
||||
// Validar que cada path exista en disco antes de mandar nada a Chrome.
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
return fmt.Errorf("cdp set file input: path vacio en la lista")
|
||||
}
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("cdp set file input: el archivo no existe: %q", p)
|
||||
}
|
||||
return fmt.Errorf("cdp set file input: no se puede acceder al archivo %q: %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener el nodo raiz del documento.
|
||||
docRes, err := c.sendCDP("DOM.getDocument", map[string]any{"depth": 0})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp set file input: DOM.getDocument: %w", err)
|
||||
}
|
||||
root, ok := docRes["root"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp set file input: respuesta de DOM.getDocument sin root")
|
||||
}
|
||||
rootNodeID, ok := root["nodeId"].(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("cdp set file input: DOM.getDocument sin nodeId raiz")
|
||||
}
|
||||
|
||||
// Resolver el input por selector.
|
||||
qsRes, err := c.sendCDP("DOM.querySelector", map[string]any{
|
||||
"nodeId": int(rootNodeID),
|
||||
"selector": selector,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp set file input: DOM.querySelector %q: %w", selector, err)
|
||||
}
|
||||
nodeIDVal, ok := qsRes["nodeId"].(float64)
|
||||
if !ok || int(nodeIDVal) == 0 {
|
||||
return fmt.Errorf("cdp set file input: el selector %q no coincide con ningun elemento", selector)
|
||||
}
|
||||
|
||||
// Asignar los archivos al input.
|
||||
files := make([]any, len(paths))
|
||||
for i, p := range paths {
|
||||
files[i] = p
|
||||
}
|
||||
if _, err := c.sendCDP("DOM.setFileInputFiles", map[string]any{
|
||||
"files": files,
|
||||
"nodeId": int(nodeIDVal),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("cdp set file input: DOM.setFileInputFiles en %q: %w", selector, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: cdp_set_file_input
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CdpSetFileInput(c *CDPConn, selector string, paths []string) error"
|
||||
description: "Sube archivos a un <input type=\"file\"> identificado por selector CSS, sin abrir el dialogo nativo de seleccion de archivos. Resuelve el nodo via DOM.getDocument + DOM.querySelector y asigna los archivos con DOM.setFileInputFiles. Valida con os.Stat que cada path exista en disco antes de tocar el DOM."
|
||||
tags: [chrome, cdp, browser, automation, upload, file, input, form, dom, devtools]
|
||||
uses_functions: [cdp_connect_go_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os]
|
||||
params:
|
||||
- name: c
|
||||
desc: "conexión CDP activa (*CDPConn)"
|
||||
- name: selector
|
||||
desc: "selector CSS del <input type=\"file\"> destino (ej. 'input[type=file]', '#avatar')"
|
||||
- name: paths
|
||||
desc: "rutas absolutas de los archivos a subir; cada una debe existir y ser accesible por el proceso Chrome"
|
||||
output: "error si algún path no existe, si el selector no coincide con ningún nodo, o si falla el comando CDP; nil si los archivos quedaron asignados al input"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/browser/cdp_set_file_input.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
conn, _ := CdpConnect(9222)
|
||||
CdpNavigate(conn, "https://example.com/upload")
|
||||
|
||||
// Subir un solo archivo
|
||||
err := CdpSetFileInput(conn, "input[type=file]", []string{"/home/enmanuel/docs/cv.pdf"})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Subir varios archivos a un input con multiple
|
||||
err = CdpSetFileInput(conn, "#gallery", []string{
|
||||
"/home/enmanuel/fotos/1.jpg",
|
||||
"/home/enmanuel/fotos/2.jpg",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando automatices un formulario web de subida de archivos y necesites rellenar un
|
||||
`<input type="file">` sin poder interactuar con el dialogo nativo del sistema
|
||||
operativo (que CDP no puede manejar haciendo click). Llamala despues de navegar a
|
||||
la pagina y de que el input exista en el DOM; combina con `CdpWaitElement` si el
|
||||
input aparece de forma dinamica.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Los paths deben ser ABSOLUTOS y accesibles por el proceso de Chrome**, no por
|
||||
este programa. Chrome lee los archivos desde su propio contexto/usuario; un path
|
||||
relativo o un archivo en un directorio que Chrome no puede leer fallara en el
|
||||
navegador aunque `os.Stat` pase localmente (caso tipico: Chrome corriendo en otro
|
||||
usuario, contenedor o maquina remota via CDP).
|
||||
- La validacion `os.Stat` se ejecuta en la maquina donde corre esta funcion. Si el
|
||||
Chrome del CDP esta en otra maquina/contenedor, que `os.Stat` pase NO garantiza
|
||||
que Chrome encuentre el archivo. En ese escenario los paths deben ser validos en
|
||||
el filesystem de Chrome.
|
||||
- El selector debe apuntar a un `<input type="file">` real. Apuntar a un boton o
|
||||
label que dispara el dialogo nativo no funciona: hay que resolver el input
|
||||
subyacente.
|
||||
- Asignar mas de un archivo requiere que el input tenga el atributo `multiple`; si
|
||||
no lo tiene, Chrome puede rechazar o quedarse solo con el primero.
|
||||
- No dispara automaticamente el submit del formulario ni eventos `change`
|
||||
personalizados mas alla de los que el propio CDP emite al asignar los archivos;
|
||||
si la pagina depende de listeners adicionales, comprueba el comportamiento.
|
||||
Reference in New Issue
Block a user