Files
Cyrene_v2/internal/capability/browser/cdp.go
T
AskaEth d6493d79f4 fix(upstream): M51 收敛剩余上游自由文本出口 - CDP/MCP/flowgate/channelbridge WARN
- channelbridge: 拨号失败的 WARN 不再带原始 dial 错误(含 ?access_token=),
  URLCause 剥壳 + 内嵌地址 MaskQuery 打码 + HostOf 保留目标来源;控制流零改动
- browser/cdp.go: CDP error.message 只有像协议枚举(ErrorToken)才出镜,其余只留字节数
- mcp: 第三方 server 的错误文本默认不带,只留 server/工具/JSON-RPC 码/message 长度,
  分类链(errors.Is/As、context.Canceled)经 Unwrap 保留
- channel/adapter/flowgate: 审查输出摘要由 40 字节改 40 rune(防切 UTF-8)并过 RedactText

四处均补了会随回退变红的用例(变异已实测)。
2026-10-26 19:44:22 +08:00

281 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package browser
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
// 调用方的 ctx(配置的 timeoutSeconds)更早掐断,所以它刻意给得比配置默认值宽。
const cdpCallTimeout = 60 * time.Second
// cdpMessage 是 CDP 线上信封:命令与响应共用(靠 id 关联),事件没有 id。
type cdpMessage struct {
ID int `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *cdpError `json:"error,omitempty"`
}
// cdpError 是 CDP 协议级错误(例如 "Cannot find context with specified id")。
type cdpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// Error 渲染协议错误(实现 error 接口)。
//
// 🔴 Message 是**上游自由文本**:Chrome 与各类兼容实现可以往里面放任何东西 —— 包括
// 它刚被要求操作的表达式、地址或页面内容。原样进错误等于把那段内容抄进日志与模型
// 可见视图,因此默认**不出镜**:
// - 只有像协议枚举的短串(trace.ErrorToken:≤64 字节、仅 [A-Za-z0-9_.-])才保留
// —— 真正的枚举(context_not_found 之类)能过,散文与回显一律过不了;
// - 其余只留**长度**(字节数),够判断「对端给了句话」而不带那句话。
//
// 方法名由调用点补齐(cdpClient.call / Call 的 %w 文案),诊断面并不只剩一个码。
func (e *cdpError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "CDP %d", e.Code)
switch token := trace.ErrorToken(e.Message); {
case token != "":
b.WriteString(": ")
b.WriteString(token)
case e.Message != "":
fmt.Fprintf(&b, " (message %d bytes withheld)", len(e.Message))
}
return b.String()
}
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
//
// 它**只做请求-响应**,读到的**事件一律丢弃** —— 这不是省事,而是刻意的:
// `Runtime.enable` 是本薄层唯一会「自曝自动化」的域(reference/browser-stealth/06),
// 我们从不开启它,因此既不需要事件,也不需要 `Runtime.addBinding` 那套绕法。
// evaluate 走 `Page.createIsolatedWorld` 出来的执行上下文(见 session.go)。
type cdpClient struct {
conn *websocket.Conn
writeMu sync.Mutex
mu sync.Mutex
nextID int
pending map[int]chan cdpMessage
readErr error
closed chan struct{}
once sync.Once
}
// newCDPClient 连上 wsURL 并启动读协程。Proxy 显式置空:CDP 端点在 127.0.0.1,
// 走系统代理会连不上(本机装有代理时这是最常见的「明明在跑却连不上」)。
func newCDPClient(ctx context.Context, wsURL string) (*cdpClient, error) {
dialer := websocket.Dialer{Proxy: nil, HandshakeTimeout: cdpCallTimeout}
conn, resp, err := dialer.DialContext(ctx, wsURL, nil)
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
return nil, fmt.Errorf("%w: 连接 %s: %v(HTTP %d)", ErrCDP, wsURL, err, status)
}
c := &cdpClient{
conn: conn,
pending: make(map[int]chan cdpMessage),
closed: make(chan struct{}),
}
go c.readLoop()
return c, nil
}
// readLoop 持续读帧直到出错:带 id 的帧派发给等待者,事件帧直接丢。
func (c *cdpClient) readLoop() {
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
c.fail(fmt.Errorf("%w: 读帧: %v", ErrCDP, err))
return
}
var msg cdpMessage
if uerr := json.Unmarshal(data, &msg); uerr != nil {
c.fail(fmt.Errorf("%w: 解码帧: %v", ErrCDP, uerr))
return
}
if msg.ID == 0 {
continue // 事件(Page.loadEventFired 之类):本薄层不消费
}
c.deliver(msg)
}
}
// deliver 把一帧响应交给它的等待者;没有等待者(已被超时放弃)就丢弃。
func (c *cdpClient) deliver(msg cdpMessage) {
c.mu.Lock()
ch, ok := c.pending[msg.ID]
delete(c.pending, msg.ID)
c.mu.Unlock()
if ok {
ch <- msg
}
}
// fail 记录读协程的错误并叫醒所有等待者(连接断开后任何等待都不该继续挂)。
func (c *cdpClient) fail(err error) {
c.once.Do(func() {
c.mu.Lock()
c.readErr = err
waiters := c.pending
c.pending = make(map[int]chan cdpMessage)
close(c.closed)
c.mu.Unlock()
for _, ch := range waiters {
close(ch)
}
})
}
// Call 发一条 CDP 命令并等它的结果;out 非 nil 时把 result 反序列化进去。
func (c *cdpClient) Call(ctx context.Context, method string, params any, out any) error {
raw, err := c.call(ctx, method, params)
if err != nil {
return err
}
if out != nil && len(raw) > 0 {
if uerr := json.Unmarshal(raw, out); uerr != nil {
return fmt.Errorf("%w: 解码 %s 的结果: %v", ErrCDP, method, uerr)
}
}
return nil
}
// call 是 Call 的内核:分配 id、登记等待、写帧、等响应或 ctx 取消。
func (c *cdpClient) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
if err := c.errIfClosed(); err != nil {
return nil, err
}
id, ch, err := c.enqueue()
if err != nil {
return nil, err
}
defer c.forget(id)
if err := c.write(cdpMessage{ID: id, Method: method, Params: mustJSON(params)}); err != nil {
return nil, err
}
select {
case msg, ok := <-ch:
if !ok {
return nil, c.closedErr(method)
}
if msg.Error != nil {
return nil, fmt.Errorf("%w: %s: %v", ErrCDP, method, msg.Error)
}
return msg.Result, nil
case <-ctx.Done():
return nil, fmt.Errorf("%w: %s 被取消: %v", ErrCDP, method, ctx.Err())
}
}
// enqueue 分配命令 id 与等待通道(并发安全)。
func (c *cdpClient) enqueue() (int, chan cdpMessage, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.readErr != nil {
return 0, nil, c.readErr
}
c.nextID++
id := c.nextID
ch := make(chan cdpMessage, 1)
c.pending[id] = ch
return id, ch, nil
}
// forget 清掉一个等待登记(超时/取消路径上不留给读协程)。
func (c *cdpClient) forget(id int) {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}
// write 串行化写帧:gorilla 的写方法不允许并发调用。
func (c *cdpClient) write(msg cdpMessage) error {
payload, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("%w: 编码 %s: %v", ErrCDP, msg.Method, err)
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return fmt.Errorf("%w: 写 %s: %v", ErrCDP, msg.Method, err)
}
return nil
}
// errIfClosed 报告连接是否已断(读协程记下的原始错误优先)。
func (c *cdpClient) errIfClosed() error {
select {
case <-c.closed:
return c.closedErr("")
default:
return nil
}
}
// closedErr 统一「连接已断」的错误,带上读协程记下的原因。
func (c *cdpClient) closedErr(method string) error {
c.mu.Lock()
readErr := c.readErr
c.mu.Unlock()
if readErr != nil {
return readErr
}
return fmt.Errorf("%w: 连接已关闭(%s)", ErrCDP, method)
}
// Close 关掉连接(幂等)。
func (c *cdpClient) Close() error {
err := c.conn.Close()
c.fail(fmt.Errorf("%w: 连接被主动关闭", ErrCDP))
return err
}
// mustJSON 把 params 编成 RawMessage;编码失败返回 nil(参数都是本包自造的
// map/结构体,编不出来属程序错误,交给上层按缺参数处理,绝不在这里 panic)。
func mustJSON(params any) json.RawMessage {
if params == nil {
return nil
}
raw, err := json.Marshal(params)
if err != nil {
return nil
}
return raw
}
// httpGetJSON 对 CDP 的 HTTP 端点发一次 GET 并按 out 解码(/json/list、/json/version)。
func httpGetJSON(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("%w: 构造请求 %s: %v", ErrCDP, url, err)
}
client := &http.Client{Timeout: cdpCallTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("%w: 请求 %s: %v", ErrCDP, url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%w: 请求 %s: HTTP %d", ErrCDP, url, resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("%w: 解码 %s 的响应: %v", ErrCDP, url, err)
}
return nil
}