- 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 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
359 lines
11 KiB
Go
359 lines
11 KiB
Go
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
|
||
}
|