feat(browser): B1 接线 + B2 薄层工具面(V5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
AskaEth committed 2026-10-06 20:25:16 +08:00
1 parent ea150ceea9
commit f1fbc249fd
21 files changed
+3579

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
package main
// 本文件是 design/28(浏览器与页面截图)B1→B2 的**组合根接线**:把纯库
// `internal/capability/browsercfg`(配置读写 + 可用性探测)接进能力面,并在可用时
// 注册 `browser` 工具(`plugins/tools/browser` 的薄层)。
//
// 六条口径:
//
// 1. **配置只从文件来**:`$DATA_DIR/config/plugins/browser.json`(design/13 §2.1 的
// 插件配置落点、design/28 §1 M2)。**不读任何新增 env 键** —— `.env` 已冻结为
// 「最小化功能可跑」的引导面(AGENTS §10);本文件通篇没有 `os.Getenv`。
// 2. **文件缺失 = 交给自动探测**(`browsercfg.Resolve(nil, …)` 走「本机
// Chrome/Chromium → 已在跑的 CDP 端点」);**文件非法 = 不注册**(如实报错,
// 不静默拿默认值顶替 —— 那会让「面板里那份坏配置」永远看不见)。
// 3. **不可用就不注册**(照 web-search `off` 的诚实口径,design/28 §7 判据 1):
// 模型根本看不见这个工具,而不是看得见、一调就报错。原因**分类**打进日志
// (`reason=no_source` / `executable_missing` / `cdp_unreachable`),不糊成
// 「失败」两个字。
// 4. **profile 目录含凭据**:注册之前先过 `EnsureProfileDir`(0700 + 不得位于 git
// 工作树内,design/28 §2 三件套之 2、§4);不过关就不注册。
// 5. **两条失败都不阻塞启动**(与 wireVision / wireTranscode 同族):浏览器是**可选
// 增强**,磁盘上没有 Chrome 的机器照样得把整个进程拉起来。
// 6. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。
//
// 关于挂点:组合根在启动期只调用 `ioToolOptions()`(wire_runtime.go)这一个归属本片
// 的装配函数,而本片不得改他人拥有的 `wire_runtime.go` / `wire.go`
// ⇒ browser 的接线由 `wire_io_tools.go` 的 `ioToolOptions()` 转交给本文件的
// `wireBrowser()`(那一处有同样的说明)。
import (
"context"
"log/slog"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
const (
// browserLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。
browserLogSource = "cmd/cyrene/wire_browser"
// browserProbeTimeout 约束可用性探测。探测问的是「有没有在听」(本机可执行文件
// 是否存在、CDP 端点是否回话),不是「页面能不能加载」,所以给得短:最坏情况
// 也只是启动路径上多等 3 秒,而不是拖住整个组合根。
browserProbeTimeout = 3 * time.Second
// 生效配置的来源标签(只进日志):一眼看出本次结论是「面板文件说的」还是
// 「没有配置文件、自动探测出来的」。
browserConfigSourceFile = "panel-file"
browserConfigSourceProbe = "probe"
// 不注册工具时的原因码(进日志的 reason 字段,与 browsercfg.Reason 同风格)。
browserReasonInvalidConfig = "invalid-config"
browserReasonProfileRejected = "profile-dir-rejected"
// browserNotRegisteredEffect 是「不注册」这件事对模型面**实际**意味着什么:
// 写死在日志里,免得读日志的人以为只是「降级运行」。
browserNotRegisteredEffect = "the model will not see a browser tool at all"
)
// wireBrowser 装配 `browser` 工具。它没有错误返回值:一切失败都是**如实降级**
// (记分类原因 + 不注册),绝不阻塞启动。
func (a *app) wireBrowser() {
log := logger.WithSource(a.log, browserLogSource)
if a.caps == nil || a.cfg == nil {
// 部分测试夹具只填了 app 的几个字段;没有注册面就没有「注册」这件事。
return
}
if _, err := a.caps.Get(browsertools.ToolName); err == nil {
return // 同一 app 上重复装配:已经注册过,不重复探测
}
dataDir := resolveDataDir(a.cfg)
configPath := browsercfg.DefaultConfigPath(dataDir)
fileCfg, err := browsercfg.Load(configPath, dataDir)
if err != nil {
log.Warn("browser tool not registered",
"reason", browserReasonInvalidConfig, "configPath", configPath, "error", err,
"effect", browserNotRegisteredEffect)
return
}
effective := browsercfg.Defaults(dataDir)
configSource := browserConfigSourceProbe
if fileCfg != nil {
effective = *fileCfg
configSource = browserConfigSourceFile
}
if !a.registerBrowserTool(log, fileCfg, effective, configPath, configSource, dataDir) {
return
}
}
// registerBrowserTool 做「探测 → 建管理器 → 注册」这三步,返回是否真的注册了。
// 抽成独立函数是为了让每一处「不注册」都**只**有一个出口(口径不会在两条分支里漂移)。
func (a *app) registerBrowserTool(
log *slog.Logger, fileCfg *browsercfg.Config, effective browsercfg.Config,
configPath, configSource, dataDir string,
) bool {
ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout)
avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber())
cancel()
if !avail.Usable {
log.Warn("browser tool not registered",
"reason", string(avail.Reason), "detail", avail.Detail,
"configPath", configPath, "configSource", configSource,
"effect", browserNotRegisteredEffect)
return false
}
// profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。
if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil {
log.Warn("browser tool not registered",
"reason", browserReasonProfileRejected, "profileDir", effective.ProfileDir,
"error", err, "effect", browserNotRegisteredEffect)
return false
}
mgr := browsercap.NewManager(browsercap.Options{
Availability: avail,
Config: effective,
DataDir: dataDir,
Logger: a.log,
})
tool := browsertools.New(mgr, browsertools.WithLogger(a.log))
if err := a.caps.RegisterTool(tool); err != nil {
log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err)
return false
}
log.Info("browser tool registered",
"source", string(avail.Source), "headless", avail.Headless,
"configSource", configSource, "configPath", configPath,
"shotsDir", browsercfg.DefaultShotsDir(dataDir),
"richActions", "click/type/wait/extract 由 MCP(Playwright/Puppeteer server)提供,本工具不重造")
return true
}
+147
View File
@@ -0,0 +1,147 @@
package main
// 本文件是 `browser` 接线的**真机端到端**(默认不跑):从浏览器配置 → 组合根接线 →
// 注册面 → 模型会调用的那把工具 → 真 Chrome → 截图落盘,全程不假任何一层。
//
// 用法(Windows / Git Bash):
//
// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestBrowserTool_RealHostEndToEnd -v -timeout 5m
//
// 为什么默认 Skip:它会**真的起一个浏览器**、并且要打一次外网(本机没有 Chrome 时
// 自动 Skip)。这类验证属于「人工按需复跑」,不该混进普通 `go test`(AGENTS §10
// 的压测纪律:不在验收里无界占机)。
//
// 为什么用**无头**:这是自动化验证,不该在主人的桌面上弹一个窗口;有头路径的
// 真实验证走 `internal/capability/browser/compare_test.go` 的对照脚本(它默认无头/有头各跑一遍)。
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// browserE2EEnabled 报告是否显式开启了真机 e2e。
func browserE2EEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("CYRENE_BROWSER_E2E"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// TestBrowserTool_RealHostEndToEnd 走完整条链并断言**页面自报**的事实。
func TestBrowserTool_RealHostEndToEnd(t *testing.T) {
if !browserE2EEnabled() {
t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)")
}
exe, ok := browsercfg.NewProber().FindLocalBrowser()
if !ok {
t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行")
}
cfg := wsLoadConfig(t)
raw, _ := json.Marshal(map[string]any{
"executable": exe,
"headless": true,
"timeoutSeconds": 60,
})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
cap, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
tool, ok := cap.Value.(sdk.Tool)
if !ok {
t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value)
}
// 收尾必须真的把浏览器关掉:用例结束会删临时目录(含 profile),Chrome 还在跑就会被
// 占用而删不掉。这条 defer 同时是**生产缺口**的现场证明(Tool.Close 目前无人调用)。
if closer, ok := tool.(interface{ Close() error }); ok {
defer func() {
if cerr := closer.Close(); cerr != nil {
t.Errorf("Close: %v", cerr)
}
}()
} else {
t.Error("工具没实现 Close:组合根将没有关闭浏览器的挂点")
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// ① 指纹自检:证明「这次真的是没在自曝」——navigator.webdriver 必须是 false。
info, err := tool.Execute(ctx, map[string]any{"action": "info"})
if err != nil {
t.Fatalf("action=info: %v", err)
}
fp, ok := info.(browsercap.Fingerprint)
if !ok {
t.Fatalf("info 结果类型 = %T", info)
}
if fp.Webdriver {
t.Errorf("navigator.webdriver = true:自动化痕迹没关掉(%+v)", fp)
}
t.Logf("指纹自检:headless=%v webdriver=%v ua=%s tz=%s lang=%s screen=%dx%d outer=%dx%d",
fp.Headless, fp.Webdriver, fp.UserAgent, fp.Timezone, fp.Language,
fp.ScreenWidth, fp.ScreenHeight, fp.OuterWidth, fp.OuterHeight)
if strings.Contains(strings.ToLower(fp.UserAgent), "headlesschrome") {
t.Logf("提示:无头档的 UA 带 HeadlessChrome —— 这是无头与有头唯一必然不同的一位")
}
// ② 导航:读回页面**自报**的标题(不是我们以为的)。
opened, err := tool.Execute(ctx, map[string]any{"action": "open", "url": "https://example.com/"})
if err != nil {
t.Fatalf("action=open: %v", err)
}
page, ok := opened.(browsercap.PageResult)
if !ok {
t.Fatalf("open 结果类型 = %T", opened)
}
if !strings.Contains(page.Title, "Example") {
t.Errorf("标题 = %q,期望页面自报的 Example Domain", page.Title)
}
if page.ReadyState != "complete" {
t.Errorf("readyState = %q", page.ReadyState)
}
t.Logf("导航:title=%q url=%q readyState=%q source=%q", page.Title, page.URL, page.ReadyState, page.Source)
// ③ 截图落盘:路径存在、非空、尺寸来自字节本身。
shotRaw, err := tool.Execute(ctx, map[string]any{"action": "screenshot"})
if err != nil {
t.Fatalf("action=screenshot: %v", err)
}
shot := shotRaw.(map[string]any)
path, _ := shot["path"].(string)
if path == "" {
t.Fatalf("截图没有路径: %#v", shot)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("截图没落盘: %v", err)
}
if fi.Size() == 0 {
t.Fatal("截图是 0 字节")
}
if !strings.HasPrefix(path, filepath.Join(cfg.DataDir, "browser", "shots")) {
t.Errorf("截图没落在 $DATA_DIR/browser/shots 下: %s", path)
}
width, _ := shot["width"].(int)
height, _ := shot["height"].(int)
if width <= 0 || height <= 0 {
t.Errorf("尺寸没解出来: %#v", shot)
}
t.Logf("截图:path=%s bytes=%v %dx%d sha256=%v", path, shot["bytes"], width, height, shot["sha256"])
}
+225
View File
@@ -0,0 +1,225 @@
package main
// 本文件钉住切片 V5 的组合根接线:`browser` 工具**只在有可用来源时**进注册面,
// 每一个「不注册」的原因都**分类**写进日志(照 web-search `off` 的诚实口径),
// 而 profile 目录的凭据守卫(不得进 git 工作树)在注册之前生效。
//
// 断言落在**注册表**(模型真正看到的那一面)与**日志字段**上,而不是源码字符串。
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
)
// browserTestApp 造一个「有注册面 + 有日志出口」的 app:browser 的接线只依赖这三件。
func browserTestApp(t *testing.T, cfg *config.Config, out *bytes.Buffer) *app {
t.Helper()
return &app{
cfg: cfg,
log: logger.New(logger.Options{Out: out, Level: "debug"}),
caps: registry.New(),
}
}
// writeBrowserConfig 把一份 browser.json 写进 $DATA_DIR/config/plugins/(原样字节)。
func writeBrowserConfig(t *testing.T, cfg *config.Config, raw string) string {
t.Helper()
path := browsercfg.DefaultConfigPath(cfg.DataDir)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("建配置目录: %v", err)
}
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("写 %s: %v", path, err)
}
return path
}
// browserEntry 报告注册面里有没有 browser 工具。
func browserEntry(a *app) (registry.Capability, bool) {
cap, err := a.caps.Get(browsertools.ToolName)
return cap, err == nil
}
// TestWireBrowser_InvalidConfigDoesNotRegister 钉住「配置非法 ⇒ 不注册 + 分类原因」:
// 静默拿默认值顶替会让面板里那份坏配置永远看不见。
func TestWireBrowser_InvalidConfigDoesNotRegister(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
writeBrowserConfig(t, cfg, "{ this is not json")
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("配置非法时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != browserReasonInvalidConfig {
t.Errorf("reason = %q, want %q", got, browserReasonInvalidConfig)
}
}
// TestWireBrowser_MissingExecutableDoesNotRegister 钉住「面板把路径写错 ⇒ 不注册」,
// 且原因来自 browsercfg 的分类码(不偷偷回退到自动探测)。
func TestWireBrowser_MissingExecutableDoesNotRegister(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
raw, _ := json.Marshal(map[string]any{"executable": filepath.Join(t.TempDir(), "no-such-browser.exe")})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("可执行文件不存在时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonExecutableMissing) {
t.Errorf("reason = %q, want %q", got, browsercfg.ReasonExecutableMissing)
}
}
// TestWireBrowser_UnreachableEndpointDoesNotRegister 钉住 CDP 端点连不上 ⇒ 不注册,
// 原因分类为 cdp_unreachable(配置显式给了端点却不可达,就是「我配的容器没生效」,
// 必须让人看见)。
func TestWireBrowser_UnreachableEndpointDoesNotRegister(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
// 127.0.0.1 上一个确定没人听的端口:连不上会立刻返回(不是超时)。
raw, _ := json.Marshal(map[string]any{"cdpEndpoint": "http://127.0.0.1:1"})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("CDP 端点不可达时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonCDPUnreachable) {
t.Errorf("reason = %q, want %q", got, browsercfg.ReasonCDPUnreachable)
}
}
// TestWireBrowser_UsableExecutableRegisters 钉住正向路径:配置给出的可执行文件存在
// ⇒ 工具进注册面,且 profile 目录被按 0700 建好(含凭据的目录不能是 0755)。
func TestWireBrowser_UsableExecutableRegisters(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
profileDir := filepath.Join(cfg.DataDir, "browser", "profile-x")
raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
cap, ok := browserEntry(a)
if !ok {
t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String())
}
if cap.Kind != registry.KindTool {
t.Errorf("Kind = %q, want tool", cap.Kind)
}
// 默认权限口径:owner-only(两布尔皆 false)。
if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
t.Errorf("Policy = %+v,默认应是 owner-only(design/28 §4 第 2 条)", cap.Policy)
}
if _, err := os.Stat(profileDir); err != nil {
t.Errorf("profile 目录应先被建好: %v", err)
}
// 工具名/动作面:注册进去的那把工具就是薄层那一个。
if tool, ok := cap.Value.(interface{ Name() string }); !ok || tool.Name() != browsertools.ToolName {
t.Errorf("注册的值不是 browser 工具: %#v", cap.Value)
}
}
// TestWireBrowser_ProfileInGitTreeIsRejected 钉住凭据守卫:profile 落在 git 工作树里
// ⇒ **不注册**(被 `git add` 收走就是把登录态提交出去,design/28 §4)。
func TestWireBrowser_ProfileInGitTreeIsRejected(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
// 造一个「像仓库」的目录:里面有 `.git`,profile 落在它下面、且在 dataDir 之外。
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("造 .git: %v", err)
}
profileDir := filepath.Join(repo, "browser-profile")
raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.wireBrowser()
if _, ok := browserEntry(a); ok {
t.Fatal("profile 位于 git 工作树内时 browser 不该进注册面")
}
if got := wsLogField(t, out.String(), "reason"); got != browserReasonProfileRejected {
t.Errorf("reason = %q, want %q", got, browserReasonProfileRejected)
}
}
// TestIOToolOptions_WiresBrowserExactlyOnce 钉住**挂点与幂等**:browser 的接线挂在
// `ioToolOptions()`(组合根启动期唯一归属本片的装配入口)上,且重复调用不重复注册。
func TestIOToolOptions_WiresBrowserExactlyOnce(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
exe := filepath.Join(t.TempDir(), "fake-browser.exe")
if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil {
t.Fatalf("造假可执行文件: %v", err)
}
raw, _ := json.Marshal(map[string]any{"executable": exe})
writeBrowserConfig(t, cfg, string(raw))
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
a.ioToolOptions()
first, ok := browserEntry(a)
if !ok {
t.Fatalf("ioToolOptions 应当把 browser 接上(日志:%s)", out.String())
}
a.ioToolOptions()
second, ok := browserEntry(a)
if !ok {
t.Fatal("第二次调用后 browser 不该消失")
}
if first.Name != second.Name || first.Kind != second.Kind {
t.Errorf("重复装配改写了条目:%+v -> %+v", first, second)
}
// 注册面里只该有一个 browser(registry 重名会被拒;这里确认没被静默换掉)。
count := 0
for _, c := range a.caps.List(registry.KindTool) {
if c.Name == browsertools.ToolName {
count++
}
}
if count != 1 {
t.Errorf("注册面里 browser 条目数 = %d, want 1", count)
}
}
// TestWireBrowser_NilRegistryIsSkipped 钉住夹具友好性:没有注册面时**不 panic**
// (部分既有测试夹具只填了 app 的几个字段)。
func TestWireBrowser_NilRegistryIsSkipped(t *testing.T) {
t.Parallel()
a := &app{}
a.wireBrowser()
}
+7
View File
@@ -80,7 +80,14 @@ const webSearchToolName = "web-search"
// ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New,
// 因此「注册面里有哪些 io 工具」只由这一处决定。
//
// 它**同时是切片 V5 的 browser 接线挂点**:组合根在启动期只调用这一个归属本片的装配
// 函数(wire_runtime.go 的 `a.ioToolOptions()`),而 browser 的接线不得改他人拥有的
// `wire_runtime.go` / `wire.go` ⇒ 由这里转交给 `wireBrowser()`(见 wire_browser.go 的
// 口径说明)。转交对 io 选项**零影响**(它不改、不追加任何 iotools.Option),并且
// **幂等**:同一个 app 上重复调用只会发现工具已注册并跳过。
func (a *app) ioToolOptions() []iotools.Option {
a.wireBrowser()
opts := []iotools.Option{
iotools.WithKnowledgeRetriever(a.retriever),
iotools.WithLogger(a.log),
+257
View File
@@ -0,0 +1,257 @@
package browser
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
// 调用方的 ctx(配置的 timeoutSeconds)更早掐断,所以它刻意给得比配置默认值宽。
const cdpCallTimeout = 60 * time.Second
// cdpMessage 是 CDP 线上信封:命令与响应共用(靠 id 关联),事件没有 id。
type cdpMessage struct {
ID int `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *cdpError `json:"error,omitempty"`
}
// cdpError 是 CDP 协议级错误(例如 "Cannot find context with specified id")。
type cdpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// Error 实现 error 接口。
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) }
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
//
// 它**只做请求-响应**,读到的**事件一律丢弃** —— 这不是省事,而是刻意的:
// `Runtime.enable` 是本薄层唯一会「自曝自动化」的域(reference/browser-stealth/06),
// 我们从不开启它,因此既不需要事件,也不需要 `Runtime.addBinding` 那套绕法。
// evaluate 走 `Page.createIsolatedWorld` 出来的执行上下文(见 session.go)。
type cdpClient struct {
conn *websocket.Conn
writeMu sync.Mutex
mu sync.Mutex
nextID int
pending map[int]chan cdpMessage
readErr error
closed chan struct{}
once sync.Once
}
// newCDPClient 连上 wsURL 并启动读协程。Proxy 显式置空:CDP 端点在 127.0.0.1,
// 走系统代理会连不上(本机装有代理时这是最常见的「明明在跑却连不上」)。
func newCDPClient(ctx context.Context, wsURL string) (*cdpClient, error) {
dialer := websocket.Dialer{Proxy: nil, HandshakeTimeout: cdpCallTimeout}
conn, resp, err := dialer.DialContext(ctx, wsURL, nil)
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
return nil, fmt.Errorf("%w: 连接 %s: %v(HTTP %d)", ErrCDP, wsURL, err, status)
}
c := &cdpClient{
conn: conn,
pending: make(map[int]chan cdpMessage),
closed: make(chan struct{}),
}
go c.readLoop()
return c, nil
}
// readLoop 持续读帧直到出错:带 id 的帧派发给等待者,事件帧直接丢。
func (c *cdpClient) readLoop() {
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
c.fail(fmt.Errorf("%w: 读帧: %v", ErrCDP, err))
return
}
var msg cdpMessage
if uerr := json.Unmarshal(data, &msg); uerr != nil {
c.fail(fmt.Errorf("%w: 解码帧: %v", ErrCDP, uerr))
return
}
if msg.ID == 0 {
continue // 事件(Page.loadEventFired 之类):本薄层不消费
}
c.deliver(msg)
}
}
// deliver 把一帧响应交给它的等待者;没有等待者(已被超时放弃)就丢弃。
func (c *cdpClient) deliver(msg cdpMessage) {
c.mu.Lock()
ch, ok := c.pending[msg.ID]
delete(c.pending, msg.ID)
c.mu.Unlock()
if ok {
ch <- msg
}
}
// fail 记录读协程的错误并叫醒所有等待者(连接断开后任何等待都不该继续挂)。
func (c *cdpClient) fail(err error) {
c.once.Do(func() {
c.mu.Lock()
c.readErr = err
waiters := c.pending
c.pending = make(map[int]chan cdpMessage)
close(c.closed)
c.mu.Unlock()
for _, ch := range waiters {
close(ch)
}
})
}
// Call 发一条 CDP 命令并等它的结果;out 非 nil 时把 result 反序列化进去。
func (c *cdpClient) Call(ctx context.Context, method string, params any, out any) error {
raw, err := c.call(ctx, method, params)
if err != nil {
return err
}
if out != nil && len(raw) > 0 {
if uerr := json.Unmarshal(raw, out); uerr != nil {
return fmt.Errorf("%w: 解码 %s 的结果: %v", ErrCDP, method, uerr)
}
}
return nil
}
// call 是 Call 的内核:分配 id、登记等待、写帧、等响应或 ctx 取消。
func (c *cdpClient) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
if err := c.errIfClosed(); err != nil {
return nil, err
}
id, ch, err := c.enqueue()
if err != nil {
return nil, err
}
defer c.forget(id)
if err := c.write(cdpMessage{ID: id, Method: method, Params: mustJSON(params)}); err != nil {
return nil, err
}
select {
case msg, ok := <-ch:
if !ok {
return nil, c.closedErr(method)
}
if msg.Error != nil {
return nil, fmt.Errorf("%w: %s: %v", ErrCDP, method, msg.Error)
}
return msg.Result, nil
case <-ctx.Done():
return nil, fmt.Errorf("%w: %s 被取消: %v", ErrCDP, method, ctx.Err())
}
}
// enqueue 分配命令 id 与等待通道(并发安全)。
func (c *cdpClient) enqueue() (int, chan cdpMessage, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.readErr != nil {
return 0, nil, c.readErr
}
c.nextID++
id := c.nextID
ch := make(chan cdpMessage, 1)
c.pending[id] = ch
return id, ch, nil
}
// forget 清掉一个等待登记(超时/取消路径上不留给读协程)。
func (c *cdpClient) forget(id int) {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}
// write 串行化写帧:gorilla 的写方法不允许并发调用。
func (c *cdpClient) write(msg cdpMessage) error {
payload, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("%w: 编码 %s: %v", ErrCDP, msg.Method, err)
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return fmt.Errorf("%w: 写 %s: %v", ErrCDP, msg.Method, err)
}
return nil
}
// errIfClosed 报告连接是否已断(读协程记下的原始错误优先)。
func (c *cdpClient) errIfClosed() error {
select {
case <-c.closed:
return c.closedErr("")
default:
return nil
}
}
// closedErr 统一「连接已断」的错误,带上读协程记下的原因。
func (c *cdpClient) closedErr(method string) error {
c.mu.Lock()
readErr := c.readErr
c.mu.Unlock()
if readErr != nil {
return readErr
}
return fmt.Errorf("%w: 连接已关闭(%s)", ErrCDP, method)
}
// Close 关掉连接(幂等)。
func (c *cdpClient) Close() error {
err := c.conn.Close()
c.fail(fmt.Errorf("%w: 连接被主动关闭", ErrCDP))
return err
}
// mustJSON 把 params 编成 RawMessage;编码失败返回 nil(参数都是本包自造的
// map/结构体,编不出来属程序错误,交给上层按缺参数处理,绝不在这里 panic)。
func mustJSON(params any) json.RawMessage {
if params == nil {
return nil
}
raw, err := json.Marshal(params)
if err != nil {
return nil
}
return raw
}
// httpGetJSON 对 CDP 的 HTTP 端点发一次 GET 并按 out 解码(/json/list、/json/version)。
func httpGetJSON(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("%w: 构造请求 %s: %v", ErrCDP, url, err)
}
client := &http.Client{Timeout: cdpCallTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("%w: 请求 %s: %v", ErrCDP, url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%w: 请求 %s: HTTP %d", ErrCDP, url, resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("%w: 解码 %s 的响应: %v", ErrCDP, url, err)
}
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package browser
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
// fakeCDP 是一个「协议形状对」的假 CDP server:它按方法名应答,用来在没有浏览器的
// 机器上覆盖客户端的连接/关联/错误/取消四条路径。
type fakeCDP struct {
srv *httptest.Server
// base 是 HTTP 基地址(`pageTargetWS` 与 `/json/version` 用它)。
base string
// pageWS 是 page target 的 WebSocket 地址(`newCDPClient` 用它)。
pageWS string
}
// newFakeCDP 起一个假 CDP:/json/list 返回一个 page target,/devtools/page/1 是 WS。
// handler 收到 (method, params),返回 (result, cdpError)。
func newFakeCDP(t *testing.T, handler func(method string, params map[string]any) (any, *cdpError)) *fakeCDP {
t.Helper()
f := &fakeCDP{}
mux := http.NewServeMux()
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
mux.HandleFunc("/devtools/page/1", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
for {
_, data, err := conn.ReadMessage()
if err != nil {
return
}
var msg cdpMessage
if err := json.Unmarshal(data, &msg); err != nil {
return
}
var params map[string]any
_ = json.Unmarshal(msg.Params, &params)
result, cdpErr := handler(msg.Method, params)
out := map[string]any{"id": msg.ID}
if cdpErr != nil {
out["error"] = cdpErr
} else {
out["result"] = result
}
payload, _ := json.Marshal(out)
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return
}
}
})
mux.HandleFunc("/json/list", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"type":"page","url":"about:blank","webSocketDebuggerUrl":"` + f.pageWS + `"}]`))
})
mux.HandleFunc("/json/version", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0","webSocketDebuggerUrl":"` + f.pageWS + `"}`))
})
f.srv = httptest.NewServer(mux)
f.base = f.srv.URL
f.pageWS = "ws://" + strings.TrimPrefix(f.srv.URL, "http://") + "/devtools/page/1"
t.Cleanup(f.srv.Close)
return f
}
// TestCDPClient_CallRoundTrip 钉住最基本的一条:命令发出去、按 id 回来的结果能解出来。
func TestCDPClient_CallRoundTrip(t *testing.T) {
f := newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
if method != "Page.getFrameTree" {
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
}
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
})
wsURL, err := pageTargetWS(context.Background(), f.base)
if err != nil {
t.Fatalf("pageTargetWS: %v", err)
}
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
var tree struct {
FrameTree struct {
Frame struct {
ID string `json:"id"`
} `json:"frame"`
} `json:"frameTree"`
}
if err := c.Call(context.Background(), "Page.getFrameTree", nil, &tree); err != nil {
t.Fatalf("Call: %v", err)
}
if tree.FrameTree.Frame.ID != "frame-1" {
t.Errorf("frame id = %q, want frame-1", tree.FrameTree.Frame.ID)
}
}
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此
// 判定「连接级问题 ⇒ 丢会话重起」)。
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32000, Message: "boom"}
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err)
}
if !strings.Contains(err.Error(), "CDP 调用失败") {
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
}
}
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
time.Sleep(2 * time.Second)
return map[string]any{}, nil
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
start := time.Now()
if err := c.Call(ctx, "Page.navigate", nil, nil); err == nil {
t.Fatal("ctx 到期应当返回错误")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("取消后仍等了 %v,说明没接 ctx", elapsed)
}
}
// TestPageTargetWS_NoPageTarget 钉住「连上了却没有 page target」被如实报出来
// (不是含糊成一个 nil 端点)。
func TestPageTargetWS_NoPageTarget(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"type":"service_worker","webSocketDebuggerUrl":"ws://x/1"}]`))
}))
defer srv.Close()
if _, err := pageTargetWS(context.Background(), srv.URL); err == nil {
t.Fatal("没有 page target 时应当报错")
}
}
// TestCDPClient_CloseFailsPendingCalls 钉住连接断开后等待者被叫醒(否则一次断线
// 会把调用方永久挂住)。
func TestCDPClient_CloseFailsPendingCalls(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
time.Sleep(3 * time.Second)
return map[string]any{}, nil
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
done := make(chan error, 1)
go func() { done <- c.Call(context.Background(), "Page.navigate", nil, nil) }()
time.Sleep(50 * time.Millisecond)
_ = c.Close()
select {
case err := <-done:
if err == nil {
t.Fatal("连接关闭后应返回错误")
}
case <-time.After(2 * time.Second):
t.Fatal("连接关闭后等待者没被叫醒")
}
}
+365
View File
@@ -0,0 +1,365 @@
package browser
// 本文件是 `DevDocs/reference/browser-stealth/07-有头vs无头对照实测方案.md` 的**可复跑
// 落地版**:同一 URL、同一出口 IP、同一窗口,只改「有头 / 无头」这一个变量,对
// 「headless 被拦 / headful 通过」这句待验命题给出可证伪的读数。
//
// 它**默认不跑**(没设 `CYRENE_BROWSER_COMPARE_URLS` 就 Skip):这是一次真的会开浏览器、
// 打外网站点的实验,不该混进普通 `go test`。
//
// 用法(Windows / Git Bash):
//
// CYRENE_BROWSER_COMPARE_URLS="https://example.com/" \
// CYRENE_BROWSER_COMPARE_REPEATS=1 \
// CYRENE_BROWSER_COMPARE_ALLOW=example.com \
// go test ./internal/capability/browser/ -run TestHeadfulVsHeadlessCompare -v -timeout 20m
//
// 变量(**只服务本测试,不是配置键**,`.env` 冻结口径不受影响):
// - `CYRENE_BROWSER_COMPARE_URLS`:逗号分隔的目标 URL;**必填**,否则 Skip。
// - `CYRENE_BROWSER_COMPARE_REPEATS`:交错重复次数(默认 1;reference/07 §5 建议 ≥3)。
// - `CYRENE_BROWSER_COMPARE_OUT`:跑批产物目录(默认落系统临时目录并打印路径)。
// - `CYRENE_BROWSER_COMPARE_ALLOW`:SSRF 白名单例外(测本机 httptest 服务时用)。
//
// 判定口径照 reference/07 §3(多信号,不看单一状态码);记录字段照 §6。
// ⚠️ 结论规则(reference/07 §7):**A blocked & B pass 才算「有头在此站有意义」**;
// 两边都 pass ⇒ 本站**未被证明**需要有头;两边都拦 ⇒ 如实登记失败,别假装成功。
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
const (
// compareGap 是**同一站两次请求之间**的最小间隔。它是 reference/07 §5 的**去 flake
// 纪律**(别让 A/B 两次请求挨在一起,否则读到的是时序噪声),**不是**使用政策上的
// 限速 —— 主人 2026-10-06 定稿的「仅自用、不加人为限速」针对的是使用政策,两者不
// 冲突。想连续打就连着打,这里只是默认值。
compareGap = 5 * time.Second
// compareNavTimeout 是单次导航的预算(冷启动 + 渲染 + 挑战页也要能落下来)。
compareNavTimeout = 45 * time.Second
// compareSampleBytes 是抓回正文样本的上限(只用于正则判定与人工核对,不落全量 HTML)。
compareSampleBytes = 20000
)
// compareGroup 是一个对照臂。A = 无头(基准),B = 有头(被测变量)。
type compareGroup struct {
Name string
Headless bool
}
// compareGroups 返回对照组:reference/07 §1 的 A / B 两组(本落地版不做 C/D 两组 ——
// C 需要预热 cookie,D 需要换 CDP 姿势,都属后续切片;见文件末尾「未做」)。
func compareGroups() []compareGroup {
return []compareGroup{
{Name: "A", Headless: true},
{Name: "B", Headless: false},
}
}
// compareRecord 是一条记录(字段对齐 reference/07 §6 的 JSON)。
type compareRecord struct {
TS string `json:"ts"`
Group string `json:"group"`
Mode string `json:"mode"`
URL string `json:"url"`
FinalURL string `json:"final_url"`
Title string `json:"title"`
LaunchFlags []string `json:"launch_flags"`
CDPRuntimeEnable bool `json:"cdp_runtime_enable"`
Driver string `json:"driver"`
ChromeVersion string `json:"chrome_version"`
UserDataDir string `json:"user_data_dir"`
Headless bool `json:"headless"`
Webdriver bool `json:"webdriver"`
UA string `json:"ua"`
UAHeadlessTell bool `json:"ua_headless_tell"`
Timezone string `json:"timezone"`
Language string `json:"language"`
ScreenWidth int `json:"screen_width"`
OuterWidth int `json:"outer_width"`
HTTPStatus int `json:"http_status"`
MatchedMarkers []string `json:"matched_markers"`
BodyBytes int `json:"body_bytes"`
ScreenshotPath string `json:"screenshot_path"`
ScreenshotSHA256 string `json:"screenshot_sha256"`
Verdict string `json:"verdict"`
Note string `json:"note"`
}
// TestHeadfulVsHeadlessCompare 跑一次对照实验。默认 Skip(见文件顶部)。
func TestHeadfulVsHeadlessCompare(t *testing.T) {
urls := splitList(os.Getenv("CYRENE_BROWSER_COMPARE_URLS"))
if len(urls) == 0 {
t.Skip("未设 CYRENE_BROWSER_COMPARE_URLS:对照脚本默认不跑(用法见本文件顶部)")
}
repeats := envInt("CYRENE_BROWSER_COMPARE_REPEATS", 1)
outDir := strings.TrimSpace(os.Getenv("CYRENE_BROWSER_COMPARE_OUT"))
if outDir == "" {
outDir = filepath.Join(os.TempDir(), "cyrene-browser-compare")
}
runDir := filepath.Join(outDir, "run-"+time.Now().Format("20060102-150405"))
if err := os.MkdirAll(runDir, 0o700); err != nil {
t.Fatalf("建产物目录 %s: %v", runDir, err)
}
allow := splitList(os.Getenv("CYRENE_BROWSER_COMPARE_ALLOW"))
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
var records []compareRecord
t.Logf("对照开始:urls=%v repeats=%d runDir=%s", urls, repeats, runDir)
for _, raw := range urls {
for repeat := 0; repeat < repeats; repeat++ {
for _, g := range compareGroups() {
rec := runCompareOnce(t, raw, g, repeat, runDir, allow, log)
records = append(records, rec)
t.Logf("%s", summarizeRecord(rec))
time.Sleep(compareGap)
}
}
}
writeRecords(t, runDir, records)
t.Logf("汇总:\n%s", compareTable(records))
t.Logf("原始记录:%s", filepath.Join(runDir, "records.jsonl"))
}
// runCompareOnce 跑一臂一次:起浏览器 → 导航 → 采集 → 判定 → 截图落盘 → 关掉。
func runCompareOnce(t *testing.T, rawURL string, g compareGroup, repeat int, runDir string, allow []string, log *slog.Logger) compareRecord {
t.Helper()
profile := filepath.Join(runDir, "profile-"+g.Name)
drv := &localChrome{
exe: compareExecutable(t),
profileDir: profile,
headless: g.Headless,
viewportW: browsercfg.DefaultViewportWidth,
viewportH: browsercfg.DefaultViewportHeight,
timeout: compareNavTimeout,
log: log,
}
ctx, cancel := context.WithTimeout(context.Background(), compareNavTimeout*2)
defer cancel()
s, err := openSession(ctx, drv, configView{
profileDir: profile, shotsDir: runDir, headless: g.Headless,
viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight,
timeout: compareNavTimeout, idle: time.Minute, shotMaxBytes: browsercfg.DefaultScreenshotMaxBytes,
})
if err != nil {
t.Fatalf("%s 组第 %d 次起会话失败: %v", g.Name, repeat+1, err)
}
defer s.Close()
rec := compareRecord{
TS: time.Now().Format(time.RFC3339), Group: g.Name, URL: rawURL,
Mode: modeName(g.Headless), LaunchFlags: drv.launchArgs(),
CDPRuntimeEnable: false, // 本驱动从源头**不发** Runtime.enable(reference/06 的 P0)
Driver: "cyrene-go-cdp", ChromeVersion: chromeVersionOf(t, s.endpoint),
UserDataDir: profile, Headless: g.Headless,
}
state, err := s.navigate(ctx, rawURL)
if err != nil {
rec.Verdict = "error"
rec.Note = "导航失败:" + err.Error()
return rec
}
rec.FinalURL, rec.Title = state.URL, state.Title
fp, ferr := readFingerprint(ctx, s)
if ferr == nil {
rec.Webdriver, rec.Timezone, rec.Language = fp.Webdriver, fp.Timezone, fp.Language
rec.ScreenWidth, rec.OuterWidth = fp.ScreenWidth, fp.OuterWidth
// UA 是**有头与无头唯一必然不同**的那一位(reference/05:`--headless=new` 的 UA
// 带 `HeadlessChrome` 段)。把它单独记成一个布尔,是为了让「有头抹掉了什么」
// 这件事从「人工看图」变成可机读的事实。
rec.UA = fp.UserAgent
rec.UAHeadlessTell = strings.Contains(strings.ToLower(fp.UserAgent), "headless")
}
var page struct {
HTMLBytes int `json:"htmlBytes"`
Sample string `json:"sample"`
Status int `json:"status"`
}
if perr := s.evaluate(ctx, pageSampleExpr(), &page); perr == nil {
rec.BodyBytes, rec.HTTPStatus = page.HTMLBytes, page.Status
rec.MatchedMarkers = matchedMarkers(page.Sample)
} else {
rec.Note = "页面采样失败:" + perr.Error()
}
rec.Verdict = verdictOf(rec)
if data, err := s.captureScreenshot(ctx, "png", 0); err == nil {
if shot, serr := saveShot(runDir, data, shotExtPNG); serr == nil {
rec.ScreenshotPath, rec.ScreenshotSHA256 = shot.Path, shot.SHA256
}
}
if rec.Note == "" {
rec.Note = "自动判定;结论前须人工看图(reference/07 §3)"
}
return rec
}
// pageSampleExpr 一次求值取回:文档字节数、正文样本、HTTP 状态。
// `performance.getEntriesByType('navigation')[0].responseStatus` 是 Chrome 109+ 的
// 导航响应状态;取不到就报 0(**不猜**)。
func pageSampleExpr() string {
return fmt.Sprintf(`(() => {
const nav = performance.getEntriesByType('navigation')[0];
const text = document.body ? (document.body.innerText || '') : '';
const html = document.documentElement ? document.documentElement.outerHTML : '';
return {
htmlBytes: html.length,
status: nav && nav.responseStatus ? nav.responseStatus : 0,
sample: (document.title + '\n' + text).slice(0, %d)
};
})()`, compareSampleBytes)
}
// blockMarkers 是 reference/07 §3 的判定标记表(大小写不敏感;小写存)。
func blockMarkers() []string {
return []string{
"just a moment", "cf-chl", "__cf_chl", "/cdn-cgi/challenge-platform", "attention required",
"captcha-delivery.com", "datadome", "access denied", "verifying you are human",
"unusual traffic", "请求过于频繁", "访问被拒绝", "verify you are human",
}
}
// matchedMarkers 返回样本里命中的拦截标记(去重、稳定顺序)。
func matchedMarkers(sample string) []string {
lower := strings.ToLower(sample)
var hits []string
for _, m := range blockMarkers() {
if strings.Contains(lower, m) {
hits = append(hits, m)
}
}
sort.Strings(hits)
return hits
}
// verdictOf 按 reference/07 §3 的优先级给判定:强信号 ⇒ blocked/challenge;
// 否则 pass。error 由调用方在失败路径上直接写入。
func verdictOf(rec compareRecord) string {
switch rec.HTTPStatus {
case 403, 429, 503:
return "blocked"
}
if len(rec.MatchedMarkers) > 0 {
for _, m := range rec.MatchedMarkers {
if strings.Contains(m, "cf-chl") || strings.Contains(m, "challenge-platform") || m == "just a moment" {
return "challenge"
}
}
return "blocked"
}
if rec.BodyBytes <= 0 {
return "error"
}
return "pass"
}
// compareExecutable 找到本次实验用的浏览器可执行文件(缺了就直接跳过整个测试)。
func compareExecutable(t *testing.T) string {
t.Helper()
exe, ok := browsercfg.NewProber().FindLocalBrowser()
if !ok {
t.Skip("本机没有 Chrome/Chromium,对照无法进行")
}
return exe
}
// chromeVersionOf 从 CDP 的 /json/version 读浏览器版本(记录用,失败留空不猜)。
func chromeVersionOf(t *testing.T, endpoint string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var v struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(ctx, endpoint+"/json/version", &v); err != nil {
return ""
}
return v.Browser
}
// writeRecords 落 records.jsonl(0600:里面含目标 URL 与截图指纹)。
func writeRecords(t *testing.T, runDir string, records []compareRecord) {
t.Helper()
path := filepath.Join(runDir, "records.jsonl")
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
t.Fatalf("写 %s: %v", path, err)
}
defer func() { _ = f.Close() }()
enc := json.NewEncoder(f)
for _, rec := range records {
if err := enc.Encode(rec); err != nil {
t.Fatalf("编码记录: %v", err)
}
}
}
// summarizeRecord 是一条可读摘要(t.Log 用)。
func summarizeRecord(rec compareRecord) string {
return fmt.Sprintf("[%s/%s] verdict=%s status=%d body=%d markers=%v webdriver=%v uaHeadlessTell=%v tz=%s screen=%d outer=%d",
rec.Group, rec.Mode, rec.Verdict, rec.HTTPStatus, rec.BodyBytes, rec.MatchedMarkers,
rec.Webdriver, rec.UAHeadlessTell, rec.Timezone, rec.ScreenWidth, rec.OuterWidth)
}
// compareTable 输出交付用的汇总表(组 × 判定 + 两件关键指纹事实)。
func compareTable(records []compareRecord) string {
var b strings.Builder
b.WriteString("group | mode | headless | webdriver | ua_headless | status | verdict | markers | body_bytes | shot_sha256\n")
for _, rec := range records {
sum := rec.ScreenshotSHA256
if len(sum) > 8 {
sum = sum[:8]
}
b.WriteString(fmt.Sprintf("%-5s | %-8s | %-8v | %-9v | %-11v | %-6d | %-8s | %-7d | %-10d | %s\n",
rec.Group, rec.Mode, rec.Headless, rec.Webdriver, rec.UAHeadlessTell, rec.HTTPStatus,
rec.Verdict, len(rec.MatchedMarkers), rec.BodyBytes, sum))
}
return b.String()
}
// modeName 把有头/无头映射成记录里的 mode 字段。
func modeName(headless bool) string {
if headless {
return "headless"
}
return "headful"
}
// splitList 拆逗号分隔的列表(去空白与空项)。
func splitList(raw string) []string {
var out []string
for _, part := range strings.Split(raw, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// envInt 读一个正整数环境变量;缺失/非法/≤0 时返回 def。
func envInt(key string, def int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
return n
}
+39
View File
@@ -0,0 +1,39 @@
// Package browser 是 design/28(浏览器与页面截图)B2 的自研**薄层**:把「本机真
// Chromium 有头」跑起来,对它做**出站 CDP** 调用,并把页面截图按 0600 落到
// `$DATA_DIR/browser/shots/`,供既有 image/vision 管线读取。
//
// 为什么这么薄(AGENTS §9.5):
//
// - **工具面优先复用 MCP**:`goto` / `click` / `type` / `snapshot` / `extract`
// 这一整套成熟动作由 Playwright/Puppeteer 的官方 MCP server 提供(design/28 §0
// 「Playwright MCP / Puppeteer MCP」行、§2 结论);本包**不重造**它们。
// - **本项目独有的价值只有两处**:① 宿主真 Chromium 的**默认有头 + 持久 profile**
// 启动(design/28 §2 的过风控三件套);② 截图 → 落盘(0600)→ 进上下文/面板的
// 闭环入口。本包只做这两处,外加够它们用的**最小 CDP 客户端**。
//
// 运行形态(design/28 §2「选型修正」的主路径):
//
// 1. **本机真 Chromium 有头**(默认档,`browsercfg.DefaultHeadless=false`):
// 真显示 + 真 GPU/字体/时区,指纹最像真人;
// 2. **次选:已跑的 CDP 端点**(容器 + Xvfb + 真 Chromium,配置显式给出):
// 本包只**出站连接**它。
//
// 红线(design/28 §3/§4、AGENTS §9/§10):
//
// - **不新开对外监听端口**:调试端口由 Chromium 自己绑在 `127.0.0.1` 的**随机**
// 端口(`--remote-debugging-port=0`,端口从 profile 目录的 `DevToolsActivePort`
// 读回),我们是**出站连接方**;容器路径同理(我们连它)。
// - **profile 含凭据**(cookie / 登录态):目录 0700、不得位于 git 工作树内
// (`browsercfg.EnsureProfileDir`),内容绝不进上下文与长期记忆。
// - **截图可能含凭据/二维码**:文件 0600、默认不进长期记忆(design/28 §4)。
// - **CDP 层不自曝**:本包**不发** `Runtime.enable`(主流反爬用它判定「被自动化
// 控制」,见 `DevDocs/reference/browser-stealth/06`);`Runtime.evaluate` 一律走
// `Page.createIsolatedWorld` 出来的执行上下文。
// - **失败如实报**:探测不到就不注册工具;跑不起来 / 被拦 / 超限都返回**分类原因**,
// 绝不假装成功(design/28 §6 坑 5)。
//
// 已知未做(本片如实登记,见交付报告):
// - 面板「看一眼」入口(B4)、搜索降级档(B5)未做;
// - 除「导航 + 截图 + 页面指纹自检」之外的动作(click/type/extract/wait)**交给 MCP**,
// 本包不实现。
package browser
+81
View File
@@ -0,0 +1,81 @@
package browser
import "errors"
// 本包对外哨兵错误,一律用 errors.Is 判定;文案由调用方补齐上下文(AGENTS §10:
// 错误一律 %w wrap,禁裸 return err)。**不设包级可变状态**(除这些 var ErrXxx)。
var (
// ErrCDP 表示 CDP 层失败:WebSocket 连不上 / 命令返回协议错误 / 连接被打断。
ErrCDP = errors.New("browser: CDP 调用失败")
// ErrLaunch 表示启动本机浏览器失败:可执行文件拉不起来、进程立刻退出、
// 或者等不到调试端口就绪。
ErrLaunch = errors.New("browser: 启动本机浏览器失败")
// ErrNavigate 表示导航失败(CDP 报 errorText,或等待页面就绪超时)。
ErrNavigate = errors.New("browser: 导航失败")
// ErrEvaluate 表示在页面里求值失败(表达式异常 / 拿不到执行上下文)。
ErrEvaluate = errors.New("browser: 页面求值失败")
// ErrBlockedURL 表示目标 URL 被 SSRF 守卫拒绝(回环/私网/链路本地等)。
ErrBlockedURL = errors.New("browser: URL 被安全守卫拒绝")
// ErrScreenshotTooLarge 表示截图在两次尝试(PNG → JPEG)之后仍超过配置的字节上限。
// 超限**不截断**:半个 PNG 是坏图,模型读到的会是噪声(design/28 §3「超限如实
// 截断/缩放并标注」的落地口径 —— 能降编码就降,降不下来就如实报错)。
ErrScreenshotTooLarge = errors.New("browser: 截图超过字节上限")
// ErrNoSession 表示管理器已被关闭,或当前没有可用会话(不可用时不假装可用)。
ErrNoSession = errors.New("browser: 没有可用的浏览器会话")
// ErrUnavailable 表示这台机器上没有任何可用来源(没装 Chrome、也没有在跑的
// CDP 端点)。它由组合根在探测阶段判定,此处只用于「手里没有 dialer」这一态。
ErrUnavailable = errors.New("browser: 本机没有可用的浏览器来源")
)
// Reason 是「这次为什么没成功」的**分类原因码**:上层据此如实降级与记日志,
// 不许把它含糊成「失败」两个字(design/28 §6 坑 5)。取值稳定、可断言;人类可读
// 文案见 Message()。
type Reason string
// 分类原因码。
const (
// ReasonLaunched 表示会话由本机 Chrome/Chromium 新起(默认有头、持久 profile)。
ReasonLaunched Reason = "launched-local-chrome"
// ReasonRemoteCDP 表示会话复用了配置里显式给出的 CDP 端点(容器/手动实例)。
ReasonRemoteCDP Reason = "remote-cdp"
// ReasonIdleRecycled 表示上一次会话因空闲超时被回收(下一个动作会重新起)。
ReasonIdleRecycled Reason = "idle-recycled"
// ReasonLaunchFailed 表示本机浏览器没拉起来(可执行文件 / 端口未就绪)。
ReasonLaunchFailed Reason = "launch-failed"
// ReasonNoPageTarget 表示连上了浏览器却找不到可用的 page target。
ReasonNoPageTarget Reason = "no-page-target"
// ReasonBlockedURL 表示 URL 没过 SSRF 守卫。
ReasonBlockedURL Reason = "blocked-url"
// ReasonNavigateFailed 表示导航失败或等待就绪超时。
ReasonNavigateFailed Reason = "navigate-failed"
// ReasonShotTooLarge 表示截图超过字节上限(如实报,不交坏图)。
ReasonShotTooLarge Reason = "screenshot-too-large"
// ReasonClosed 表示管理器已关闭。
ReasonClosed Reason = "closed"
)
// Message 返回原因码对应的中文文案;未知码返回空串(调用方回落到错误的 Error())。
func (r Reason) Message() string {
switch r {
case ReasonLaunched:
return "已启动本机浏览器(默认有头 + 持久 profile)"
case ReasonRemoteCDP:
return "已连接到配置指定的 CDP 端点"
case ReasonIdleRecycled:
return "空闲超时,上一次会话已回收"
case ReasonLaunchFailed:
return "本机浏览器未能启动"
case ReasonNoPageTarget:
return "连上了浏览器,但没有可用的 page target"
case ReasonBlockedURL:
return "目标 URL 未通过安全守卫"
case ReasonNavigateFailed:
return "导航失败或等待页面就绪超时"
case ReasonShotTooLarge:
return "截图超过配置的字节上限"
case ReasonClosed:
return "浏览器管理器已关闭"
default:
return ""
}
}
+106
View File
@@ -0,0 +1,106 @@
package browser
import (
"context"
"fmt"
"net"
"net/url"
"strings"
)
// ⚠️ **已知偏差(如实登记,不美化)**:design/28 §4 要求「SSRF 复用既有 `io/ssrf`
// 白名单,**不新增第三份实现**」。但既有实现位于 `plugins/tools/io`(`ssrf.go` 的
// `validateRequestURL`)且**未导出**,本切片的文件面不含该包 ⇒ 本片无法直接复用,
// 也不擅自改别人的文件。因此这里落的是**与 `io/ssrf` 同一条策略**的判定(先放行
// 白名单、再按 IP 网段否决;网段口径逐条对齐),并登记为待收口项:
// **收口动作 = 把 `plugins/tools/io` 的 `validateRequestURL` 导出(或提到共享包),
// 删掉本文件**。在收口之前,浏览器侧与 web-fetch 侧的放行/拒绝口径**按同一张表**
// 执行,不存在「一个放行另一个拒绝」的分歧。
//
// 判定口径(与 `io/ssrf` 逐条对齐):
// - 只接受 `http` / `https`;
// - 命中调用方白名单的主机**直接放行**(同源自取:本地 searxng、本机 upload 路由);
// - 字面 IP 落在回环/私网/链路本地/组播/未指定 ⇒ 拒绝;
// - 主机名先解析,**任一条**解析结果落在上面那些网段 ⇒ 拒绝(防 DNS rebinding 的
// 第一跳;真正的 rebinding 需要连上时再校验,既有两份实现都没做,本片不越界)。
//
// 为什么浏览器侧必须有这道闸:导航是**服务端代取用户/模型给的任意 URL**,不过闸就
// 等于把云元数据端点(`169.254.169.254`)、内网管理面暴露给一次提示词注入
// (design/28 §4 第 1 条;M3 面板入口同样过闸)。
func GuardURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) {
if strings.TrimSpace(rawURL) == "" {
return nil, fmt.Errorf("%w: url 为空", ErrBlockedURL)
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("%w: 解析 url: %v", ErrBlockedURL, err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("%w: 不支持协议 %q", ErrBlockedURL, u.Scheme)
}
host := u.Hostname()
if host == "" {
return nil, fmt.Errorf("%w: url 没有主机名", ErrBlockedURL)
}
if hostAllowed(host, allowed) {
return u, nil
}
if ip := net.ParseIP(host); ip != nil {
if deniedIP(ip) {
return nil, fmt.Errorf("%w: %s 属回环/私网/链路本地地址", ErrBlockedURL, host)
}
return u, nil
}
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
if err != nil {
return nil, fmt.Errorf("%w: 解析 %q: %v", ErrBlockedURL, host, err)
}
for _, a := range addrs {
if ip := net.ParseIP(a); ip != nil && deniedIP(ip) {
return nil, fmt.Errorf("%w: %q 解析到 %q", ErrBlockedURL, host, a)
}
}
return u, nil
}
// deniedIP 报告 ip 是否属于守卫默认拒绝的网段:回环、私网、链路本地(单播/组播)、
// 未指定。口径与 `plugins/tools/io/ssrf.go` 的 `isDeniedIP` 逐条相同。
func deniedIP(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified()
}
// hostAllowed 报告 host 是否命中白名单(逐条按主机名比较,大小写不敏感,端口忽略)。
func hostAllowed(host string, allowed []string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "" {
return false
}
for _, a := range allowed {
if strings.EqualFold(hostOf(a), h) {
return true
}
}
return false
}
// hostOf 从白名单条目里取主机名:允许写成 `host`、`host:port` 或完整 URL。
func hostOf(entry string) string {
raw := strings.TrimSpace(entry)
if raw == "" {
return ""
}
if u, err := url.Parse(raw); err == nil && u.Hostname() != "" {
return u.Hostname()
}
if host, _, err := net.SplitHostPort(raw); err == nil {
return host
}
return raw
}
+90
View File
@@ -0,0 +1,90 @@
package browser
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestGuardURL_DeniesLoopbackAndPrivate 钉住守卫的**默认拒绝面**:回环、私网、
// 链路本地、非 http(s) 协议、云元数据端点一律拒绝 —— 这是 design/28 §4 第 1 条
// (服务端代取任意 URL 必须过闸)的可执行判据。
func TestGuardURL_DeniesLoopbackAndPrivate(t *testing.T) {
t.Parallel()
denied := []string{
"http://127.0.0.1/",
"http://localhost/",
"http://[::1]/",
"http://192.168.1.10/admin",
"http://10.0.0.5/",
"http://172.16.3.4/",
"http://169.254.169.254/latest/meta-data/",
"http://0.0.0.0/",
"file:///etc/passwd",
"ftp://example.com/",
"http:///no-host",
"",
}
for _, raw := range denied {
if _, err := GuardURL(context.Background(), raw, nil); err == nil {
t.Errorf("GuardURL(%q) = nil,应当拒绝", raw)
} else if !strings.Contains(err.Error(), "安全守卫拒绝") && !strings.Contains(err.Error(), "URL 被安全守卫拒绝") {
t.Errorf("GuardURL(%q) 的错误不像守卫拒绝: %v", raw, err)
}
}
}
// TestGuardURL_AllowsPublicAndAllowlisted 钉住**放行面**:公网地址放行;白名单里的
// 主机(同源自取:本地 searxng / 本机 upload 路由)即使落在回环也放行。
func TestGuardURL_AllowsPublicAndAllowlisted(t *testing.T) {
t.Parallel()
if _, err := GuardURL(context.Background(), "https://example.com/a?b=1", nil); err != nil {
t.Fatalf("公网地址被拒: %v", err)
}
if _, err := GuardURL(context.Background(), "http://93.184.216.34/", nil); err != nil {
t.Fatalf("公网字面 IP 被拒: %v", err)
}
// 白名单是**按主机名字符串**比较的(与 io/ssrf 的 isHostAllowed 同口径):
// 允许 `localhost` 不等于允许 `127.0.0.1` —— 两者是两个主机名,要各写一条。
exempt := []struct{ entry, raw string }{
{"127.0.0.1", "http://127.0.0.1:8080/x"},
{"http://localhost:8080", "http://localhost/x"},
{"LocalHost", "http://localhost:9999/x"},
}
for _, tc := range exempt {
if _, err := GuardURL(context.Background(), tc.raw, []string{tc.entry}); err != nil {
t.Errorf("白名单 %q 未放行 %q: %v", tc.entry, tc.raw, err)
}
}
}
// TestGuardURL_AllowlistEntryForms 钉住白名单条目的三种写法(裸主机 / host:port / 完整 URL)
// 都按主机名匹配 —— 少一种,面板里写的白名单就会静默失效。
func TestGuardURL_AllowlistEntryForms(t *testing.T) {
t.Parallel()
for _, entry := range []string{"127.0.0.1", "127.0.0.1:9", "http://127.0.0.1:9999/"} {
if _, err := GuardURL(context.Background(), "http://127.0.0.1:7777/", []string{entry}); err != nil {
t.Errorf("条目 %q 未命中: %v", entry, err)
}
}
}
// TestGuardURL_RejectsHostResolvingToPrivate 钉住 DNS 侧的判定:一个解析到私网的主机名
// 也要被拒(用 httptest 起在本机、把它的主机名当目标;`localhost` 已由字面量覆盖,
// 这里覆盖「解析后才发现是私网」这条路径)。
func TestGuardURL_RejectsHostResolvingToPrivate(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
// httptest 的 URL 形如 http://127.0.0.1:<port>,字面 IP 路径已由上面的用例覆盖;
// 这里钉住「解析结果里含私网地址」的判定函数本身。
host := strings.TrimPrefix(srv.URL, "http://")
hostIP, _, _ := strings.Cut(host, ":")
if _, err := GuardURL(context.Background(), srv.URL, nil); err == nil {
t.Errorf("httptest 地址 %s 应被拒(字面回环)", hostIP)
}
}
+60
View File
@@ -0,0 +1,60 @@
package browser
import "context"
// Fingerprint 是一次「这台浏览器到底长什么样」的自检读数,对应
// `DevDocs/reference/browser-stealth/10 §4.1` 的落地自检项 L2~L5。它存在的意义是
// **可证伪**:有头到底有没有生效、webdriver 有没有被点亮、屏幕/时区/语言与设定是否
// 一致 —— 全靠这组**页面自报**的数字,而不是我们以为的启动参数。
type Fingerprint struct {
// Headless 来自本次会话的启动参数(我们有权威来源:参数是我们自己拼的),
// 不靠嗅探 UA —— `--headless=new` 的 UA **不含** Headless 字样(reference/05)。
Headless bool `json:"headless"`
// Webdriver 是 `navigator.webdriver`:false 才正常(我们不传 --enable-automation)。
Webdriver bool `json:"webdriver"`
// UserAgent / Platform 是页面自报的 UA 与平台串(原样,不做任何改写)。
UserAgent string `json:"userAgent"`
Platform string `json:"platform"`
// Language / Languages 是页面自报的语言列表(应与启动的 --lang 一致)。
Language string `json:"language"`
Languages []string `json:"languages"`
// Timezone 是 `Intl.DateTimeFormat().resolvedOptions().timeZone`(应与出口 IP 同源)。
Timezone string `json:"timezone"`
// ScreenWidth / ScreenHeight / ColorDepth 是 `screen.*`(应与 Xvfb 参数或真实桌面一致)。
ScreenWidth int `json:"screenWidth"`
ScreenHeight int `json:"screenHeight"`
ColorDepth int `json:"colorDepth"`
// OuterWidth / OuterHeight 是窗口外框尺寸:有头下应 > 0(无头常见 0)。
OuterWidth int `json:"outerWidth"`
OuterHeight int `json:"outerHeight"`
// DevicePixelRatio / HardwareConcurrency 是两条常见的指纹量。
DevicePixelRatio float64 `json:"devicePixelRatio"`
HardwareConcurrency int `json:"hardwareConcurrency"`
}
// fingerprintExpr 一次求值取回整组读数:分多次往返会让页面中途重定向后的读数互相
// 矛盾(一半是旧页、一半是新页)。它只在**隔离世界**里跑(session.evaluate)。
const fingerprintExpr = `(() => ({
webdriver: navigator.webdriver === true,
userAgent: navigator.userAgent,
platform: navigator.platform,
language: navigator.language,
languages: navigator.languages,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
screenWidth: screen.width,
screenHeight: screen.height,
colorDepth: screen.colorDepth,
outerWidth: window.outerWidth,
outerHeight: window.outerHeight,
devicePixelRatio: window.devicePixelRatio,
hardwareConcurrency: navigator.hardwareConcurrency
}))()`
// readFingerprint 读一次页面指纹。Headless 取自会话的启动参数,其余取自页面自报。
func readFingerprint(ctx context.Context, s *session) (Fingerprint, error) {
fp := Fingerprint{Headless: s.headless}
if err := s.evaluate(ctx, fingerprintExpr, &fp); err != nil {
return Fingerprint{}, err
}
return fp, nil
}
+270
View File
@@ -0,0 +1,270 @@
package browser
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。
const (
// devToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写回端口号的
// 文件名(profile 目录下):首行端口、次行 browser_guid
// (reference/browser-stealth/10 §2.4,依据 content/browser/devtools/devtools_http_handler.cc)。
devToolsPortFile = "DevToolsActivePort"
// portPollInterval 是「等调试端口就绪」的轮询间隔:冷启动通常 0.3~1.5s,
// 100ms 的粒度既不迟钝也不白烧 CPU。
portPollInterval = 100 * time.Millisecond
// defaultLang 是启动时声明的 UI 语言。口径:与「本机 / 出口 IP 所在地」对齐
// (design/28 §2 指纹一致性);写死 zh-CN 是本部署的实情,配置面(browser.json)
// 尚未开放语言项 —— 见本片报告「未做」。
defaultLang = "zh-CN"
)
// adoptProbeTimeout 是「要不要认领已在跑的实例」这一次探测的超时(也是远端端点探活
// 用的那一档):它只问「端口有没有在听」,给得短。
const adoptProbeTimeout = 2 * time.Second
// dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把
// 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。
type dialer interface {
// Dial 返回端点;stop 释放本次会话占用的资源(本机路径 = 杀掉进程,容器路径 = 空操作)。
Dial(ctx context.Context) (endpoint string, stop func(), err error)
// Describe 返回一句人类可读的来源描述(进日志与工具结果,不进提示词模板)。
Describe() string
// Local 报告这个端点是不是**我们自己的本机浏览器**:只有它为 true 时,会话收尾
// 才允许关闭浏览器。容器/手动实例(remoteCDP)是别人的,绝不代关。
Local() bool
}
// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。
//
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地:
// - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、
// `--no-default-browser-check`、`--disable-blink-features=AutomationControlled`、
// `--remote-debugging-port=0`;
// - **不传**:`--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
// `--disable-extensions` / `--disable-default-apps` /
// `--disable-component-extensions-with-background-pages` / `--disable-component-update` /
// `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、
// 以及一切 `--headless*`(有头就是不能有它)。
//
// 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍:
// 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows
// 上不支持**。因此 Windows 主路径用次选 `--remote-debugging-port=0`(内核分配随机
// 回环端口,从 profile 目录的 DevToolsActivePort 读回):**仍是我们出站连它**,
// 不开任何对外监听端口(AGENTS §9)。
type localChrome struct {
exe string
profileDir string
headless bool
viewportW int
viewportH int
timeout time.Duration
log *slog.Logger
}
// Describe 实现 dialer。
func (l *localChrome) Describe() string {
mode := "headful"
if l.headless {
mode = "headless"
}
return fmt.Sprintf("本机浏览器 %s(%s)", l.exe, mode)
}
// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。
func (l *localChrome) Local() bool { return true }
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
//
// 两条**先于启动**的判定(都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
//
// 1. **认领已在跑的实例**:profile 目录的 DevToolsActivePort 还活着(端口在听)⇒ 直接
// 出站连接它,**不再起第二个**。这不是优化,是**正确性**:同一个 user-data-dir 上起
// 第二个实例时 Chromium 会「委托给已在跑的那个」然后自己退出,于是新进程永远不写
// DevToolsActivePort ⇒ 功能会一直报启动超时,直到人工杀掉那个进程。认领把这一步
// 变成「复用」,也更贴合 design/28 §2「profile 长期保留、任务间复用」。
// 2. **端口文件先清掉**:只在真的要走启动路径时才清,否则会把旧端口当成本次结果。
//
// 停止函数只对**本次启动**的进程有效(认领来的实例不归我们杀):进程退出后 Chrome 的
// 渲染进程随之退出,因此进程级 Kill 足够。
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
if strings.TrimSpace(l.exe) == "" {
return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch)
}
if endpoint, ok := l.adoptExisting(ctx); ok {
l.log.Info("browser session adopted", "endpoint", endpoint, "profile", l.profileDir)
return endpoint, func() {}, nil
}
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
}
_ = os.Remove(filepath.Join(l.profileDir, devToolsPortFile))
cmd := exec.Command(l.exe, l.launchArgs()...)
if err := cmd.Start(); err != nil {
return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err)
}
exited := make(chan struct{})
go func() {
defer close(exited)
_, _ = cmd.Process.Wait()
}()
port, err := l.waitDevToolsPort(ctx, exited)
if err != nil {
stopProcess(cmd)
return "", nil, err
}
l.log.Debug("browser launched", "exe", l.exe, "port", port, "profile", l.profileDir)
return fmt.Sprintf("http://127.0.0.1:%d", port), func() { stopProcess(cmd) }, nil
}
// adoptExisting 报告 profile 目录里记录的调试端点是否**仍然活着**;活着就返回它。
// 探测超时给得很短:这是一个「要不要复用」的判断,不该拖住启动路径。
func (l *localChrome) adoptExisting(ctx context.Context) (string, bool) {
port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile))
if !ok {
return "", false
}
endpoint := fmt.Sprintf("http://127.0.0.1:%d", port)
probeCtx, cancel := context.WithTimeout(ctx, adoptProbeTimeout)
defer cancel()
var version struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(probeCtx, endpoint+"/json/version", &version); err != nil {
return "", false
}
return endpoint, true
}
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
func (l *localChrome) launchArgs() []string {
args := []string{
// profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。
"--user-data-dir=" + l.profileDir,
// 内核分配随机回环端口;端口从 DevToolsActivePort 读回(见本类型文档)。
"--remote-debugging-port=0",
// 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝;
// reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」,
// 不开任何对外服务面。
"--remote-allow-origins=*",
"--no-first-run",
"--no-default-browser-check",
fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH),
"--lang=" + defaultLang,
// 兜底防线:真正的关键是**不传** `--enable-automation`(见类型文档)。
"--disable-blink-features=AutomationControlled",
"about:blank",
}
if l.headless {
// 只在面板显式降级时出现;默认档(有头)**绝不传**任何 `--headless*`。
args = append([]string{"--headless=new"}, args...)
}
return args
}
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**:
// 进程立刻退出(多半是 profile 锁被占 / 浏览器已在运行)、ctx 取消、超时。
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
path := filepath.Join(l.profileDir, devToolsPortFile)
deadline := time.NewTimer(l.timeout)
defer deadline.Stop()
ticker := time.NewTicker(portPollInterval)
defer ticker.Stop()
for {
if port, ok := readDevToolsPort(path); ok {
return port, nil
}
select {
case <-exited:
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
// 最常见的原因是同一 profile 已被另一个实例占用(Chromium 的 profile 锁)
// 或浏览器已被别处启动:委托出去的新进程不写 DevToolsActivePort。
return 0, fmt.Errorf("%w: 浏览器进程立刻退出(多半是该 profile 已被另一个实例占用)", ErrLaunch)
case <-ctx.Done():
return 0, fmt.Errorf("%w: 等待调试端口时被取消: %v", ErrLaunch, ctx.Err())
case <-deadline.C:
return 0, fmt.Errorf("%w: 等 %s 超时(%s)", ErrLaunch, devToolsPortFile, l.timeout)
case <-ticker.C:
}
}
}
// readDevToolsPort 读端口文件的首行;文件还没出现 / 还没写完 / 内容不合法都返回 false
// (交给调用方继续轮询,而不是把「正在写」误判成「配错」)。
func readDevToolsPort(path string) (int, bool) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, false
}
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
port, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil || port <= 0 || port > 65535 {
return 0, false
}
return port, true
}
// remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium,
// 或主人手动起的实例)。它是纯出站客户端,不启动任何进程、不监听任何端口。
type remoteCDP struct{ endpoint string }
// Describe 实现 dialer。
func (r *remoteCDP) Describe() string { return "已跑的 CDP 端点 " + r.endpoint }
// Local 实现 dialer:容器/手动实例**不是我们的**,会话收尾绝不代它关闭。
func (r *remoteCDP) Local() bool { return false }
// Dial 先探活再交出端点;连不上就如实报,不偷偷回退本机(design/28 §6 坑 5)。
func (r *remoteCDP) Dial(ctx context.Context) (string, func(), error) {
endpoint := strings.TrimRight(strings.TrimSpace(r.endpoint), "/")
if endpoint == "" {
return "", nil, fmt.Errorf("%w: CDP 端点为空白", ErrUnavailable)
}
var version struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(ctx, endpoint+"/json/version", &version); err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return endpoint, func() {}, nil
}
// stopProcess 结束本进程拉起的浏览器(保留为具名函数:Kill 的语义在这一处写清楚,
// 调用点不必解释;Wait 由 Dial 里的等待协程负责,这里只 Kill,避免双重 Wait)。
// Windows 上渲染进程随浏览器进程一起退,因此进程级 Kill 足够。
func stopProcess(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
}
// newDialer 按「配置显式端点 → 配置显式可执行文件 → 本机探测结果」组装 dialer。
// 可用性判定由 browsercfg.Resolve 做(优先级单一出处),这里只把结论映射成具体 dialer。
func newDialer(avail browsercfg.Availability, cfg browsercfg.Config, log *slog.Logger) dialer {
if e := strings.TrimSpace(avail.Endpoint); e != "" {
return &remoteCDP{endpoint: e}
}
return &localChrome{
exe: strings.TrimSpace(avail.Executable),
profileDir: cfg.ProfileDir,
headless: cfg.Headless,
viewportW: cfg.ViewportWidth,
viewportH: cfg.ViewportHeight,
timeout: cfg.Timeout(),
log: log,
}
}
+195
View File
@@ -0,0 +1,195 @@
package browser
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestLaunchArgs_HeadfulCarriesNoAutomationTraces 是本片**最重要的一条测试**:它把
// `DevDocs/reference/browser-stealth/10 §2.1` 的黑名单钉成可执行判据 ——
// 有头路径**不许**出现任何 `--headless*`,也不许出现「自动化味道」的参数
// (`--enable-automation` 以及 Playwright/Puppeteer 默认传的那一串)。
// 有人往清单里加回一条,这里立刻红。
func TestLaunchArgs_HeadfulCarriesNoAutomationTraces(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: `C:\tmp\p`, headless: false, viewportW: 1280, viewportH: 800}
args := l.launchArgs()
forbidden := []string{
"--headless",
"--enable-automation",
"--disable-extensions",
"--disable-default-apps",
"--disable-component-extensions-with-background-pages",
"--disable-component-update",
"--disable-popup-blocking",
}
for _, bad := range forbidden {
for _, a := range args {
if strings.HasPrefix(a, bad) {
t.Errorf("有头启动参数里出现了不许传的 %q:%v", bad, args)
}
}
}
}
// TestLaunchArgs_RequiredFlagsPresent 钉住「必须传」的那几条:profile、随机回环调试
// 端口、窗口尺寸、语言、免首次运行、以及 AutomationControlled 兜底防线。
func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/prof", headless: false, viewportW: 1024, viewportH: 768}
args := l.launchArgs()
for _, want := range []string{
"--user-data-dir=/tmp/prof",
"--remote-debugging-port=0",
"--window-size=1024,768",
"--lang=zh-CN",
"--no-first-run",
"--no-default-browser-check",
"--disable-blink-features=AutomationControlled",
"--remote-allow-origins=*",
"about:blank",
} {
if !containsArg(args, want) {
t.Errorf("启动参数缺少 %q:%v", want, args)
}
}
}
// TestLaunchArgs_HeadlessOnlyWhenConfigured 钉住「无头只在显式配置时出现」:
// 默认档是有头(design/28 §2),无头是面板显式降级的结果。
func TestLaunchArgs_HeadlessOnlyWhenConfigured(t *testing.T) {
t.Parallel()
l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/p", headless: true, viewportW: 800, viewportH: 600}
args := l.launchArgs()
if len(args) == 0 || args[0] != "--headless=new" {
t.Errorf("无头档的第一个参数应为 --headless=new,实得:%v", args)
}
if !containsArg(args, "--disable-blink-features=AutomationControlled") {
t.Error("无头档也必须保留 AutomationControlled 防线")
}
}
// TestReadDevToolsPort 钉住端口文件解析:正常两行、只有一行、缺文件、垃圾内容。
func TestReadDevToolsPort(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, devToolsPortFile)
if _, ok := readDevToolsPort(path); ok {
t.Error("文件不存在时不该报 ok")
}
if err := os.WriteFile(path, []byte("54321\nbrowser-guid-xyz\n"), 0o600); err != nil {
t.Fatal(err)
}
if port, ok := readDevToolsPort(path); !ok || port != 54321 {
t.Errorf("读到 (%d,%v), want (54321,true)", port, ok)
}
if err := os.WriteFile(path, []byte("not-a-port\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readDevToolsPort(path); ok {
t.Error("垃圾内容不该报 ok")
}
if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := readDevToolsPort(path); ok {
t.Error("端口 0 不该报 ok")
}
}
// TestLocalChromeDescribe 钉住来源描述如实反映有头/无头(日志与工具结果都用它)。
func TestLocalChromeDescribe(t *testing.T) {
t.Parallel()
headful := &localChrome{exe: "chrome.exe"}
if got := headful.Describe(); !strings.Contains(got, "headful") {
t.Errorf("Describe() = %q,应含 headful", got)
}
headless := &localChrome{exe: "chrome.exe", headless: true}
if got := headless.Describe(); !strings.Contains(got, "headless") {
t.Errorf("Describe() = %q,应含 headless", got)
}
}
// TestLocalChromeAdoptsLiveEndpoint 钉住「认领已在跑的实例」:profile 目录里的
// DevToolsActivePort 还活着 ⇒ Dial 直接交出那个端点、**不启动任何进程**。
// 这不是优化:同一 profile 起第二个实例时 Chromium 会委托给已在跑的那个然后自己退出,
// 新进程永远不写端口文件 ⇒ 不做认领,功能会被一个残留实例卡死。
func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/json/version" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0"}`))
}))
defer srv.Close()
port := strings.TrimPrefix(srv.URL, "http://127.0.0.1:")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte(port+"\nGUID\n"), 0o600); err != nil {
t.Fatal(err)
}
l := &localChrome{exe: "definitely-not-a-browser.exe", profileDir: dir, timeout: time.Second, log: discardLog()}
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("应当认领活着端点,实得: %v", err)
}
if endpoint != "http://127.0.0.1:"+port {
t.Errorf("endpoint = %q", endpoint)
}
stop() // 认领来的实例不归我们杀:停止函数必须是空操作(调用不 panic 即通过)
}
// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口文件在、但没人听 ⇒ **不认领**
// (否则会连到一个死端口,比启动失败更难排查)。
func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte("1\nGUID\n"), 0o600); err != nil {
t.Fatal(err)
}
l := &localChrome{exe: "x", profileDir: dir, timeout: time.Second, log: discardLog()}
if endpoint, ok := l.adoptExisting(context.Background()); ok {
t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint)
}
}
// TestWaitDevToolsPort_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把
// 最可能的原因写进错误(profile 被占用)—— 而不是把超时白等完、只留一句「超时」。
func TestWaitDevToolsPort_ReportsEarlyExit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
l := &localChrome{profileDir: dir, timeout: 10 * time.Second, log: discardLog()}
exited := make(chan struct{})
close(exited)
start := time.Now()
_, err := l.waitDevToolsPort(context.Background(), exited)
if err == nil {
t.Fatal("进程退出后应当报错")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("等了 %v 才报错,应当立刻返回", elapsed)
}
if !strings.Contains(err.Error(), "profile") {
t.Errorf("错误里应点明 profile 占用这个最常见原因: %v", err)
}
}
// containsArg 报告参数表里是否有这一条(逐字相等)。
func containsArg(args []string, want string) bool {
for _, a := range args {
if a == want {
return true
}
}
return false
}
+358
View File
@@ -0,0 +1,358 @@
package browser
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 清理与降级编码的具名常量。
const (
// minReapInterval / maxReapInterval 夹住空闲清扫的滴答间隔:太密是白烧 CPU,
// 太疏则「空闲 N 秒回收」会拖成「空闲 N 分钟」。
minReapInterval = 5 * time.Second
maxReapInterval = 30 * time.Second
// jpegQuality 是 PNG 超过字节上限时降级重抓的 JPEG 质量。80 是「看得清正文、
// 体积约为 PNG 的 1/5~1/10」的经验值(截图进 vision 用,不需要无损)。
jpegQuality = 80
)
// configView 是 manager 需要的配置切片:从 `browsercfg.Config`(已是归一化后的)
// 投影出来,使本包不必到处引用 browsercfg 的字段名,也让单测能直接构造。
type configView struct {
profileDir string
shotsDir string
headless bool
viewportW int
viewportH int
timeout time.Duration
idle time.Duration
shotMaxBytes int
}
// viewOf 把归一化后的配置投影成 configView。
func viewOf(cfg browsercfg.Config, dataDir string) configView {
profile := cfg.ProfileDir
if profile == "" {
profile = browsercfg.DefaultProfileDir(dataDir)
}
return configView{
profileDir: profile,
shotsDir: browsercfg.DefaultShotsDir(dataDir),
headless: cfg.Headless,
viewportW: cfg.ViewportWidth,
viewportH: cfg.ViewportHeight,
timeout: cfg.Timeout(),
idle: cfg.IdleTimeout(),
shotMaxBytes: cfg.ScreenshotMaxBytes,
}
}
// Options 是构造 Manager 的入参。Availability 为空(Usable=false)时 Manager 仍可
// 构造,但**没有任何会话可用** —— 组合根据此不注册工具(design/28 §7 判据 1)。
type Options struct {
// Availability 是 browsercfg.Resolve 的结论(来源、可执行文件或端点、原因)。
Availability browsercfg.Availability
// Config 是已 Normalize 的配置。
Config browsercfg.Config
// DataDir 是数据目录(profile 与截图落点的根)。
DataDir string
// AllowedHosts 是 SSRF 白名单(回环/私网目标里允许的例外)。
AllowedHosts []string
// Logger 是基日志;本包统一补 `capability/browser` 来源。
Logger *slog.Logger
}
// logSource 是本包的日志来源标签(AGENTS §10:禁止无来源裸日志)。
const logSource = "capability/browser"
// discardLog 返回一个「丢弃输出但**带来源标签**」的 logger:本包允许 Options.Logger
// 为 nil(单测夹具常这样造),而 AGENTS §10 禁止无来源裸日志 —— 所以 nil 的解释是
// 「不输出」而不是「不标注」,这样 m.log 永不为 nil,调用点不必到处判空。
func discardLog() *slog.Logger {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), logSource)
}
// Manager 是浏览器会话的**租约管理器**:并发上限 1(同一个 profile 起两个实例会
// 互锁)、空闲 N 秒回收、会话失效后下次自动重起。design/28 §3。
type Manager struct {
mu sync.Mutex
cfg configView
d dialer
log *slog.Logger
allowed []string
sess *session
lastUsed time.Time
closed bool
source string
stopReaper chan struct{}
reaperDone chan struct{}
closeOnce sync.Once
}
// NewManager 构造管理器并启动空闲清扫协程。avail 不可用时 d 为 nil,所有动作都会
// 返回 ErrUnavailable(**不假装可用**);调用方仍应据 Available() 决定是否接线。
func NewManager(opts Options) *Manager {
log := discardLog()
if opts.Logger != nil {
log = logger.WithSource(opts.Logger, logSource)
}
cfg := opts.Config
cfg = cfg.Normalize(opts.DataDir)
m := &Manager{
cfg: viewOf(cfg, opts.DataDir),
log: log,
allowed: append([]string(nil), opts.AllowedHosts...),
stopReaper: make(chan struct{}),
reaperDone: make(chan struct{}),
}
m.source = opts.Availability.Detail
if opts.Availability.Usable {
m.d = newDialer(opts.Availability, cfg, log)
}
go m.reapLoop()
return m
}
// Available 报告当前有没有可用来源(组合根据此决定是否注册工具)。
func (m *Manager) Available() bool { return m != nil && m.d != nil }
// Source 返回来源描述(进日志与工具结果;不含凭据)。
func (m *Manager) Source() string {
if m == nil {
return ""
}
return m.source
}
// Open 在受 SSRF 守卫保护的 URL 上导航,返回页面**自报**的地址/标题/就绪态。
func (m *Manager) Open(ctx context.Context, rawURL string) (PageResult, error) {
if _, err := GuardURL(ctx, rawURL, m.allowed); err != nil {
m.log.Warn("browser navigate blocked by URL guard", "reason", ReasonBlockedURL, "error", err)
return PageResult{}, err
}
out := PageResult{Source: m.Source(), Headless: m.cfg.headless}
err := m.withSession(ctx, func(s *session) error {
state, err := s.navigate(ctx, rawURL)
if err != nil {
return err
}
out.URL, out.Title, out.ReadyState = state.URL, state.Title, state.ReadyState
return nil
})
if err != nil {
return PageResult{}, err
}
return out, nil
}
// PageResult 是一次导航的结论(全部来自页面自报,不做美化)。
type PageResult struct {
// URL 是导航结束后页面自报的地址(可能因重定向与请求的不同)。
URL string `json:"url"`
// Title 是页面标题。
Title string `json:"title"`
// ReadyState 是 document.readyState(等就绪后才返回,正常为 complete)。
ReadyState string `json:"readyState"`
// Source 是会话来源(本机有头 / 容器端点),如实标注走的是哪条路。
Source string `json:"source"`
// Headless 是本次会话是否无头(默认 false = 有头)。
Headless bool `json:"headless"`
}
// Screenshot 抓一张视口截图并落盘(0600)。超过配置上限时先降级为 JPEG 重抓,
// 仍超限则**如实报错**,绝不交出半张坏图。
func (m *Manager) Screenshot(ctx context.Context) (Shot, error) {
var shot Shot
err := m.withSession(ctx, func(s *session) error {
data, format, err := m.capture(ctx, s)
if err != nil {
return err
}
saved, err := saveShot(m.cfg.shotsDir, data, format)
if err != nil {
return err
}
shot = saved
return nil
})
if err != nil {
return Shot{}, err
}
return shot, nil
}
// capture 取截图的**字节与真实格式**:先 PNG,超上限才退 JPEG,退不下来就报错。
func (m *Manager) capture(ctx context.Context, s *session) ([]byte, string, error) {
png, err := s.captureScreenshot(ctx, "png", 0)
if err != nil {
return nil, "", err
}
if len(png) <= m.cfg.shotMaxBytes {
return png, shotExtPNG, nil
}
jpeg, jerr := s.captureScreenshot(ctx, "jpeg", jpegQuality)
if jerr != nil {
return nil, "", fmt.Errorf("%w: PNG %d 字节超上限 %d,JPEG 降级重抓也失败: %v",
ErrScreenshotTooLarge, len(png), m.cfg.shotMaxBytes, jerr)
}
if len(jpeg) > m.cfg.shotMaxBytes {
return nil, "", fmt.Errorf("%w: PNG %d / JPEG %d 字节都超过上限 %d",
ErrScreenshotTooLarge, len(png), len(jpeg), m.cfg.shotMaxBytes)
}
return jpeg, shotExtJPEG, nil
}
// Fingerprint 在**隔离世界**里读一次页面指纹(reference/10 §4.1 的 L2~L5 自检项),
// 用来证明「这次跑的是有头、webdriver 关、屏幕/时区/语言与设定一致」。
func (m *Manager) Fingerprint(ctx context.Context) (Fingerprint, error) {
var fp Fingerprint
err := m.withSession(ctx, func(s *session) error {
got, err := readFingerprint(ctx, s)
if err != nil {
return err
}
fp = got
return nil
})
if err != nil {
return Fingerprint{}, err
}
return fp, nil
}
// withSession 在一把大锁下完成一次动作:锁本身就是「并发上限 1」的实现(design/28
// §3),而不是另造一个信号量。会话在以下任一情况下重建:还没有、已空闲超时、上次
// 动作报了连接级错误(dropLocked)。
func (m *Manager) withSession(ctx context.Context, fn func(*session) error) error {
m.mu.Lock()
defer m.mu.Unlock()
s, err := m.ensureLocked(ctx)
if err != nil {
return err
}
m.lastUsed = time.Now()
if err := fn(s); err != nil {
if errors.Is(err, ErrCDP) {
m.dropLocked(err)
}
return err
}
return nil
}
// ensureLocked 交出一个可用会话(调用方必须已持锁)。
func (m *Manager) ensureLocked(ctx context.Context) (*session, error) {
if m.closed {
return nil, fmt.Errorf("%w: %s", ErrNoSession, ReasonClosed)
}
if m.d == nil {
return nil, fmt.Errorf("%w: %s", ErrUnavailable, m.source)
}
if m.sess != nil && m.idleExpired() {
m.recycleLocked(ReasonIdleRecycled)
}
if m.sess != nil {
return m.sess, nil
}
openCtx, cancel := context.WithTimeout(ctx, m.cfg.timeout)
defer cancel()
s, err := openSession(openCtx, m.d, m.cfg)
if err != nil {
m.log.Warn("browser session open failed", "source", m.source, "error", err)
return nil, err
}
m.sess = s
m.lastUsed = time.Now()
m.log.Info("browser session opened", "source", m.source, "headless", m.cfg.headless)
return s, nil
}
// idleExpired 报告当前会话是否已超过空闲上限。
func (m *Manager) idleExpired() bool {
if m.cfg.idle <= 0 {
return false
}
return time.Since(m.lastUsed) >= m.cfg.idle
}
// recycleLocked 关掉当前会话并留一条可追溯的日志(原因分类,不美化)。
func (m *Manager) recycleLocked(reason Reason) {
if m.sess == nil {
return
}
m.sess.Close()
m.sess = nil
m.log.Info("browser session recycled", "reason", reason, "detail", reason.Message())
}
// dropLocked 因连接级错误丢弃当前会话:下一个动作会重起一个,而不是一直对着一具
// 尸体报错(崩溃重启的退避由「每次动作只重试一次」自然给出)。
func (m *Manager) dropLocked(cause error) {
if m.sess == nil {
return
}
m.log.Warn("browser session dropped after connection error", "error", cause)
m.sess.Close()
m.sess = nil
}
// reapLoop 是空闲清扫协程:滴答间隔跟随空闲上限的一半(夹在具名上下界之间)。
func (m *Manager) reapLoop() {
defer close(m.reaperDone)
interval := m.cfg.idle / 2
if interval < minReapInterval {
interval = minReapInterval
}
if interval > maxReapInterval {
interval = maxReapInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-m.stopReaper:
return
case <-ticker.C:
m.reapOnce()
}
}
}
// reapOnce 做一次空闲判定(供清扫协程与单测调用)。
func (m *Manager) reapOnce() {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed || m.sess == nil || !m.idleExpired() {
return
}
m.recycleLocked(ReasonIdleRecycled)
}
// Close 停掉清扫协程并关掉会话(幂等,可重复调用)。
func (m *Manager) Close() error {
if m == nil {
return nil
}
m.closeOnce.Do(func() {
close(m.stopReaper)
<-m.reaperDone
m.mu.Lock()
m.closed = true
if m.sess != nil {
m.sess.Close()
m.sess = nil
}
m.mu.Unlock()
})
return nil
}
+179
View File
@@ -0,0 +1,179 @@
package browser
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// newTestManager 造一个管理器并在用例结束时关掉(reaper 协程必须收敛,否则用例会漏协程)。
func newTestManager(t *testing.T, avail browsercfg.Availability) *Manager {
t.Helper()
m := NewManager(Options{
Availability: avail,
Config: browsercfg.Defaults(t.TempDir()),
DataDir: t.TempDir(),
})
t.Cleanup(func() { _ = m.Close() })
return m
}
// TestManager_UnavailableDoesNotPretend 钉住「不可用就不假装可用」:没有来源时
// Available() == false,每个动作都返回 ErrUnavailable(组合根据前者决定不注册工具)。
func TestManager_UnavailableDoesNotPretend(t *testing.T) {
t.Parallel()
m := newTestManager(t, browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"})
if m.Available() {
t.Fatal("没有来源时 Available() 应为 false")
}
ctx := context.Background()
if _, err := m.Open(ctx, "https://example.com/"); err == nil {
t.Error("Open 应当报不可用")
}
if _, err := m.Screenshot(ctx); err == nil {
t.Error("Screenshot 应当报不可用")
}
if _, err := m.Fingerprint(ctx); err == nil {
t.Error("Fingerprint 应当报不可用")
}
}
// TestManager_BlockedURLRejectedBeforeAnyProcess 钉住**顺序**:SSRF 守卫在起浏览器
// **之前**跑。可执行文件是假的,若顺序颠倒,这里会拿到启动错误而不是守卫错误。
func TestManager_BlockedURLRejectedBeforeAnyProcess(t *testing.T) {
t.Parallel()
m := newTestManager(t, browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe",
})
_, err := m.Open(context.Background(), "http://169.254.169.254/latest/meta-data/")
if err == nil {
t.Fatal("云元数据地址应当被拒")
}
if !strings.Contains(err.Error(), "安全守卫") {
t.Fatalf("错误应来自 URL 守卫,实得: %v", err)
}
}
// TestManager_LaunchFailureIsClassified 钉住「起不来就如实报」:不存在的可执行文件
// 必须包住 ErrLaunch,而不是含糊成「失败」。
func TestManager_LaunchFailureIsClassified(t *testing.T) {
t.Parallel()
m := newTestManager(t, browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe",
})
_, err := m.Open(context.Background(), "https://example.com/")
if err == nil {
t.Fatal("假可执行文件应当启动失败")
}
if !strings.Contains(err.Error(), "启动本机浏览器失败") {
t.Fatalf("错误应来自 ErrLaunch,实得: %v", err)
}
}
// TestManager_IdleRecycle 钉住空闲回收:超过空闲上限的会话在下一次判定时被关掉
// (design/28 §3「空闲 N 秒回收」)。这里直接构造会话与时间,不真起浏览器。
func TestManager_IdleRecycle(t *testing.T) {
t.Parallel()
cfg := browsercfg.Defaults(t.TempDir())
cfg.IdleTimeoutSeconds = 1
m := NewManager(Options{Availability: browsercfg.Availability{Usable: true}, Config: cfg, DataDir: t.TempDir()})
t.Cleanup(func() { _ = m.Close() })
m.mu.Lock()
m.sess = &session{} // 空会话:Close 对 nil client / nil stop 是安全的
m.lastUsed = time.Now().Add(-2 * time.Second)
m.mu.Unlock()
m.reapOnce()
m.mu.Lock()
alive := m.sess != nil
m.mu.Unlock()
if alive {
t.Fatal("空闲超时后会话应被回收")
}
}
// TestManager_KeepsFreshSession 钉住「没过期就别动它」:不活跃判定的下限。
func TestManager_KeepsFreshSession(t *testing.T) {
t.Parallel()
cfg := browsercfg.Defaults(t.TempDir())
cfg.IdleTimeoutSeconds = 3600
m := NewManager(Options{Availability: browsercfg.Availability{Usable: true}, Config: cfg, DataDir: t.TempDir()})
t.Cleanup(func() { _ = m.Close() })
m.mu.Lock()
m.sess = &session{}
m.lastUsed = time.Now()
m.mu.Unlock()
m.reapOnce()
m.mu.Lock()
alive := m.sess != nil
m.mu.Unlock()
if !alive {
t.Fatal("未超时的会话不该被回收")
}
}
// TestManager_CloseIsIdempotentAndBlocksFurtherWork 钉住关闭语义:重复 Close 不 panic,
// 关闭后动作一律报「已关闭」。
func TestManager_CloseIsIdempotentAndBlocksFurtherWork(t *testing.T) {
t.Parallel()
m := NewManager(Options{
Availability: browsercfg.Availability{Usable: true, Executable: "x.exe"},
Config: browsercfg.Defaults(t.TempDir()),
DataDir: t.TempDir(),
})
if err := m.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := m.Close(); err != nil {
t.Fatalf("第二次 Close: %v", err)
}
if _, err := m.Open(context.Background(), "https://example.com/"); err == nil {
t.Fatal("关闭后 Open 应当报错")
}
}
// TestViewOf_ProjectsNormalizedConfig 钉住配置投影:profile 与截图目录落在 dataDir 下,
// 超时/空闲/上限都来自配置(不是本包另造一套默认值)。
func TestViewOf_ProjectsNormalizedConfig(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
cfg := browsercfg.Defaults(dataDir)
v := viewOf(cfg, dataDir)
if v.profileDir != browsercfg.DefaultProfileDir(dataDir) {
t.Errorf("profileDir = %q", v.profileDir)
}
if v.shotsDir != browsercfg.DefaultShotsDir(dataDir) {
t.Errorf("shotsDir = %q", v.shotsDir)
}
if v.viewportW != browsercfg.DefaultViewportWidth || v.viewportH != browsercfg.DefaultViewportHeight {
t.Errorf("视口 = %dx%d", v.viewportW, v.viewportH)
}
if v.timeout != cfg.Timeout() || v.idle != cfg.IdleTimeout() {
t.Errorf("超时投影不一致: %v / %v", v.timeout, v.idle)
}
if v.shotMaxBytes != browsercfg.DefaultScreenshotMaxBytes {
t.Errorf("截图上限 = %d", v.shotMaxBytes)
}
}
// TestReasonMessagesAreNonEmpty 钉住「原因码都有话说」:日志/面板只打原因码而不
// 附文案的地方,读的人要能懂。
func TestReasonMessagesAreNonEmpty(t *testing.T) {
t.Parallel()
for _, r := range []Reason{
ReasonLaunched, ReasonRemoteCDP, ReasonIdleRecycled, ReasonLaunchFailed,
ReasonNoPageTarget, ReasonBlockedURL, ReasonNavigateFailed, ReasonShotTooLarge, ReasonClosed,
} {
if r.Message() == "" {
t.Errorf("原因码 %q 没有文案", r)
}
}
}
+276
View File
@@ -0,0 +1,276 @@
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)
}
+98
View File
@@ -0,0 +1,98 @@
package browser
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
_ "image/jpeg" // 注册 JPEG 解码器:截图的宽高要如实报,不能靠猜
_ "image/png" // 注册 PNG 解码器
"os"
"path/filepath"
"strconv"
"time"
)
// 截图落盘的具名常量。
const (
// shotFileMode 是截图文件的权限:0600。截图里有页面内容,可能是登录态、二维码、
// 订单号 —— 它按凭据对待(design/28 §4 第 3 条)。
shotFileMode os.FileMode = 0o600
// shotDirMode 是截图目录的权限:0700(只有本用户可进)。
shotDirMode os.FileMode = 0o700
// shotPrefix 是截图文件名前缀,便于人工在 `$DATA_DIR/browser/shots/` 里辨认。
shotPrefix = "shot-"
// shotExtPNG 与 shotExtJPEG 是两种落盘扩展名,也是「实际用了哪种编码」的凭证。
shotExtPNG = "png"
shotExtJPEG = "jpeg"
)
// Shot 是一次截图的落盘结论:**路径 + 指纹 + 真实尺寸**,供 vision/image-crop
// 循既有路径进上下文,也供面板按路径展示(B4)。
type Shot struct {
// Path 是落盘的绝对路径(0600)。它是闭环入口:vision 工具按本地路径读它。
Path string `json:"path"`
// Bytes 是落盘字节数(如实,不含任何缩放)。
Bytes int `json:"bytes"`
// SHA256 是内容指纹的十六进制全串(对齐 reference/browser-stealth/07 §6 的字段口径)。
SHA256 string `json:"sha256"`
// Format 是实际使用的编码("png",或超上限降级后的 "jpeg")。
Format string `json:"format"`
// Width / Height 是从字节里解出来的**真实**像素尺寸;解不出来时为 0 且
// DimensionsKnown=false(不编造数字)。
Width int `json:"width"`
Height int `json:"height"`
// DimensionsKnown 报告宽高是否可信(false ⇒ 字节不是本函数认识的图像格式)。
DimensionsKnown bool `json:"dimensionsKnown"`
// Degraded 报告这张图是否是「PNG 超上限、降级成 JPEG」的产物(如实标注)。
Degraded bool `json:"degraded"`
}
// saveShot 把截图字节写进 dir(0700),文件 0600,返回结论。
//
// 文件名 = `shot-<36 进制纳秒时间戳>.<ext>`:时间戳是的的确确的(便于按时间排序),
// 而**不含任何 URL/站点信息** —— 截图路径会进上下文与日志,站点名本身就是信息
// (对齐 design/28 §4「审计只记必要项」)。
func saveShot(dir string, data []byte, format string) (Shot, error) {
if len(data) == 0 {
return Shot{}, fmt.Errorf("browser: 截图字节为空")
}
if err := os.MkdirAll(dir, shotDirMode); err != nil {
return Shot{}, fmt.Errorf("browser: 建截图目录 %s: %w", dir, err)
}
ext := shotExtPNG
if format == shotExtJPEG {
ext = shotExtJPEG
}
name := shotPrefix + strconv.FormatInt(time.Now().UnixNano(), 36) + "." + ext
path := filepath.Join(dir, name)
if err := os.WriteFile(path, data, shotFileMode); err != nil {
return Shot{}, fmt.Errorf("browser: 写截图 %s: %w", path, err)
}
sum := sha256.Sum256(data)
width, height, known := imageSize(data)
return Shot{
Path: path,
Bytes: len(data),
SHA256: hex.EncodeToString(sum[:]),
Format: ext,
Width: width,
Height: height,
DimensionsKnown: known,
Degraded: ext == shotExtJPEG,
}, nil
}
// imageSize 从字节里解出像素宽高。解不出来返回 (0, 0, false) —— 如实标注未知,
// 而不是拿配置里的视口尺寸冒充(那是「我们以为的」,不是「图里的」)。
func imageSize(data []byte) (int, int, bool) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return 0, 0, false
}
return cfg.Width, cfg.Height, true
}
+88
View File
@@ -0,0 +1,88 @@
package browser
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"runtime"
"testing"
)
// pngBytes 造一张 w×h 的合法 PNG(用标准编码器,保证 imageSize 真能解出来)。
func pngBytes(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
img.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("编码 PNG: %v", err)
}
return buf.Bytes()
}
// TestSaveShot_Writes0600AndDimensions 钉住截图落盘的**三件事**:0600 权限、
// 名字不含站点信息、宽高来自字节本身(而不是配置里的视口)。
func TestSaveShot_Writes0600AndDimensions(t *testing.T) {
dir := filepath.Join(t.TempDir(), "shots")
data := pngBytes(t, 7, 5)
shot, err := saveShot(dir, data, shotExtPNG)
if err != nil {
t.Fatalf("saveShot: %v", err)
}
if shot.Bytes != len(data) {
t.Errorf("Bytes = %d, want %d", shot.Bytes, len(data))
}
if !shot.DimensionsKnown || shot.Width != 7 || shot.Height != 5 {
t.Errorf("尺寸 = (%d,%d) known=%v, want (7,5) true", shot.Width, shot.Height, shot.DimensionsKnown)
}
if shot.Format != shotExtPNG || shot.Degraded {
t.Errorf("format=%q degraded=%v, want png/false", shot.Format, shot.Degraded)
}
if len(shot.SHA256) != 64 {
t.Errorf("SHA256 长度 = %d, want 64", len(shot.SHA256))
}
if filepath.Ext(shot.Path) != ".png" {
t.Errorf("扩展名 = %q, want .png", filepath.Ext(shot.Path))
}
if fi, err := os.Stat(shot.Path); err != nil {
t.Fatalf("stat %s: %v", shot.Path, err)
} else if runtime.GOOS != "windows" && fi.Mode().Perm() != shotFileMode {
// Windows 不做 POSIX 权限(Go 只映射只读位),因此这一条只在类 Unix 平台上断言;
// Windows 上 0600 是**尽力而为**(对齐 browsercfg.EnsureProfileDir 的既有口径)。
t.Errorf("权限 = %v, want %v", fi.Mode().Perm(), shotFileMode)
}
// 目录权限:非 Windows 上必须是 0700(Windows 不做 POSIX 权限,跳过)。
if runtime.GOOS != "windows" {
if fi, err := os.Stat(dir); err != nil {
t.Fatalf("stat %s: %v", dir, err)
} else if fi.Mode().Perm() != shotDirMode {
t.Errorf("目录权限 = %v, want %v", fi.Mode().Perm(), shotDirMode)
}
}
}
// TestSaveShot_DegradedJPEGFlagged 钉住「降级要说出来」:JPEG 落盘必须置 degraded
// 且扩展名是 .jpeg(模型/面板据此知道这张图不是无损的那张)。
func TestSaveShot_DegradedJPEGFlagged(t *testing.T) {
dir := t.TempDir()
shot, err := saveShot(dir, []byte{0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x01}, shotExtJPEG)
if err != nil {
t.Fatalf("saveShot: %v", err)
}
if !shot.Degraded || shot.Format != shotExtJPEG {
t.Errorf("degraded=%v format=%q, want true/jpeg", shot.Degraded, shot.Format)
}
if shot.DimensionsKnown {
t.Error("非图像字节不该被判成「尺寸已知」")
}
}
// TestSaveShot_RejectsEmpty 钉住空字节不落盘(不写一个 0 字节的「截图」骗人)。
func TestSaveShot_RejectsEmpty(t *testing.T) {
if _, err := saveShot(t.TempDir(), nil, shotExtPNG); err == nil {
t.Fatal("空字节应当报错")
}
}
+219
View File
@@ -0,0 +1,219 @@
// Package browser 是 `browser` 工具(design/28 B2 的**自研薄层**)。
//
// 它只补本项目独有的那一层:把「本机真 Chromium 有头」的**导航 / 截图 / 指纹自检**
// 暴露给模型,并把截图按 0600 落盘成一个 **vision/image-crop 能直接读的本地路径** ——
// 那才是「截图进上下文」的闭环入口(design/28 §1 M4、§5)。
//
// 为什么只有三个动作(AGENTS §9.5):`click` / `type` / `snapshot` / `extract` /
// `wait` 这一整套成熟工具面**优先复用 MCP**(Playwright MCP / Puppeteer MCP 官方
// server,`design/09 §1`:MCP server = 外部工具提供者,工具自动注册进 registry)。
// 本项目**不重造**一遍等价物;本包只补 MCP 给不了的两件事 ——
// ① 宿主真 Chromium 的默认有头 + 持久 profile 启动(过风控三件套);
// ② 截图落盘 → 经既有 image/vision 管线进上下文的闭环。
//
// 权限(design/28 §4 第 2 条):本工具**默认 owner-only** —— 与其它能力一样,
// 注册时带的是零值 ToolPolicy(registry 的口径:两布尔皆 false = 只有主人环境可调),
// 群聊/陌生人不因它放宽。
package browser
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 工具名与动作名(模型看到的那一面)。名字一旦漂移,`cmd/cyrene` 的接线测试会红。
const (
// ToolName 是工具在能力注册表里的名字。
ToolName = "browser"
// actionOpen / actionScreenshot / actionInfo 是三个动作。
actionOpen = "open"
actionScreenshot = "screenshot"
actionInfo = "info"
)
// logSource 是本包的日志来源(AGENTS §10:禁止无来源裸日志)。
const logSource = "plugins/tools/browser"
// Compile-time assertion that Tool implements sdk.Tool.
var _ sdk.Tool = (*Tool)(nil)
// Tool 是 `browser` 工具。它只依赖驱动层的 Manager(进程内依赖注入,不起第二套
// 浏览器栈),因此本包可单测(假 Manager 接口)。
type Tool struct {
mgr Driver
log *slog.Logger
}
// Driver 是 Tool 需要的最小驱动面(*browsercap.Manager 实现它)。用接口而不是具体
// 类型,是为了让工具层能在**不装浏览器**的机器上被单测覆盖。
type Driver interface {
Available() bool
Source() string
Open(ctx context.Context, rawURL string) (browsercap.PageResult, error)
Screenshot(ctx context.Context) (browsercap.Shot, error)
Fingerprint(ctx context.Context) (browsercap.Fingerprint, error)
}
// New 构造工具;mgr 为 nil 时工具**仍可构造**但每个动作都如实报「不可用」
// (组合根的正确做法是压根不注册它,见 wire_browser.go)。
func New(mgr Driver, opts ...Option) *Tool {
t := &Tool{mgr: mgr, log: discardLog()}
for _, opt := range opts {
if opt != nil {
opt(t)
}
}
return t
}
// discardLog 返回一个「丢弃输出但**带来源标签**」的 logger:New(nil) 或 WithLogger(nil)
// 都不该让 t.log 变成 nil(AGENTS §10 禁止无来源裸日志,也不该在 nil 上 panic)。
func discardLog() *slog.Logger {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), logSource)
}
// Option 配置 Tool。
type Option func(*Tool)
// WithLogger 注入基日志(本包会补 `plugins/tools/browser` 来源标签)。
func WithLogger(l *slog.Logger) Option {
if l == nil {
return func(t *Tool) { t.log = discardLog() }
}
return func(t *Tool) { t.log = logger.WithSource(l, logSource) }
}
// Name 实现 sdk.Tool。
func (t *Tool) Name() string { return ToolName }
// Close 释放驱动占用的资源(本机浏览器进程)。驱动实现 io.Closer 时生效,否则空操作。
//
// ⚠️ **当前没有生产调用者**(如实登记):组合根的关闭链在 `a.close`(`wire_services.go`)
// 与 `app` 结构体(`wire.go`)里,两者都不属本切片的文件面 ⇒ 加不上「关掉它」这一行。
// 本方法先把**挂点**摆出来,供主会话在关闭链上接一行;在被接上之前,残留的浏览器由
// 驱动层的两道机制兜住:① 空闲回收时对会发 `Browser.close` 优雅关停;② 下一次会话
// **认领**同一个 profile 上还活着的实例(不再起第二个、也不会被 profile 锁卡死)。
func (t *Tool) Close() error {
if t == nil {
return nil
}
if c, ok := t.mgr.(io.Closer); ok {
return c.Close()
}
return nil
}
// Description 实现 sdk.Tool。**能力边界写在这里**:模型只读这段文字,所以「哪些
// 动作没做、 richer 动作去 MCP」必须说清楚,不能让模型以为它有 click/type。
func (t *Tool) Description() string {
return "Drive a real headful Chromium on this host and bring a page into the conversation. " +
"Actions: " +
"'open' navigates to an http(s) URL and returns the page's own title/url/readyState; " +
"'screenshot' captures the current viewport, saves it to disk (0600) and returns its path — " +
"pass that path to the vision or image-crop tool to actually read the page, which is how a " +
"rendered chart/QR/login page becomes visible to a text-only model; " +
"'info' returns the browser fingerprint (headless, navigator.webdriver, screen, timezone, language) " +
"for verifying that a headful session really is headful. " +
"Rich interaction (click/type/wait/extract/network) is NOT implemented here on purpose: " +
"configure a Playwright or Puppeteer MCP server and use its tools for that."
}
// Parameters 实现 sdk.Tool。
func (t *Tool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []any{actionOpen, actionScreenshot, actionInfo},
"description": "Which browser action to run.",
},
"url": map[string]any{
"type": "string",
"description": "Absolute http(s) URL. Required for action=open, ignored otherwise.",
},
},
"required": []any{"action"},
}, nil
}
// Execute 实现 sdk.Tool。
func (t *Tool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("browser: args must be an object")
}
action := strings.ToLower(strings.TrimSpace(argString(m, "action")))
if t.mgr == nil || !t.mgr.Available() {
return nil, fmt.Errorf("browser: 本机没有可用的浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)")
}
switch action {
case actionOpen:
return t.open(ctx, m)
case actionScreenshot:
return t.screenshot(ctx)
case actionInfo:
return t.info(ctx)
default:
return nil, fmt.Errorf("browser: 未知 action %q(可用:%s / %s / %s)", action,
actionOpen, actionScreenshot, actionInfo)
}
}
// open 处理 action=open。
func (t *Tool) open(ctx context.Context, args map[string]any) (any, error) {
rawURL := strings.TrimSpace(argString(args, "url"))
if rawURL == "" {
return nil, fmt.Errorf("browser: action=open 需要 url")
}
res, err := t.mgr.Open(ctx, rawURL)
if err != nil {
return nil, err
}
return res, nil
}
// screenshot 处理 action=screenshot:返回落盘路径与统计,「路径交 vision 读」这一步
// 由模型按描述自行发起(本层不替它调模型,也不把图塞进上下文)。
func (t *Tool) screenshot(ctx context.Context) (any, error) {
shot, err := t.mgr.Screenshot(ctx)
if err != nil {
return nil, err
}
return map[string]any{
"path": shot.Path,
"bytes": shot.Bytes,
"sha256": shot.SHA256,
"format": shot.Format,
"width": shot.Width,
"height": shot.Height,
"dimensionsKnown": shot.DimensionsKnown,
"degraded": shot.Degraded,
"hint": "Pass this path to the vision or image-crop tool to read the page content; the image is not inlined into the conversation.",
}, nil
}
// info 处理 action=info。
func (t *Tool) info(ctx context.Context) (any, error) {
fp, err := t.mgr.Fingerprint(ctx)
if err != nil {
return nil, err
}
return fp, nil
}
// argString 取一个字符串参数(缺失/类型不符一律当空串,与 io 包的同名口径一致)。
func argString(m map[string]any, key string) string {
if raw, ok := m[key].(string); ok {
return raw
}
return ""
}
+194
View File
@@ -0,0 +1,194 @@
package browser
import (
"context"
"errors"
"strings"
"testing"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// fakeDriver 是 Tool 依赖的最小驱动面(*browsercap.Manager 的真实现由驱动包的测试覆盖;
// 这里只验证**工具层**的接线与能力边界)。
type fakeDriver struct {
available bool
openedURL string
openErr error
shot browsercap.Shot
fp browsercap.Fingerprint
}
func (f *fakeDriver) Available() bool { return f.available }
func (f *fakeDriver) Source() string { return "fake" }
func (f *fakeDriver) Open(_ context.Context, raw string) (browsercap.PageResult, error) {
f.openedURL = raw
if f.openErr != nil {
return browsercap.PageResult{}, f.openErr
}
return browsercap.PageResult{URL: raw, Title: "t", ReadyState: "complete", Source: "fake"}, nil
}
func (f *fakeDriver) Screenshot(context.Context) (browsercap.Shot, error) { return f.shot, nil }
func (f *fakeDriver) Fingerprint(context.Context) (browsercap.Fingerprint, error) {
return f.fp, nil
}
// TestTool_ImplementsSDKAndName 钉住工具名(注册表条目名与热重载/接线测试都依赖它)。
func TestTool_ImplementsSDKAndName(t *testing.T) {
t.Parallel()
var _ sdk.Tool = New(&fakeDriver{})
if ToolName != "browser" {
t.Fatalf("ToolName = %q, want browser", ToolName)
}
if got := New(&fakeDriver{}).Name(); got != ToolName {
t.Fatalf("Name() = %q", got)
}
}
// TestTool_DescriptionDeclaresTheMissingActions 钉住**能力边界写进描述**:
// 模型只读这段文字,所以必须明说 click/type 不在这里实现、要走 MCP
// (AGENTS §9.5 的「不重造成熟工具面」要落成模型看得见的事实)。
func TestTool_DescriptionDeclaresTheMissingActions(t *testing.T) {
t.Parallel()
desc := New(&fakeDriver{}).Description()
for _, want := range []string{"MCP", "click", "vision"} {
if !strings.Contains(desc, want) {
t.Errorf("描述里缺少 %q:%s", want, desc)
}
}
}
// TestTool_ParametersExposeExactlyThreeActions 钉住动作面就是三个(多一个都要在
// 这里显式改写,避免「悄悄长出一整套自研工具面」)。
func TestTool_ParametersExposeExactlyThreeActions(t *testing.T) {
t.Parallel()
params, err := New(&fakeDriver{}).Parameters()
if err != nil {
t.Fatalf("Parameters: %v", err)
}
props, _ := params["properties"].(map[string]any)
action, _ := props["action"].(map[string]any)
enum, _ := action["enum"].([]any)
if len(enum) != 3 {
t.Fatalf("动作数 = %d, want 3: %v", len(enum), enum)
}
got := map[string]bool{}
for _, e := range enum {
s, _ := e.(string)
got[s] = true
}
for _, want := range []string{actionOpen, actionScreenshot, actionInfo} {
if !got[want] {
t.Errorf("动作枚举缺 %q", want)
}
}
required, _ := params["required"].([]any)
if len(required) != 1 || required[0] != "action" {
t.Errorf("required = %v, want [action]", required)
}
}
// TestTool_UnavailableReportsHonestly 钉住「不可用不假装可用」:驱动不可用(或没注入)
// 时每个动作都如实报错,不返回半真半假的结果。
func TestTool_UnavailableReportsHonestly(t *testing.T) {
t.Parallel()
for _, tool := range []*Tool{New(nil), New(&fakeDriver{available: false})} {
if _, err := tool.Execute(context.Background(), map[string]any{"action": actionInfo}); err == nil {
t.Fatal("不可用时应当报错")
} else if !strings.Contains(err.Error(), "没有可用的浏览器来源") {
t.Errorf("错误没有说清原因: %v", err)
}
}
}
// TestTool_OpenRequiresURL 钉住 action=open 缺 url 时**不发**导航请求(而不是拿空串去导航)。
func TestTool_OpenRequiresURL(t *testing.T) {
t.Parallel()
d := &fakeDriver{available: true}
tool := New(d)
if _, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen}); err == nil {
t.Fatal("缺 url 应当报错")
}
if _, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen, "url": " "}); err == nil {
t.Fatal("空白 url 应当报错")
}
if d.openedURL != "" {
t.Fatalf("不该发起导航,实得 %q", d.openedURL)
}
}
// TestTool_UnknownActionNamesTheAlternatives 钉住未知动作的错误里带可用清单
// (模型据此自我纠正,而不是盲试)。
func TestTool_UnknownActionNamesTheAlternatives(t *testing.T) {
t.Parallel()
_, err := New(&fakeDriver{available: true}).Execute(context.Background(), map[string]any{"action": "click"})
if err == nil {
t.Fatal("未知动作应当报错")
}
for _, want := range []string{"open", "screenshot", "info"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里应列出可用动作 %q: %v", want, err)
}
}
}
// TestTool_ScreenshotReturnsClosedLoopEntry 钉住截图动作的返回值**就是闭环入口**:
// 路径 + 指纹 + 提示「交给 vision」,而不是把图塞进上下文。
func TestTool_ScreenshotReturnsClosedLoopEntry(t *testing.T) {
t.Parallel()
d := &fakeDriver{available: true, shot: browsercap.Shot{
Path: "/data/browser/shots/shot-1.png", Bytes: 4242, SHA256: "abc",
Format: "png", Width: 1264, Height: 649, DimensionsKnown: true,
}}
got, err := New(d).Execute(context.Background(), map[string]any{"action": actionScreenshot})
if err != nil {
t.Fatalf("screenshot: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("返回值类型 = %T", got)
}
if m["path"] != d.shot.Path || m["sha256"] != "abc" || m["bytes"] != 4242 {
t.Errorf("返回值丢了落盘事实: %#v", m)
}
hint, _ := m["hint"].(string)
if !strings.Contains(hint, "vision") {
t.Errorf("提示里应指向 vision 工具: %q", hint)
}
}
// TestTool_OpenPassesURLThroughAndInfoReturnsFingerprint 钉住两个透传动作不打折扣。
func TestTool_OpenPassesURLThroughAndInfoReturnsFingerprint(t *testing.T) {
t.Parallel()
d := &fakeDriver{available: true, fp: browsercap.Fingerprint{Headless: false, Webdriver: false, Timezone: "Asia/Shanghai"}}
tool := New(d)
res, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen, "url": "https://example.com/"})
if err != nil {
t.Fatalf("open: %v", err)
}
if d.openedURL != "https://example.com/" {
t.Errorf("驱动收到的 URL = %q", d.openedURL)
}
if pr, ok := res.(browsercap.PageResult); !ok || pr.Title != "t" {
t.Errorf("open 结果 = %#v", res)
}
fp, err := tool.Execute(context.Background(), map[string]any{"action": actionInfo})
if err != nil {
t.Fatalf("info: %v", err)
}
if got, ok := fp.(browsercap.Fingerprint); !ok || got.Timezone != "Asia/Shanghai" {
t.Errorf("info 结果 = %#v", fp)
}
}
// TestTool_OpenSurfacesDriverError 钉住驱动错误原样上抛(不吞、不改写成「成功」)。
func TestTool_OpenSurfacesDriverError(t *testing.T) {
t.Parallel()
sentinel := errors.New("browser: 导航失败: net::ERR_NAME_NOT_RESOLVED")
_, err := New(&fakeDriver{available: true, openErr: sentinel}).Execute(
context.Background(), map[string]any{"action": actionOpen, "url": "https://nope.example/"})
if !errors.Is(err, sentinel) {
t.Fatalf("错误应可 errors.Is 回到驱动的那一个,实得: %v", err)
}
}