Files
EveryPublish/client/core/internal/exec/cdp/cdp.go
T

232 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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...)
}