fix(browser)!: 升级 chromedp v0.9.5→v0.20.1,修视口截图必挂 (#1)

用户报告截图不对(此前已改为落盘返回路径)。查下去发现**两个叠加的问题**,
其中一个是既有的、与上次改动无关的严重缺陷。

## ① 协议解析失败:chromedp v0.9.5 不认 Chrome 154

```
ERROR: could not unmarshal event: parse error: expected string near offset 411 of 'cookiePart...'
```

cdproto 把 `partitionKey` 定义为 `string`,而 Chrome 154 已改成对象
(`cookiePartitionKey`)。新版 cdproto 定义了 `CookiePartitionKey` 结构体,
且 `UnmarshalJSON` **双向兼容**(字符串 → 结构体,旧浏览器也不坏)。

副作用:`Navigate` 从 2.5s 降到 85ms(解析错误消失,不再反复重试)。

## ② 视口截图在 headless 下**永远挂住**(既有缺陷,不是升级引入)

`Screenshot(sel)` 内部是 `QueryAfter` + `NodeVisible`,而 NodeVisible 在
headless Chromium 上永远不满足 —— 两步单独看都成功,但它一直等下去:

```
Do(Query body)          ✓        Do(WaitReady body)      ✓
Do(WaitVisible body)    ✗挂住     Do(WaitVisible html)    ✗挂住
Run(Screenshot html)    ✗挂住     Run(FullScreenshot)     ✓
Run(CaptureScreenshot)  ✓(70KB)
```

**决定性对照:旧版 chromedp v0.9.5 在同一个后端上同样挂。**
所以这不是升级引入的。生产日志也佐证:

- 成功的 8 次 —— agent 都传了 `full:true`(走 FullScreenshot)
- 超时的 5 次 —— 都是默认分支(走 Screenshot("body"))

且 `NodeVisible` 的两步实测都正常(`dom.GetBoxModel` 300µs 成功、
`visibleJS` 返回 true / offsetWidth=480),就是等待机制本身不返回。
没有深挖到 chromedp 内部的根因,但**换用 `CaptureScreenshot` 完全绕开这条链**,
且语义更对:「不传 full 就只截当前视口」本来就是 API 文档的含义。

## 迁移方式:兼容层收敛,业务代码最小改动

v0.20 是**泛型重写**(`Action[T]` 返回值而不填指针),25 处调用点受影响。
新增 `chromedp_compat.go` 把新 API 收敛成几个小助手
(`chromedpDo` / `newRemoteAllocator` / `chromedpCaptureScreenshot` …),
业务代码保持旧写法。

为何不逐处改:20+ 处机械改写会把 diff 淹没,真正要审的
「行为变了没有」反而看不清。

`NewRemoteAllocator` 在 v0.20 移到了独立子模块 `remote`(核心模块现在
只用标准库 + cdproto,需要 websocket 的东西全部搬出去)。

## 顺带修掉 go.mod 的重复 replace

`go mod tidy` 清掉了 4 条重复的 `replace ... => ../../`(hmapdev 往已有
go.mod 追加却不去重导致的)。Go 容忍它,但 `go mod tidy` 会报错。

## 判据(可杀变异,实测)

`chromedp_compat_test.go`:
- 视口截图必须走 `CaptureScreenshot`,不得退回 `Screenshot(sel)`
  (改回挂住的写法 → FAIL ×2)
- 兼容层关键助手都在(去掉 CaptureScreenshot → build FAIL)
- 无旧式「传指针接收截图」残留

全部 20 个 example `go vet` 通过;SDK 根 module build/test 通过。
生产实测:agent 做视口截图返回
`/home/newqqagent/browser_screenshots/shot-20261011-104225.610-browser_1.png`
(1280×713 PNG / 70518 字节),不再超时。

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
JianFeeeee
2026-10-11 10:45:41 +08:00
committed by GitHub
parent 25bd243662
commit 0b497f4876
5 changed files with 249 additions and 36 deletions

View File

@ -0,0 +1,71 @@
package main
import (
"context"
"github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/remote"
)
// chromedp v0.20 是**泛型重写**(Action[T] 返回值而不填指针),
// 本文件把旧写法收敛成几个小助手,让业务代码的改动面最小。
//
// 为何不直接在每个调用点改:插件里有 20+ 处 `chromedp.Run(ctx, 步骤...)`,
// 逐处改成「Run 取一个值 / Do 跑多步」会让 diff 淹没在机械改写里,
// 真正要审的「行为变了没有」反而看不清。收敛到助手后,业务代码保持原样。
// chromedpDo 依次跑多个不返回值的步骤(旧 Run(ctx, steps...) 的等价物)。
//
// 对应 v0.20 的 chromedp.Do。放在这里是为了让调用点看起来与旧代码一致。
func chromedpDo(ctx context.Context, steps ...chromedp.Action[chromedp.Void]) error {
return chromedp.Do(ctx, steps...)
}
// newRemoteAllocator 连接一个已在运行的 CDP 端点(如共享浏览器后端)。
//
// v0.20 把它移到了独立子模块:chromedp.NewRemoteAllocator → remote.NewAllocator。
// 子模块存在的理由:核心模块现在只用标准库 + cdproto,需要 websocket 的东西
// 全部搬出去(见 chromedp docs/decisions/2026-10-04-...)。
func newRemoteAllocator(parent context.Context, url string) (context.Context, context.CancelFunc) {
return remote.NewAllocator(parent, url)
}
// chromedpTitle 取当前页标题。
func chromedpTitle(sel ...chromedp.QueryOption) chromedp.Action[string] {
return chromedp.Title()
}
// chromedpLocation 取当前页 URL。
func chromedpLocation() chromedp.Action[string] {
return chromedp.Location()
}
// chromedpOuterHTML 取元素的外部 HTML(旧写法传 &html 接收,现在直接返回)。
func chromedpOuterHTML(sel string, opts ...chromedp.QueryOption) chromedp.Action[string] {
return chromedp.OuterHTML(sel, opts...)
}
// chromedpScreenshot 截图(旧写法传 &buf 接收,现在直接返回)。
func chromedpScreenshot(sel string, opts ...chromedp.QueryOption) chromedp.Action[[]byte] {
return chromedp.Screenshot(sel, opts...)
}
// chromedpFullScreenshot 整页截图。
func chromedpFullScreenshot(quality int) chromedp.Action[[]byte] {
return chromedp.FullScreenshot(quality)
}
// chromedpCaptureScreenshot 截当前**视口**。
//
// ★ 为何不用 chromedp.Screenshot("body")(2026-10-11):后者内部是
// QueryAfter + NodeVisible,而 NodeVisible 在本机的 headless Chromium 上
// 永远不会满足 —— 实测会一直挂到调用方超时(生产表现为 60s 工具超时)。
// 详见 plugin.go 里 handleScreenshot 的注释(含完整对照表)。
func chromedpCaptureScreenshot() chromedp.Action[[]byte] {
return chromedp.CaptureScreenshot()
}
// chromedpEvaluate 执行 JS(返回 Void 表示不关心返回值)。
func chromedpEvaluateVoid(expr string) chromedp.Action[chromedp.Void] {
return chromedp.Evaluate[chromedp.Void](expr, nil)
}