package publisher import ( "context" "fmt" "geo/internal/config" "log" "strings" "time" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/proto" ) type ToutiaoPublisher struct { *BasePublisher } // NewToutiaoPublisher 构造函数 func NewToutiaoPublisher(ctx context.Context, task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { return &ToutiaoPublisher{NewBasePublisher(ctx, task, cfg, logger)} } func (p *ToutiaoPublisher) CheckLoginStatus() bool { currentURL := p.GetCurrentURL() // 如果在登录页面,未登录 if strings.Contains(currentURL, p.LoginURL) { return false } return true } func (p *ToutiaoPublisher) WaitLogin() (bool, string) { p.LogInfo("开始等待登录...") if err := p.SetupDriver(); err != nil { return false, fmt.Sprintf("浏览器启动失败: %v", err) } defer p.Close() // 先尝试访问已登录页面 p.Page.MustNavigate(p.LoginedURL) p.Sleep(3) if p.CheckLoginStatus() { p.SaveCookies() p.LogInfo("已有登录状态") return true, "already_logged_in" } // 未登录,跳转到登录页 p.Page.MustNavigate(p.LoginURL) p.WaitForPageReady(5) p.LogInfo("请扫码登录...") // 等待登录完成,最多120秒 for i := 0; i < 120; i++ { currentURL := p.GetCurrentURL() if strings.Contains(currentURL, p.LoginedURL) { p.SaveCookies() p.LogInfo("登录成功") return true, "login_success" } time.Sleep(1 * time.Second) } return false, "登录超时,请检查网络或账号状态" } // closeCloseBtn 关闭页面上的关闭按钮(class="close-btn"的svg) func (p *ToutiaoPublisher) closeCloseBtn() { p.LogInfo("检查并关闭页面上的关闭按钮...") // 查找所有 class="close-btn" 的元素 closeBtns, err := p.Page.Elements(".close-btn") if err != nil { p.LogInfo("查找关闭按钮失败或无关闭按钮") return } if len(closeBtns) == 0 { p.LogInfo("未找到关闭按钮") return } p.LogInfo(fmt.Sprintf("找到 %d 个关闭按钮,尝试点击...", len(closeBtns))) for _, btn := range closeBtns { if btn == nil { continue } // 检查元素是否可见 visible, err := btn.Visible() if err != nil { continue } if visible { p.LogInfo("点击关闭按钮...") // 使用 JavaScript 强制点击 if err := p.JSClick(btn); err != nil { p.LogInfo(fmt.Sprintf("点击关闭按钮失败: %v", err)) } else { p.LogInfo("成功点击关闭按钮") p.SleepMs(500) // 等待弹窗关闭动画 } } } } // inputTitle 输入标题 func (p *ToutiaoPublisher) inputTitle() error { p.LogInfo("输入文章标题...") // 尝试多种选择器查找标题输入框 titleSelectors := []string{ ".publish-editor-title textarea", "#txtTitle", ".title-input textarea", "textarea[placeholder*='标题']", } var titleInput *rod.Element var err error for _, selector := range titleSelectors { titleInput, err = p.WaitForElementVisible(selector, 5) if err == nil && titleInput != nil { p.LogInfo(fmt.Sprintf("找到标题输入框: %s", selector)) break } } if titleInput == nil { return fmt.Errorf("未找到标题输入框") } // 点击获取焦点 if err := titleInput.Click(proto.InputMouseButtonLeft, 1); err != nil { return fmt.Errorf("点击标题框失败: %v", err) } p.SleepMs(300) // 清空输入框 if err := p.ClearInput(titleInput); err != nil { titleInput.Input("") } p.SleepMs(300) // 输入标题 if err := p.SetInputValue(titleInput, p.Title); err != nil { titleInput.Input(p.Title) } p.LogInfo(fmt.Sprintf("标题已输入: %s", p.Title)) p.SleepMs(500) return nil } // inputContent 通过导入文件输入内容 func (p *ToutiaoPublisher) inputContent() error { p.LogInfo("开始导入文章内容...") // 查找所有 class="close-btn" 的元素 p.closeCloseBtn() p.SleepMs(500) // 1. 找到并点击导入按钮(class="syl-toolbar-button") p.LogInfo("查找导入按钮...") p.LogInfo("查找导入按钮...") // 先找 class 包含 doc-import 的 div docImportDiv, err := p.WaitForElementVisible("[class*='doc-import']", 10) if err != nil { return fmt.Errorf("未找到包含doc-import的元素: %v", err) } p.LogInfo("找到包含doc-import的元素,开始查找其中的button...") // 在该 div 下查找 button importBtn, err := docImportDiv.Element("button") if err != nil { // 尝试查找 button 的多种可能选择器 importBtn, err = docImportDiv.Element("button:first-child") if err != nil { importBtn, err = docImportDiv.Element(".syl-toolbar-button") if err != nil { return fmt.Errorf("在doc-import元素下未找到按钮: %v", err) } } } if importBtn == nil { return fmt.Errorf("未找到导入按钮") } p.LogInfo("找到导入按钮,准备点击...") // 点击导入按钮 if err := p.JSClick(importBtn); err != nil { return fmt.Errorf("点击导入按钮失败: %v", err) } p.LogInfo("已点击导入按钮") p.SleepMs(1000) // 2. 找到文件上传输入框并上传文件 p.LogInfo("查找文件上传输入框...") // 文件上传输入框的选择器 fileInputSelectors := []string{ "input[type='file'][accept*='.doc']", "input[type='file'][accept*='application']", "input[type='file']", } var fileInput *rod.Element for _, selector := range fileInputSelectors { fileInput, err = p.Page.Element(selector) if err == nil && fileInput != nil { p.LogInfo(fmt.Sprintf("找到文件上传输入框: %s", selector)) break } } if fileInput == nil { return fmt.Errorf("未找到文件上传输入框") } // 检查是否有可用的文件路径 if p.SourcePath == "" && p.ImagePath == "" { return fmt.Errorf("未提供要导入的文件路径") } // 优先使用 SourcePath(Word文档路径),其次使用 ImagePath filePath := p.SourcePath if filePath == "" { filePath = p.ImagePath } p.LogInfo(fmt.Sprintf("开始上传文件: %s", filePath)) // 上传文件 if err := fileInput.SetFiles([]string{filePath}); err != nil { return fmt.Errorf("上传文件失败: %v", err) } p.LogInfo("文件已上传,等待导入处理...") // 3. 等待导入成功的弹窗出现 p.LogInfo("等待导入成功弹窗...") // 等待导入成功的提示出现 successSelectors := []string{ ".byte-message-success", ".toast-success", "[class*='success']", "[class*='导入成功']", ".syl-toast-success", } var successMsg *rod.Element for attempt := 0; attempt < 30; attempt++ { for _, selector := range successSelectors { successMsg, err = p.Page.Element(selector) if err == nil && successMsg != nil { text, _ := successMsg.Text() if strings.Contains(text, "成功") || strings.Contains(text, "导入") { p.LogInfo(fmt.Sprintf("检测到导入成功提示: %s", text)) break } } } if successMsg != nil { break } p.SleepMs(500) } // 等待弹窗消失(导入完成的标志) p.LogInfo("等待导入完成,弹窗消失...") time.Sleep(2 * time.Second) // 等待内容加载完成 p.LogInfo("等待内容加载到编辑器...") time.Sleep(3 * time.Second) // 验证内容是否已导入 contentEditor, err := p.WaitForElementVisible(".ProseMirror", 10) if err == nil { text, _ := contentEditor.Text() if len(text) > 0 { p.LogInfo(fmt.Sprintf("内容导入成功,内容长度: %d", len(text))) } else { p.LogWarning("内容可能未正确导入,编辑器为空") } } else { p.LogWarning("未找到内容编辑器,无法验证导入结果") } p.LogInfo("文章内容导入完成") return nil } // isDrawerClosed 检查弹窗是否已关闭 func (p *ToutiaoPublisher) isDrawerClosed() bool { drawerWrappers, err := p.Page.Elements(".byte-drawer-wrapper") if err != nil { // 没找到弹窗元素,可能已关闭 return true } for _, wrapper := range drawerWrappers { className, err := wrapper.Attribute("class") if err == nil && className != nil && strings.Contains(*className, "byte-drawer-wrapper-hide") { return true } } return false } // clickPublish 点击发布按钮 func (p *ToutiaoPublisher) clickPublish() error { p.LogInfo("点击发布按钮...") // 查找发布按钮 publishBtn, err := p.WaitForElementClickable(".publish-btn-last, .publish-footer .byte-btn-primary", 5) if err != nil { // 尝试其他选择器 publishBtn, err = p.Page.Element("button:contains('预览并发布')") if err != nil { return fmt.Errorf("未找到发布按钮: %v", err) } } // 滚动到按钮位置 if err := p.ScrollToElement(publishBtn); err != nil { p.LogInfo(fmt.Sprintf("滚动到发布按钮失败: %v", err)) } p.SleepMs(500) // 点击第一次发布按钮 if err := p.JSClick(publishBtn); err != nil { return fmt.Errorf("点击发布按钮失败: %v", err) } p.LogInfo("已点击第一次发布按钮") p.SleepMs(2000) // 第二次点击确认发布 p.LogInfo("查找第二次确认发布按钮...") secondPublishBtn, err := p.WaitForElementClickable(".publish-btn.publish-btn-last", 5) if err != nil { secondPublishBtn, err = p.Page.Element("button:contains('预览并发布')") if err != nil { p.LogInfo("未找到第二次发布确认按钮,可能已经发布") return nil } } if secondPublishBtn != nil { if err := p.ScrollToElement(secondPublishBtn); err != nil { p.LogInfo(fmt.Sprintf("滚动到确认按钮失败: %v", err)) } p.SleepMs(500) if err := p.JSClick(secondPublishBtn); err != nil { p.LogInfo(fmt.Sprintf("点击第二次发布确认按钮失败: %v", err)) } else { p.LogInfo("已点击第二次发布确认按钮") p.SleepMs(3000) } } return nil } // waitForPublishResult 等待发布结果 func (p *ToutiaoPublisher) waitForPublishResult() (bool, string) { p.LogInfo("等待发布结果...") for attempt := 0; attempt < p.MaxRetries; attempt++ { currentURL := p.GetCurrentURL() p.LogInfo(fmt.Sprintf("第 %d 次检查 - URL: %s", attempt+1, currentURL)) // 检查是否发布成功 if strings.Contains(currentURL, "success") || !strings.Contains(currentURL, "publish") { p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL)) return true, "发布成功" } // 检查错误提示 errorSelectors := []string{ "[class*='error']", "[class*='toast-error']", ".byte-message-error", } for _, selector := range errorSelectors { errorMsgs, err := p.Page.Elements(selector) if err == nil { for _, elem := range errorMsgs { text, _ := elem.Text() if text != "" && (strings.Contains(text, "失败") || strings.Contains(strings.ToLower(text), "error")) { p.LogError(fmt.Sprintf("发布失败: %s", text)) return false, fmt.Sprintf("发布失败: %s", text) } } } } // 检查成功提示 successSelectors := []string{ "[class*='success']", ".byte-message-success", } for _, selector := range successSelectors { successMsgs, err := p.Page.Elements(selector) if err == nil { for _, elem := range successMsgs { text, _ := elem.Text() if text != "" && strings.Contains(text, "成功") { p.LogInfo(fmt.Sprintf("发布成功: %s", text)) return true, text } } } } p.SleepMs(p.RetryDelay) } p.LogWarning("发布结果未知") return false, "发布结果未知" } // InitPage 初始化页面 func (p *ToutiaoPublisher) InitPage() error { // 访问发布页面 // 尝试加载cookies并检查登录状态 if err := p.LoadCookies(); err == nil { p.Page.MustNavigate(p.EditorURL) p.WaitForPageReady(5) if p.CheckLoginStatus() { p.SaveCookies() return nil } } return fmt.Errorf("需要登录") } // PublishNote 发布文章 func (p *ToutiaoPublisher) PublishNote() (bool, string) { p.StartNote() // 初始化浏览器 if err := p.SetupDriver(); err != nil { return false, fmt.Sprintf("浏览器启动失败: %v", err) } defer p.Close() // 执行发布流程 steps := []struct { name string fn func() error }{ {"初始化页面", p.InitPage}, {"输入内容", p.inputContent}, {"输入标题", p.inputTitle}, {"点击发布", p.clickPublish}, } for _, step := range steps { if err := step.fn(); err != nil { // 失败时截图 screenshotFile := fmt.Sprintf("screenshot_%s_%d.png", step.name, time.Now().Unix()) p.Screenshot(screenshotFile) p.LogStep(step.name, false, err.Error()) return false, fmt.Sprintf("%s失败: %v", step.name, err) } p.LogStep(step.name, true, "") p.SleepMs(500) } // 等待发布结果 return p.waitForPublishResult() } // LogWarning 记录警告日志 func (p *ToutiaoPublisher) LogWarning(message string) { p.Logger.Printf("⚠️ %s", message) }