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) }