Files
Cyrene_v2/internal/capability/browser/session.go
T
AskaEth 8058660537 fix(browser): M53 两条页面侧自由文本出口收敛脱敏
- 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 形状表。三条泄露断言
已验证「退回原样直吐即红」。
2026-10-26 21:57:48 +08:00

280 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}