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 }