138 lines
3.7 KiB
Go
138 lines
3.7 KiB
Go
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()
|
|
}
|