geoGo/internal/publisher/xiaohongshu.go

484 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package publisher
import (
"fmt"
"geo/pkg"
"log"
"strings"
"time"
"geo/internal/config"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
)
type XiaohongshuPublisher struct {
*BasePublisher
maxRetries int
retryDelay int
}
// NewXiaohongshuPublisher 构造函数,增加 logger 参数
func NewXiaohongshuPublisher(headless bool, title, content string, tags []string, tenantID, platIndex, requestID, imagePath, wordPath string, platInfo map[string]interface{}, cfg *config.Config, logger *log.Logger) PublisherInerface {
base := NewBasePublisher(headless, title, content, tags, tenantID, platIndex, requestID, imagePath, wordPath, platInfo, cfg, logger)
if platInfo != nil {
base.LoginURL = pkg.GetString(platInfo, "login_url")
base.EditorURL = pkg.GetString(platInfo, "edit_url")
base.LoginedURL = pkg.GetString(platInfo, "logined_url")
}
return &XiaohongshuPublisher{BasePublisher: base, maxRetries: 5, retryDelay: 200}
}
func (p *XiaohongshuPublisher) CheckLogin() (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)
//p.WaitForPageReady(5)
if p.CheckLoginStatus() {
p.SaveCookies()
return true, "已登录"
}
return false, "未登录"
}
func (p *XiaohongshuPublisher) 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.LogInfo("请扫描二维码登录...")
// 等待登录完成最多120秒
for i := 0; i < 120; i++ {
time.Sleep(1 * time.Second)
if p.CheckLoginStatus() {
p.SaveCookies()
p.LogInfo("登录成功")
return true, "login_success"
}
}
return false, "登录超时"
}
func (p *XiaohongshuPublisher) inputContent() error {
p.LogInfo("输入文章内容...")
// 等待编辑器加载
contentEditor, err := p.WaitForElementVisible(".tiptap.ProseMirror", 10)
if err != nil {
// 尝试其他选择器
contentEditor, err = p.WaitForElementVisible("[contenteditable='true']", 10)
if err != nil {
return fmt.Errorf("未找到内容编辑器: %v", err)
}
}
// 点击获取焦点 - 使用 Click 方法
if err := contentEditor.Click(proto.InputMouseButtonLeft, 1); err != nil {
return fmt.Errorf("点击编辑器失败: %v", err)
}
p.SleepMs(500)
// 清空现有内容 - 使用 JavaScript 清空
if err := p.ClearContentEditable(contentEditor); err != nil {
p.LogInfo(fmt.Sprintf("清空编辑器失败: %v", err))
}
p.SleepMs(300)
// 输入新内容 - 使用 JavaScript 设置内容
if err := p.SetContentEditable(contentEditor, p.Content); err != nil {
// 如果 JS 方式失败,尝试直接输入
contentEditor.Input(p.Content)
}
p.LogInfo(fmt.Sprintf("内容已输入,长度: %d", len(p.Content)))
return nil
}
func (p *XiaohongshuPublisher) inputTitle() error {
p.LogInfo("输入标题...")
// 查找标题输入框
titleSelectors := []string{
"textarea.d-input",
".d-input input",
"textarea[placeholder*='标题']",
"textarea",
}
var titleInput *rod.Element
var err error
for _, selector := range titleSelectors {
titleInput, err = p.WaitForElementVisible(selector, 3)
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(500)
// 清空输入框
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))
return nil
}
func (p *XiaohongshuPublisher) inputTags() error {
if len(p.Tags) == 0 {
p.LogInfo("无标签需要设置")
return nil
}
p.LogInfo(fmt.Sprintf("设置标签: %v", p.Tags))
// 构建标签字符串
tagStr := ""
for _, tag := range p.Tags {
if tagStr != "" {
tagStr += " "
}
tagStr += "#" + tag
}
// 查找标签输入区域
tagInput, err := p.WaitForElementVisible(".tiptap-container [contenteditable='true']", 5)
if err != nil {
p.LogInfo("未找到标签输入框,跳过标签设置")
return nil
}
if err := tagInput.Click(proto.InputMouseButtonLeft, 1); err != nil {
p.LogInfo(fmt.Sprintf("点击标签框失败: %v", err))
}
p.SleepMs(500)
if err := p.SetContentEditable(tagInput, tagStr); err != nil {
tagInput.Input(tagStr)
}
p.LogInfo("标签设置完成")
return nil
}
func (p *XiaohongshuPublisher) uploadImage() error {
if p.ImagePath == "" {
p.LogInfo("无封面图片,跳过上传")
return nil
}
p.LogInfo(fmt.Sprintf("上传封面图片: %s", p.ImagePath))
// 查找封面上传按钮
uploadBtn, err := p.WaitForElementClickable(".upload-content", 5)
if err != nil {
p.LogInfo("未找到封面上传区域,跳过")
return nil
}
// 使用 Click 方法
if err := uploadBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
p.LogInfo(fmt.Sprintf("点击上传按钮失败: %v", err))
}
p.SleepMs(1000)
// 查找文件输入框
fileInput, err := p.Page.Element("input[type='file']")
if err != nil {
return fmt.Errorf("未找到文件输入框: %v", err)
}
// 使用 SetFiles 上传文件
if err := fileInput.SetFiles([]string{p.ImagePath}); err != nil {
return fmt.Errorf("上传图片失败: %v", err)
}
p.LogInfo("图片上传成功")
p.Sleep(3)
return nil
}
func (p *XiaohongshuPublisher) clickPublish() error {
p.LogInfo("点击发布按钮...")
// 滚动到底部
if _, err := p.Page.Eval(`() => window.scrollTo(0, document.body.scrollHeight)`); err != nil {
p.LogInfo(fmt.Sprintf("滚动到底部失败: %v", err))
}
p.SleepMs(1000)
// 查找并点击 next-btn对应Python中的第一步
for attempt := 0; attempt < p.maxRetries; attempt++ {
nextBtns, err := p.Page.Elements("button[class*='next-btn']")
if err != nil || len(nextBtns) == 0 {
nextBtns, err = p.Page.Elements(".next-btn")
}
if err == nil && len(nextBtns) > 0 {
if err := p.JSClick(nextBtns[0]); err != nil {
p.LogInfo(fmt.Sprintf("点击next-btn失败: %v", err))
} else {
p.LogInfo("已点击next-btn")
p.SleepMs(1000)
}
}
p.SleepMs(p.retryDelay)
}
// 进入发布设置页面点击submit按钮
p.LogInfo("进入发布设置页面...")
for attempt := 0; attempt < p.maxRetries; attempt++ {
submitBtn, err := p.WaitForElement("button[class*='submit']", 3)
if err != nil {
submitBtn, err = p.WaitForElement("button.submit", 3)
}
if err == nil && submitBtn != nil {
if err := p.JSClick(submitBtn); err != nil {
p.LogInfo(fmt.Sprintf("点击submit按钮失败: %v", err))
} else {
p.LogInfo("已点击submit按钮")
p.SleepMs(2000)
break
}
}
p.LogInfo(fmt.Sprintf("未找到submit按钮第%d次重试...", attempt+1))
p.SleepMs(p.retryDelay)
}
// 输入话题标签
p.LogInfo("输入话题标签...")
tiptap, err := p.WaitForElement(".tiptap-container", 10)
if err == nil && tiptap != nil {
editors, err := tiptap.Elements("[contenteditable='true']")
if err == nil && len(editors) > 0 {
// 将tags转换为 #tag1 #tag2 格式
var tagStrings []string
for _, tag := range p.Tags {
if tag != "" && strings.TrimSpace(tag) != "" {
tagStrings = append(tagStrings, "#"+tag)
}
}
tagString := strings.Join(tagStrings, " ")
p.LogInfo(fmt.Sprintf("输入标签: %s", tagString))
if err := p.JSInputContentEditable(editors[0], tagString); err != nil {
p.LogInfo(fmt.Sprintf("输入标签失败: %v", err))
}
p.SleepMs(1000)
}
}
p.SleepMs(2000)
// 最终发布
p.LogInfo("最终发布...")
for attempt := 0; attempt < p.maxRetries; attempt++ {
if p.CheckElementExists(".publish-page-publish-btn", 2) {
publishDiv, err := p.Page.Element(".publish-page-publish-btn")
if err == nil && publishDiv != nil {
buttons, err := publishDiv.Elements("button")
if err == nil && len(buttons) >= 2 {
if err := p.JSClick(buttons[1]); err != nil {
p.LogInfo(fmt.Sprintf("点击最终发布按钮失败: %v", err))
} else {
p.LogInfo("已点击最终发布按钮")
break
}
}
}
}
p.SleepMs(p.retryDelay)
}
return nil
}
func (p *XiaohongshuPublisher) waitForPublishResult() (bool, string) {
p.LogInfo("等待发布结果...")
for attempt := 0; attempt < 30; attempt++ {
// 检查是否出现失败提示
toastDiv, err := p.WaitForElement(".creator-publish-toast", 2)
if err == nil && toastDiv != nil {
toastText, err := toastDiv.Text()
if err == nil && toastText != "" {
p.LogInfo(fmt.Sprintf("发布失败提示: %s", toastText))
return false, fmt.Sprintf("发布失败: %s", toastText)
}
}
// 检查URL是否包含success
info, err := p.Page.Info()
if err == nil && strings.Contains(info.URL, "success") {
p.LogInfo(fmt.Sprintf("发布成功URL包含success: %s", info.URL))
return true, "发布成功"
}
p.SleepMs(1000)
}
return false, "发布结果未知"
}
// JSInputContentEditable 向contenteditable元素输入内容
func (p *XiaohongshuPublisher) JSInputContentEditable(element *rod.Element, text string) error {
_, err := element.Eval(fmt.Sprintf(`() => { this.innerText = %s; }`, jsQuote(text)))
return err
}
// CheckElementExists 检查元素是否存在
func (p *XiaohongshuPublisher) CheckElementExists(selector string, timeout int) bool {
_, err := p.WaitForElement(selector, timeout)
return err == nil
}
func jsQuote(s string) string {
return "`" + strings.ReplaceAll(s, "`", "\\`") + "`"
}
func (p *XiaohongshuPublisher) CheckLoginStatus() bool {
url := p.GetCurrentURL()
// 如果URL包含登录相关关键词表示未登录
if strings.Contains(url, p.LoginURL) {
return false
}
return true
}
func (p *XiaohongshuPublisher) PublishNote() (bool, string) {
p.LogInfo(strings.Repeat("=", 50))
p.LogInfo("开始发布小红书笔记...")
p.LogInfo(fmt.Sprintf("标题: %s", p.Title))
p.LogInfo(fmt.Sprintf("内容长度: %d", len(p.Content)))
p.LogInfo(fmt.Sprintf("标签: %v", p.Tags))
p.LogInfo(strings.Repeat("=", 50))
// 初始化浏览器
if err := p.SetupDriver(); err != nil {
return false, fmt.Sprintf("浏览器启动失败: %v", err)
}
defer p.Close()
// 访问发布页面
p.Page.MustNavigate(p.LoginedURL)
p.WaitForPageReady(5)
// 尝试加载cookies
if err := p.LoadCookies(); err == nil {
p.RefreshPage()
p.Sleep(2)
if p.CheckLoginStatus() {
p.LogInfo("使用cookies登录成功")
} else {
p.LogInfo("cookies已过期需要重新登录")
return false, "需要登录"
}
}
// 检查登录状态
if !p.CheckLoginStatus() {
return false, "需要登录"
}
// 保存cookies
p.SaveCookies()
// 访问发布页面
p.Page.MustNavigate(p.EditorURL)
p.WaitForPageReady(5)
// 执行发布流程之前,先点击上传区域的第一个按钮
p.LogInfo("点击上传按钮...")
uploadDiv, err := p.WaitForElement(".upload-content", 10)
if err != nil {
p.LogInfo(fmt.Sprintf("未找到上传区域: %v", err))
} else {
buttons, err := uploadDiv.Elements("button")
if err != nil {
p.LogInfo(fmt.Sprintf("查找按钮失败: %v", err))
} else if len(buttons) > 0 {
if err := p.JSClick(buttons[0]); err != nil {
p.LogInfo(fmt.Sprintf("JS点击按钮失败: %v", err))
} else {
p.LogInfo("已点击上传按钮")
p.Sleep(1)
}
}
}
// 执行发布流程
steps := []struct {
name string
fn func() error
}{
{"输入内容", p.inputContent},
{"输入标题", p.inputTitle},
}
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)
}
// 点击发布
if err := p.clickPublish(); err != nil {
return false, err.Error()
}
// 等待发布结果
return p.waitForPublishResult()
}