484 lines
12 KiB
Go
484 lines
12 KiB
Go
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()
|
||
}
|