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。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:34 +08:00
1 parent b7fb4dd1c7
commit 29181245ce
9 files changed
+1398

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
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")
}