9798aed2cf
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>
219 lines
7.0 KiB
Go
219 lines
7.0 KiB
Go
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
|
|
}
|