package cdp import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "sync" "sync/atomic" "github.com/gorilla/websocket" ) // Client representa un cliente del Chrome DevTools Protocol. type Client struct { wsURL string conn *websocket.Conn mu sync.Mutex nextID atomic.Int64 callbacks map[int64]chan *Response events map[string][]EventHandler eventMu sync.RWMutex ctx context.Context cancel context.CancelFunc closeCh chan struct{} closeOnce sync.Once } // EventHandler es una función que maneja eventos CDP. type EventHandler func(params json.RawMessage) // Request representa una solicitud CDP. type Request struct { ID int64 `json:"id"` Method string `json:"method"` Params interface{} `json:"params,omitempty"` } // Response representa una respuesta CDP. type Response struct { ID int64 `json:"id"` Result json.RawMessage `json:"result,omitempty"` Error *ErrorResponse `json:"error,omitempty"` } // Event representa un evento CDP. type Event struct { Method string `json:"method"` Params json.RawMessage `json:"params"` } // ErrorResponse representa un error en una respuesta CDP. type ErrorResponse struct { Code int64 `json:"code"` Message string `json:"message"` Data string `json:"data,omitempty"` } // NewClient crea un nuevo cliente CDP conectado al WebSocket URL especificado. func NewClient(ctx context.Context, wsURL string) (*Client, error) { conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) if err != nil { return nil, fmt.Errorf("failed to connect to CDP websocket: %w", err) } clientCtx, cancel := context.WithCancel(ctx) c := &Client{ wsURL: wsURL, conn: conn, callbacks: make(map[int64]chan *Response), events: make(map[string][]EventHandler), ctx: clientCtx, cancel: cancel, closeCh: make(chan struct{}), } // Iniciar goroutine para recibir mensajes go c.receiveLoop() return c, nil } // receiveLoop procesa mensajes entrantes del WebSocket. func (c *Client) receiveLoop() { defer close(c.closeCh) for { select { case <-c.ctx.Done(): return default: } _, data, err := c.conn.ReadMessage() if err != nil { if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { // Log error pero no hacer panic } return } // Intentar parsear como Response primero var resp Response if err := json.Unmarshal(data, &resp); err == nil && resp.ID > 0 { c.handleResponse(&resp) continue } // Sino, es un evento var event Event if err := json.Unmarshal(data, &event); err == nil && event.Method != "" { c.handleEvent(&event) } } } // handleResponse procesa una respuesta CDP. func (c *Client) handleResponse(resp *Response) { c.mu.Lock() ch, ok := c.callbacks[resp.ID] if ok { delete(c.callbacks, resp.ID) } c.mu.Unlock() if ok { select { case ch <- resp: default: } } } // handleEvent procesa un evento CDP. func (c *Client) handleEvent(event *Event) { c.eventMu.RLock() handlers := c.events[event.Method] c.eventMu.RUnlock() for _, handler := range handlers { go handler(event.Params) } } // Execute envía un comando CDP y espera la respuesta. func (c *Client) Execute(ctx context.Context, method string, params interface{}, result interface{}) error { id := c.nextID.Add(1) req := Request{ ID: id, Method: method, Params: params, } data, err := json.Marshal(req) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } // Crear canal para respuesta respCh := make(chan *Response, 1) c.mu.Lock() c.callbacks[id] = respCh c.mu.Unlock() // Enviar request c.mu.Lock() err = c.conn.WriteMessage(websocket.TextMessage, data) c.mu.Unlock() if err != nil { c.mu.Lock() delete(c.callbacks, id) c.mu.Unlock() return fmt.Errorf("failed to send request: %w", err) } // Esperar respuesta select { case resp := <-respCh: if resp.Error != nil { return fmt.Errorf("CDP error %d: %s - %s", resp.Error.Code, resp.Error.Message, resp.Error.Data) } if result != nil && len(resp.Result) > 0 { if err := json.Unmarshal(resp.Result, result); err != nil { return fmt.Errorf("failed to unmarshal result: %w", err) } } return nil case <-ctx.Done(): c.mu.Lock() delete(c.callbacks, id) c.mu.Unlock() return ctx.Err() case <-c.closeCh: return errors.New("client closed") } } // SendCommand envía un comando CDP y retorna el resultado como map func (c *Client) SendCommand(ctx context.Context, method string, params interface{}) (map[string]interface{}, error) { var result map[string]interface{} if err := c.Execute(ctx, method, params, &result); err != nil { return nil, err } return result, nil } // On registra un handler para un evento específico. func (c *Client) On(event string, handler EventHandler) { c.eventMu.Lock() defer c.eventMu.Unlock() c.events[event] = append(c.events[event], handler) } // Close cierra la conexión CDP. func (c *Client) Close() error { var err error c.closeOnce.Do(func() { c.cancel() c.mu.Lock() if c.conn != nil { err = c.conn.Close() } c.mu.Unlock() }) return err } // GetWebSocketURL obtiene la URL del WebSocket CDP desde el endpoint HTTP. func GetWebSocketURL(ctx context.Context, debugURL string) (string, error) { // Primero intentar con /json para obtener lista de targets req, err := http.NewRequestWithContext(ctx, "GET", debugURL+"/json", nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("failed to get CDP info: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response body: %w", err) } // /json retorna un array de targets var targets []struct { WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` Type string `json:"type"` } if err := json.Unmarshal(body, &targets); err != nil { return "", fmt.Errorf("failed to unmarshal response: %w", err) } // Buscar el primer target de tipo "page" for _, target := range targets { if target.Type == "page" && target.WebSocketDebuggerURL != "" { return target.WebSocketDebuggerURL, nil } } // Si no hay page, usar el primero disponible if len(targets) > 0 && targets[0].WebSocketDebuggerURL != "" { return targets[0].WebSocketDebuggerURL, nil } return "", errors.New("no webSocketDebuggerUrl found in targets") }