Files
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

276 lines
9.7 KiB
Go
Raw Permalink 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 (
"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",
}
}
}