// Package dock 拓展坞适配器层:五个引擎适配器(searxng-cn / searxng-global / // trafilatura / lightpanda / headless-shell)实现 contract.DockAdapter 五方法。 // // 复用声明:CDP 调用面(json/version → ws → Target.createTarget/attachToTarget // → Page/Runtime/Network enable → UA override → Page.navigate → loadEventFired → // Runtime.evaluate 提取)与 detectVendor 反爬判定正则,均移植自 // bench/site-matrix/cdp_fetch.mjs(Go 重写,面窄)。trafilatura 契约 // (POST /v1/read,响应 {ok,title,markdown,char_count,truncated})来自 // bench/trafilatura-http/app.py。searxng 参数对齐 bench/searxng-{cn,global}/settings.yml。 // // ws.go:手写 RFC6455 最小 WebSocket 客户端帧编解码(依赖纪律禁新增第三方库: // 只需握手 + 无压缩文本帧收发,不实现分片/子协议,面窄可控)。 package dock import ( "bufio" "crypto/rand" "crypto/sha1" "encoding/base64" "encoding/binary" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "sync" "time" ) // wsHandshakeKey GUID(RFC6455 §1.3)。 const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" // wsConn 已完成握手的 WebSocket 客户端连接(文本帧收发)。 // 并发模型:写侧串行(writeMu),读侧单 goroutine 持有。 type wsConn struct { conn net.Conn br *bufio.Reader writeMu sync.Mutex } // wsDial 建立 WebSocket 连接:HTTP Upgrade 握手(RFC6455 client 端最小面)。 // scheme 仅支持 ws(CDP 调试端点均为明文 ws://,overlay 内网无 TLS 需求)。 func wsDial(rawURL string, timeout time.Duration) (*wsConn, error) { u, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("dock: ws URL 解析: %w", err) } if u.Scheme != "ws" { // 兼容 wss:本版不实现 TLS 握手(引擎端点均为内网明文)。 if u.Scheme == "wss" { return nil, errors.New("dock: wss 不支持(引擎端点应为内网明文 ws://)") } return nil, fmt.Errorf("dock: 非 ws scheme: %q", u.Scheme) } host := u.Host if u.Port() == "" { host = net.JoinHostPort(u.Hostname(), "80") } conn, err := net.DialTimeout("tcp", host, timeout) if err != nil { return nil, fmt.Errorf("dock: ws dial %s: %w", host, err) } // 16 字节随机 key。 keyBytes := make([]byte, 16) if _, err := rand.Read(keyBytes); err != nil { conn.Close() return nil, fmt.Errorf("dock: ws key 随机源: %w", err) } key := base64.StdEncoding.EncodeToString(keyBytes) reqLines := []string{ "GET " + u.RequestURI() + " HTTP/1.1", "Host: " + u.Host, "Upgrade: websocket", "Connection: Upgrade", "Sec-WebSocket-Key: " + key, "Sec-WebSocket-Version: 13", } upgradeReq := strings.Join(reqLines, "\r\n") + "\r\n\r\n" if _, err := conn.Write([]byte(upgradeReq)); err != nil { conn.Close() return nil, fmt.Errorf("dock: ws 握手写入: %w", err) } br := bufio.NewReader(conn) resp, err := http.ReadResponse(br, nil) if err != nil { conn.Close() return nil, fmt.Errorf("dock: ws 握手响应: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusSwitchingProtocols { conn.Close() return nil, fmt.Errorf("dock: ws 握手状态 %d", resp.StatusCode) } // Sec-WebSocket-Accept 校验(防非 ws 服务误挂)。 want := base64.StdEncoding.EncodeToString(sha1Sum(key + wsGUID)) if got := resp.Header.Get("Sec-WebSocket-Accept"); got != want { conn.Close() return nil, fmt.Errorf("dock: ws accept 不匹配(got=%s want=%s)", got, want) } return &wsConn{conn: conn, br: br}, nil } // sha1Sum SHA-1 摘要(RFC6455 accept 计算)。 func sha1Sum(s string) []byte { h := sha1.Sum([]byte(s)) return h[:] } // writeFrame 写一帧文本(客户端帧必须掩码,RFC6455 §5.3)。 // 本面窄实现:单帧 ≤2^16-1 载荷(CDP evaluate 表达式与事件均远小于此), // 超长自动升级 64-bit 长度字段。 func (c *wsConn) writeFrame(opcode byte, payload []byte) error { c.writeMu.Lock() defer c.writeMu.Unlock() var hdr []byte hdr = append(hdr, 0x80|opcode) // FIN=1,无扩展位 n := len(payload) maskBit := byte(0x80) switch { case n < 126: hdr = append(hdr, maskBit|byte(n)) case n <= 0xFFFF: hdr = append(hdr, maskBit|126) var l [2]byte binary.BigEndian.PutUint16(l[:], uint16(n)) hdr = append(hdr, l[:]...) default: hdr = append(hdr, maskBit|127) var l [8]byte binary.BigEndian.PutUint64(l[:], uint64(n)) hdr = append(hdr, l[:]...) } // 掩码 key(RFC6455 客户端强制)。 var mask [4]byte if _, err := rand.Read(mask[:]); err != nil { return fmt.Errorf("dock: ws 掩码随机源: %w", err) } hdr = append(hdr, mask[:]...) if _, err := c.conn.Write(hdr); err != nil { return fmt.Errorf("dock: ws 写头: %w", err) } masked := make([]byte, n) for i, b := range payload { masked[i] = b ^ mask[i%4] } if _, err := c.conn.Write(masked); err != nil { return fmt.Errorf("dock: ws 写体: %w", err) } return nil } // errConnClosed 连接已关闭哨兵。 var errConnClosed = errors.New("dock: ws 连接已关闭") // readFrame 读一帧(自动聚合分片 CONT;忽略 ping/pong/close 控制帧以外的处理: // ping 回 pong,close 视为对端关闭)。只支持文本帧(opcode=1)产出。 func (c *wsConn) readFrame() ([]byte, error) { var acc []byte for { var h [2]byte if _, err := io.ReadFull(c.br, h[:]); err != nil { return nil, fmt.Errorf("dock: ws 读头: %w", err) } fin := h[0]&0x80 != 0 opcode := h[0] & 0x0F masked := h[1]&0x80 != 0 length := int(h[1] & 0x7F) switch length { case 126: var l [2]byte if _, err := io.ReadFull(c.br, l[:]); err != nil { return nil, fmt.Errorf("dock: ws 读len16: %w", err) } length = int(binary.BigEndian.Uint16(l[:])) case 127: var l [8]byte if _, err := io.ReadFull(c.br, l[:]); err != nil { return nil, fmt.Errorf("dock: ws 读len64: %w", err) } length = int(binary.BigEndian.Uint64(l[:])) } var mask [4]byte if masked { if _, err := io.ReadFull(c.br, mask[:]); err != nil { return nil, fmt.Errorf("dock: ws 读掩码: %w", err) } } payload := make([]byte, length) if _, err := io.ReadFull(c.br, payload); err != nil { return nil, fmt.Errorf("dock: ws 读载荷: %w", err) } if masked { for i := range payload { payload[i] ^= mask[i%4] } } switch opcode { case 0x1: // 文本 acc = append(acc, payload...) if fin { return acc, nil } case 0x0: // CONT 分片 acc = append(acc, payload...) if fin { return acc, nil } case 0x8: // close return nil, errConnClosed case 0x9: // ping → pong(无载荷回显) if err := c.writeFrame(0xA, payload); err != nil { return nil, err } case 0xA: // pong 忽略 default: return nil, fmt.Errorf("dock: ws 未知 opcode %d", opcode) } } } // Close 关闭底层连接。 func (c *wsConn) Close() error { return c.conn.Close() }