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
|
||||
}
|
||||
Reference in New Issue
Block a user