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

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

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
package browser
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。
const (
// devToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写回端口号的
// 文件名(profile 目录下):首行端口、次行 browser_guid
// (reference/browser-stealth/10 §2.4,依据 content/browser/devtools/devtools_http_handler.cc)。
devToolsPortFile = "DevToolsActivePort"
// portPollInterval 是「等调试端口就绪」的轮询间隔:冷启动通常 0.3~1.5s,
// 100ms 的粒度既不迟钝也不白烧 CPU。
portPollInterval = 100 * time.Millisecond
// defaultLang 是启动时声明的 UI 语言。口径:与「本机 / 出口 IP 所在地」对齐
// (design/28 §2 指纹一致性);写死 zh-CN 是本部署的实情,配置面(browser.json)
// 尚未开放语言项 —— 见本片报告「未做」。
defaultLang = "zh-CN"
)
// adoptProbeTimeout 是「要不要认领已在跑的实例」这一次探测的超时(也是远端端点探活
// 用的那一档):它只问「端口有没有在听」,给得短。
const adoptProbeTimeout = 2 * time.Second
// dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把
// 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。
type dialer interface {
// Dial 返回端点;stop 释放本次会话占用的资源(本机路径 = 杀掉进程,容器路径 = 空操作)。
Dial(ctx context.Context) (endpoint string, stop func(), err error)
// Describe 返回一句人类可读的来源描述(进日志与工具结果,不进提示词模板)。
Describe() string
// Local 报告这个端点是不是**我们自己的本机浏览器**:只有它为 true 时,会话收尾
// 才允许关闭浏览器。容器/手动实例(remoteCDP)是别人的,绝不代关。
Local() bool
}
// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。
//
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地:
// - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、
// `--no-default-browser-check`、`--disable-blink-features=AutomationControlled`、
// `--remote-debugging-port=0`;
// - **不传**:`--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
// `--disable-extensions` / `--disable-default-apps` /
// `--disable-component-extensions-with-background-pages` / `--disable-component-update` /
// `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、
// 以及一切 `--headless*`(有头就是不能有它)。
//
// 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍:
// 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows
// 上不支持**。因此 Windows 主路径用次选 `--remote-debugging-port=0`(内核分配随机
// 回环端口,从 profile 目录的 DevToolsActivePort 读回):**仍是我们出站连它**,
// 不开任何对外监听端口(AGENTS §9)。
type localChrome struct {
exe string
profileDir string
headless bool
viewportW int
viewportH int
timeout time.Duration
log *slog.Logger
}
// Describe 实现 dialer。
func (l *localChrome) Describe() string {
mode := "headful"
if l.headless {
mode = "headless"
}
return fmt.Sprintf("本机浏览器 %s(%s)", l.exe, mode)
}
// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。
func (l *localChrome) Local() bool { return true }
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
//
// 两条**先于启动**的判定(都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
//
// 1. **认领已在跑的实例**:profile 目录的 DevToolsActivePort 还活着(端口在听)⇒ 直接
// 出站连接它,**不再起第二个**。这不是优化,是**正确性**:同一个 user-data-dir 上起
// 第二个实例时 Chromium 会「委托给已在跑的那个」然后自己退出,于是新进程永远不写
// DevToolsActivePort ⇒ 功能会一直报启动超时,直到人工杀掉那个进程。认领把这一步
// 变成「复用」,也更贴合 design/28 §2「profile 长期保留、任务间复用」。
// 2. **端口文件先清掉**:只在真的要走启动路径时才清,否则会把旧端口当成本次结果。
//
// 停止函数只对**本次启动**的进程有效(认领来的实例不归我们杀):进程退出后 Chrome 的
// 渲染进程随之退出,因此进程级 Kill 足够。
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
if strings.TrimSpace(l.exe) == "" {
return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch)
}
if endpoint, ok := l.adoptExisting(ctx); ok {
l.log.Info("browser session adopted", "endpoint", endpoint, "profile", l.profileDir)
return endpoint, func() {}, nil
}
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
}
_ = os.Remove(filepath.Join(l.profileDir, devToolsPortFile))
cmd := exec.Command(l.exe, l.launchArgs()...)
if err := cmd.Start(); err != nil {
return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err)
}
exited := make(chan struct{})
go func() {
defer close(exited)
_, _ = cmd.Process.Wait()
}()
port, err := l.waitDevToolsPort(ctx, exited)
if err != nil {
stopProcess(cmd)
return "", nil, err
}
l.log.Debug("browser launched", "exe", l.exe, "port", port, "profile", l.profileDir)
return fmt.Sprintf("http://127.0.0.1:%d", port), func() { stopProcess(cmd) }, nil
}
// adoptExisting 报告 profile 目录里记录的调试端点是否**仍然活着**;活着就返回它。
// 探测超时给得很短:这是一个「要不要复用」的判断,不该拖住启动路径。
func (l *localChrome) adoptExisting(ctx context.Context) (string, bool) {
port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile))
if !ok {
return "", false
}
endpoint := fmt.Sprintf("http://127.0.0.1:%d", port)
probeCtx, cancel := context.WithTimeout(ctx, adoptProbeTimeout)
defer cancel()
var version struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(probeCtx, endpoint+"/json/version", &version); err != nil {
return "", false
}
return endpoint, true
}
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
func (l *localChrome) launchArgs() []string {
args := []string{
// profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。
"--user-data-dir=" + l.profileDir,
// 内核分配随机回环端口;端口从 DevToolsActivePort 读回(见本类型文档)。
"--remote-debugging-port=0",
// 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝;
// reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」,
// 不开任何对外服务面。
"--remote-allow-origins=*",
"--no-first-run",
"--no-default-browser-check",
fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH),
"--lang=" + defaultLang,
// 兜底防线:真正的关键是**不传** `--enable-automation`(见类型文档)。
"--disable-blink-features=AutomationControlled",
"about:blank",
}
if l.headless {
// 只在面板显式降级时出现;默认档(有头)**绝不传**任何 `--headless*`。
args = append([]string{"--headless=new"}, args...)
}
return args
}
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**:
// 进程立刻退出(多半是 profile 锁被占 / 浏览器已在运行)、ctx 取消、超时。
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
path := filepath.Join(l.profileDir, devToolsPortFile)
deadline := time.NewTimer(l.timeout)
defer deadline.Stop()
ticker := time.NewTicker(portPollInterval)
defer ticker.Stop()
for {
if port, ok := readDevToolsPort(path); ok {
return port, nil
}
select {
case <-exited:
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
// 最常见的原因是同一 profile 已被另一个实例占用(Chromium 的 profile 锁)
// 或浏览器已被别处启动:委托出去的新进程不写 DevToolsActivePort。
return 0, fmt.Errorf("%w: 浏览器进程立刻退出(多半是该 profile 已被另一个实例占用)", ErrLaunch)
case <-ctx.Done():
return 0, fmt.Errorf("%w: 等待调试端口时被取消: %v", ErrLaunch, ctx.Err())
case <-deadline.C:
return 0, fmt.Errorf("%w: 等 %s 超时(%s)", ErrLaunch, devToolsPortFile, l.timeout)
case <-ticker.C:
}
}
}
// readDevToolsPort 读端口文件的首行;文件还没出现 / 还没写完 / 内容不合法都返回 false
// (交给调用方继续轮询,而不是把「正在写」误判成「配错」)。
func readDevToolsPort(path string) (int, bool) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, false
}
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
port, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil || port <= 0 || port > 65535 {
return 0, false
}
return port, true
}
// remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium,
// 或主人手动起的实例)。它是纯出站客户端,不启动任何进程、不监听任何端口。
type remoteCDP struct{ endpoint string }
// Describe 实现 dialer。
func (r *remoteCDP) Describe() string { return "已跑的 CDP 端点 " + r.endpoint }
// Local 实现 dialer:容器/手动实例**不是我们的**,会话收尾绝不代它关闭。
func (r *remoteCDP) Local() bool { return false }
// Dial 先探活再交出端点;连不上就如实报,不偷偷回退本机(design/28 §6 坑 5)。
func (r *remoteCDP) Dial(ctx context.Context) (string, func(), error) {
endpoint := strings.TrimRight(strings.TrimSpace(r.endpoint), "/")
if endpoint == "" {
return "", nil, fmt.Errorf("%w: CDP 端点为空白", ErrUnavailable)
}
var version struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(ctx, endpoint+"/json/version", &version); err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return endpoint, func() {}, nil
}
// stopProcess 结束本进程拉起的浏览器(保留为具名函数:Kill 的语义在这一处写清楚,
// 调用点不必解释;Wait 由 Dial 里的等待协程负责,这里只 Kill,避免双重 Wait)。
// Windows 上渲染进程随浏览器进程一起退,因此进程级 Kill 足够。
func stopProcess(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
}
// newDialer 按「配置显式端点 → 配置显式可执行文件 → 本机探测结果」组装 dialer。
// 可用性判定由 browsercfg.Resolve 做(优先级单一出处),这里只把结论映射成具体 dialer。
func newDialer(avail browsercfg.Availability, cfg browsercfg.Config, log *slog.Logger) dialer {
if e := strings.TrimSpace(avail.Endpoint); e != "" {
return &remoteCDP{endpoint: e}
}
return &localChrome{
exe: strings.TrimSpace(avail.Executable),
profileDir: cfg.ProfileDir,
headless: cfg.Headless,
viewportW: cfg.ViewportWidth,
viewportH: cfg.ViewportHeight,
timeout: cfg.Timeout(),
log: log,
}
}