1b9dc96556
Agrega método conveniente SendCommand() que retorna map directamente. Simplifica llamadas CDP que necesitan resultado como map en lugar de struct. Antes: Execute(ctx, method, params, &result) Ahora: SendCommand(ctx, method, params) retorna map Archivo: pkg/cdp/client.go
284 lines
6.6 KiB
Go
284 lines
6.6 KiB
Go
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")
|
|
}
|