// cdp 共享浏览器基础设施:独立档案启动(每账号一档案)、代理、指纹、扫码登录。 // 一期驱动 rod-launcher 管理的 Chromium;ChromePath 给定则驱动系统 Chrome/Edge。 package cdp import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "runtime" "strings" "time" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" rodstealth "github.com/go-rod/stealth" ) // Options 浏览器启动选项 type Options struct { UserDataDir string // 每账号独立档案目录 Proxy string // 账号固定 IP 代理(http://ip:port) Headless bool ChromePath string // 系统 Chrome/Edge 可执行文件(空=自动探测/下载) } // Browser 浏览器封装 type Browser struct { rod *rod.Browser Page *rod.Page } // defaultChromePath 常见系统浏览器路径(Windows 优先,macOS 兜底) func defaultChromePath() string { candidates := []string{ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", } if runtime.GOOS == "darwin" { candidates = append(candidates, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") } for _, p := range candidates { if _, err := os.Stat(p); err == nil { return p } } return "" } // Launch 启动浏览器(独立档案 + 指纹 + 反自动化) func Launch(ctx context.Context, opts Options) (*Browser, error) { l := launcher.New() if opts.UserDataDir != "" { if err := os.MkdirAll(opts.UserDataDir, 0o700); err != nil { return nil, err } l = l.UserDataDir(opts.UserDataDir) } if opts.Proxy != "" { l = l.Set("proxy-server", opts.Proxy) } bin := opts.ChromePath if bin == "" { bin = defaultChromePath() } if bin != "" { l = l.Bin(bin) } l = l.Headless(opts.Headless). Set("no-first-run"). Set("no-default-browser-check"). Set("disable-blink-features", "AutomationControlled"). Set("window-size", "1440,900") url, err := l.Launch() if err != nil { return nil, err } browser := rod.New().ControlURL(url) if err = browser.Connect(); err != nil { return nil, err } // 指纹/反自动化(stealth 注入) page, err := rodstealth.Page(browser) if err != nil { browser.Close() return nil, err } return &Browser{rod: browser, Page: page}, nil } // Close 关闭浏览器 func (b *Browser) Close() { _ = b.rod.Close() } // Screenshot 截图回执(PNG 本地保存,返回路径;二期上传服务器) func (b *Browser) Screenshot(receiptDir, name string) string { if err := os.MkdirAll(receiptDir, 0o755); err != nil { return "" } path := filepath.Join(receiptDir, name) _ = b.Page.MustWaitLoad().MustScreenshot(path) return path } // InjectCookies 注入登录 cookie(免重复扫码) func (b *Browser) InjectCookies(cookieStr, domain string) error { for _, part := range strings.Split(cookieStr, ";") { part = strings.TrimSpace(part) if part == "" { continue } i := strings.Index(part, "=") if i <= 0 { continue } name, value := part[:i], part[i+1:] cookie := &proto.NetworkCookieParam{ Name: name, Value: value, Domain: domain, Path: "/", } _ = b.rod.SetCookies([]*proto.NetworkCookieParam{cookie}) } return nil } // Cookies 导出当前浏览器 cookie(域名过滤) func (b *Browser) Cookies(domain string) string { cookies, err := b.rod.GetCookies() if err != nil { return "" } parts := make([]string, 0, len(cookies)) for _, c := range cookies { if domain != "" && !strings.Contains(c.Domain, domain) { continue } parts = append(parts, c.Name+"="+c.Value) } return strings.Join(parts, "; ") } // QRLoginConfig 扫码登录配置 type QRLoginConfig struct { LoginURL string LoginPathMarker string // 登录页 URL 特征(登录成功=URL 离开该特征) QRSelector string // 二维码元素选择器 SuccessWait time.Duration } // QRLogin 通用扫码登录:打开登录页 → 截图二维码经 onQR 展示 → 轮询 URL 直到登录成功 func (b *Browser) QRLogin(ctx context.Context, cfg QRLoginConfig, onQR func(qrToken, qrURL string)) (string, error) { if err := b.Page.Navigate(cfg.LoginURL); err != nil { return "", err } _ = b.Page.WaitLoad() // 截图二维码 → 临时 PNG → file:// URI → onQR(壳端 BitmapImage 展示) qrEl, err := b.Page.Element(cfg.QRSelector) if err == nil { raw, serr := qrEl.Screenshot(proto.PageCaptureScreenshotFormatPng, 100) if serr == nil && len(raw) > 0 { qrPath := filepath.Join(os.TempDir(), "ep-qr-"+time.Now().Format("150405")+".png") if werr := os.WriteFile(qrPath, raw, 0o600); werr == nil && onQR != nil { onQR("", "file://"+qrPath) } } } // 轮询登录状态 deadline := time.Now().Add(cfg.SuccessWait) for time.Now().Before(deadline) { select { case <-ctx.Done(): return "", fmt.Errorf("cancelled") default: } info, ierr := b.Page.Info() if ierr == nil && !strings.Contains(info.URL, cfg.LoginPathMarker) { return b.Cookies(""), nil } time.Sleep(2 * time.Second) } return "", fmt.Errorf("扫码登录超时") } // DownloadFile 下载素材到临时文件(返回路径与清理函数) func DownloadFile(ctx context.Context, rawURL string) (string, func(), error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return "", nil, err } client := &http.Client{Timeout: 10 * time.Minute} resp, err := client.Do(req) if err != nil { return "", nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", nil, fmt.Errorf("下载 http %d", resp.StatusCode) } tmp, err := os.CreateTemp("", "ep-material-*") if err != nil { return "", nil, err } _, err = io.Copy(tmp, resp.Body) tmp.Close() if err != nil { os.Remove(tmp.Name()) return "", nil, err } return tmp.Name(), func() { _ = os.Remove(tmp.Name()) }, nil } // EscapeJSON 转义字符串供嵌入 JSON func EscapeJSON(s string) string { raw, _ := json.Marshal(s) return string(raw[1 : len(raw)-1]) } // Log 调试输出 func Log(format string, args ...interface{}) { log.Printf("[cdp] "+format, args...) }