Files
Cyrene_v2/internal/capability/browser/manager.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

359 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}