按 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。
276 lines
9.7 KiB
Go
276 lines
9.7 KiB
Go
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",
|
||
}
|
||
}
|
||
}
|