client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较

This commit is contained in:
Qiufeng
2026-08-20 20:38:21 +08:00
parent 33b7d57498
commit 9abf7f8213
60 changed files with 10025 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
// 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...)
}