feat(browser): B1 接线 + B2 薄层工具面(V5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
AskaEth committed 2026-10-06 20:25:16 +08:00
1 parent ea150ceea9
commit f1fbc249fd
21 files changed
+3579

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
package browser
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// browserCloseTimeout 是收尾时「优雅关掉浏览器」那一条命令的预算。给得短:它失败也
// 不该让一次会话回收拖住调用方(自有进程还有 Kill 兜底)。
const browserCloseTimeout = 3 * time.Second
// readyPollInterval 是「等页面就绪」的轮询间隔。200ms 是冷启动导航与抖动之间的
// 折中:比它更密只是白烧 CPU,比它更疏会让一次 3s 的导航多等出可感知的延迟。
const readyPollInterval = 200 * time.Millisecond
// session 是一次浏览器会话:一条 CDP 连接 + 一个 page target。它**不管理生命周期**
// (何时起、何时回收由 manager 决定),只做「连上、导航、截图、求值」四件事。
type session struct {
client *cdpClient
stop func()
endpoint string
source string
timeout time.Duration
viewport [2]int
headless bool
// local 报告这条会话是不是「我们自己的本机浏览器」:只有它为 true 时收尾才允许
// 发 Browser.close(容器/手动实例是别人的,绝不代关)。
local bool
}
// pageState 是导航之后可以从页面里读到的**真实**状态(不是我们以为的状态)。
type pageState struct {
URL string `json:"url"`
Title string `json:"title"`
ReadyState string `json:"readyState"`
}
// openSession 起一条会话:dialer 交出端点 → 找 page target → 连 CDP → 开 Page 域。
// 中途任何一步失败都会把已占用的资源还回去(不泄漏子进程)。
func openSession(ctx context.Context, d dialer, cfg configView) (*session, error) {
endpoint, stop, err := d.Dial(ctx)
if err != nil {
return nil, err
}
wsURL, err := pageTargetWS(ctx, endpoint)
if err != nil {
stop()
return nil, err
}
client, err := newCDPClient(ctx, wsURL)
if err != nil {
stop()
return nil, err
}
s := &session{
client: client,
stop: stop,
endpoint: endpoint,
source: d.Describe(),
timeout: cfg.timeout,
viewport: [2]int{cfg.viewportW, cfg.viewportH},
headless: cfg.headless,
local: d.Local(),
}
// Page 域是导航与截图的前提;它**不是**反爬信号(被点名的是 Runtime.enable)。
if err := s.client.Call(ctx, "Page.enable", nil, nil); err != nil {
s.Close()
return nil, err
}
return s, nil
}
// pageTargetWS 从 `/json/list` 里挑第一个 page target 的 WebSocket 地址。
func pageTargetWS(ctx context.Context, endpoint string) (string, error) {
var targets []struct {
Type string `json:"type"`
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
if err := httpGetJSON(ctx, endpoint+"/json/list", &targets); err != nil {
return "", err
}
for _, t := range targets {
if t.Type == "page" && t.WebSocketDebuggerURL != "" {
return t.WebSocketDebuggerURL, nil
}
}
return "", fmt.Errorf("%w: %s 下没有可用的 page target", ErrCDP, endpoint)
}
// navigate 导航到 url 并等页面就绪,随后读回页面自报的地址/标题/就绪态。
func (s *session) navigate(ctx context.Context, url string) (pageState, error) {
var res struct {
ErrorText string `json:"errorText"`
}
if err := s.client.Call(ctx, "Page.navigate", map[string]any{"url": url}, &res); err != nil {
return pageState{}, err
}
if text := strings.TrimSpace(res.ErrorText); text != "" {
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text)
}
if err := s.waitReady(ctx); err != nil {
return pageState{}, err
}
return s.pageState(ctx)
}
// waitReady 轮询 `document.readyState` 直到 `complete`;ctx 到期即停止(错误如实报)。
//
// 为什么轮询而不是等 `Page.loadEventFired` 事件:本薄层的 CDP 客户端刻意不消费事件
// (见 cdp.go),而 readyState 轮询对「导航后立刻被重定向 / 单页应用」也更宽容。
func (s *session) waitReady(ctx context.Context) error {
ticker := time.NewTicker(readyPollInterval)
defer ticker.Stop()
for {
state, err := s.pageState(ctx)
if err == nil && state.ReadyState == "complete" {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("%w: 等待页面就绪超时: %v", ErrNavigate, ctx.Err())
case <-ticker.C:
}
}
}
// pageState 读一次页面的 url / title / readyState(一次求值,避免三次往返的竞态)。
func (s *session) pageState(ctx context.Context) (pageState, error) {
var out pageState
expr := `(() => ({url: location.href, title: document.title, readyState: document.readyState}))()`
if err := s.evaluate(ctx, expr, &out); err != nil {
return pageState{}, err
}
return out, nil
}
// evaluate 在**隔离世界**里求值并把结果按值取回。它绝不调用 `Runtime.enable`
// (design/28 §2 的「CDP 层不自曝」,reference/browser-stealth/06 的 P0)。
func (s *session) evaluate(ctx context.Context, expr string, out any) error {
ctxID, err := s.isolatedContext(ctx)
if err != nil {
return err
}
var res struct {
Result struct {
Value json.RawMessage `json:"value"`
} `json:"result"`
ExceptionDetails *struct {
Text string `json:"text"`
} `json:"exceptionDetails"`
}
params := map[string]any{
"expression": expr,
"contextId": ctxID,
"returnByValue": true,
"awaitPromise": true,
}
if err := s.client.Call(ctx, "Runtime.evaluate", params, &res); err != nil {
return err
}
if res.ExceptionDetails != nil {
return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text)
}
if out == nil || len(res.Result.Value) == 0 {
return nil
}
if err := json.Unmarshal(res.Result.Value, out); err != nil {
return fmt.Errorf("%w: 解码求值结果: %v", ErrEvaluate, err)
}
return nil
}
// isolatedContext 取一个隔离世界的执行上下文 id:`Page.getFrameTree` 拿主 frame,
// `Page.createIsolatedWorld` 在它里面开一个**匿名**世界。worldName 刻意留空 ——
// utility world 的**名字**本身就是可识别信号(reference/06),不该起 driver 味的名字。
func (s *session) isolatedContext(ctx context.Context) (int, error) {
var tree struct {
FrameTree struct {
Frame struct {
ID string `json:"id"`
} `json:"frame"`
} `json:"frameTree"`
}
if err := s.client.Call(ctx, "Page.getFrameTree", nil, &tree); err != nil {
return 0, err
}
frameID := tree.FrameTree.Frame.ID
if frameID == "" {
return 0, fmt.Errorf("%w: 拿不到主 frame id", ErrEvaluate)
}
var world struct {
ExecutionContextID int `json:"executionContextId"`
}
if err := s.client.Call(ctx, "Page.createIsolatedWorld", map[string]any{"frameId": frameID}, &world); err != nil {
return 0, err
}
if world.ExecutionContextID == 0 {
return 0, fmt.Errorf("%w: createIsolatedWorld 未返回 executionContextId", ErrEvaluate)
}
return world.ExecutionContextID, nil
}
// captureScreenshot 抓一张图(format = png / jpeg,quality 仅 JPEG 有意义)。
// `captureBeyondViewport=false`:我们要的是**用户看到的那个视口**,不是整页长图
// (整页长图在动态页上会触发 ScrollTo 行为,更像自动化)。
func (s *session) captureScreenshot(ctx context.Context, format string, quality int) ([]byte, error) {
params := map[string]any{"format": format, "captureBeyondViewport": false}
if quality > 0 {
params["quality"] = quality
}
var res struct {
Data string `json:"data"`
}
if err := s.client.Call(ctx, "Page.captureScreenshot", params, &res); err != nil {
return nil, err
}
data, err := base64.StdEncoding.DecodeString(res.Data)
if err != nil {
return nil, fmt.Errorf("%w: 解码截图: %v", ErrCDP, err)
}
if len(data) == 0 {
return nil, fmt.Errorf("%w: 截图返回了空数据", ErrCDP)
}
return data, nil
}
// Close 关连接并释放会话占用的资源(幂等)。顺序讲究:
//
// 1. 本机会话先发一条 **`Browser.close`** 优雅关掉浏览器 —— 这是**认领来的实例**
// (不是我们的子进程、杀不掉)唯一的收尾手段,也比 Kill 干净(profile 落盘完整、
// 不留 SingletonLock 残留)。容器/手动实例(`Local()==false`)**绝不代关**。
// 2. 再关我们这一条 CDP 连接。
// 3. 最后对自有进程补一次 Kill(优雅关停已经生效时,这一次是无害的兜底)。
func (s *session) Close() {
if s == nil {
return
}
if s.local {
ctx, cancel := context.WithTimeout(context.Background(), browserCloseTimeout)
s.requestBrowserClose(ctx)
cancel()
}
if s.client != nil {
_ = s.client.Close()
s.client = nil
}
if s.stop != nil {
s.stop()
s.stop = nil
}
}
// requestBrowserClose 在**浏览器级**端点发一条 `Browser.close`。失败一律咽下:
// 收尾路径上的失败不该覆盖调用方正在处理的错误(进程 Kill 仍是兜底)。
func (s *session) requestBrowserClose(ctx context.Context) {
var version struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
if err := httpGetJSON(ctx, s.endpoint+"/json/version", &version); err != nil {
return
}
if strings.TrimSpace(version.WebSocketDebuggerURL) == "" {
return
}
browser, err := newCDPClient(ctx, version.WebSocketDebuggerURL)
if err != nil {
return
}
defer func() { _ = browser.Close() }()
// 浏览器关掉时这条连接会一起断,因此这次 Call 的返回值不可信,不再判读。
_ = browser.Call(ctx, "Browser.close", nil, nil)
}