623 lines
15 KiB
Go
623 lines
15 KiB
Go
package publisher
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"geo/internal/config"
|
||
"log"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-rod/rod"
|
||
"github.com/go-rod/rod/lib/proto"
|
||
)
|
||
|
||
type WangyiPublisher struct {
|
||
*BasePublisher
|
||
Category string
|
||
IsOriginal bool
|
||
}
|
||
|
||
// NewWangyiPublisher 构造函数
|
||
func NewWangyiPublisher(ctx context.Context, task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface {
|
||
return &WangyiPublisher{
|
||
BasePublisher: NewBasePublisher(ctx, task, cfg, logger),
|
||
Category: "",
|
||
IsOriginal: true,
|
||
}
|
||
}
|
||
|
||
func (p *WangyiPublisher) CheckLogin() (bool, string) {
|
||
p.LogInfo("检查登录状态...")
|
||
|
||
if err := p.SetupDriver(); err != nil {
|
||
return false, fmt.Sprintf("浏览器启动失败: %v", err)
|
||
}
|
||
defer p.Page.Close()
|
||
|
||
p.Page.MustNavigate(p.EditorURL)
|
||
p.Sleep(3)
|
||
p.WaitForPageReady(5)
|
||
|
||
if p.CheckLoginStatus() {
|
||
p.SaveCookies()
|
||
return true, "已登录"
|
||
}
|
||
return false, "未登录"
|
||
}
|
||
|
||
func (p *WangyiPublisher) CheckLoginStatus() bool {
|
||
currentURL := p.GetCurrentURL()
|
||
|
||
// 如果在登录页面,未登录
|
||
if strings.Contains(currentURL, p.LoginURL) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (p *WangyiPublisher) WaitLogin() (bool, string) {
|
||
p.LogInfo("开始等待登录...")
|
||
|
||
if err := p.SetupDriver(); err != nil {
|
||
return false, fmt.Sprintf("浏览器启动失败: %v", err)
|
||
}
|
||
defer p.Close()
|
||
|
||
// 先尝试访问已登录页面
|
||
if p.LoginedURL != "" {
|
||
p.Page.MustNavigate(p.LoginedURL)
|
||
p.Sleep(3)
|
||
|
||
if p.CheckLoginStatus() {
|
||
p.SaveCookies()
|
||
p.LogInfo("已有登录状态")
|
||
return true, "already_logged_in"
|
||
}
|
||
}
|
||
|
||
// 未登录,跳转到登录页
|
||
if p.LoginURL != "" {
|
||
p.Page.MustNavigate(p.LoginURL)
|
||
p.WaitForPageReady(5)
|
||
}
|
||
|
||
p.LogInfo("请扫码登录...")
|
||
|
||
// 等待登录完成,最多120秒
|
||
for i := 0; i < 120; i++ {
|
||
currentURL := p.GetCurrentURL()
|
||
|
||
// 检查是否跳转到发布页面或主页
|
||
if p.EditorURL != "" && strings.Contains(currentURL, p.EditorURL) {
|
||
p.SaveCookies()
|
||
p.LogInfo("登录成功")
|
||
return true, "login_success"
|
||
}
|
||
if p.LoginedURL != "" && strings.Contains(currentURL, p.LoginedURL) {
|
||
p.SaveCookies()
|
||
p.LogInfo("登录成功")
|
||
return true, "login_success"
|
||
}
|
||
|
||
// 检查登录状态
|
||
if p.CheckLoginStatus() {
|
||
p.SaveCookies()
|
||
p.LogInfo("登录成功")
|
||
return true, "login_success"
|
||
}
|
||
|
||
time.Sleep(1 * time.Second)
|
||
}
|
||
|
||
return false, "登录超时,请检查网络或账号状态"
|
||
}
|
||
|
||
// inputTitle 输入标题
|
||
func (p *WangyiPublisher) inputTitle() error {
|
||
p.LogInfo("输入文章标题...")
|
||
|
||
titleSelectors := []string{
|
||
"textarea[placeholder*='标题']",
|
||
"input[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
|
||
}
|
||
|
||
// insertImageToWord 将图片插入到Word文档头部
|
||
func (p *WangyiPublisher) insertImageToWord(docPath, imagePath string) (string, error) {
|
||
// 由于Go中处理Word文档需要额外库,这里先返回原路径
|
||
// 如果需要此功能,可以使用 unioffice 或 excelize 等库
|
||
p.LogInfo(fmt.Sprintf("插入图片到Word: %s -> %s", imagePath, docPath))
|
||
return docPath, nil
|
||
}
|
||
|
||
// importDocument 导入Word文档
|
||
func (p *WangyiPublisher) importDocument() error {
|
||
if p.SourcePath == "" {
|
||
p.LogInfo("未提供文档路径,使用内容文本")
|
||
return p.inputContentDirect()
|
||
}
|
||
|
||
if _, err := os.Stat(p.SourcePath); os.IsNotExist(err) {
|
||
p.LogWarning(fmt.Sprintf("文档不存在: %s", p.SourcePath))
|
||
return p.inputContentDirect()
|
||
}
|
||
|
||
p.LogInfo(fmt.Sprintf("尝试导入文档: %s", p.SourcePath))
|
||
|
||
// 查找导入文档按钮
|
||
importSelectors := []string{
|
||
".ne-rich-editor-upload-button",
|
||
"[class*='rich-editor-upload']",
|
||
"label[for='ne-rich-editor-upload-input']",
|
||
".rich-editor-panel-item",
|
||
}
|
||
|
||
var importBtn *rod.Element
|
||
var err error
|
||
|
||
for _, selector := range importSelectors {
|
||
importBtn, err = p.WaitForElementClickable(selector, 5)
|
||
if err == nil && importBtn != nil {
|
||
p.LogInfo(fmt.Sprintf("找到导入文档按钮: %s", selector))
|
||
break
|
||
}
|
||
}
|
||
|
||
if importBtn == nil {
|
||
// 尝试通过文本查找
|
||
importBtn, err = p.Page.Element("button:contains('导入文档')")
|
||
if err != nil {
|
||
p.LogWarning("未找到导入文档按钮,使用直接输入内容")
|
||
return p.inputContentDirect()
|
||
}
|
||
}
|
||
|
||
p.SleepMs(500)
|
||
|
||
// 查找文件上传输入框
|
||
fileInputSelectors := []string{
|
||
"#ne-rich-editor-upload-input",
|
||
"input[type='file'][accept*='.doc']",
|
||
"input[type='file'][accept*='.docx']",
|
||
".ne-rich-editor-upload-button 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 {
|
||
p.LogWarning("未找到文件上传输入框,使用直接输入内容")
|
||
return p.inputContentDirect()
|
||
}
|
||
|
||
// 上传文件
|
||
if err := fileInput.SetFiles([]string{p.SourcePath}); err != nil {
|
||
p.LogWarning(fmt.Sprintf("上传文件失败: %v,使用直接输入内容", err))
|
||
return p.inputContentDirect()
|
||
}
|
||
p.LogInfo(fmt.Sprintf("文档已上传: %s", p.SourcePath))
|
||
p.SleepMs(3000)
|
||
|
||
// 等待导入完成
|
||
for attempt := 0; attempt < 30; attempt++ {
|
||
// 检查内容编辑器是否有内容
|
||
contentEditor, err := p.Page.Element(".rich-editor-stage, .ProseMirror, [contenteditable='true']")
|
||
if err == nil && contentEditor != nil {
|
||
text, _ := contentEditor.Text()
|
||
if len(text) > 0 {
|
||
p.LogInfo("文档导入成功")
|
||
return nil
|
||
}
|
||
}
|
||
p.SleepMs(1000)
|
||
}
|
||
|
||
p.LogInfo("文档导入完成")
|
||
return nil
|
||
}
|
||
|
||
// inputContentDirect 直接输入内容
|
||
func (p *WangyiPublisher) inputContentDirect() error {
|
||
p.LogInfo("直接输入文章内容...")
|
||
|
||
// 查找内容编辑器
|
||
contentSelectors := []string{
|
||
".rich-editor-stage",
|
||
".ProseMirror",
|
||
"[contenteditable='true']",
|
||
}
|
||
|
||
var contentEditor *rod.Element
|
||
var err error
|
||
|
||
for _, selector := range contentSelectors {
|
||
contentEditor, err = p.WaitForElementVisible(selector, 10)
|
||
if err == nil && contentEditor != nil {
|
||
p.LogInfo(fmt.Sprintf("找到内容编辑器: %s", selector))
|
||
break
|
||
}
|
||
}
|
||
|
||
if contentEditor == nil {
|
||
return fmt.Errorf("未找到内容编辑器")
|
||
}
|
||
|
||
// 点击获取焦点
|
||
if err := contentEditor.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return fmt.Errorf("点击编辑器失败: %v", err)
|
||
}
|
||
p.SleepMs(500)
|
||
|
||
// 清空现有内容
|
||
if err := p.ClearContentEditable(contentEditor); err != nil {
|
||
p.LogInfo(fmt.Sprintf("清空编辑器失败: %v", err))
|
||
}
|
||
p.SleepMs(300)
|
||
|
||
// 输入内容
|
||
if err := p.SetContentEditable(contentEditor, p.Content); err != nil {
|
||
contentEditor.Input(p.Content)
|
||
}
|
||
|
||
p.LogInfo(fmt.Sprintf("内容已输入,长度: %d", len(p.Content)))
|
||
return nil
|
||
}
|
||
|
||
// setCover 设置封面为自动模式
|
||
func (p *WangyiPublisher) setCover() error {
|
||
p.LogInfo("设置封面为自动模式...")
|
||
|
||
// 先找到 class="post-footer__container" 的 div,点击
|
||
footerDiv, err := p.Page.ElementX("//span[contains(text(), '设置区')]")
|
||
if err != nil {
|
||
p.LogWarning("未找到 设置区")
|
||
return fmt.Errorf("未找到设置区")
|
||
}
|
||
|
||
// 点击这个div
|
||
if err := p.JSClick(footerDiv); err != nil {
|
||
p.LogInfo(fmt.Sprintf("点击 设置区 失败: %v", err))
|
||
}
|
||
p.SleepMs(2000)
|
||
|
||
// 查找包含"自动"文本的span
|
||
autoSpan, err := p.Page.ElementX("//span[contains(text(), '自动')]")
|
||
if err != nil {
|
||
p.LogWarning("未找到包含'自动'文本的span")
|
||
return fmt.Errorf("未找到包含'自动'文本的span")
|
||
}
|
||
|
||
p.SleepMs(500)
|
||
//
|
||
// 查找父级label并点击
|
||
parentLabel, err := autoSpan.Parent()
|
||
if err != nil {
|
||
return fmt.Errorf("未找到父级label元素: %v", err)
|
||
}
|
||
|
||
if err := p.JSClick(parentLabel); err != nil {
|
||
return fmt.Errorf("点击自动封面选项失败: %v", err)
|
||
}
|
||
|
||
p.LogInfo("已点击自动封面选项")
|
||
p.SleepMs(1000)
|
||
|
||
return nil
|
||
}
|
||
|
||
// clickPublish 点击发布按钮
|
||
func (p *WangyiPublisher) clickPublish() error {
|
||
p.LogInfo("点击发布按钮...")
|
||
|
||
publishSelectors := []string{
|
||
"button:contains('发布')",
|
||
"button.ne-button:contains('发布')",
|
||
"button.primary_button",
|
||
"button.ne-button-color-primary",
|
||
".netease-button.primary_button",
|
||
".publish-btn",
|
||
".submit-btn",
|
||
}
|
||
|
||
var publishBtn *rod.Element
|
||
var err error
|
||
|
||
for _, selector := range publishSelectors {
|
||
publishBtn, err = p.WaitForElementClickable(selector, 3)
|
||
if err == nil && publishBtn != nil {
|
||
p.LogInfo(fmt.Sprintf("找到发布按钮: %s", selector))
|
||
break
|
||
}
|
||
}
|
||
|
||
if publishBtn == nil {
|
||
// 遍历所有按钮查找文本为"发布"的
|
||
buttons, err := p.Page.Elements("button")
|
||
if err == nil {
|
||
for _, btn := range buttons {
|
||
text, _ := btn.Text()
|
||
if text == "发布" || strings.Contains(text, "发布") {
|
||
publishBtn = btn
|
||
p.LogInfo("通过遍历按钮找到发布按钮")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if publishBtn == nil {
|
||
return fmt.Errorf("未找到发布按钮")
|
||
}
|
||
|
||
p.SleepMs(500)
|
||
|
||
// 点击发布按钮
|
||
if err := p.JSClick(publishBtn); err != nil {
|
||
return fmt.Errorf("点击发布按钮失败: %v", err)
|
||
}
|
||
|
||
p.LogInfo("已点击发布按钮")
|
||
p.SleepMs(3000)
|
||
|
||
return nil
|
||
}
|
||
|
||
// waitForPublishResult 等待发布结果
|
||
func (p *WangyiPublisher) waitForPublishResult() (bool, string) {
|
||
p.LogInfo("等待发布结果...")
|
||
|
||
timeout := 60
|
||
retryInterval := 5
|
||
maxRetries := 5
|
||
lastClickTime := int64(0)
|
||
retryCount := 0
|
||
|
||
startTime := time.Now()
|
||
errorText := ""
|
||
|
||
for time.Since(startTime).Seconds() < float64(timeout) {
|
||
currentURL := p.GetCurrentURL()
|
||
|
||
// 检查是否发布成功
|
||
successKeywords := []string{"success", "article-manage", "content-manage", "list", "article/list"}
|
||
for _, keyword := range successKeywords {
|
||
if strings.Contains(currentURL, keyword) {
|
||
p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL))
|
||
return true, "发布成功"
|
||
}
|
||
}
|
||
|
||
// 检查成功提示
|
||
successSelectors := []string{
|
||
"[class*='success']",
|
||
".toast-success",
|
||
".message-success",
|
||
".ne-message-success",
|
||
}
|
||
for _, selector := range successSelectors {
|
||
msgs, err := p.Page.Elements(selector)
|
||
if err == nil {
|
||
for _, elem := range msgs {
|
||
visible, _ := elem.Visible()
|
||
if visible {
|
||
text, _ := elem.Text()
|
||
if text != "" && (strings.Contains(text, "成功") || strings.Contains(text, "已发布")) {
|
||
p.LogInfo(fmt.Sprintf("发布成功: %s", text))
|
||
return true, text
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查错误提示
|
||
errorFound := false
|
||
errorSelectors := []string{
|
||
"[class*='error']",
|
||
".toast-error",
|
||
".message-error",
|
||
".ne-message-error",
|
||
}
|
||
for _, selector := range errorSelectors {
|
||
msgs, err := p.Page.Elements(selector)
|
||
if err == nil {
|
||
for _, elem := range msgs {
|
||
visible, _ := elem.Visible()
|
||
if visible {
|
||
text, _ := elem.Text()
|
||
if text != "" && (strings.Contains(text, "失败") || strings.Contains(text, "error")) {
|
||
errorText = text
|
||
errorFound = true
|
||
p.LogError(fmt.Sprintf("发布失败: %s", errorText))
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if errorFound {
|
||
break
|
||
}
|
||
}
|
||
|
||
// 检查是否需要重试
|
||
currentTime := time.Now().Unix()
|
||
if currentTime-lastClickTime >= int64(retryInterval) && retryCount < maxRetries {
|
||
if strings.Contains(currentURL, p.EditorURL) {
|
||
p.LogInfo(fmt.Sprintf("第 %d 次重试,重新点击发布按钮...", retryCount+1))
|
||
|
||
// 如果有错误提示,先尝试关闭
|
||
if errorFound {
|
||
closeBtns, _ := p.Page.Elements(".close-btn, .ne-message-close, [class*='close']")
|
||
for _, btn := range closeBtns {
|
||
visible, _ := btn.Visible()
|
||
if visible {
|
||
p.JSClick(btn)
|
||
p.LogInfo("关闭错误提示")
|
||
p.SleepMs(1000)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// 重新点击发布按钮
|
||
if err := p.clickPublish(); err == nil {
|
||
retryCount++
|
||
lastClickTime = currentTime
|
||
errorFound = false
|
||
p.LogInfo(fmt.Sprintf("第 %d 次重试已点击发布按钮,继续等待结果...", retryCount))
|
||
} else {
|
||
p.LogWarning(fmt.Sprintf("第 %d 次重试点击发布按钮失败", retryCount+1))
|
||
lastClickTime = currentTime
|
||
retryCount++
|
||
}
|
||
|
||
p.SleepMs(2000)
|
||
continue
|
||
} else if errorFound {
|
||
return false, fmt.Sprintf("发布失败: %s", errorText)
|
||
}
|
||
}
|
||
|
||
p.SleepMs(1000)
|
||
}
|
||
|
||
// 超时后最后一次尝试
|
||
if retryCount < maxRetries {
|
||
p.LogInfo("超时前最后一次尝试点击发布按钮...")
|
||
p.clickPublish()
|
||
p.SleepMs(5000)
|
||
|
||
// 再次检查结果
|
||
successSelectors := []string{
|
||
"[class*='success']",
|
||
".toast-success",
|
||
".message-success",
|
||
}
|
||
for _, selector := range successSelectors {
|
||
msgs, err := p.Page.Elements(selector)
|
||
if err == nil {
|
||
for _, elem := range msgs {
|
||
text, _ := elem.Text()
|
||
if text != "" && strings.Contains(text, "成功") {
|
||
return true, text
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return false, "发布结果未知(超时)"
|
||
}
|
||
|
||
// InitPage 初始化页面
|
||
func (p *WangyiPublisher) 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
|
||
}
|
||
|
||
// PublishNote 发布文章
|
||
func (p *WangyiPublisher) 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.inputTitle},
|
||
{"导入内容", p.importDocument},
|
||
{"设置封面", p.setCover},
|
||
{"点击发布", p.clickPublish},
|
||
}
|
||
|
||
for _, step := range steps {
|
||
if err := step.fn(); err != nil {
|
||
// 失败时截图
|
||
screenshotFile := fmt.Sprintf("wangyi_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)
|
||
}
|
||
|
||
// 等待发布结果
|
||
success, message := p.waitForPublishResult()
|
||
if success {
|
||
p.LogInfo(fmt.Sprintf("🎉 发布成功!%s", message))
|
||
} else {
|
||
p.LogError(fmt.Sprintf("发布失败: %s", message))
|
||
}
|
||
return success, message
|
||
}
|
||
|
||
// LogWarning 记录警告日志
|
||
func (p *WangyiPublisher) LogWarning(message string) {
|
||
p.Logger.Printf("⚠️ %s", message)
|
||
}
|