- 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 四处均补了会随回退变红的用例(变异已实测)。
281 lines
8.5 KiB
Go
281 lines
8.5 KiB
Go
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
|
||
}
|