Files
Cyrene_v2/internal/capability/browsercfg/config.go
T
AskaEth 29181245ce feat(browser): 浏览器配置与探测库(internal/capability/browsercfg,纯库零接线)
按 design/28 §2/§3 的「配置不进 .env」口径:读 $DATA_DIR/config/plugins/browser.json
(0600 + temp+rename 范式,**不读 env、不加新键**);有头/无头、CDP 端点、profile 目录、
视口/超时/并发(1)/截图上限全部类型化 + 常量默认值;**探测优先级**(本机 Chrome/Chromium
可执行文件 → 已在跑的容器)并返回可分类的「为什么不可用」(供上层如实降级,不假装可用);
profile 目录含凭据 ⇒ 0600 + 不进 git/不进上下文的注释与校验。

本片零既有文件改动(不注册工具、不碰 cmd/),接线留 B2。
2026-10-06 09:03:34 +08:00

186 lines
8.6 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 browsercfg
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
)
// ---- 默认值与硬上限(design/28 §3;一切具名,本包不存在裸数字)----
//
// 口径(design/28 §8 B1):`.env` 已冻结为「最小化功能可跑」的引导面 ⇒ B1 的一切可调项
// **只**落在 `$DATA_DIR/config/plugins/browser.json` + 插件面板上,代码只提供默认值与
// 自动探测。因此下面的常量是「配置文件缺失 / 字段 ≤0」时的回落值。
const (
// DefaultHeadless 默认档 = **有头**(design/28 §2:「默认档 = 有头 + 持久化 profile」)。
// 有头的目的是过风控;无头只在「对反爬不敏感 / 要省资源」时由面板显式降级。
DefaultHeadless = false
// DefaultViewportWidth / DefaultViewportHeight 是视口默认 1280×800:桌面常见分辨率的
// 下界,且与 design/28 §2「指纹一致性」对齐 —— 刻意避开 800×600 那种一眼自动化的尺寸。
DefaultViewportWidth = 1280
DefaultViewportHeight = 800
// MaxViewportWidth / MaxViewportHeight 是视口硬上限 7680×4320(8K,design/28 §3
// 「视口尺寸上限」)。超过即判配置非法:不设上限等于允许一次渲染把整机内存交出去。
MaxViewportWidth = 7680
MaxViewportHeight = 4320
// DefaultTimeoutSeconds 是单次浏览器动作超时 30s:够一次冷启动导航 + 渲染完成。
// 它是**正常路径**的预算,不是重试预算(design/28 §3)。
DefaultTimeoutSeconds = 30
// DefaultIdleTimeoutSeconds 是空闲回收 300s(design/28 §3「空闲 N 秒回收」的常量):
// 5 分钟无动作即回收会话,避免一个 profile 被长期占着。
DefaultIdleTimeoutSeconds = 300
// DefaultConcurrency 并发上限恒为 1,**不是可调项**:同一 user-data-dir 起两个实例
// 会互锁(design/28 §3、§6 坑 2),且 profile 含凭据,串行最省心。Normalize 把任何
// >1 的取值夹回 1,而不是照单收下。
DefaultConcurrency = 1
// DefaultScreenshotMaxBytes 是单张截图字节上限 8 MiB:1280×800 的 PNG/JPEG 远小于它,
// 而 vision 管线也只该吃这么大的图(design/28 §3「超限如实截断/缩放并标注」)。
DefaultScreenshotMaxBytes = 8 << 20
// MaxScreenshotMaxBytes 是单张截图字节上限的**硬上限** 32 MiB(design/28 §3
// 「单张字节上限」):在默认 8 MiB 之上给面板留「显式放大」的余量,但绝不让单张截图无界。
MaxScreenshotMaxBytes = 32 << 20
// DefaultCDPEndpoint 是「已在跑的容器」的候选端点:CDP 默认调试端口 9222 绑回环。
// 它只是**出站探测目标**,不新开我们的监听端口(AGENTS §9 红线、design/28 §3)。
DefaultCDPEndpoint = "http://127.0.0.1:9222"
)
// Config 是 browser 插件的落盘配置(`$DATA_DIR/config/plugins/browser.json`,
// design/28 §1 M2、§8 B1)。零值经 Normalize 即为「全默认」,所以 `Config{}` 是安全的。
type Config struct {
// Headless 为 true 用无头;默认 false(有头,见 DefaultHeadless)。
// 用 plain bool 而不是 *bool:默认值本身就是 false,「缺失」与「显式 false」同义,无歧义。
Headless bool `json:"headless"`
// Executable 显式指定本机 Chrome/Chromium 可执行文件;空 ⇒ 交给自动探测。
Executable string `json:"executable,omitempty"`
// CDPEndpoint 显式指定已在跑的 CDP 端点(如容器)。空 ⇒ 交给自动探测;**显式给出时
// 它取代自动探测**:连不上就如实报 cdp_unreachable,不偷偷回退本机(design/28 §6 坑 5)。
CDPEndpoint string `json:"cdpEndpoint,omitempty"`
// ProfileDir 是持久化 profile(user-data-dir)目录,**含 cookie / 登录态等凭据**
// (design/28 §2 三件套之 2):空 ⇒ 默认 `$DATA_DIR/browser/profile`。使用前须过
// CheckProfileLocation(不得进 git 工作树),目录 0700、内容绝不进上下文。
ProfileDir string `json:"profileDir,omitempty"`
// ViewportWidth / ViewportHeight 是视口尺寸;≤0 ⇒ 用默认(见 DefaultViewport*),
// 超过上限 ⇒ Validate 判非法(见 MaxViewport*)。
ViewportWidth int `json:"viewportWidth"`
ViewportHeight int `json:"viewportHeight"`
// TimeoutSeconds 是单次动作超时;≤0 ⇒ 用默认(见 DefaultTimeoutSeconds)。
TimeoutSeconds int `json:"timeoutSeconds"`
// IdleTimeoutSeconds 是空闲回收秒数;≤0 ⇒ 用默认(见 DefaultIdleTimeoutSeconds)。
IdleTimeoutSeconds int `json:"idleTimeoutSeconds"`
// Concurrency 是并发上限;≤0 ⇒ 用默认、>1 ⇒ 夹回 1(见 DefaultConcurrency:profile 锁)。
Concurrency int `json:"concurrency"`
// ScreenshotMaxBytes 是单张截图字节上限;≤0 ⇒ 用默认、超过硬上限 ⇒ Validate 判非法。
ScreenshotMaxBytes int `json:"screenshotMaxBytes"`
}
// Defaults 返回一组全默认的配置(ProfileDir 落在 dataDir 下)。
func Defaults(dataDir string) Config {
return Config{}.Normalize(dataDir)
}
// Normalize 用常量默认补足「≤0 / 缺失」的字段,并把并发夹到 1。它**只填不改**:
// 越界的值原样保留,交给 Validate 拒绝 —— 悄悄改小会让面板显示的与生效的不是一回事。
func (c Config) Normalize(dataDir string) Config {
if c.ViewportWidth <= 0 {
c.ViewportWidth = DefaultViewportWidth
}
if c.ViewportHeight <= 0 {
c.ViewportHeight = DefaultViewportHeight
}
if c.TimeoutSeconds <= 0 {
c.TimeoutSeconds = DefaultTimeoutSeconds
}
if c.IdleTimeoutSeconds <= 0 {
c.IdleTimeoutSeconds = DefaultIdleTimeoutSeconds
}
if c.Concurrency <= 0 {
c.Concurrency = DefaultConcurrency
}
if c.Concurrency > DefaultConcurrency {
c.Concurrency = DefaultConcurrency
}
if c.ScreenshotMaxBytes <= 0 {
c.ScreenshotMaxBytes = DefaultScreenshotMaxBytes
}
if strings.TrimSpace(c.ProfileDir) == "" {
c.ProfileDir = DefaultProfileDir(dataDir)
}
return c
}
// Validate 检查配置是否在硬上限内(design/28 §3)。它判的是**原始值**(未 Normalize 也可用):
// 负值/零按「用默认」放行(≤0 的语义由 Normalize 承担),只有**越上限**与**端点非法**算错。
// 错误一律 %w 包住 ErrInvalidConfig;配置错误里不含任何凭据(本类型的字段就没有凭据)。
func (c Config) Validate() error {
if c.ViewportWidth > MaxViewportWidth {
return fmt.Errorf("%w: viewportWidth=%d 超过上限 %d", ErrInvalidConfig, c.ViewportWidth, MaxViewportWidth)
}
if c.ViewportHeight > MaxViewportHeight {
return fmt.Errorf("%w: viewportHeight=%d 超过上限 %d", ErrInvalidConfig, c.ViewportHeight, MaxViewportHeight)
}
if c.ScreenshotMaxBytes > MaxScreenshotMaxBytes {
return fmt.Errorf("%w: screenshotMaxBytes=%d 超过上限 %d", ErrInvalidConfig, c.ScreenshotMaxBytes, MaxScreenshotMaxBytes)
}
if raw := strings.TrimSpace(c.CDPEndpoint); raw != "" && !isHTTPAbsolute(raw) {
return fmt.Errorf("%w: cdpEndpoint %q 不是可用的 http(s) 绝对地址", ErrInvalidConfig, raw)
}
return nil
}
// Timeout 返回单次动作超时;调用前应已 Normalize(否则可能为 0)。
func (c Config) Timeout() time.Duration {
return time.Duration(c.TimeoutSeconds) * time.Second
}
// IdleTimeout 返回空闲回收时长;调用前应已 Normalize(否则可能为 0)。
func (c Config) IdleTimeout() time.Duration {
return time.Duration(c.IdleTimeoutSeconds) * time.Second
}
// isHTTPAbsolute 报告 raw 是否为带主机的 http(s) 绝对地址(照 config.ResolveEndpoint 口径:
// `ftp://x`、`127.0.0.1:9222` 这类一律不算)。
func isHTTPAbsolute(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
// DefaultConfigPath 返回 browser 插件配置文件的落点:
// `$DATA_DIR/config/plugins/browser.json`(design/13 §2.1「一个插件一个文件,文件名 = 插件 id」
// + design/28 §1 M2)。
func DefaultConfigPath(dataDir string) string {
return filepath.Join(dataDir, "config", "plugins", "browser.json")
}
// DefaultProfileDir 返回持久化 profile 目录的默认落点:`$DATA_DIR/browser/profile`。
// **它含凭据**:0700、不进 git、内容不进上下文(见 EnsureProfileDir / CheckProfileLocation)。
func DefaultProfileDir(dataDir string) string {
return filepath.Join(dataDir, "browser", "profile")
}
// DefaultShotsDir 返回截图落盘目录的默认落点(design/28 §4:「截图存
// `$DATA_DIR/browser/shots/`,0600」)。本片不落盘,只给 B3 一个一致的默认位置。
func DefaultShotsDir(dataDir string) string {
return filepath.Join(dataDir, "browser", "shots")
}