feat(browser): B1 接线 + B2 薄层工具面(V5)
- 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:
1 parent
ea150ceea9
commit
f1fbc249fd
21 files changed
+3579
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¶ms)
|
||||
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("连接关闭后等待者没被叫醒")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("空字节应当报错")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user