package backend import ( "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/gorilla/websocket" ) const ( cdpHTTPTimeout = 3 * time.Second cdpWebSocketHandshakeTimeout = 3 * time.Second cdpWebSocketReadTimeout = 5 * time.Second ) // ============================================================================ // Cookie 管理 API(通过 CDP) // ============================================================================ // CookieInfo 表示单条浏览器 Cookie type CookieInfo struct { Name string `json:"name"` Value string `json:"value"` Domain string `json:"domain"` Path string `json:"path"` Expires float64 `json:"expires"` HttpOnly bool `json:"httpOnly"` Secure bool `json:"secure"` SameSite string `json:"sameSite"` } // cdpTarget 表示 /json 接口返回的调试目标 type cdpTarget struct { WebSocketDebuggerUrl string `json:"webSocketDebuggerUrl"` Type string `json:"type"` } type cdpBrowserVersion struct { WebSocketDebuggerUrl string `json:"webSocketDebuggerUrl"` } // cdpMessage 是 CDP 协议消息结构 type cdpMessage struct { Id int `json:"id"` Method string `json:"method,omitempty"` Params map[string]any `json:"params,omitempty"` } // cdpResponse 是 CDP 协议响应结构 type cdpResponse struct { Id int `json:"id"` Result map[string]any `json:"result,omitempty"` Error *struct { Message string `json:"message"` } `json:"error,omitempty"` } func cdpGetEndpointBody(debugPort int, endpoint string) ([]byte, error) { client := &http.Client{Timeout: cdpHTTPTimeout} resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d%s", debugPort, endpoint)) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status: %s", resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %w", err) } return body, nil } func cdpDialWebSocket(wsURL string) (*websocket.Conn, error) { dialer := *websocket.DefaultDialer dialer.HandshakeTimeout = cdpWebSocketHandshakeTimeout conn, _, err := dialer.Dial(wsURL, nil) if err != nil { return nil, err } return conn, nil } // cdpCall 向指定 debugPort 发送单次 CDP 命令并返回 result 字段 func cdpCall(debugPort int, method string, params map[string]any) (map[string]any, error) { // 1. 获取 WebSocket 调试地址 body, err := cdpGetEndpointBody(debugPort, "/json") if err != nil { return nil, fmt.Errorf("CDP /json 请求失败: %w", err) } var targets []cdpTarget if err := json.Unmarshal(body, &targets); err != nil || len(targets) == 0 { return nil, fmt.Errorf("CDP targets 解析失败或为空") } wsURL := "" for _, t := range targets { if t.Type == "page" && t.WebSocketDebuggerUrl != "" { wsURL = t.WebSocketDebuggerUrl break } } if wsURL == "" && targets[0].WebSocketDebuggerUrl != "" { wsURL = targets[0].WebSocketDebuggerUrl } if wsURL == "" { return nil, fmt.Errorf("未找到可用的 WebSocket 调试地址") } // 2. 建立 WebSocket 连接 conn, err := cdpDialWebSocket(wsURL) if err != nil { return nil, fmt.Errorf("WebSocket 连接失败: %w", err) } defer conn.Close() conn.SetReadDeadline(time.Now().Add(cdpWebSocketReadTimeout)) // 3. 发送 CDP 命令 msg := cdpMessage{Id: 1, Method: method, Params: params} if err := conn.WriteJSON(msg); err != nil { return nil, fmt.Errorf("CDP 命令发送失败: %w", err) } // 4. 等待响应 var cdpResp cdpResponse if err := conn.ReadJSON(&cdpResp); err != nil { return nil, fmt.Errorf("CDP 响应读取失败: %w", err) } if cdpResp.Error != nil { return nil, fmt.Errorf("CDP 错误: %s", cdpResp.Error.Message) } return cdpResp.Result, nil } func cdpBrowserCall(debugPort int, method string, params map[string]any) error { body, err := cdpGetEndpointBody(debugPort, "/json/version") if err != nil { return fmt.Errorf("CDP /json/version 请求失败: %w", err) } var version cdpBrowserVersion if err := json.Unmarshal(body, &version); err != nil { return fmt.Errorf("CDP browser target 解析失败: %w", err) } wsURL := strings.TrimSpace(version.WebSocketDebuggerUrl) if wsURL == "" { return fmt.Errorf("未找到浏览器级 WebSocket 调试地址") } conn, err := cdpDialWebSocket(wsURL) if err != nil { return fmt.Errorf("浏览器级 WebSocket 连接失败: %w", err) } defer conn.Close() conn.SetReadDeadline(time.Now().Add(cdpWebSocketReadTimeout)) msg := cdpMessage{Id: 1, Method: method, Params: params} if err := conn.WriteJSON(msg); err != nil { return fmt.Errorf("浏览器级 CDP 命令发送失败: %w", err) } var cdpResp cdpResponse if err := conn.ReadJSON(&cdpResp); err != nil { // Browser.close 可能会直接关闭 websocket,视为成功。 if strings.EqualFold(method, "Browser.close") { return nil } return fmt.Errorf("浏览器级 CDP 响应读取失败: %w", err) } if cdpResp.Error != nil { return fmt.Errorf("浏览器级 CDP 错误: %s", cdpResp.Error.Message) } return nil } // getDebugPort 获取运行中实例的调试端口 func (a *App) getDebugPort(profileId string) (int, error) { a.browserMgr.Mutex.Lock() defer a.browserMgr.Mutex.Unlock() profile, exists := a.browserMgr.Profiles[profileId] if !exists { return 0, fmt.Errorf("profile not found: %s", profileId) } if !profile.Running { return 0, fmt.Errorf("实例未运行") } if profile.DebugPort == 0 || !profile.DebugReady { return 0, fmt.Errorf("实例调试接口尚未就绪,请稍后重试") } return profile.DebugPort, nil } // BrowserGetCookies 通过 CDP 获取实例所有 Cookie func (a *App) BrowserGetCookies(profileId string) ([]CookieInfo, error) { debugPort, err := a.getDebugPort(profileId) if err != nil { return nil, err } result, err := cdpCall(debugPort, "Network.getAllCookies", nil) if err != nil { return nil, err } cookiesRaw, ok := result["cookies"] if !ok { return []CookieInfo{}, nil } // 通过 JSON 二次解析 data, _ := json.Marshal(cookiesRaw) var cookies []CookieInfo if err := json.Unmarshal(data, &cookies); err != nil { return nil, fmt.Errorf("Cookie 解析失败: %w", err) } return cookies, nil } // BrowserClearCookies 通过 CDP 清除实例所有 Cookie func (a *App) BrowserClearCookies(profileId string) error { debugPort, err := a.getDebugPort(profileId) if err != nil { return err } _, err = cdpCall(debugPort, "Network.clearBrowserCookies", nil) return err } // BrowserExportCookies 导出 Netscape 格式 Cookie 字符串 func (a *App) BrowserExportCookies(profileId string) (string, error) { cookies, err := a.BrowserGetCookies(profileId) if err != nil { return "", err } var sb strings.Builder sb.WriteString("# Netscape HTTP Cookie File\n") sb.WriteString("# Generated by BrowserManager\n\n") for _, c := range cookies { includeSubdomains := "FALSE" if strings.HasPrefix(c.Domain, ".") { includeSubdomains = "TRUE" } secure := "FALSE" if c.Secure { secure = "TRUE" } expires := int64(c.Expires) if expires < 0 { expires = 0 } sb.WriteString(fmt.Sprintf("%s\t%s\t%s\t%s\t%d\t%s\t%s\n", c.Domain, includeSubdomains, c.Path, secure, expires, c.Name, c.Value)) } return sb.String(), nil }