package publisher import ( "context" "encoding/json" "fmt" "geo/internal/entitys" "geo/pkg" "geo/utils/utils_oss" "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 ossClient *utils_oss.Client } // 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 { 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) } base := &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, PlatInfo: task.PublishData, config: config, LoginURL: task.PublishData.LoginUrl, EditorURL: task.PublishData.EditUrl, LoginedURL: task.PublishData.LoginedUrl, MaxRetries: 3, RetryDelay: 200, } base.CookiesFile = filepath.Join(base.cookiesDir(), task.PlatIndex+".json") return base } func (b *BasePublisher) cookiesDir() string { dir := filepath.Join(b.config.Sys.CookiesDir, b.UserIndex) os.MkdirAll(dir, 0755) return dir } 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 } os.WriteFile(b.CookiesFile, data, 0644) if err != nil { return err } return b.uploadToOss(data) } func (b *BasePublisher) LoadCookies() error { var data []byte if _, err := os.Stat(b.CookiesFile); os.IsNotExist(err) { _err := b.getCookieFromUrl() if _err != nil { return _err } } 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) 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 } // RenameDocument 将指定绝对路径的文档更名为新名称(不含后缀) // filePath: 文档的绝对路径 // newName: 希望的新文件名(不含后缀,例如 "newfile") // 返回值: 新文件的绝对路径, 错误信息 func (b *BasePublisher) RenameDocument() error { // 检查原文件是否存在 if _, err := os.Stat(b.SourcePath); os.IsNotExist(err) { return fmt.Errorf("文件不存在: %s", b.SourcePath) } // 获取原文件的扩展名 ext := filepath.Ext(b.SourcePath) // 如果新文件名已经包含扩展名,则不再添加 var fullNewName string if strings.HasSuffix(b.Title, ext) { fullNewName = b.Title } else { fullNewName = b.Title + ext } // 获取原文件所在目录 dir := filepath.Dir(b.Title) // 构造新路径 newPath := filepath.Join(dir, fullNewName) // 如果目标文件已存在,先删除它(实现覆盖) if _, err := os.Stat(newPath); err == nil { err := os.Remove(newPath) if err != nil { return fmt.Errorf("删除已存在的目标文件失败: %v", err) } } // 执行重命名(移动) err := os.Rename(b.SourcePath, newPath) if err != nil { return fmt.Errorf("重命名失败: %v", err) } // 返回更名后的绝对路径 absPath, err := filepath.Abs(newPath) if err != nil { return fmt.Errorf("获取绝对路径失败: %v", err) } b.SourcePath = absPath return nil } func (b *BasePublisher) uploadToOss(data []byte) (err error) { if b.ossClient == nil { b.ossClient, err = utils_oss.NewClient(b.config) if err != nil { return } } _, err = b.ossClient.UploadBytes(fmt.Sprintf("%scookie/%s/%s.json", b.config.Oss.FilePath, b.UserIndex, b.PlatIndex), data) return } func (b *BasePublisher) getCookieFromUrl() (err error) { b.LogInfo("正在加载cookie。。。。") url := fmt.Sprintf("%s/%scookie/%s/%s.json", b.config.Oss.Domain, b.config.Oss.FilePath, b.UserIndex, b.PlatIndex) cookieDir := b.cookiesDir() _, err = pkg.DownloadFile(url, cookieDir, b.PlatIndex+".json") if err != nil { return err } b.LogInfo("cookie加载完成。。。。") return nil }