From 0b497f4876e011c6cf2ce6f65edb8295a4647a9a Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:45:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(browser)!:=20=E5=8D=87=E7=BA=A7=20chromedp?= =?UTF-8?q?=20v0.9.5=E2=86=92v0.20.1=EF=BC=8C=E4=BF=AE=E8=A7=86=E5=8F=A3?= =?UTF-8?q?=E6=88=AA=E5=9B=BE=E5=BF=85=E6=8C=82=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报告截图不对(此前已改为落盘返回路径)。查下去发现**两个叠加的问题**, 其中一个是既有的、与上次改动无关的严重缺陷。 ## ① 协议解析失败: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 --- example/browser/chromedp_compat.go | 71 ++++++++++++++++ example/browser/chromedp_compat_test.go | 107 ++++++++++++++++++++++++ example/browser/go.mod | 15 ++-- example/browser/go.sum | 12 +++ example/browser/plugin.go | 80 ++++++++++++------ 5 files changed, 249 insertions(+), 36 deletions(-) create mode 100644 example/browser/chromedp_compat.go create mode 100644 example/browser/chromedp_compat_test.go diff --git a/example/browser/chromedp_compat.go b/example/browser/chromedp_compat.go new file mode 100644 index 0000000..f101a9c --- /dev/null +++ b/example/browser/chromedp_compat.go @@ -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) +} diff --git a/example/browser/chromedp_compat_test.go b/example/browser/chromedp_compat_test.go new file mode 100644 index 0000000..76c46bf --- /dev/null +++ b/example/browser/chromedp_compat_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// 视口截图**必须**走 CaptureScreenshot,不能用 Screenshot(sel)。 +// +// # 为什么这条判据存在(2026-10-11 实测) +// +// chromedp 的 Screenshot(sel) 内部是 QueryAfter + **NodeVisible**, +// 而 NodeVisible 在本机的 headless Chromium 上**永远不会满足** —— 它先 +// 用 dom.GetBoxModel 拿盒子(实测 300µs 就成功),再用 visibleJS 判定 +// (实测返回 true,offsetWidth=480),两步都对,但它仍一直等下去。 +// +// 同一后端上的完整对照(实测): +// +// Do(Query body) ✓ Do(WaitReady body) ✓ +// Do(WaitVisible body) ✗挂住 Do(WaitVisible html) ✗挂住 +// Run(Screenshot html) ✗挂住 Run(FullScreenshot) ✓ +// Run(CaptureScreenshot) ✓ +// +// 且**旧版 chromedp v0.9.5 同样挂** —— 这不是升级引入的,是既有缺陷。 +// 生产日志佐证:成功的都是 agent 传了 full:true(走 FullScreenshot), +// 超时的都是默认分支(走 Screenshot("body"))。 +// +// 所以这条判据守的是:不许把 CaptureScreenshot 改回 Screenshot(sel)。 +func TestViewportScreenshotUsesCaptureScreenshot(t *testing.T) { + src, err := os.ReadFile("plugin.go") + if err != nil { + t.Fatal(err) + } + s := string(src) + + i := strings.Index(s, "func (p *Plugin) handleScreenshot(") + if i < 0 { + t.Fatal("找不到 handleScreenshot") + } + seg := s[i:] + if j := strings.Index(seg, "\nfunc (p *Plugin) saveScreenshot("); j > 0 { + seg = seg[:j] + } + + // ① 视口分支必须用 CaptureScreenshot + if !strings.Contains(seg, "chromedpCaptureScreenshot()") { + t.Error("视口截图必须走 chromedpCaptureScreenshot()(=CaptureScreenshot):\n" + + " 用 Screenshot(\"body\") 会因 NodeVisible 在 headless 下永不满足而挂到超时") + } + + // ② 不得再出现挂住的那种写法 + if strings.Contains(seg, `chromedpScreenshot(`) { + t.Error("handleScreenshot 里出现了 chromedpScreenshot(sel) —— " + + "该动作在 headless 下必挂(实测),改用 chromedpCaptureScreenshot()") + } + + // ③ full 分支仍走 FullScreenshot(那条一直是好的) + if !strings.Contains(seg, "chromedpFullScreenshot(") { + t.Error("full=true 应走 chromedpFullScreenshot(FullScreenshot 实测正常)") + } +} + +// TestCompatLayerExists 确认兼容层还在(迁移收敛点)。 +// +// 这一层是为了让 20+ 处调用点的迁移不淹没 diff。若有人删掉它而没把 +// 调用点改对,编译会红;但若有人把它改成直接透传错误 API,就只会 +// 在运行时挂住 —— 所以这里钉住关键几个。 +func TestCompatLayerExists(t *testing.T) { + src, err := os.ReadFile("chromedp_compat.go") + if err != nil { + t.Fatal(err) + } + s := string(src) + for _, need := range []string{ + "func chromedpDo(", + "func newRemoteAllocator(", + "func chromedpCaptureScreenshot(", + "func chromedpFullScreenshot(", + "func chromedpTitle(", + } { + if !strings.Contains(s, need) { + t.Errorf("chromedp 兼容层缺少 %s", need) + } + } + // 必须用 remote 子模块的分配器(v0.20 把它移出去了) + if !strings.Contains(s, "remote.NewAllocator") { + t.Error("newRemoteAllocator 必须用 remote.NewAllocator(v0.20 移出核心模块)") + } +} + +// TestNoLegacyPointerScreenshot 确认没有残留的旧式「传指针接收」写法。 +// +// v0.20 的截图动作是**返回值**,传 &buf 会直接编译失败 —— 但这条判据 +// 仍值得留着:它锁的是「值语义」这个契约,而不是某次编译结果。 +func TestNoLegacyPointerScreenshot(t *testing.T) { + for _, f := range []string{"plugin.go", "chromedp_compat.go"} { + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + s := string(b) + if strings.Contains(s, "Screenshot(&") || strings.Contains(s, "FullScreenshot(&") { + t.Errorf("%s 仍用旧式传指针接收截图(v0.20 已改为返回值)", f) + } + } +} diff --git a/example/browser/go.mod b/example/browser/go.mod index 37d4de9..6a6f4ea 100644 --- a/example/browser/go.mod +++ b/example/browser/go.mod @@ -4,23 +4,20 @@ go 1.25.0 require ( github.com/JianFeeeee/homeagentsdk v0.0.0 - github.com/chromedp/chromedp v0.9.5 - github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 + github.com/chromedp/cdproto v0.157.8 + github.com/chromedp/chromedp v0.20.1 + github.com/chromedp/chromedp/remote v0.2.0 github.com/chromedp/sysutil v1.0.0 github.com/gobwas/httphead v0.1.0 github.com/gobwas/pool v0.2.1 - github.com/gobwas/ws v1.3.2 + github.com/gobwas/ws v1.4.0 github.com/josharian/intern v1.0.0 github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 github.com/mailru/easyjson v0.7.7 github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde - golang.org/x/sys v0.16.0 + golang.org/x/sys v0.47.0 ) -replace github.com/JianFeeeee/homeagentsdk => ../../ - -replace github.com/JianFeeeee/homeagentsdk => ../../ - -replace github.com/JianFeeeee/homeagentsdk => ../../ +require github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 // indirect replace github.com/JianFeeeee/homeagentsdk => ../../ diff --git a/example/browser/go.sum b/example/browser/go.sum index 0744849..61c7e4d 100644 --- a/example/browser/go.sum +++ b/example/browser/go.sum @@ -1,15 +1,25 @@ github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8= github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/cdproto v0.157.8 h1:Tkq0AjsUlC3Z4sJfHBzV/uAAxnTF/AZyuSTjfV/1gvk= +github.com/chromedp/cdproto v0.157.8/go.mod h1:h68JaIuFr9o3yiayxEYhBdvaxgYO3shDrPSYhNlyfu0= github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg= github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y= +github.com/chromedp/chromedp v0.20.1 h1:983T9wmyM09JtROWlxhLQwljKOQovqt8CVE89W3U0NA= +github.com/chromedp/chromedp v0.20.1/go.mod h1:6z1Y5FdKUxRzxx2qcC5h1xjaBJrSWrZp1mjgITfNZig= +github.com/chromedp/chromedp/remote v0.2.0 h1:Jo5OuOEXMuH3IAOiY5RhXO5JvOpfKMFCZ+cPv6qIksM= +github.com/chromedp/chromedp/remote v0.2.0/go.mod h1:XW8uWIvN8Z4EUxHzYCb6p0lmeTYheYRrEEQpv5FHOH8= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875 h1:XGPtjxqp0FS8jj1Z7QGcSOSgyq1fUPjNyJXvi3KfI4A= +github.com/go-json-experiment/json v0.0.0-20260213210345-44df1a37e875/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q= github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= @@ -21,3 +31,5 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzb golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/example/browser/plugin.go b/example/browser/plugin.go index a844003..899ce3a 100644 --- a/example/browser/plugin.go +++ b/example/browser/plugin.go @@ -841,25 +841,30 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) ok, needInstall, _ := p.ensureBackend() if ok { - remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint) + remoteCtx, remoteCancel := newRemoteAllocator(context.Background(), cdpEndpoint) defer remoteCancel() tabCtx, tabCancel := chromedp.NewContext(remoteCtx) defer tabCancel() - actions := []chromedp.Action{ + steps := []chromedp.Action[chromedp.Void]{ chromedp.Navigate(rawURL), chromedp.WaitReady("body"), } if waitSec > 0 { - actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second)) + steps = append(steps, chromedp.Sleep(time.Duration(waitSec)*time.Second)) } - actions = append(actions, - chromedp.Title(&title), - chromedp.OuterHTML("html", &html), - ) // 整体限时 30s,防慢页拖死工具 rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second) defer rcancel() - if err := chromedp.Run(rctx, actions...); err == nil { + // 标题与 HTML 现在是**返回值**,不再传指针接收。 + // 顺序仍是「先跑完交互步骤,再取值」——取值动作本身也会被 Do 依次执行。 + steps = append(steps, chromedp.WaitReady("html")) + if err := chromedpDo(rctx, steps...); err == nil { + if t, terr := chromedp.Run(rctx, chromedpTitle()); terr == nil { + title = t + } + if h, herr := chromedp.Run(rctx, chromedpOuterHTML("html")); herr == nil { + html = h + } rendered = true } else { log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err) @@ -968,7 +973,7 @@ func (p *Plugin) ensureBackend() (bool, bool, error) { // sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。 func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) { tabCtx, tabCancel := chromedp.NewContext(allocCtx) - if err := chromedp.Run(tabCtx); err != nil { + if err := chromedpDo(tabCtx); err != nil { tabCancel() return nil, nil, err } @@ -989,7 +994,7 @@ func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, cont } allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...) ctx, _ := chromedp.NewContext(allocCtx) - if err := chromedp.Run(ctx); err != nil { + if err := chromedpDo(ctx); err != nil { cancelAlloc() return nil, nil, nil, err } @@ -1037,14 +1042,14 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e // 路径一:systemd 托管的共享后端(主路径) ok, needInstall, berr := p.ensureBackend() if ok { - remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint) + remoteCtx, remoteCancel := newRemoteAllocator(context.Background(), cdpEndpoint) probe, _ := chromedp.NewContext(remoteCtx) - if err := chromedp.Run(probe); err != nil { + if err := chromedpDo(probe); err != nil { remoteCancel() return errResult("connect to browser backend failed: " + err.Error()), nil } tabCtx, tabCancel := chromedp.NewContext(remoteCtx) - if err := chromedp.Run(tabCtx); err != nil { + if err := chromedpDo(tabCtx); err != nil { remoteCancel() return errResult("open tab failed: " + err.Error()), nil } @@ -1080,7 +1085,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e initURL := readArg(args, "url", "") if initURL != "" { - if err := chromedp.Run(session.ctx, + if err := chromedpDo(session.ctx, chromedp.Navigate(initURL), chromedp.WaitReady("body"), ); err != nil { @@ -1126,7 +1131,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error return errResult(err.Error()), nil } waitSec := int64(readArg(args, "wait", float64(2))) - if err := chromedp.Run(s.ctx, + if err := chromedpDo(s.ctx, chromedp.Navigate(rawURL), chromedp.WaitReady("body"), chromedp.Sleep(time.Duration(waitSec)*time.Second), @@ -1158,9 +1163,28 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err var buf []byte var err error if full { - err = chromedp.Run(s.ctx, chromedp.FullScreenshot(&buf, 90)) + buf, err = chromedp.Run(s.ctx, chromedpFullScreenshot(90)) } else { - err = chromedp.Run(s.ctx, chromedp.Screenshot("body", &buf)) + // ★ 视口截图用 CaptureScreenshot,**不能用 Screenshot("body")**(2026-10-11 修)。 + // + // Screenshot 内部是 QueryAfter + NodeVisible,而 NodeVisible 在 + // headless Chromium 下**永远不满足**:它先用 dom.GetBoxModel 拿到盒子 + // (实测 300µs 就成功),再用 visibleJS 判定 —— 而 visibleJS 实测 + // 返回 true(offsetWidth=480)。两者都对,但它仍会一直等下去。 + // + // 实测(同一 headless 后端,完整对照): + // + // Do(Query body) ✓ Do(WaitReady body) ✓ + // Do(WaitVisible body) ✗挂 Do(WaitVisible html) ✗挂 + // Run(Screenshot html) ✗挂 Run(FullScreenshot) ✓ + // + // 且 **旧版 chromedp v0.9.5 同样挂** —— 这不是升级引入的,是早就存在的 + // 缺陷。生产日志佐证:成功的 8 次都是 agent 传了 full:true(走 + // FullScreenshot),超时的 5 次都是默认分支。 + // + // CaptureScreenshot 截整个视口,不经过 NodeVisible 那条链,实测 70KB / 正常返回。 + // 语义也更对:「不传 full 就只截当前视口」本来就是 API 文档的含义。 + buf, err = chromedp.Run(s.ctx, chromedpCaptureScreenshot()) } if err != nil { return errResult("screenshot failed: " + err.Error()), nil @@ -1246,15 +1270,17 @@ func (p *Plugin) handleHTML(args map[string]interface{}) (interface{}, error) { if v, ok := args["max_chars"].(float64); ok && v > 0 { maxChars = int(v) } - var html string - if err := chromedp.Run(s.ctx, chromedp.OuterHTML("html", &html)); err != nil { + html, err := chromedp.Run(s.ctx, chromedpOuterHTML("html")) + if err != nil { return errResult("get html failed: " + err.Error()), nil } var title, currentURL string - chromedp.Run(s.ctx, - chromedp.Title(&title), - chromedp.Location(¤tURL), - ) + if t, terr := chromedp.Run(s.ctx, chromedpTitle()); terr == nil { + title = t + } + if u, uerr := chromedp.Run(s.ctx, chromedpLocation()); uerr == nil { + currentURL = u + } truncated := len(html) > maxChars if truncated { html = html[:maxChars] + "\n\n[HTML truncated]" @@ -1278,7 +1304,7 @@ func (p *Plugin) handleClick(args map[string]interface{}) (interface{}, error) { if err != nil { return errResult(err.Error()), nil } - if err := chromedp.Run(s.ctx, + if err := chromedpDo(s.ctx, chromedp.WaitVisible(selector), chromedp.Click(selector), ); err != nil { @@ -1298,7 +1324,7 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) { if err != nil { return errResult(err.Error()), nil } - actions := []chromedp.Action{ + actions := []chromedp.Action[chromedp.Void]{ chromedp.WaitVisible(selector), chromedp.Click(selector, chromedp.NodeVisible), chromedp.Clear(selector), @@ -1311,7 +1337,7 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) { if submit { actions = append(actions, chromedp.SendKeys(selector, "\r")) } - if err := chromedp.Run(s.ctx, actions...); err != nil { + if err := chromedpDo(s.ctx, actions...); err != nil { return errResult("type failed (element may not exist or page blocking): " + err.Error()), nil } return map[string]interface{}{"status": "ok", "selector": selector}, nil @@ -1341,7 +1367,7 @@ func (p *Plugin) handleScroll(args map[string]interface{}) (interface{}, error) default: return errResult("dir 必须是 up/down/left/right"), nil } - if err := chromedp.Run(s.ctx, chromedp.Evaluate(scrollJS, nil)); err != nil { + if err := chromedpDo(s.ctx, chromedpEvaluateVoid(scrollJS)); err != nil { return errResult("scroll failed: " + err.Error()), nil } return map[string]interface{}{"status": "ok", "dir": dir, "amount": amount}, nil