geoGo/internal/publisher/base.go

437 lines
11 KiB
Go

package publisher
import (
"context"
"encoding/json"
"fmt"
"geo/internal/entitys"
"log"
"os"
"path/filepath"
"strings"
"time"
"geo/internal/config"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
type BasePublisher struct {
ctx context.Context
Headless bool
Title string
Content string
Tags []string
UserIndex string
PlatIndex string
RequestID string
ImagePath string
SourcePath string
Browser *rod.Browser
Page *rod.Page
Logger *log.Logger
LogFile *os.File
LoginURL string
EditorURL string
LoginedURL string
CookiesFile string
PlatInfo *entitys.PublishTaskDetail
config *config.Config
MaxRetries int
RetryDelay int
}
// taskParams 任务参数结构体
type TaskParams struct {
Headless bool
Title string
TagRaw string
UserIndex string
PlatIndex string
RequestID string
ImagePath string
SourcePath string
Content string
Tags []string
PublishData *entitys.PublishTaskDetail
}
// NewBasePublisher 构造函数,增加 logger 参数
func NewBasePublisher(ctx context.Context, task *TaskParams, config *config.Config, logger *log.Logger) *BasePublisher {
cookiesDir := filepath.Join(config.Sys.CookiesDir, task.UserIndex)
os.MkdirAll(cookiesDir, 0755)
cookiesFile := filepath.Join(cookiesDir, task.PlatIndex+".json")
var baseLogger *log.Logger
var logFile *os.File
if logger != nil {
// 使用传入的logger
baseLogger = logger
logFile = nil
} else {
// 兼容旧逻辑
logsDir := config.Sys.LogsDir
if logsDir == "" {
logsDir = "./logs"
}
os.MkdirAll(logsDir, 0755)
logFile, _ = os.Create(filepath.Join(logsDir, task.RequestID+".log"))
baseLogger = log.New(logFile, "", log.LstdFlags)
}
return &BasePublisher{
ctx: ctx,
Headless: task.Headless,
Title: task.Title,
Content: task.Content,
Tags: task.Tags,
UserIndex: task.UserIndex,
PlatIndex: task.PlatIndex,
RequestID: task.RequestID,
ImagePath: task.ImagePath,
SourcePath: task.SourcePath,
Logger: baseLogger,
LogFile: logFile,
CookiesFile: cookiesFile,
PlatInfo: task.PublishData,
config: config,
LoginURL: task.PublishData.LoginUrl,
EditorURL: task.PublishData.EditUrl,
LoginedURL: task.PublishData.LoginedUrl,
MaxRetries: 3,
RetryDelay: 200,
}
}
func (b *BasePublisher) SetupDriver() error {
b.LogInfo("初始化浏览器。。。。")
userDataDir := filepath.Join(b.config.Sys.ChromeDataDir, b.UserIndex, b.RequestID+fmt.Sprintf("___%d", time.Now().UnixNano()))
//userDataDir := fmt.Sprintf("./chrome_data/user_%d_", time.Now().UnixNano())
os.MkdirAll(userDataDir, 0755)
l := launcher.New().
Bin(b.config.Sys.ChromePath).
UserDataDir(userDataDir).
Headless(b.Headless).
Leakless(false).Set("disable-blink-features", "AutomationControlled")
if b.Headless {
// 无头模式专用参数
l.Set("headless", "new") // 使用新版无头模式
l.Set("disable-gpu")
l.Set("no-sandbox")
l.Set("disable-dev-shm-usage")
} else {
l.Set("window-size", "1920,1080")
l.Set("start-maximized")
// 移除 headless 相关参数
l.Delete("headless")
}
// 设置用户数据目录
l.UserDataDir(userDataDir)
// 关键优化:不重新使用已有的数据目录时不要清除
//l.Set("profile-directory", "Default")
// 设置 Chrome 启动参数
//l.Set("no-sandbox")
//l.Set("disable-dev-shm-usage")
// 关键:禁用后台限制,让页面在后台也能正常执行
//l.Set("disable-background-timer-throttling")
//l.Set("disable-backgrounding-occluded-windows")
//l.Set("disable-renderer-backgrounding")
//l.Set("disable-ipc-flooding-protection")
// 窗口大小
l.Set("window-size", "1920,1080")
l.Set("lang", "zh-CN")
l.Set("force-device-scale-factor", "1")
url, err := l.Launch()
if err != nil {
return fmt.Errorf("启动浏览器失败: %v", err)
}
b.Browser = rod.New().Context(b.ctx).ControlURL(url).MustConnect()
b.Page = b.Browser.MustPage()
return nil
}
func (b *BasePublisher) Close() {
if b.Page != nil {
b.Page.Close()
}
if b.Browser != nil {
b.Browser.Close()
}
if b.LogFile != nil {
b.LogFile.Close()
}
}
func (b *BasePublisher) SaveCookies() error {
cookies, err := b.Page.Cookies(nil)
if err != nil {
return err
}
data, err := json.Marshal(cookies)
if err != nil {
return err
}
return os.WriteFile(b.CookiesFile, data, 0644)
}
func (b *BasePublisher) LoadCookies() error {
data, err := os.ReadFile(b.CookiesFile)
if err != nil {
return err
}
var cookies []*proto.NetworkCookieParam
if err := json.Unmarshal(data, &cookies); err != nil {
return err
}
return b.Page.SetCookies(cookies)
}
func (b *BasePublisher) DelCookies() error {
err := os.Remove(b.CookiesFile)
if err != nil {
return err
}
return nil
}
func (b *BasePublisher) RefreshPage() error {
_, err := b.Page.Eval(`() => location.reload()`)
return err
}
func (b *BasePublisher) WaitForPageReady(timeout int) error {
return b.Page.Context(b.ctx).WaitLoad()
}
func (b *BasePublisher) WaitForElement(selector string, timeout int) (*rod.Element, error) {
return b.Page.Context(b.ctx).Element(selector)
}
func (b *BasePublisher) WaitForElementVisible(selector string, timeout int) (*rod.Element, error) {
el, err := b.WaitForElement(selector, timeout)
if err != nil {
return nil, err
}
if err := el.WaitVisible(); err != nil {
return nil, err
}
return el, nil
}
func (b *BasePublisher) WaitForElementClickable(selector string, timeout int) (*rod.Element, error) {
el, err := b.WaitForElementVisible(selector, timeout)
if err != nil {
return nil, err
}
if err := el.WaitVisible(); err != nil {
return nil, err
}
return el, nil
}
func (b *BasePublisher) JSClick(element *rod.Element) error {
if element == nil {
b.Logger.Printf("element is nil")
return fmt.Errorf("element is nil")
}
err := element.Click(proto.InputMouseButtonLeft, 1)
if err != nil {
b.Logger.Printf("click fail:" + err.Error())
}
return err
}
func (b *BasePublisher) JSClick2(element *rod.Element) error {
if element == nil {
b.Logger.Printf("element is nil")
return fmt.Errorf("element is nil")
}
err := element.Click(proto.InputMouseButtonLeft, 1)
if err != nil {
b.Logger.Printf("click fail:" + err.Error())
}
return err
}
func (b *BasePublisher) ScrollToElement(element *rod.Element) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `el => el.scrollIntoView({block: 'center', behavior: 'smooth'})`,
})
return err
}
func (b *BasePublisher) Sleep(seconds int) {
time.Sleep(time.Duration(seconds) * time.Second)
}
func (b *BasePublisher) LogStep(stepName string, success bool, message string) {
if success {
b.Logger.Printf("✅ %s: 成功 %s", stepName, message)
} else {
b.Logger.Printf("❌ %s: 失败 %s", stepName, message)
}
}
func (b *BasePublisher) LogInfo(message string) {
b.Logger.Printf("📌 %s", message)
}
func (b *BasePublisher) LogInfof(message string, arg ...any) {
b.Logger.Printf("📌 "+message, arg)
}
func (b *BasePublisher) LogError(message string) {
b.Logger.Printf("❌ %s", message)
}
func (b *BasePublisher) GetCurrentURL() string {
info := b.Page.MustInfo()
return info.URL
}
func (b *BasePublisher) Screenshot(filename string) error {
data, err := b.Page.Screenshot(false, nil)
if err != nil {
return err
}
return os.WriteFile(filename, data, 0644)
}
// 抽象方法 - 子类需要实现
func (b *BasePublisher) WaitLogin() (bool, string) {
return false, "需要实现"
}
func (p *BasePublisher) CheckLoginStatus() bool {
currentURL := p.GetCurrentURL()
if strings.Contains(currentURL, p.LoginURL) {
return false
}
return true
}
func (b *BasePublisher) CheckLogin() (bool, string) {
return false, "需要实现"
}
func (b *BasePublisher) PublishNote() (bool, string) {
return false, "需要实现"
}
// ClearContentEditable 清空 contenteditable 元素的内容
func (b *BasePublisher) ClearContentEditable(element *rod.Element) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `el => { el.innerText = ''; el.innerHTML = ''; el.dispatchEvent(new Event('input', {bubbles: true})); }`,
})
return err
}
// SetContentEditable 设置 contenteditable 元素的内容
func (b *BasePublisher) SetContentEditable(element *rod.Element, content string) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `(el, val) => { el.innerText = val; el.dispatchEvent(new Event('input', {bubbles: true})); }`,
JSArgs: []interface{}{content},
})
return err
}
// SetInputValue 设置输入框值并触发事件
func (b *BasePublisher) SetInputValue(element *rod.Element, value string) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `(el, val) => { el.value = val; el.dispatchEvent(new Event('input', {bubbles: true})); el.dispatchEvent(new Event('change', {bubbles: true})); }`,
JSArgs: []interface{}{value},
})
return err
}
// ClearInput 清空输入框
func (b *BasePublisher) ClearInput(element *rod.Element) error {
_, err := element.Evaluate(&rod.EvalOptions{
JS: `el => { el.value = ''; el.dispatchEvent(new Event('input', {bubbles: true})); }`,
})
return err
}
// SleepMs 毫秒级等待
func (b *BasePublisher) SleepMs(milliseconds int) {
time.Sleep(time.Duration(milliseconds) * time.Millisecond)
}
// StartNote 开始任务日志
func (b *BasePublisher) StartNote() {
b.LogInfo(strings.Repeat("=", 50))
b.LogInfo(fmt.Sprintf("开始执行:%s", b.PlatIndex))
b.LogInfo(fmt.Sprintf("标题: %s", b.Title))
b.LogInfo(fmt.Sprintf("标签: %v", b.Tags))
b.LogInfo(strings.Repeat("=", 50))
}
// InitPage 初始化页面
func (p *BasePublisher) InitPage() error {
// 尝试加载cookies并检查登录状态
if err := p.LoadCookies(); err == nil {
p.Page.MustNavigate(p.EditorURL)
p.WaitForPageReady(5)
p.Sleep(2)
}
// 统一检查登录状态
if !p.CheckLoginStatus() {
p.LogInfo("未登录或登录已过期,需要重新登录")
return fmt.Errorf("需要登录")
}
p.SaveCookies()
return nil
}
// SafeElement 安全地获取元素,如果不存在立即返回 nil 而不阻塞
func (b *BasePublisher) SafeElement(selector string) (*rod.Element, error) {
// 先检查是否存在
exists, _, err := b.Page.Has(selector)
if err != nil {
return nil, err
}
if !exists {
return nil, nil
}
// 存在再获取,不会阻塞
return b.Page.Element(selector)
}
// SafeElementInParent 在父元素中安全查找
func (b *BasePublisher) SafeElementInParent(parent *rod.Element, selector string) (*rod.Element, error) {
// 使用 Elements 获取所有子元素(非阻塞)
children, err := parent.Elements(selector)
if err != nil {
return nil, err
}
if len(children) == 0 {
return nil, nil
}
return children[0], nil
}