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

+358
View File
@@ -0,0 +1,358 @@
package browser
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// 清理与降级编码的具名常量。
const (
// minReapInterval / maxReapInterval 夹住空闲清扫的滴答间隔:太密是白烧 CPU,
// 太疏则「空闲 N 秒回收」会拖成「空闲 N 分钟」。
minReapInterval = 5 * time.Second
maxReapInterval = 30 * time.Second
// jpegQuality 是 PNG 超过字节上限时降级重抓的 JPEG 质量。80 是「看得清正文、
// 体积约为 PNG 的 1/5~1/10」的经验值(截图进 vision 用,不需要无损)。
jpegQuality = 80
)
// configView 是 manager 需要的配置切片:从 `browsercfg.Config`(已是归一化后的)
// 投影出来,使本包不必到处引用 browsercfg 的字段名,也让单测能直接构造。
type configView struct {
profileDir string
shotsDir string
headless bool
viewportW int
viewportH int
timeout time.Duration
idle time.Duration
shotMaxBytes int
}
// viewOf 把归一化后的配置投影成 configView。
func viewOf(cfg browsercfg.Config, dataDir string) configView {
profile := cfg.ProfileDir
if profile == "" {
profile = browsercfg.DefaultProfileDir(dataDir)
}
return configView{
profileDir: profile,
shotsDir: browsercfg.DefaultShotsDir(dataDir),
headless: cfg.Headless,
viewportW: cfg.ViewportWidth,
viewportH: cfg.ViewportHeight,
timeout: cfg.Timeout(),
idle: cfg.IdleTimeout(),
shotMaxBytes: cfg.ScreenshotMaxBytes,
}
}
// Options 是构造 Manager 的入参。Availability 为空(Usable=false)时 Manager 仍可
// 构造,但**没有任何会话可用** —— 组合根据此不注册工具(design/28 §7 判据 1)。
type Options struct {
// Availability 是 browsercfg.Resolve 的结论(来源、可执行文件或端点、原因)。
Availability browsercfg.Availability
// Config 是已 Normalize 的配置。
Config browsercfg.Config
// DataDir 是数据目录(profile 与截图落点的根)。
DataDir string
// AllowedHosts 是 SSRF 白名单(回环/私网目标里允许的例外)。
AllowedHosts []string
// Logger 是基日志;本包统一补 `capability/browser` 来源。
Logger *slog.Logger
}
// logSource 是本包的日志来源标签(AGENTS §10:禁止无来源裸日志)。
const logSource = "capability/browser"
// discardLog 返回一个「丢弃输出但**带来源标签**」的 logger:本包允许 Options.Logger
// 为 nil(单测夹具常这样造),而 AGENTS §10 禁止无来源裸日志 —— 所以 nil 的解释是
// 「不输出」而不是「不标注」,这样 m.log 永不为 nil,调用点不必到处判空。
func discardLog() *slog.Logger {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), logSource)
}
// Manager 是浏览器会话的**租约管理器**:并发上限 1(同一个 profile 起两个实例会
// 互锁)、空闲 N 秒回收、会话失效后下次自动重起。design/28 §3。
type Manager struct {
mu sync.Mutex
cfg configView
d dialer
log *slog.Logger
allowed []string
sess *session
lastUsed time.Time
closed bool
source string
stopReaper chan struct{}
reaperDone chan struct{}
closeOnce sync.Once
}
// NewManager 构造管理器并启动空闲清扫协程。avail 不可用时 d 为 nil,所有动作都会
// 返回 ErrUnavailable(**不假装可用**);调用方仍应据 Available() 决定是否接线。
func NewManager(opts Options) *Manager {
log := discardLog()
if opts.Logger != nil {
log = logger.WithSource(opts.Logger, logSource)
}
cfg := opts.Config
cfg = cfg.Normalize(opts.DataDir)
m := &Manager{
cfg: viewOf(cfg, opts.DataDir),
log: log,
allowed: append([]string(nil), opts.AllowedHosts...),
stopReaper: make(chan struct{}),
reaperDone: make(chan struct{}),
}
m.source = opts.Availability.Detail
if opts.Availability.Usable {
m.d = newDialer(opts.Availability, cfg, log)
}
go m.reapLoop()
return m
}
// Available 报告当前有没有可用来源(组合根据此决定是否注册工具)。
func (m *Manager) Available() bool { return m != nil && m.d != nil }
// Source 返回来源描述(进日志与工具结果;不含凭据)。
func (m *Manager) Source() string {
if m == nil {
return ""
}
return m.source
}
// Open 在受 SSRF 守卫保护的 URL 上导航,返回页面**自报**的地址/标题/就绪态。
func (m *Manager) Open(ctx context.Context, rawURL string) (PageResult, error) {
if _, err := GuardURL(ctx, rawURL, m.allowed); err != nil {
m.log.Warn("browser navigate blocked by URL guard", "reason", ReasonBlockedURL, "error", err)
return PageResult{}, err
}
out := PageResult{Source: m.Source(), Headless: m.cfg.headless}
err := m.withSession(ctx, func(s *session) error {
state, err := s.navigate(ctx, rawURL)
if err != nil {
return err
}
out.URL, out.Title, out.ReadyState = state.URL, state.Title, state.ReadyState
return nil
})
if err != nil {
return PageResult{}, err
}
return out, nil
}
// PageResult 是一次导航的结论(全部来自页面自报,不做美化)。
type PageResult struct {
// URL 是导航结束后页面自报的地址(可能因重定向与请求的不同)。
URL string `json:"url"`
// Title 是页面标题。
Title string `json:"title"`
// ReadyState 是 document.readyState(等就绪后才返回,正常为 complete)。
ReadyState string `json:"readyState"`
// Source 是会话来源(本机有头 / 容器端点),如实标注走的是哪条路。
Source string `json:"source"`
// Headless 是本次会话是否无头(默认 false = 有头)。
Headless bool `json:"headless"`
}
// Screenshot 抓一张视口截图并落盘(0600)。超过配置上限时先降级为 JPEG 重抓,
// 仍超限则**如实报错**,绝不交出半张坏图。
func (m *Manager) Screenshot(ctx context.Context) (Shot, error) {
var shot Shot
err := m.withSession(ctx, func(s *session) error {
data, format, err := m.capture(ctx, s)
if err != nil {
return err
}
saved, err := saveShot(m.cfg.shotsDir, data, format)
if err != nil {
return err
}
shot = saved
return nil
})
if err != nil {
return Shot{}, err
}
return shot, nil
}
// capture 取截图的**字节与真实格式**:先 PNG,超上限才退 JPEG,退不下来就报错。
func (m *Manager) capture(ctx context.Context, s *session) ([]byte, string, error) {
png, err := s.captureScreenshot(ctx, "png", 0)
if err != nil {
return nil, "", err
}
if len(png) <= m.cfg.shotMaxBytes {
return png, shotExtPNG, nil
}
jpeg, jerr := s.captureScreenshot(ctx, "jpeg", jpegQuality)
if jerr != nil {
return nil, "", fmt.Errorf("%w: PNG %d 字节超上限 %d,JPEG 降级重抓也失败: %v",
ErrScreenshotTooLarge, len(png), m.cfg.shotMaxBytes, jerr)
}
if len(jpeg) > m.cfg.shotMaxBytes {
return nil, "", fmt.Errorf("%w: PNG %d / JPEG %d 字节都超过上限 %d",
ErrScreenshotTooLarge, len(png), len(jpeg), m.cfg.shotMaxBytes)
}
return jpeg, shotExtJPEG, nil
}
// Fingerprint 在**隔离世界**里读一次页面指纹(reference/10 §4.1 的 L2~L5 自检项),
// 用来证明「这次跑的是有头、webdriver 关、屏幕/时区/语言与设定一致」。
func (m *Manager) Fingerprint(ctx context.Context) (Fingerprint, error) {
var fp Fingerprint
err := m.withSession(ctx, func(s *session) error {
got, err := readFingerprint(ctx, s)
if err != nil {
return err
}
fp = got
return nil
})
if err != nil {
return Fingerprint{}, err
}
return fp, nil
}
// withSession 在一把大锁下完成一次动作:锁本身就是「并发上限 1」的实现(design/28
// §3),而不是另造一个信号量。会话在以下任一情况下重建:还没有、已空闲超时、上次
// 动作报了连接级错误(dropLocked)。
func (m *Manager) withSession(ctx context.Context, fn func(*session) error) error {
m.mu.Lock()
defer m.mu.Unlock()
s, err := m.ensureLocked(ctx)
if err != nil {
return err
}
m.lastUsed = time.Now()
if err := fn(s); err != nil {
if errors.Is(err, ErrCDP) {
m.dropLocked(err)
}
return err
}
return nil
}
// ensureLocked 交出一个可用会话(调用方必须已持锁)。
func (m *Manager) ensureLocked(ctx context.Context) (*session, error) {
if m.closed {
return nil, fmt.Errorf("%w: %s", ErrNoSession, ReasonClosed)
}
if m.d == nil {
return nil, fmt.Errorf("%w: %s", ErrUnavailable, m.source)
}
if m.sess != nil && m.idleExpired() {
m.recycleLocked(ReasonIdleRecycled)
}
if m.sess != nil {
return m.sess, nil
}
openCtx, cancel := context.WithTimeout(ctx, m.cfg.timeout)
defer cancel()
s, err := openSession(openCtx, m.d, m.cfg)
if err != nil {
m.log.Warn("browser session open failed", "source", m.source, "error", err)
return nil, err
}
m.sess = s
m.lastUsed = time.Now()
m.log.Info("browser session opened", "source", m.source, "headless", m.cfg.headless)
return s, nil
}
// idleExpired 报告当前会话是否已超过空闲上限。
func (m *Manager) idleExpired() bool {
if m.cfg.idle <= 0 {
return false
}
return time.Since(m.lastUsed) >= m.cfg.idle
}
// recycleLocked 关掉当前会话并留一条可追溯的日志(原因分类,不美化)。
func (m *Manager) recycleLocked(reason Reason) {
if m.sess == nil {
return
}
m.sess.Close()
m.sess = nil
m.log.Info("browser session recycled", "reason", reason, "detail", reason.Message())
}
// dropLocked 因连接级错误丢弃当前会话:下一个动作会重起一个,而不是一直对着一具
// 尸体报错(崩溃重启的退避由「每次动作只重试一次」自然给出)。
func (m *Manager) dropLocked(cause error) {
if m.sess == nil {
return
}
m.log.Warn("browser session dropped after connection error", "error", cause)
m.sess.Close()
m.sess = nil
}
// reapLoop 是空闲清扫协程:滴答间隔跟随空闲上限的一半(夹在具名上下界之间)。
func (m *Manager) reapLoop() {
defer close(m.reaperDone)
interval := m.cfg.idle / 2
if interval < minReapInterval {
interval = minReapInterval
}
if interval > maxReapInterval {
interval = maxReapInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-m.stopReaper:
return
case <-ticker.C:
m.reapOnce()
}
}
}
// reapOnce 做一次空闲判定(供清扫协程与单测调用)。
func (m *Manager) reapOnce() {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed || m.sess == nil || !m.idleExpired() {
return
}
m.recycleLocked(ReasonIdleRecycled)
}
// Close 停掉清扫协程并关掉会话(幂等,可重复调用)。
func (m *Manager) Close() error {
if m == nil {
return nil
}
m.closeOnce.Do(func() {
close(m.stopReaper)
<-m.reaperDone
m.mu.Lock()
m.closed = true
if m.sess != nil {
m.sess.Close()
m.sess = nil
}
m.mu.Unlock()
})
return nil
}