509 lines
13 KiB
Go
509 lines
13 KiB
Go
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) CheckLogin() (bool, string) {
|
||
p.LogInfo("检查登录状态...")
|
||
|
||
if err := p.SetupDriver(); err != nil {
|
||
return false, fmt.Sprintf("浏览器启动失败: %v", err)
|
||
}
|
||
defer p.Close()
|
||
|
||
p.Page.MustNavigate(p.EditorURL)
|
||
p.Sleep(2)
|
||
p.WaitForPageReady(5)
|
||
|
||
if p.CheckLoginStatus() {
|
||
p.SaveCookies()
|
||
return true, "已登录"
|
||
}
|
||
return false, "未登录"
|
||
}
|
||
|
||
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)
|
||
}
|