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

+275
View File
@@ -0,0 +1,275 @@
package browsercfg
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// probeHTTPTimeout 是 CDP 存活探测的单次超时(2s)。探测问的是「有没有在听」,不是
// 「页面能不能加载」,所以给得短:连不上就是连不上,不该拖住启动路径。
const probeHTTPTimeout = 2 * time.Second
// Source 标识浏览器从哪来(探测优先级见 design/28 §8 B1)。
type Source string
// 来源取值。SourceNone 用于不可用(Usable=false)的结论。
const (
// SourceNone 表示没有可用来源。
SourceNone Source = ""
// SourceLocalChrome 表示本机 Chrome/Chromium 可执行文件(默认有头)。
SourceLocalChrome Source = "local-chrome"
// SourceCDP 表示一个已在响应 CDP 的端点(容器,或本机手动起的实例)。
SourceCDP Source = "cdp"
)
// Availability 是可用性判定的结论。**不可用也是结论**:Usable=false 时 Reason 说明分类
// 原因、Detail 给出可排查的细节,上层据此如实降级(不注册工具)与记日志。
type Availability struct {
// Usable 为 true 表示探测到可用来源。
Usable bool
// Source 是选定的来源;不可用时为 SourceNone。
Source Source
// Executable 是选定的本机可执行文件(Source=SourceLocalChrome 时有值)。
Executable string
// Endpoint 是选定的 CDP 端点(Source=SourceCDP 时有值)。
Endpoint string
// Headless 是本次判定采用的档位(来自配置,默认有头)。
Headless bool
// Reason 是分类原因码(可用时恒为 ReasonUsable)。
Reason Reason
// Detail 是可排查的补充说明(含具体路径/端点/错误),进日志与面板,**不进提示词**。
Detail string
}
// Prober 执行可用性探测。字段可注入,使单测无需真的装浏览器 / 起容器;零接线:除
// CheckCDP 会发一次出站 HTTP GET 外,它不建立任何长连接、不启动任何进程。
type Prober struct {
// LookPath 在 PATH 上查找可执行文件(默认 exec.LookPath)。
LookPath func(string) (string, error)
// Stat 检查候选绝对路径(默认 os.Stat)。
Stat func(string) (os.FileInfo, error)
// Getenv 读系统环境(默认 os.Getenv),只用于定位 Windows 的安装目录。
Getenv func(string) string
// GOOS 决定候选路径表(默认 runtime.GOOS)。
GOOS string
// CheckCDP 探测一个 CDP 端点,返回 nil 表示可达(默认对 <endpoint>/json/version 发 GET)。
CheckCDP func(ctx context.Context, endpoint string) error
}
// NewProber 返回接好真实现的 Prober。
func NewProber() *Prober { return (&Prober{}).withDefaults() }
// withDefaults 给零值字段补上真实现,使 `&Prober{}` 也安全(照 transcode.Limits 的口径:
// 零值 = 全默认)。测试只覆写关心的那几个字段即可。
func (p *Prober) withDefaults() *Prober {
if p.LookPath == nil {
p.LookPath = exec.LookPath
}
if p.Stat == nil {
p.Stat = os.Stat
}
if p.Getenv == nil {
p.Getenv = os.Getenv
}
if p.GOOS == "" {
p.GOOS = runtime.GOOS
}
if p.CheckCDP == nil {
p.CheckCDP = checkCDP
}
return p
}
// Resolve 判定浏览器是否可用并给出分类原因。探测优先级(design/28 §8 B1):
//
// 1. 配置**显式**给出的来源(executable / cdpEndpoint)—— 面板是权威:连不上就如实报
// 对应原因,**不偷偷回退**到自动探测(design/28 §6 坑 5:失败要如实报);
// 2. 无显式来源 ⇒ 本机 Chrome/Chromium 可执行文件;
// 3. 仍无 ⇒ 已在跑的 CDP 端点(容器,或本机手动实例);
// 4. 都没有 ⇒ Usable=false、Reason=no_source —— 上层据此**不注册工具**,不假装可用。
//
// cfg 为 nil 表示没有配置文件(Load 返回 nil, nil),直接走 2→3→4。p 为 nil 时用真实现。
func Resolve(ctx context.Context, cfg *Config, p *Prober) Availability {
if p == nil {
p = &Prober{}
}
p = p.withDefaults()
headless := cfg != nil && cfg.Headless
if cfg != nil {
if exe := strings.TrimSpace(cfg.Executable); exe != "" {
return resolveConfiguredExecutable(p, exe, headless)
}
if endpoint := strings.TrimSpace(cfg.CDPEndpoint); endpoint != "" {
return resolveConfiguredEndpoint(ctx, p, endpoint, headless)
}
}
return resolveByProbe(ctx, p, headless)
}
// resolveConfiguredExecutable 处理配置显式给的浏览器可执行文件:不存在即
// Reason=executable_missing(不做「换一个」的猜测——面板写错了就该让人看见)。
func resolveConfiguredExecutable(p *Prober, exe string, headless bool) Availability {
if !p.isExecutable(exe) {
return Availability{
Headless: headless,
Reason: ReasonExecutableMissing,
Detail: fmt.Sprintf("配置的 executable 不存在或不可执行:%s", exe),
}
}
return Availability{
Usable: true,
Source: SourceLocalChrome,
Executable: exe,
Headless: headless,
Reason: ReasonUsable,
Detail: "配置指定的本机浏览器:" + exe,
}
}
// resolveConfiguredEndpoint 处理配置显式给的 CDP 端点:连不上即 Reason=cdp_unreachable,
// 绝不悄悄改用本机浏览器(否则「我配的容器没生效」会变成静默的语义漂移)。
func resolveConfiguredEndpoint(ctx context.Context, p *Prober, endpoint string, headless bool) Availability {
if err := p.CheckCDP(ctx, endpoint); err != nil {
return Availability{
Headless: headless,
Reason: ReasonCDPUnreachable,
Detail: fmt.Sprintf("配置的 cdpEndpoint 不可达:%s(%v)", endpoint, err),
}
}
return Availability{
Usable: true,
Source: SourceCDP,
Endpoint: endpoint,
Headless: headless,
Reason: ReasonUsable,
Detail: "配置指定的 CDP 端点:" + endpoint,
}
}
// resolveByProbe 按优先级自动探测:本机可执行文件 → 已在跑的 CDP 端点 → 都没有。
func resolveByProbe(ctx context.Context, p *Prober, headless bool) Availability {
if exe, ok := p.FindLocalBrowser(); ok {
return Availability{
Usable: true,
Source: SourceLocalChrome,
Executable: exe,
Headless: headless,
Reason: ReasonUsable,
Detail: "自动探测到的本机浏览器:" + exe,
}
}
if err := p.CheckCDP(ctx, DefaultCDPEndpoint); err == nil {
return Availability{
Usable: true,
Source: SourceCDP,
Endpoint: DefaultCDPEndpoint,
Headless: headless,
Reason: ReasonUsable,
Detail: "自动探测到在跑的 CDP 端点:" + DefaultCDPEndpoint,
}
}
return Availability{
Headless: headless,
Reason: ReasonNoSource,
Detail: fmt.Sprintf("本机未发现 Chrome/Chromium,%s 也没有在跑的 CDP 端点", DefaultCDPEndpoint),
}
}
// FindLocalBrowser 按「PATH 上的常见命令名 → 各平台固定安装位置」查找本机 Chrome/Chromium,
// 返回第一个可执行文件。找不到返回 ("", false)。
func (p *Prober) FindLocalBrowser() (string, bool) {
for _, name := range lookPathNames(p.GOOS) {
if path, err := p.LookPath(name); err == nil && strings.TrimSpace(path) != "" {
return path, true
}
}
for _, path := range absoluteCandidates(p.GOOS, p.Getenv) {
if p.isExecutable(path) {
return path, true
}
}
return "", false
}
// isExecutable 判定 path 是否是一个可执行文件:存在、不是目录,且在非 Windows 平台上有
// 执行位(Windows 不看执行位)。
func (p *Prober) isExecutable(path string) bool {
info, err := p.Stat(path)
if err != nil || info == nil || info.IsDir() {
return false
}
if p.GOOS == "windows" {
return true
}
return info.Mode().Perm()&0o111 != 0
}
// checkCDP 对 `<endpoint>/json/version` 发一次出站 GET:CDP 端点活着就会回版本 JSON。
// 它是**出站**请求,只连调用方给的地址(默认 127.0.0.1),不新开任何监听端口(AGENTS §9)。
func checkCDP(ctx context.Context, endpoint string) error {
base := strings.TrimRight(strings.TrimSpace(endpoint), "/")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/version", nil)
if err != nil {
return fmt.Errorf("browsercfg: 构造探测请求: %w", err)
}
client := &http.Client{Timeout: probeHTTPTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("browsercfg: 探测 %s: %w", endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("browsercfg: 探测 %s: HTTP %d", endpoint, resp.StatusCode)
}
return nil
}
// lookPathNames 是各平台 PATH 上常见的浏览器命令名。
func lookPathNames(goos string) []string {
switch goos {
case "windows":
return []string{"chrome.exe", "chrome", "chromium.exe", "chromium"}
case "darwin":
return []string{"google-chrome", "chromium", "chrome"}
default:
return []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"}
}
}
// absoluteCandidates 是各平台**不在 PATH 上**的固定安装位置。
func absoluteCandidates(goos string, getenv func(string) string) []string {
switch goos {
case "windows":
var out []string
for _, key := range []string{"ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"} {
if base := strings.TrimSpace(getenv(key)); base != "" {
out = append(out,
filepath.Join(base, "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(base, "Chromium", "Application", "chrome.exe"),
)
}
}
return out
case "darwin":
return []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
}
default:
return []string{
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/chrome",
}
}
}