package publisher import ( "context" "fmt" "geo/internal/config" "log" "path/filepath" "strings" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/proto" ) type BaijiahaoPublisher struct { *BasePublisher } func NewBaijiahaoPublisher(ctx context.Context, task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { return &BaijiahaoPublisher{NewBasePublisher(ctx, task, cfg, logger)} } func (p *BaijiahaoPublisher) CheckLoginStatus() bool { currentURL := p.GetCurrentURL() if strings.Contains(currentURL, p.LoginURL) { return false } return true } func (p *BaijiahaoPublisher) CheckLogin() (bool, string) { driverCreated := false defer func() { if driverCreated && p.Browser != nil { p.Close() } }() if err := p.SetupDriver(); err != nil { return false, fmt.Sprintf("浏览器启动失败: %v", err) } driverCreated = true p.Page.MustNavigate(p.EditorURL) p.Sleep(3) p.WaitForPageReady(5) if p.CheckLoginStatus() { p.SaveCookies() return true, "已登录" } return false, "未登录" } func (p *BaijiahaoPublisher) WaitLogin() (bool, string) { driverCreated := false defer func() { if driverCreated && p.Browser != nil { p.Close() } }() if err := p.SetupDriver(); err != nil { return false, fmt.Sprintf("浏览器启动失败: %v", err) } driverCreated = true p.Page.MustNavigate(p.LoginedURL) p.Sleep(3) if p.CheckLoginStatus() { p.SaveCookies() return true, "already_logged_in" } p.Page.MustNavigate(p.LoginURL) p.LogInfo("请扫描二维码登录...") for i := 0; i < 120; i++ { p.Sleep(1) if p.CheckLoginStatus() { p.SaveCookies() return true, "login_success" } } return false, "登录超时" } func (p *BaijiahaoPublisher) checkElementExists(selector string, timeout int) bool { _, err := p.WaitForElement(selector, timeout) return err == nil } func (p *BaijiahaoPublisher) PublishNote() (bool, string) { if err := p.SetupDriver(); err != nil { return false, fmt.Sprintf("浏览器启动失败: %v", err) } defer p.Page.Close() if p.LoadCookies() == nil { p.Page.MustNavigate(p.EditorURL) p.WaitForPageReady(5) if p.CheckLoginStatus() { return p.doPublish() } } if p.CheckLoginStatus() { p.SaveCookies() return p.doPublish() } return false, "需要登录" } func (p *BaijiahaoPublisher) doPublish() (bool, string) { p.LogInfo("开始发布百家号文章...") steps := []struct { name string fn func() error }{ //{"切换到图文编辑模式", p.switchToGraphicMode}, {"输入内容", p.inputContent}, {"输入标题", p.inputTitle}, {"设置封面", p.uploadImage}, {"点击发布按钮", p.clickPublish}, {"处理确认弹窗", p.handleConfirmModal}, } for _, step := range steps { if err := step.fn(); err != nil { 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() } func (p *BaijiahaoPublisher) switchToGraphicMode() error { p.LogInfo("切换到图文编辑模式...") tabSelectors := []string{ ".list-item.item-active", ".header-list-content .list-item", "div[role='tab']:first-child", } for _, selector := range tabSelectors { tab, err := p.Page.Element(selector) if err == nil && tab != nil { visible, _ := tab.Visible() if visible { p.JSClick(tab) p.LogInfo(fmt.Sprintf("已点击图文标签: %s", selector)) p.Sleep(1) return nil } } } return nil } func (p *BaijiahaoPublisher) inputTitle() error { p.LogInfo("输入文章标题...") titleSelectors := []string{ ".client_pages_edit_components_titleInput ._9ddb7e475b559749-editor", ".input-box ._9ddb7e475b559749-editor", "[contenteditable='true']", ".bjh-news-drag-tip + div [contenteditable='true']", } var titleInput *rod.Element for _, selector := range titleSelectors { titleInput, _ = p.WaitForElementVisible(selector, 5) if titleInput != nil { p.LogInfo(fmt.Sprintf("找到标题输入框: %s", selector)) break } } if titleInput == nil { return fmt.Errorf("未找到标题输入框") } titleInput.Click(proto.InputMouseButtonLeft, 1) p.SleepMs(500) currentTitle, _ := titleInput.Text() if currentTitle != "" { p.LogInfo(fmt.Sprintf("清空当前标题: %s", currentTitle[:min(50, len(currentTitle))])) p.ClearContentEditable(titleInput) p.SleepMs(200) } titleInput.Input(p.Title) p.LogInfo(fmt.Sprintf("新标题已输入: %s", p.Title)) p.triggerInputEvents(titleInput) p.SleepMs(500) finalTitle, _ := titleInput.Text() if finalTitle != p.Title { p.Page.Eval(fmt.Sprintf(`() => { arguments[0].innerHTML = '%s'; }`, p.Title)) p.triggerInputEvents(titleInput) p.LogInfo("已通过 JavaScript 重新设置标题") } return nil } func (p *BaijiahaoPublisher) inputContent() error { p.LogInfo("开始导入文档内容...") // 1. 找到 id="edui41" 的 div 并 hover edui41, err := p.WaitForElement("#edui41", 10) if err != nil { return fmt.Errorf("未找到编辑器工具栏: %v", err) } // 鼠标 hover if err := edui41.Hover(); err != nil { return fmt.Errorf("hover 失败: %v", err) } p.LogInfo("已 hover 到编辑器工具栏") p.SleepMs(500) // 2. 查找并点击"导入文档" var importDocBtn *rod.Element // 等待 popover 出现 for i := 0; i < 10; i++ { // 查找 class 包含 cheetah-popover 的元素 popover, err := p.Page.Element("[class*='cheetah-popover']") if err != nil || popover == nil { p.SleepMs(500) continue } // 在 popover 内查找 class 包含 "-label" 且文本为"导入文档"的 div // 使用正则匹配 class 包含随机字符-label 的模式 importDocBtn, err = popover.ElementX("//div[contains(@class, '-label') and contains(text(), '导入文档')]") if err == nil && importDocBtn != nil { p.LogInfo("找到导入文档按钮") break } // 备用查找方式:直接在整个页面中查找 importDocBtn, err = p.Page.ElementX("//div[contains(@class, '-label') and contains(text(), '导入文档')]") if err == nil && importDocBtn != nil { p.LogInfo("通过 XPath 找到导入文档按钮") break } p.SleepMs(500) } if importDocBtn == nil { return fmt.Errorf("未找到导入文档按钮") } // 点击导入文档按钮 if err := p.JSClick(importDocBtn); err != nil { return fmt.Errorf("点击导入文档按钮失败: %v", err) } p.LogInfo("已点击导入文档按钮") p.SleepMs(1000) // 3. 查找 dialog 中的文件上传 input var fileInput *rod.Element for i := 0; i < 10; i++ { // 查找 role="dialog" 的元素 dialog, err := p.Page.Element("[role='dialog']") if err != nil || dialog == nil { p.SleepMs(500) continue } // 在 dialog 内查找 name="file" 的 input fileInput, err = dialog.Element("input[name='file']") if err == nil && fileInput != nil { p.LogInfo("找到文件上传输入框") break } // 备用:直接在整个页面中查找 fileInput, err = p.Page.Element("input[name='file']") if err == nil && fileInput != nil { p.LogInfo("通过全局选择器找到文件上传输入框") break } p.SleepMs(500) } if fileInput == nil { return fmt.Errorf("未找到文件上传输入框") } // 4. 上传文档 if p.SourcePath == "" { return fmt.Errorf("未提供文档路径") } if err := fileInput.SetFiles([]string{p.SourcePath}); err != nil { return fmt.Errorf("上传文档失败: %v", err) } p.LogInfo(fmt.Sprintf("已上传文档: %s", p.SourcePath)) // 5. 等待导入成功 // 提取文件名(不含路径) fileName := filepath.Base(p.SourcePath) // 等待导入成功的提示 for i := 0; i < 30; i++ { // 查找包含文件名的成功提示 successMsg, err := p.Page.ElementX(fmt.Sprintf("//*[contains(text(), '%s') and (contains(text(), '成功') or contains(text(), '导入'))]", fileName)) if err == nil && successMsg != nil { text, _ := successMsg.Text() p.LogInfo(fmt.Sprintf("文档导入成功: %s", text)) p.SleepMs(2000) // 等待内容加载完成 return nil } // 通用成功提示查找 successMsg, err = p.Page.ElementX("//*[contains(text(), '导入成功')]") if err == nil && successMsg != nil { text, _ := successMsg.Text() p.LogInfo(fmt.Sprintf("文档导入成功: %s", text)) p.SleepMs(2000) return nil } // 查找是否有错误提示 errorMsg, err := p.Page.ElementX("//*[contains(text(), '失败') or contains(text(), '错误')]") if err == nil && errorMsg != nil { text, _ := errorMsg.Text() if strings.Contains(text, fileName) || strings.Contains(text, "导入") { return fmt.Errorf("文档导入失败: %s", text) } } p.SleepMs(500) } // 虽然没有明确的成功提示,但等待几秒让内容加载 p.LogInfo("等待内容加载完成...") p.SleepMs(3000) return nil } func (p *BaijiahaoPublisher) uploadImage() error { if p.ImagePath == "" { p.LogInfo("未提供封面图片路径,跳过封面设置") return nil } p.LogInfo("设置文章封面...") // 查找并点击封面选择区域 coverSelectors := []string{ ".cheetah-spin-container", "._73a3a52aab7e3a36-default", ".cover-selector", "[class*='spin-container']", } var coverArea *rod.Element for _, selector := range coverSelectors { coverArea, _ = p.WaitForElement(selector, 3) if coverArea != nil { visible, _ := coverArea.Visible() if visible { p.LogInfo(fmt.Sprintf("找到封面区域: %s", selector)) break } } } if coverArea != nil { p.ScrollToElement(coverArea) p.SleepMs(500) p.JSClick(coverArea) p.LogInfo("已点击封面选择区域") p.SleepMs(2000) } //// 查找并点击上传区域 //uploadSelectors := []string{ // "div[class*='cheetah-upload']", // ".cheetah-upload", // "div[class*='upload']", // ".upload-area", // "._73a3a52aab7e3a36-content", // "._93c3fe2a3121c388-item", //} //var uploadArea *rod.Element //for _, selector := range uploadSelectors { // elements, _ := p.Page.Elements(selector) // for _, elem := range elements { // visible, _ := elem.Visible() // if visible { // uploadArea = elem // p.LogInfo(fmt.Sprintf("找到上传区域: %s", selector)) // break // } // } // if uploadArea != nil { // break // } //} //if uploadArea != nil { // p.ScrollToElement(uploadArea) // p.SleepMs(500) // p.JSClick(uploadArea) // p.LogInfo("已点击图片上传区域") // p.SleepMs(1000) //} // //// 查找cheetah-upload组件 //componentSelectors := []string{ // "div[class*='cheetah-upload']", // ".cheetah-upload", // "div[class*='upload']", //} //var uploadComponent *rod.Element //for _, selector := range componentSelectors { // elements, _ := p.Page.Elements(selector) // for _, elem := range elements { // visible, _ := elem.Visible() // if visible { // uploadComponent = elem // p.LogInfo(fmt.Sprintf("找到cheetah-upload组件: %s", selector)) // break // } // } // if uploadComponent != nil { // break // } //} //if uploadComponent != nil { // p.ScrollToElement(uploadComponent) // p.SleepMs(500) // p.JSClick(uploadComponent) // p.LogInfo("已点击cheetah-upload上传组件") // p.SleepMs(2000) //} // //// 查找文件上传输入框 //var fileInput *rod.Element //for i := 0; i < 10; i++ { // fileInput, _ = p.Page.Element("input[name='media'][type='file'][accept='image/*']") // if fileInput != nil { // p.LogInfo("找到文件上传输入框") // break // } // fileInput, _ = p.Page.Element("input[type='file'][accept*='image']") // if fileInput != nil { // p.LogInfo("通过备用选择器找到文件上传输入框") // break // } // p.SleepMs(500) //} //if fileInput != nil { // fileInput.SetFiles([]string{p.ImagePath}) // p.LogInfo(fmt.Sprintf("图片上传成功: %s", p.ImagePath)) // p.Sleep(3) //} // 查找并点击确认按钮 // 查找并点击确认按钮 // 查找并点击确认按钮 var confirmBtn *rod.Element for i := 0; i < p.MaxRetries; i++ { p.LogInfo("正在查找确认按钮...") // 精确匹配:button 包含 cheetah-btn-primary 类,且 span 文本为"确定 (1)" confirmBtn, _ = p.Page.ElementX("//button[contains(@class, 'cheetah-btn-primary')]//span[text()='确定 (1)']/..") if confirmBtn != nil { visible, _ := confirmBtn.Visible() if visible { p.LogInfo("找到确认按钮") break } } // 备选:只匹配 span 文本 confirmBtn, _ = p.Page.ElementX("//span[text()='确定 (1)']/..") if confirmBtn != nil { visible, _ := confirmBtn.Visible() if visible { p.LogInfo("通过 span 文本找到确认按钮") break } } // 备选:文本包含"确定"和数字 confirmBtn, _ = p.Page.ElementX("//button[contains(@class, 'cheetah-btn-primary') and contains(., '确定')]") if confirmBtn != nil { visible, _ := confirmBtn.Visible() if visible { p.LogInfo("通过文本内容找到确认按钮") break } } p.SleepMs(p.RetryDelay) } if confirmBtn != nil { p.JSClick(confirmBtn) p.LogInfo("已点击确认按钮") p.SleepMs(2000) } else { return fmt.Errorf("未找到确认按钮") } return nil } func (p *BaijiahaoPublisher) clickPublish() error { p.LogInfo("点击发布按钮...") publishSelectors := []string{ "[data-testid='publish-btn']", ".op-list-right .cheetah-btn-primary", } var publishBtn *rod.Element for i := 0; i < 10; i++ { for _, selector := range publishSelectors { publishBtn, _ = p.Page.Element(selector) if publishBtn != nil { visible, _ := publishBtn.Visible() if visible { p.LogInfo(fmt.Sprintf("找到发布按钮: %s", selector)) break } } } if publishBtn != nil { break } publishBtn, _ = p.Page.ElementX("//button[contains(text(), '发布')]") if publishBtn != nil { visible, _ := publishBtn.Visible() if visible { p.LogInfo("通过XPath找到发布按钮") break } } p.Sleep(1) } if publishBtn == nil { return fmt.Errorf("未找到发布按钮") } p.ScrollToElement(publishBtn) p.Sleep(1) for attempt := 0; attempt < 3; attempt++ { err := p.JSClick(publishBtn) if err == nil { p.LogInfo(fmt.Sprintf("已通过JavaScript点击发布按钮 (尝试 %d)", attempt+1)) p.Sleep(3) return nil } err = publishBtn.Click(proto.InputMouseButtonLeft, 1) if err == nil { p.LogInfo(fmt.Sprintf("已通过普通点击发布按钮 (尝试 %d)", attempt+1)) p.Sleep(3) return nil } p.Sleep(1) } return fmt.Errorf("点击发布按钮失败") } func (p *BaijiahaoPublisher) handleConfirmModal() error { confirmBtn, _ := p.WaitForElement(".cheetah-modal .cheetah-btn-primary", 3) if confirmBtn != nil { p.JSClick(confirmBtn) p.LogInfo("已点击确认弹窗") p.Sleep(2) } return nil } func (p *BaijiahaoPublisher) waitForPublishResult() (bool, string) { p.LogInfo("等待发布结果...") for attempt := 0; attempt < 60; attempt++ { currentURL := p.GetCurrentURL() p.LogInfo(fmt.Sprintf("第 %d 次检查 - URL: %s", attempt+1, currentURL)) if strings.Contains(currentURL, "clue") { p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL)) return true, "发布成功" } elements, _ := p.Page.Elements(".cheetah-message-success, .cheetah-message-info") for _, elem := range elements { visible, _ := elem.Visible() if visible { text, _ := elem.Text() if strings.Contains(text, "成功") || strings.Contains(text, "发布") { p.LogInfo(fmt.Sprintf("发布成功: %s", text)) return true, text } } } elements, _ = p.Page.Elements(".cheetah-message-error, .cheetah-message-warning") for _, elem := range elements { visible, _ := elem.Visible() if visible { text, _ := elem.Text() if strings.Contains(text, "失败") || strings.Contains(text, "错误") { p.LogError(fmt.Sprintf("发布失败: %s", text)) return false, fmt.Sprintf("发布失败: %s", text) } } } p.Sleep(1) } return false, "发布结果未知" } func (p *BaijiahaoPublisher) triggerInputEvents(el *rod.Element) { el.Eval(`() => { arguments[0].dispatchEvent(new Event('input', {bubbles: true})); arguments[0].dispatchEvent(new Event('change', {bubbles: true})); arguments[0].dispatchEvent(new Event('blur', {bubbles: true})); }`) } func min(a, b int) int { if a < b { return a } return b }