Skip to content

Commit e483861

Browse files
committed
feat: add --doctor diagnostics and --browser-path with browser probe tests
1 parent 69bb1e4 commit e483861

11 files changed

Lines changed: 563 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Go build artifacts
55
bin/
66
/dist/
7+
.gocache/
78
*.exe
89
*.exe~
910
*.dll

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to this project are documented in this file.
44

5+
## [Unreleased]
6+
7+
### Added
8+
- Added `--doctor` to run environment diagnostics and report browser/runtime readiness for `auto`/`browser` modes.
9+
- Added actionable remediation output for missing or non-working headless browser setups across Linux/macOS/Windows environments.
10+
- Added `--browser-path` to explicitly configure the browser executable (useful for containers and custom installations).
11+
- Added unit tests for doctor-mode browser detection and probe behavior.
12+
13+
### Changed
14+
- Updated README (EN/ZH) with `--doctor` usage and diagnostics guidance.
15+
- Updated `skills/agent-fetch/SKILL.md` with `--doctor` / `--browser-path` command patterns and browser troubleshooting guidance.
16+
- Unified browser executable resolution between `--doctor` and runtime `browser` mode.
17+
518
## [0.3.0] - 2026-02-19
619

720
### Added

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ agent-fetch [options] <url> [url ...]
116116
| `--user-agent` | `agent-fetch/0.1` | User-Agent header |
117117
| `--max-body-bytes` | `8388608` | Max response bytes to read |
118118
| `--concurrency` | `4` | Max concurrent fetches for multi-URL requests |
119+
| `--doctor` | `false` | Run environment checks (runtime + headless browser readiness) and print remediation guidance |
120+
| `--browser-path` | | Browser executable path/name override for `browser` and `auto` modes |
119121

120122
### Examples
121123

@@ -126,6 +128,9 @@ agent-fetch https://example.com
126128
# Force browser rendering for a JS-heavy page
127129
agent-fetch --mode browser --wait-selector 'article' https://example.com
128130

131+
# Force a specific browser binary (useful in containers/custom installs)
132+
agent-fetch --mode browser --browser-path /usr/bin/chromium https://example.com
133+
129134
# Static extraction without front matter
130135
agent-fetch --mode static --meta=false https://example.com
131136

@@ -137,6 +142,12 @@ agent-fetch --header "Authorization: Bearer $TOKEN" https://example.com
137142

138143
# Batch fetch with concurrency control
139144
agent-fetch --concurrency 4 https://example.com https://example.org
145+
146+
# Check environment readiness
147+
agent-fetch --doctor
148+
149+
# Check environment readiness with explicit browser path
150+
agent-fetch --doctor --browser-path /usr/bin/chromium
140151
```
141152

142153
## Multi-URL Batch
@@ -189,6 +200,9 @@ The table below compares agent-fetch with the built-in web-fetch capabilities fo
189200

190201
Use `--mode static` or `--mode raw` to avoid the browser dependency entirely.
191202

203+
- Run `agent-fetch --doctor` to validate runtime/browser readiness and get guided fixes.
204+
- Use `--browser-path` when the browser is installed in a non-default location (common in container images).
205+
192206
## Build
193207

194208
```bash

README.zh.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ agent-fetch [options] <url> [url ...]
116116
| `--user-agent` | `agent-fetch/0.1` | User-Agent 请求头 |
117117
| `--max-body-bytes` | `8388608` | 最大响应读取字节数 |
118118
| `--concurrency` | `4` | 多 URL 请求时的最大并发数 |
119+
| `--doctor` | `false` | 运行环境检查(运行时 + 无头浏览器可用性),并输出修复建议 |
120+
| `--browser-path` | |`browser` / `auto` 模式指定浏览器可执行文件路径或名称 |
119121

120122
### 示例
121123

@@ -126,6 +128,9 @@ agent-fetch https://example.com
126128
# 强制浏览器渲染 JS 重度页面
127129
agent-fetch --mode browser --wait-selector 'article' https://example.com
128130

131+
# 指定浏览器二进制(容器/自定义安装场景常用)
132+
agent-fetch --mode browser --browser-path /usr/bin/chromium https://example.com
133+
129134
# 静态抽取,不带 front matter
130135
agent-fetch --mode static --meta=false https://example.com
131136

@@ -137,6 +142,12 @@ agent-fetch --header "Authorization: Bearer $TOKEN" https://example.com
137142

138143
# 批量抓取,控制并发
139144
agent-fetch --concurrency 4 https://example.com https://example.org
145+
146+
# 检查环境可用性
147+
agent-fetch --doctor
148+
149+
# 使用指定浏览器路径进行环境检查
150+
agent-fetch --doctor --browser-path /usr/bin/chromium
140151
```
141152

142153
## 多 URL 批量抓取
@@ -189,6 +200,9 @@ result=$(agent-fetch --mode static https://example.com)
189200

190201
使用 `--mode static``--mode raw` 可完全避免浏览器依赖。
191202

203+
- 可以运行 `agent-fetch --doctor` 检查运行时/浏览器可用性,并在浏览器模式不可用时获得修复建议。
204+
- 当浏览器安装在非默认位置(例如容器镜像内自定义路径)时,使用 `--browser-path` 指定可执行文件。
205+
192206
## 构建
193207

194208
```bash

cmd/agent-fetch/doctor.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"runtime"
8+
"strings"
9+
"time"
10+
11+
"github.com/chromedp/chromedp"
12+
"github.com/firede/agent-fetch/internal/fetcher"
13+
)
14+
15+
const (
16+
doctorProbeTimeout = 8 * time.Second
17+
maxProbeOutputTail = 800
18+
)
19+
20+
type doctorStatus string
21+
22+
const (
23+
doctorStatusOK doctorStatus = "ok"
24+
doctorStatusWarn doctorStatus = "warn"
25+
)
26+
27+
type doctorDeps struct {
28+
resolveBrowser func(string) (string, []string, error)
29+
runProbe func(context.Context, string) (string, error)
30+
goos string
31+
goarch string
32+
}
33+
34+
type browserCheck struct {
35+
status doctorStatus
36+
candidates []string
37+
selected string
38+
probeOutput string
39+
err error
40+
guidance []string
41+
}
42+
43+
func runDoctor(ctx context.Context, out io.Writer, browserPath string) (doctorStatus, error) {
44+
deps := doctorDeps{
45+
resolveBrowser: fetcher.ResolveBrowserExecutablePath,
46+
runProbe: runBrowserProbe,
47+
goos: runtime.GOOS,
48+
goarch: runtime.GOARCH,
49+
}
50+
51+
check := diagnoseBrowser(ctx, deps, browserPath)
52+
53+
if _, err := fmt.Fprintf(out, "version: %s\n", versionString()); err != nil {
54+
return doctorStatusWarn, err
55+
}
56+
if _, err := fmt.Fprintf(out, "platform: %s/%s\n", deps.goos, deps.goarch); err != nil {
57+
return doctorStatusWarn, err
58+
}
59+
if strings.TrimSpace(browserPath) != "" {
60+
if _, err := fmt.Fprintf(out, "browser path override: %s\n", browserPath); err != nil {
61+
return doctorStatusWarn, err
62+
}
63+
}
64+
65+
if check.status == doctorStatusOK {
66+
if _, err := fmt.Fprintln(out, "browser mode: ready"); err != nil {
67+
return doctorStatusWarn, err
68+
}
69+
if _, err := fmt.Fprintf(out, "browser binary: %s\n", check.selected); err != nil {
70+
return doctorStatusWarn, err
71+
}
72+
} else {
73+
if _, err := fmt.Fprintln(out, "browser mode: not ready"); err != nil {
74+
return doctorStatusWarn, err
75+
}
76+
if len(check.candidates) > 0 {
77+
if _, err := fmt.Fprintf(out, "browser candidates found: %s\n", strings.Join(check.candidates, ", ")); err != nil {
78+
return doctorStatusWarn, err
79+
}
80+
} else {
81+
if _, err := fmt.Fprintln(out, "browser candidates found: none"); err != nil {
82+
return doctorStatusWarn, err
83+
}
84+
}
85+
if check.err != nil {
86+
if _, err := fmt.Fprintf(out, "probe error: %v\n", check.err); err != nil {
87+
return doctorStatusWarn, err
88+
}
89+
}
90+
if check.probeOutput != "" {
91+
if _, err := fmt.Fprintf(out, "probe output (tail): %q\n", check.probeOutput); err != nil {
92+
return doctorStatusWarn, err
93+
}
94+
}
95+
if len(check.guidance) > 0 {
96+
if _, err := fmt.Fprintln(out, "recommended fixes:"); err != nil {
97+
return doctorStatusWarn, err
98+
}
99+
for i, line := range check.guidance {
100+
if _, err := fmt.Fprintf(out, "%d. %s\n", i+1, line); err != nil {
101+
return doctorStatusWarn, err
102+
}
103+
}
104+
}
105+
}
106+
return check.status, nil
107+
}
108+
109+
func diagnoseBrowser(ctx context.Context, deps doctorDeps, browserPath string) browserCheck {
110+
selected, found, err := deps.resolveBrowser(browserPath)
111+
if err != nil {
112+
return browserCheck{
113+
status: doctorStatusWarn,
114+
candidates: found,
115+
err: err,
116+
guidance: browserGuidance(deps.goos, browserPath),
117+
}
118+
}
119+
120+
probeCtx, cancel := context.WithTimeout(ctx, doctorProbeTimeout)
121+
out, err := deps.runProbe(probeCtx, selected)
122+
cancel()
123+
if err == nil {
124+
return browserCheck{
125+
status: doctorStatusOK,
126+
candidates: found,
127+
selected: selected,
128+
}
129+
}
130+
131+
lastOut := clampTail(out, maxProbeOutputTail)
132+
if lastOut == "" {
133+
lastOut = clampTail(err.Error(), maxProbeOutputTail)
134+
}
135+
return browserCheck{
136+
status: doctorStatusWarn,
137+
candidates: found,
138+
selected: selected,
139+
probeOutput: lastOut,
140+
err: err,
141+
guidance: browserGuidance(deps.goos, browserPath),
142+
}
143+
}
144+
145+
func runBrowserProbe(ctx context.Context, binary string) (string, error) {
146+
allocOpts := append(chromedp.DefaultExecAllocatorOptions[:],
147+
chromedp.ExecPath(binary),
148+
chromedp.NoDefaultBrowserCheck,
149+
chromedp.NoFirstRun,
150+
)
151+
allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, allocOpts...)
152+
defer cancelAlloc()
153+
154+
tabCtx, cancelTab := chromedp.NewContext(allocCtx)
155+
defer cancelTab()
156+
157+
var finalURL string
158+
if err := chromedp.Run(tabCtx,
159+
chromedp.Navigate("about:blank"),
160+
chromedp.Location(&finalURL),
161+
); err != nil {
162+
return "", err
163+
}
164+
return finalURL, nil
165+
}
166+
167+
func clampTail(s string, max int) string {
168+
trimmed := strings.TrimSpace(strings.ReplaceAll(s, "\x00", ""))
169+
if max <= 0 || len(trimmed) <= max {
170+
return trimmed
171+
}
172+
return trimmed[len(trimmed)-max:]
173+
}
174+
175+
func browserGuidance(goos, browserPath string) []string {
176+
override := strings.TrimSpace(browserPath)
177+
if override != "" {
178+
return []string{
179+
"Verify the override path is executable in the current environment: " + override,
180+
"In containers, ensure the browser binary and required shared libraries are installed in the same image layer.",
181+
"Run doctor with a known path if needed: agent-fetch --doctor --browser-path /path/to/chrome.",
182+
}
183+
}
184+
185+
switch goos {
186+
case "linux":
187+
return []string{
188+
"Install Chrome/Chromium with your distro package manager and ensure the executable is discoverable.",
189+
"For containerized workloads, set --browser-path explicitly to the browser binary in the image (for example /usr/bin/chromium).",
190+
"If startup fails with missing shared libraries, install runtime deps such as libnss3, libatk-bridge2.0-0, libgtk-3-0, libgbm1, and fonts.",
191+
}
192+
case "darwin":
193+
return []string{
194+
"Install Google Chrome or Chromium in /Applications, or provide an explicit --browser-path override.",
195+
"If Chrome is installed in a custom location, run with --browser-path '<full path to Chrome binary>'.",
196+
}
197+
case "windows":
198+
return []string{
199+
"Install Google Chrome or Chromium and ensure the executable is discoverable, or pass --browser-path.",
200+
"Verify the configured path from the same terminal session used to run agent-fetch.",
201+
}
202+
}
203+
return []string{
204+
"Install Chrome/Chromium and ensure the executable is discoverable, or pass --browser-path.",
205+
}
206+
}

0 commit comments

Comments
 (0)