公开页抓取改为 Chrome 136 自洽身份 + 每节点 SQLite 养罐,Trafilatura 走 curl_cffi;MCP/README/接手说明与 09-02 现网复测对齐,避免消费方继续抄过期的站点三分表。 Co-authored-by: Cursor <cursoragent@cursor.com>
230 lines
7.1 KiB
Go
230 lines
7.1 KiB
Go
// 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 需求)。
|
||
// handshakeHost 非空时作为 Host 头(Lightpanda 0.3.7 只接受 127.0.0.1,
|
||
// 对 Docker 服务名回 Host not allowed / 403;TCP 仍拨 rawURL 的 host)。
|
||
func wsDial(rawURL string, timeout time.Duration, handshakeHost string) (*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)
|
||
hdrHost := u.Host
|
||
if handshakeHost != "" {
|
||
hdrHost = handshakeHost
|
||
}
|
||
reqLines := []string{
|
||
"GET " + u.RequestURI() + " HTTP/1.1",
|
||
"Host: " + hdrHost,
|
||
"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() }
|