- Page.navigate 的 errorText 是**协议枚举**面(net error 码):先剥 `net::` 命名空间再交给 trace.ErrorToken,像枚举就原样保留、散文一律丢弃只留字节数; - Runtime.evaluate 的 exceptionDetails.text 是**页面自由文本**面:压平空白 (防日志行伪造)+ trace.RedactTextLimited 按 200 rune 截断(档位理由见 errtext.go:80 会截断真实异常消息,4096 等于允许页面塞 4KB 正文); - 两类守卫不互换(口径见 trace/errortoken.go 的按字段类别判定),清洗实现 仍只有 trace 那一份,本片只做「选哪一道 + 补形状前提」。 测试:出口 A/B 各一条经 RenderText 的真实路径断言(含 URL/凭据形状的假文本), 外加 net::ERR_* 正向保留、上限截断/换行压平、netErrorCode 形状表。三条泄露断言 已验证「退回原样直吐即红」。
280 lines
9.6 KiB
Go
280 lines
9.6 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 != "" {
|
||
// errorText 是**协议枚举**面:像 net error 码就保留,散文一律丢弃(见 errtext.go)。
|
||
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, navigateCause(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 {
|
||
// text 是**页面自由文本**面:页面 JS 可以 throw 任何东西(含 DOM 正文、带 token
|
||
// 的 URL)⇒ 压平空白 + 凭据打码 + 截断后再出镜(见 errtext.go)。
|
||
return fmt.Errorf("%w: %s", ErrEvaluate, evaluateCause(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)
|
||
}
|