package browser import ( "context" "encoding/json" "fmt" "net/http" "strings" "sync/atomic" "time" "github.com/gorilla/websocket" ) // cdpClient implements BrowserClient using the CDP WebSocket endpoint. type cdpClient struct { cfg Config sem chan struct{} } // NewCDPClient returns a BrowserClient that uses CDP WebSocket sessions. func NewCDPClient(cfg Config) BrowserClient { if cfg.Timeout == 0 { cfg.Timeout = 60 * time.Second } return &cdpClient{cfg: cfg, sem: makeSem(cfg.MaxConcurrent)} } func (c *cdpClient) Strategy() Strategy { return StrategyCDP } func (c *cdpClient) GetContent(_ context.Context, _ ContentRequest) (string, error) { return "", fmt.Errorf("CDP client does not support /content; use NewContentClient") } func (c *cdpClient) ScrapePage(_ context.Context, _ ScrapeRequest) (ScrapeResponse, error) { return ScrapeResponse{}, fmt.Errorf("CDP client does not support /scrape; use NewScrapeClient") } // CDPSession opens a WebSocket to the Browserless /devtools/browser endpoint, // navigates to pageURL, and invokes fn with a live CDPConn. func (c *cdpClient) CDPSession(ctx context.Context, pageURL string, fn CDPSessionFunc) error { if err := acquire(ctx, c.sem); err != nil { return fmt.Errorf("cdp: semaphore: %w", err) } defer release(c.sem) // Build WebSocket URL: ws://host:port/devtools/browser?token=...&url=... wsURL := strings.Replace(c.cfg.BaseURL, "http://", "ws://", 1) wsURL = strings.Replace(wsURL, "https://", "wss://", 1) wsURL += "/devtools/browser" sep := "?" if c.cfg.Token != "" { wsURL += sep + "token=" + c.cfg.Token sep = "&" } wsURL += sep + "url=" + pageURL dialer := websocket.Dialer{ HandshakeTimeout: 15 * time.Second, Proxy: http.ProxyFromEnvironment, } conn, _, err := dialer.DialContext(ctx, wsURL, nil) if err != nil { return fmt.Errorf("cdp: dial %s: %w", wsURL, err) } cdp := &cdpConn{ws: conn} defer cdp.Close() return fn(ctx, cdp) } // ─── cdpConn ───────────────────────────────────────────────────────────────── type cdpConn struct { ws *websocket.Conn counter atomic.Int64 } type cdpRequest struct { ID int64 `json:"id"` Method string `json:"method"` Params map[string]any `json:"params,omitempty"` } type cdpResponse struct { ID int64 `json:"id"` Result map[string]any `json:"result,omitempty"` Error *struct { Code int `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` } func (c *cdpConn) Send(ctx context.Context, method string, params map[string]any) (map[string]any, error) { id := c.counter.Add(1) req := cdpRequest{ID: id, Method: method, Params: params} data, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("cdp send: marshal: %w", err) } if dl, ok := ctx.Deadline(); ok { _ = c.ws.SetWriteDeadline(dl) } if err := c.ws.WriteMessage(websocket.TextMessage, data); err != nil { return nil, fmt.Errorf("cdp send: write: %w", err) } // Read messages until we find the response matching our id. for { if dl, ok := ctx.Deadline(); ok { _ = c.ws.SetReadDeadline(dl) } _, msg, err := c.ws.ReadMessage() if err != nil { return nil, fmt.Errorf("cdp send: read: %w", err) } var resp cdpResponse if err := json.Unmarshal(msg, &resp); err != nil { continue // skip non-JSON frames (events etc.) } if resp.ID != id { continue // event or different command reply } if resp.Error != nil { return nil, fmt.Errorf("cdp error %d: %s", resp.Error.Code, resp.Error.Message) } return resp.Result, nil } } func (c *cdpConn) Close() error { return c.ws.Close() }