client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较
This commit is contained in:
@@ -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...)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEscapeJSON(t *testing.T) {
|
||||
s := EscapeJSON("SESSDATA=ab\"c; bili_jct=d")
|
||||
// 转义后应可嵌入 JSON 且还原一致
|
||||
var decoded string
|
||||
if err := json.Unmarshal([]byte(`"`+s+`"`), &decoded); err != nil {
|
||||
t.Fatalf("escaped string not valid json: %v", err)
|
||||
}
|
||||
if decoded != "SESSDATA=ab\"c; bili_jct=d" {
|
||||
t.Fatalf("roundtrip mismatch: %q", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("video-bytes-123"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
path, cleanup, err := DownloadFile(context.Background(), srv.URL+"/v.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || string(raw) != "video-bytes-123" {
|
||||
t.Fatalf("content mismatch: %s %v", raw, err)
|
||||
}
|
||||
cleanup()
|
||||
if _, err = os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("cleanup should remove temp file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusGone)
|
||||
_, _ = io.WriteString(w, "gone")
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, _, err := DownloadFile(context.Background(), srv.URL); err == nil {
|
||||
t.Fatal("410 should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultChromePath(t *testing.T) {
|
||||
// 至少不 panic、返回字符串
|
||||
p := defaultChromePath()
|
||||
if strings.TrimSpace(p) == "" && p != "" {
|
||||
t.Fatal("unexpected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Publish 通用发布流程:进上传页 → 注入 cookie → 传视频 → 填表单 → 点发布 → 等成功跳转 → 截图回执。
|
||||
// 各平台选择器差异经 PublishConfig 配置;平台改版时只需调整配置。
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
// PublishConfig 发布流程配置(选择器按平台填写)
|
||||
type PublishConfig struct {
|
||||
Platform string
|
||||
UploadURL string
|
||||
SuccessMarker string // 发布成功后的 URL 特征
|
||||
FileInputSel string
|
||||
TitleSel string
|
||||
DescSel string
|
||||
PublishBtnSel string
|
||||
FormWait time.Duration // 等视频处理出表单的最长时间
|
||||
SuccessWait time.Duration
|
||||
ReceiptDir string
|
||||
}
|
||||
|
||||
// PublishResult 发布结果
|
||||
type PublishResult struct {
|
||||
URL string
|
||||
Receipts []string
|
||||
}
|
||||
|
||||
// Publish 执行通用发布流程
|
||||
func (b *Browser) Publish(ctx context.Context, cfg PublishConfig, videoPath, title, content string, tags []string, cookieStr, cookieDomain string) (*PublishResult, error) {
|
||||
if err := b.Page.Navigate(cfg.UploadURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = b.Page.WaitLoad()
|
||||
// 注入登录 cookie(有则免扫码)
|
||||
if cookieStr != "" {
|
||||
_ = b.InjectCookies(cookieStr, cookieDomain)
|
||||
if err := b.Page.Navigate(cfg.UploadURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = b.Page.WaitLoad()
|
||||
}
|
||||
|
||||
// 1) 传视频
|
||||
fileInput, err := b.Page.Element(cfg.FileInputSel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("找不到上传入口(%s): %w", cfg.FileInputSel, err)
|
||||
}
|
||||
if err = fileInput.SetFiles([]string{videoPath}); err != nil {
|
||||
return nil, fmt.Errorf("选择文件失败: %w", err)
|
||||
}
|
||||
Log("%s: 视频已选择,等待平台处理...", cfg.Platform)
|
||||
|
||||
// 2) 等表单渲染(视频转码后出现标题输入框)
|
||||
titleEl, err := b.Page.ElementR(cfg.TitleSel, "")
|
||||
if err != nil {
|
||||
titleEl, err = b.waitElement(ctx, cfg.TitleSel, cfg.FormWait)
|
||||
}
|
||||
if err != nil {
|
||||
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-form-fail.png")
|
||||
return nil, fmt.Errorf("等表单渲染超时(%s): %w", cfg.TitleSel, err)
|
||||
}
|
||||
|
||||
// 3) 填标题/描述(话题并入描述)
|
||||
if err = titleEl.Input(title); err != nil {
|
||||
return nil, fmt.Errorf("填标题失败: %w", err)
|
||||
}
|
||||
desc := content
|
||||
if len(tags) > 0 {
|
||||
for _, t := range tags {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" && !strings.HasPrefix(t, "#") {
|
||||
t = "#" + t
|
||||
}
|
||||
desc += " " + t
|
||||
}
|
||||
}
|
||||
if descEl, derr := b.Page.Element(cfg.DescSel); derr == nil {
|
||||
_ = descEl.Input(desc)
|
||||
}
|
||||
|
||||
// 4) 点发布
|
||||
btn, err := b.waitElement(ctx, cfg.PublishBtnSel, 15*time.Second)
|
||||
if err != nil {
|
||||
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-btn-fail.png")
|
||||
return nil, fmt.Errorf("找不到发布按钮(%s): %w", cfg.PublishBtnSel, err)
|
||||
}
|
||||
if err = btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return nil, fmt.Errorf("点发布失败: %w", err)
|
||||
}
|
||||
Log("%s: 已点发布,等待结果...", cfg.Platform)
|
||||
|
||||
// 5) 等成功跳转
|
||||
if err = b.waitURL(ctx, cfg.SuccessMarker, cfg.SuccessWait); err != nil {
|
||||
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-publish-fail.png")
|
||||
return nil, err
|
||||
}
|
||||
info, _ := b.Page.Info()
|
||||
receipt := b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-success.png")
|
||||
Log("%s: 发布成功 %s", cfg.Platform, info.URL)
|
||||
return &PublishResult{URL: info.URL, Receipts: []string{receipt}}, nil
|
||||
}
|
||||
|
||||
// waitElement 轮询等待元素出现
|
||||
func (b *Browser) waitElement(ctx context.Context, selector string, timeout time.Duration) (*rod.Element, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("cancelled")
|
||||
default:
|
||||
}
|
||||
el, err := b.Page.Element(selector)
|
||||
if err == nil {
|
||||
return el, nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return nil, fmt.Errorf("timeout waiting %s", selector)
|
||||
}
|
||||
|
||||
// waitURL 轮询等待 URL 命中特征
|
||||
func (b *Browser) waitURL(ctx context.Context, marker string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("cancelled")
|
||||
default:
|
||||
}
|
||||
info, err := b.Page.Info()
|
||||
if err == nil && strings.Contains(info.URL, marker) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting url %s", marker)
|
||||
}
|
||||
Reference in New Issue
Block a user