- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
277 lines
9.3 KiB
Go
277 lines
9.3 KiB
Go
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)
|
||
}
|