package cdp import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" neturl "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" ) // Browser 管理一个 Chrome 实例(懒启动、进程内单例语义由 tool 层保证): // 首次使用时以 --remote-debugging-port 启动并轮询就绪;DebugBase 非空则 // 直接连接已有调试端点(测试 / 外部浏览器),不启动进程。 type Browser struct { DebugBase string // 直接使用的调试基址(http://127.0.0.1:9222) Bin string // 浏览器可执行文件覆盖;空 = 自动发现 Headless bool // 默认 true;AUTOGO_BROWSER_HEADLESS=0 关闭 ProfileDir string // 用户数据目录;空 = ~/.autogo/browser-profile(持久,登录态留得住) mu sync.Mutex cmd *exec.Cmd base string current string // 当前 page target id(open/js/screenshot 的作用目标) actionMu sync.Mutex } // Target 是调试端点上的一个 tab(page)。 type Target struct { ID string `json:"id"` Title string `json:"title"` URL string `json:"url"` Type string `json:"type"` WebSocketDebuggerURL string `json:"webSocketDebuggerURL"` } func (b *Browser) baseURL(ctx context.Context) (string, error) { if b.DebugBase != "" { return strings.TrimSuffix(b.DebugBase, "/"), nil } b.mu.Lock() defer b.mu.Unlock() if b.base != "" { return b.base, nil } bin, err := findBrowser(b.Bin) if err != nil { return "", err } port, err := freePort() if err != nil { return "", err } dir := b.ProfileDir if dir == "" { // 默认用**持久**目录,不用临时目录:Chrome 的登录态与扩展都在 user-data-dir 里, // 临时目录等于每次启动都像一台从没登录过的新机器(登录一次也留不住),而且旧的 // 临时目录从来没人清理,会在 /tmp 里越堆越多。 dir, err = PersistentProfileDir() if err != nil { return "", err } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } } // profile 体检结论不在这里直接打印(TUI 的 alt-screen 打不得),而是挂到启动 // 失败的 error 上——那正是用户需要看到它们的时候(见下面 waitDebugReady 分支)。 profileWarns := CheckProfile(dir) args := []string{ fmt.Sprintf("--remote-debugging-port=%d", port), "--user-data-dir=" + dir, "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--window-size=1366,900", "about:blank", } if b.Headless { args = append([]string{"--headless=new", "--hide-scrollbars"}, args...) } cmd := exec.Command(bin, args...) cmd.Stdout, cmd.Stderr = io.Discard, io.Discard if err := cmd.Start(); err != nil { return "", fmt.Errorf("launch %s: %w", bin, err) } base := fmt.Sprintf("http://127.0.0.1:%d", port) if err := waitDebugReady(ctx, base, 10*time.Second); err != nil { _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() // 起不来时把 profile 和体检结论一起给出:绝大多数"浏览器连不上"都是 profile // 相关(被运行中的 Chrome 占着 / 指向了默认目录 / 指向了 Default 子目录)。 msg := fmt.Sprintf("浏览器调试端口没起来(%s,profile=%s):%v", bin, dir, err) for _, w := range profileWarns { msg += "\n - " + w } if len(profileWarns) == 0 { msg += "\n - profile 看起来正常;可以先手动跑一下该浏览器确认能启动,或用 browser.debug_base 连接一个已开的调试端口" } return "", errors.New(msg) } b.cmd, b.base = cmd, base return base, nil } // waitDebugReady 轮询 /json/version 直到调试端点就绪。 func waitDebugReady(ctx context.Context, base string, timeout time.Duration) error { deadline := time.Now().Add(timeout) client := &http.Client{Timeout: 2 * time.Second} for time.Now().Before(deadline) { req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/version", nil) if req != nil { if resp, err := client.Do(req); err == nil { resp.Body.Close() return nil } } select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): } } return fmt.Errorf("debug endpoint %s not ready within %s", base, timeout) } func freePort() (int, error) { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return 0, err } port := l.Addr().(*net.TCPAddr).Port l.Close() return port, nil } // findBrowser 按 GOOS 定位浏览器可执行文件(Chrome / Chromium / Edge 均支持 CDP)。 func findBrowser(explicit string) (string, error) { if explicit != "" { if _, err := os.Stat(explicit); err == nil { return explicit, nil } if _, err := exec.LookPath(explicit); err == nil { return explicit, nil } return "", fmt.Errorf("AUTOGO_BROWSER_BIN %q not found", explicit) } var candidates []string switch runtime.GOOS { case "darwin": candidates = []string{ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", } case "windows": pf := os.Getenv("ProgramFiles") pf86 := os.Getenv("ProgramFiles(x86)") lap := os.Getenv("LOCALAPPDATA") // ARM64 Windows 可能安装在 Program Files (Arm) 或 x86 目录 for _, root := range []string{pf, pf86, lap} { if root == "" { continue } candidates = append(candidates, filepath.Join(root, `Google\Chrome\Application\chrome.exe`), filepath.Join(root, `Microsoft\Edge\Application\msedge.exe`), ) } // Chrome SxS (Canary) 和 Edge Beta 也常见于 ARM64 if lap != "" { candidates = append(candidates, filepath.Join(lap, `Google\Chrome SxS\Application\chrome.exe`), ) } default: // linux 等 unix // 标准 PATH 查找 for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable"} { if p, err := exec.LookPath(name); err == nil { return p, nil } } // Flatpak 路径 flatpakPaths := []string{ "/var/lib/flatpak/exports/bin/com.google.Chrome", "/var/lib/flatpak/exports/bin/org.chromium.Chromium", "/home/*/.local/share/flatpak/exports/bin/com.google.Chrome", "/home/*/.local/share/flatpak/exports/bin/org.chromium.Chromium", } for _, p := range flatpakPaths { if strings.Contains(p, "*") { // 展开 home 目录通配符 home, _ := os.UserHomeDir() if home != "" { p = strings.Replace(p, "/home/*", home, 1) } } if _, err := os.Stat(p); err == nil { return p, nil } } // Snap 路径 snapPaths := []string{ "/snap/chromium/current/usr/bin/chromium", "/snap/google-chrome/current/usr/bin/google-chrome", } for _, p := range snapPaths { if _, err := os.Stat(p); err == nil { return p, nil } } } for _, p := range candidates { if _, err := os.Stat(p); err == nil { return p, nil } } for _, name := range []string{"google-chrome", "chromium", "chrome"} { if p, err := exec.LookPath(name); err == nil { return p, nil } } return "", fmt.Errorf("no Chrome/Chromium/Edge found (set AUTOGO_BROWSER_BIN; candidates tried: %v)", candidates) } // ListTargets 列出调试端点上的全部 target。 func (b *Browser) ListTargets(ctx context.Context) ([]Target, error) { base, err := b.baseURL(ctx) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/list", nil) if err != nil { return nil, err } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var targets []Target if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil { return nil, err } return targets, nil } // NewTab 新开一个 page 并导航到 url。 func (b *Browser) NewTab(ctx context.Context, url string) (*Target, error) { base, err := b.baseURL(ctx) if err != nil { return nil, err } client := &http.Client{Timeout: 10 * time.Second} do := func(method string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, base+"/json/new?url="+neturl.QueryEscape(url), nil) if err != nil { return nil, err } return client.Do(req) } resp, err := do(http.MethodPut) if err == nil && resp.StatusCode == http.StatusMethodNotAllowed { resp.Body.Close() resp, err = do(http.MethodGet) // 老版本 Chrome 只支持 GET /json/new } if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("/json/new: HTTP %d", resp.StatusCode) } var t Target if err := json.NewDecoder(resp.Body).Decode(&t); err != nil { return nil, err } return &t, nil } // Close 终止托管的 Chrome 进程(DebugBase 注入模式为空操作)。 func (b *Browser) Close() { b.mu.Lock() cmd := b.cmd b.cmd, b.base = nil, "" b.mu.Unlock() if cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() } }