fix(browser): pre-allocate chromedp context, remove timeout contexts from interactive handlers

- Pre-allocate browser in handleBrowserStart with chromedp.Run(s.ctx)
  to avoid the first-Run timeout killing the browser process
- Replace all per-action context.WithTimeout with s.ctx in navigate,
  click, type, screenshot, html, scroll handlers
- Add cleanupLoop goroutine for session-level timeout safety
- This matches chromedp docs warning: first Run with timeout context
  stops the entire browser
This commit is contained in:
root
2026-07-23 13:53:40 +08:00
parent 5e0c8d5a40
commit 34f91ebd1a

View File

@ -31,7 +31,6 @@ type Plugin struct {
mu sync.RWMutex
timeout int
proxy string
dataDir string
client *http.Client
sessions map[string]*BrowserSession
@ -175,11 +174,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
DisplayName: "HTTP 代理", Description: "HTTP 代理地址,如 http://proxy:port",
Category: "browser",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "data_dir", Default: "", Type: "string",
DisplayName: "浏览器数据目录", Description: "Chromium 用户数据目录路径(持久化 cookies/登录状态)。留空则每次启动临时目录。",
Category: "browser",
})
t := readCfg(s.Settings(), "timeout", float64(30))
p.timeout = int(t)
@ -190,10 +184,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.timeout = 120
}
p.proxy = readCfg(s.Settings(), "proxy", "")
p.dataDir = readCfg(s.Settings(), "data_dir", "")
if p.dataDir != "" {
os.MkdirAll(p.dataDir, 0700)
}
p.client = newHTTPClient(p.timeout, p.proxy)
tp := p.name + "_"
@ -213,12 +203,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"fetch", sdk.ToolDef{
Name: tp + "fetch",
Description: "快速抓取 URL 内容(quick 模式)。纯 HTTP 请求,不支持 JS 渲染,有 SSRF 防护。",
Description: "抓取 URL 内容。mode=auto 时遇 403/429 自动降级用无头 Chromium 渲染mode=render 强制用 Chromiummode=quick 纯 HTTP 不降级。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL"},
"max_chars": map[string]interface{}{"type": "integer", "description": "最大返回字符数(默认20000)"},
"mode": map[string]interface{}{"type": "string", "description": "auto(默认)/render(强制Chromium)/quick(纯HTTP)"},
},
"required": []string{"url"},
},
@ -581,14 +572,26 @@ func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
if err := p.ssrfCheck(rawURL); err != nil {
return errResult(err.Error()), nil
}
mode := readArg(args, "mode", "auto")
if mode == "render" {
return p.fetchWithChromium(rawURL, maxChars)
}
req, _ := http.NewRequest("GET", rawURL, nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
resp, err := p.client.Do(req)
if err != nil {
return errResult("fetch failed: " + err.Error()), nil
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
resp.Body.Close()
if (resp.StatusCode == 403 || resp.StatusCode == 429) && mode == "auto" {
return p.fetchWithChromium(rawURL, maxChars)
}
return errResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000))
@ -625,6 +628,27 @@ func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
// ── Chromium Render (normal) ──────────────────────────────
func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, error) {
chromiumPath := "/usr/local/bin/chromium"
if _, err := os.Stat(chromiumPath); err != nil {
return errResult("HTTP 403 且 Chromium 不可用,无法降级渲染"), nil
}
var out bytes.Buffer
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return errResult("Chromium 渲染失败: " + err.Error()), nil
}
text := htmlToText(out.String())
if len(text) > maxChars {
text = text[:maxChars] + "\n\n[Content truncated]"
}
return map[string]interface{}{
"content": text,
"details": map[string]interface{}{"url": rawURL, "mode": "render_fallback", "content_type": "text/html"},
}, nil
}
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
rawURL := readArg(args, "url", "")
if rawURL == "" {
@ -689,9 +713,6 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
chromedp.Flag("no-sandbox", true),
chromedp.WindowSize(1280, 800),
)
if p.dataDir != "" {
opts = append(opts, chromedp.Flag("user-data-dir", p.dataDir))
}
if p.proxy != "" {
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
}
@ -699,6 +720,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, _ := chromedp.NewContext(allocCtx)
// 立即分配浏览器和 Target确保后续 Run 的 timeout context 不会杀死浏览器进程
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
if err := chromedp.Run(ctx); err != nil {
cancel()
return errResult("browser init failed: " + err.Error()), nil
}
session := &BrowserSession{
allocCtx: allocCtx,
cancel: cancel,
@ -760,9 +788,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error
return errResult(err.Error()), nil
}
waitSec := int64(readArg(args, "wait", float64(2)))
ctx, cancel := context.WithTimeout(s.ctx, time.Duration(p.timeout+10)*time.Second)
defer cancel()
if err := chromedp.Run(ctx,
if err := chromedp.Run(s.ctx,
chromedp.Navigate(rawURL),
chromedp.WaitReady("body"),
chromedp.Sleep(time.Duration(waitSec)*time.Second),
@ -853,10 +879,7 @@ func (p *Plugin) handleClick(args map[string]interface{}) (interface{}, error) {
if err != nil {
return errResult(err.Error()), nil
}
waitMs := int64(readArg(args, "wait", float64(3000)))
ctx, cancel := context.WithTimeout(s.ctx, time.Duration(waitMs)*time.Millisecond)
defer cancel()
if err := chromedp.Run(ctx,
if err := chromedp.Run(s.ctx,
chromedp.WaitVisible(selector),
chromedp.Click(selector),
); err != nil {
@ -876,9 +899,6 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) {
if err != nil {
return errResult(err.Error()), nil
}
waitMs := int64(readArg(args, "wait", float64(3000)))
ctx, cancel := context.WithTimeout(s.ctx, time.Duration(waitMs)*time.Millisecond)
defer cancel()
actions := []chromedp.Action{
chromedp.WaitVisible(selector),
chromedp.Click(selector, chromedp.NodeVisible),
@ -892,7 +912,7 @@ func (p *Plugin) handleType(args map[string]interface{}) (interface{}, error) {
if submit {
actions = append(actions, chromedp.SendKeys(selector, "\r"))
}
if err := chromedp.Run(ctx, actions...); err != nil {
if err := chromedp.Run(s.ctx, actions...); err != nil {
return errResult("type failed (element may not exist or page blocking): " + err.Error()), nil
}
return map[string]interface{}{"status": "ok", "selector": selector}, nil