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") }