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:
1 parent
b7fb4dd1c7
commit
29181245ce
9 files changed
+1398
No files matched your search
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user