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,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
|
||||
}
|
||||
Reference in new issue
Block a user