- 在图文发布和视频发布流程中集成商品绑定功能 - 新增 Products 字段到发布请求结构体 - 实现 go-rod 原生商品绑定函数(bindProducts) - 商品绑定失败将阻断发布流程并返回具体错误信息 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1105 lines
29 KiB
Go
1105 lines
29 KiB
Go
package xiaohongshu
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"math/rand"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-rod/rod"
|
||
"github.com/go-rod/rod/lib/input"
|
||
"github.com/go-rod/rod/lib/proto"
|
||
"github.com/pkg/errors"
|
||
"github.com/sirupsen/logrus"
|
||
)
|
||
|
||
// PublishImageContent 发布图文内容
|
||
type PublishImageContent struct {
|
||
Title string
|
||
Content string
|
||
Tags []string
|
||
ImagePaths []string
|
||
ScheduleTime *time.Time // 定时发布时间,nil 表示立即发布
|
||
IsOriginal bool // 是否声明原创
|
||
Visibility string // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
||
Products []string // 商品关键词列表,用于绑定带货商品
|
||
}
|
||
|
||
type PublishAction struct {
|
||
page *rod.Page
|
||
}
|
||
|
||
const (
|
||
urlOfPublic = `https://creator.xiaohongshu.com/publish/publish?source=official`
|
||
)
|
||
|
||
func NewPublishImageAction(page *rod.Page) (*PublishAction, error) {
|
||
|
||
pp := page.Timeout(300 * time.Second)
|
||
|
||
// 使用更稳健的导航和等待策略
|
||
if err := pp.Navigate(urlOfPublic); err != nil {
|
||
return nil, errors.Wrap(err, "导航到发布页面失败")
|
||
}
|
||
|
||
// 等待页面加载,使用 WaitLoad 代替 WaitIdle(更宽松)
|
||
if err := pp.WaitLoad(); err != nil {
|
||
logrus.Warnf("等待页面加载出现问题: %v,继续尝试", err)
|
||
}
|
||
time.Sleep(2 * time.Second)
|
||
|
||
// 等待页面稳定
|
||
if err := pp.WaitDOMStable(time.Second, 0.1); err != nil {
|
||
logrus.Warnf("等待 DOM 稳定出现问题: %v,继续尝试", err)
|
||
}
|
||
time.Sleep(1 * time.Second)
|
||
|
||
if err := mustClickPublishTab(pp, "上传图文"); err != nil {
|
||
logrus.Errorf("点击上传图文 TAB 失败: %v", err)
|
||
return nil, err
|
||
}
|
||
|
||
time.Sleep(1 * time.Second)
|
||
|
||
return &PublishAction{
|
||
page: pp,
|
||
}, nil
|
||
}
|
||
|
||
func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent) error {
|
||
if len(content.ImagePaths) == 0 {
|
||
return errors.New("图片不能为空")
|
||
}
|
||
|
||
page := p.page.Context(ctx)
|
||
|
||
if err := uploadImages(page, content.ImagePaths); err != nil {
|
||
return errors.Wrap(err, "小红书上传图片失败")
|
||
}
|
||
|
||
tags := content.Tags
|
||
if len(tags) >= 10 {
|
||
logrus.Warnf("标签数量超过10,截取前10个标签")
|
||
tags = tags[:10]
|
||
}
|
||
|
||
logrus.Infof("发布内容: title=%s, images=%v, tags=%v, schedule=%v, original=%v, visibility=%s, products=%v", content.Title, len(content.ImagePaths), tags, content.ScheduleTime, content.IsOriginal, content.Visibility, content.Products)
|
||
|
||
if err := submitPublish(page, content.Title, content.Content, tags, content.ScheduleTime, content.IsOriginal, content.Visibility, content.Products); err != nil {
|
||
return errors.Wrap(err, "小红书发布失败")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func removePopCover(page *rod.Page) {
|
||
|
||
// 先移除弹窗封面
|
||
has, elem, err := page.Has("div.d-popover")
|
||
if err != nil {
|
||
return
|
||
}
|
||
if has {
|
||
elem.MustRemove()
|
||
}
|
||
|
||
// 兜底:点击一下空位置吧
|
||
clickEmptyPosition(page)
|
||
}
|
||
|
||
func clickEmptyPosition(page *rod.Page) {
|
||
x := 380 + rand.Intn(100)
|
||
y := 20 + rand.Intn(60)
|
||
page.Mouse.MustMoveTo(float64(x), float64(y)).MustClick(proto.InputMouseButtonLeft)
|
||
}
|
||
|
||
func mustClickPublishTab(page *rod.Page, tabname string) error {
|
||
page.MustElement(`div.upload-content`).MustWaitVisible()
|
||
|
||
deadline := time.Now().Add(15 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
tab, blocked, err := getTabElement(page, tabname)
|
||
if err != nil {
|
||
logrus.Warnf("获取发布 TAB 元素失败: %v", err)
|
||
time.Sleep(200 * time.Millisecond)
|
||
continue
|
||
}
|
||
|
||
if tab == nil {
|
||
time.Sleep(200 * time.Millisecond)
|
||
continue
|
||
}
|
||
|
||
if blocked {
|
||
logrus.Info("发布 TAB 被遮挡,尝试移除遮挡")
|
||
removePopCover(page)
|
||
time.Sleep(200 * time.Millisecond)
|
||
continue
|
||
}
|
||
|
||
if err := tab.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
logrus.Warnf("点击发布 TAB 失败: %v", err)
|
||
time.Sleep(200 * time.Millisecond)
|
||
continue
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
return errors.Errorf("没有找到发布 TAB - %s", tabname)
|
||
}
|
||
|
||
func getTabElement(page *rod.Page, tabname string) (*rod.Element, bool, error) {
|
||
elems, err := page.Elements("div.creator-tab")
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
|
||
for _, elem := range elems {
|
||
if !isElementVisible(elem) {
|
||
continue
|
||
}
|
||
|
||
text, err := elem.Text()
|
||
if err != nil {
|
||
logrus.Debugf("获取发布 TAB 文本失败: %v", err)
|
||
continue
|
||
}
|
||
|
||
if strings.TrimSpace(text) != tabname {
|
||
continue
|
||
}
|
||
|
||
blocked, err := isElementBlocked(elem)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
|
||
return elem, blocked, nil
|
||
}
|
||
|
||
return nil, false, nil
|
||
}
|
||
|
||
func isElementBlocked(elem *rod.Element) (bool, error) {
|
||
result, err := elem.Eval(`() => {
|
||
const rect = this.getBoundingClientRect();
|
||
if (rect.width === 0 || rect.height === 0) {
|
||
return true;
|
||
}
|
||
const x = rect.left + rect.width / 2;
|
||
const y = rect.top + rect.height / 2;
|
||
const target = document.elementFromPoint(x, y);
|
||
return !(target === this || this.contains(target));
|
||
}`)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
return result.Value.Bool(), nil
|
||
}
|
||
|
||
func uploadImages(page *rod.Page, imagesPaths []string) error {
|
||
// 验证文件路径有效性
|
||
validPaths := make([]string, 0, len(imagesPaths))
|
||
for _, path := range imagesPaths {
|
||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||
logrus.Warnf("图片文件不存在: %s", path)
|
||
continue
|
||
}
|
||
validPaths = append(validPaths, path)
|
||
logrus.Infof("获取有效图片:%s", path)
|
||
}
|
||
|
||
// 逐张上传:每张上传后等待预览出现,再上传下一张
|
||
for i, path := range validPaths {
|
||
selector := `input[type="file"]`
|
||
if i == 0 {
|
||
selector = ".upload-input"
|
||
}
|
||
|
||
uploadInput, err := page.Element(selector)
|
||
if err != nil {
|
||
return errors.Wrapf(err, "查找上传输入框失败(第%d张)", i+1)
|
||
}
|
||
if err := uploadInput.SetFiles([]string{path}); err != nil {
|
||
return errors.Wrapf(err, "上传第%d张图片失败", i+1)
|
||
}
|
||
|
||
slog.Info("图片已提交上传", "index", i+1, "path", path)
|
||
|
||
// 等待当前图片上传完成(预览元素数量达到 i+1),最多等 60 秒
|
||
if err := waitForUploadComplete(page, i+1); err != nil {
|
||
return errors.Wrapf(err, "第%d张图片上传超时", i+1)
|
||
}
|
||
time.Sleep(1 * time.Second)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// waitForUploadComplete 等待第 expectedCount 张图片上传完成,最多等 60 秒
|
||
func waitForUploadComplete(page *rod.Page, expectedCount int) error {
|
||
maxWaitTime := 60 * time.Second
|
||
checkInterval := 500 * time.Millisecond
|
||
start := time.Now()
|
||
lastLogCount := expectedCount - 1
|
||
|
||
for time.Since(start) < maxWaitTime {
|
||
uploadedImages, err := page.Elements(".img-preview-area .pr")
|
||
if err != nil {
|
||
time.Sleep(checkInterval)
|
||
continue
|
||
}
|
||
|
||
currentCount := len(uploadedImages)
|
||
// 数量变化时才打印,避免刷屏
|
||
if currentCount != lastLogCount {
|
||
slog.Info("等待图片上传", "current", currentCount, "expected", expectedCount)
|
||
lastLogCount = currentCount
|
||
}
|
||
if currentCount >= expectedCount {
|
||
slog.Info("图片上传完成", "count", currentCount)
|
||
return nil
|
||
}
|
||
|
||
time.Sleep(checkInterval)
|
||
}
|
||
|
||
return errors.Errorf("第%d张图片上传超时(60s),请检查网络连接和图片大小", expectedCount)
|
||
}
|
||
|
||
func submitPublish(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, isOriginal bool, visibility string, products []string) error {
|
||
titleElem, err := page.Element("div.d-input input")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找标题输入框失败")
|
||
}
|
||
if err := titleElem.Input(title); err != nil {
|
||
return errors.Wrap(err, "输入标题失败")
|
||
}
|
||
|
||
// 检查标题长度
|
||
time.Sleep(500 * time.Millisecond)
|
||
if err := checkTitleMaxLength(page); err != nil {
|
||
return err
|
||
}
|
||
slog.Info("检查标题长度:通过")
|
||
|
||
time.Sleep(1 * time.Second)
|
||
|
||
contentElem, ok := getContentElement(page)
|
||
if !ok {
|
||
return errors.New("没有找到内容输入框")
|
||
}
|
||
if err := contentElem.Input(content); err != nil {
|
||
return errors.Wrap(err, "输入正文失败")
|
||
}
|
||
if err := waitAndClickTitleInput(titleElem); err != nil {
|
||
return err
|
||
}
|
||
if err := inputTags(contentElem, tags); err != nil {
|
||
return err
|
||
}
|
||
|
||
time.Sleep(1 * time.Second)
|
||
|
||
// 检查正文长度
|
||
if err := checkContentMaxLength(page); err != nil {
|
||
return err
|
||
}
|
||
slog.Info("检查正文长度:通过")
|
||
|
||
// 处理定时发布
|
||
if scheduleTime != nil {
|
||
if err := setSchedulePublish(page, *scheduleTime); err != nil {
|
||
return errors.Wrap(err, "设置定时发布失败")
|
||
}
|
||
slog.Info("定时发布设置完成", "schedule_time", scheduleTime.Format("2006-01-02 15:04"))
|
||
}
|
||
|
||
// 设置可见范围
|
||
if err := setVisibility(page, visibility); err != nil {
|
||
return errors.Wrap(err, "设置可见范围失败")
|
||
}
|
||
|
||
// 处理原创声明
|
||
if isOriginal {
|
||
if err := setOriginal(page); err != nil {
|
||
slog.Warn("设置原创声明失败,继续发布", "error", err)
|
||
} else {
|
||
slog.Info("已声明原创")
|
||
}
|
||
}
|
||
|
||
// 绑定商品
|
||
if err := bindProducts(page, products); err != nil {
|
||
return errors.Wrap(err, "绑定商品失败")
|
||
}
|
||
|
||
submitButton, err := page.Element(".publish-page-publish-btn button.bg-red")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找发布按钮失败")
|
||
}
|
||
if err := submitButton.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击发布按钮失败")
|
||
}
|
||
|
||
time.Sleep(3 * time.Second)
|
||
return nil
|
||
}
|
||
|
||
// waitAndClickTitleInput 在填写正文后等待 1 秒并回点标题输入框,增强后续交互稳定性
|
||
func waitAndClickTitleInput(titleElem *rod.Element) error {
|
||
slog.Info("正文填写完成,准备等待后回点标题输入框")
|
||
time.Sleep(1 * time.Second)
|
||
if err := titleElem.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "回点标题输入框失败")
|
||
}
|
||
slog.Info("已回点标题输入框,继续后续发布流程")
|
||
return nil
|
||
}
|
||
|
||
// 检查标题是否超过最大长度
|
||
func checkTitleMaxLength(page *rod.Page) error {
|
||
has, elem, err := page.Has(`div.title-container div.max_suffix`)
|
||
if err != nil {
|
||
return errors.Wrap(err, "检查标题长度元素失败")
|
||
}
|
||
|
||
// 元素不存在,说明标题没超长
|
||
if !has {
|
||
return nil
|
||
}
|
||
|
||
// 元素存在,说明标题超长
|
||
titleLength, err := elem.Text()
|
||
if err != nil {
|
||
return errors.Wrap(err, "获取标题长度文本失败")
|
||
}
|
||
|
||
return makeMaxLengthError(titleLength)
|
||
}
|
||
|
||
func checkContentMaxLength(page *rod.Page) error {
|
||
has, elem, err := page.Has(`div.edit-container div.length-error`)
|
||
if err != nil {
|
||
return errors.Wrap(err, "检查正文长度元素失败")
|
||
}
|
||
|
||
// 元素不存在,说明正文没超长
|
||
if !has {
|
||
return nil
|
||
}
|
||
|
||
// 元素存在,说明正文超长
|
||
contentLength, err := elem.Text()
|
||
if err != nil {
|
||
return errors.Wrap(err, "获取正文长度文本失败")
|
||
}
|
||
|
||
return makeMaxLengthError(contentLength)
|
||
}
|
||
|
||
func makeMaxLengthError(elemText string) error {
|
||
parts := strings.Split(elemText, "/")
|
||
if len(parts) != 2 {
|
||
return errors.Errorf("长度超过限制: %s", elemText)
|
||
}
|
||
|
||
currLen, maxLen := parts[0], parts[1]
|
||
|
||
return errors.Errorf("当前输入长度为%s,最大长度为%s", currLen, maxLen)
|
||
}
|
||
|
||
// 查找内容输入框 - 使用Race方法处理两种样式
|
||
func getContentElement(page *rod.Page) (*rod.Element, bool) {
|
||
var foundElement *rod.Element
|
||
var found bool
|
||
|
||
page.Race().
|
||
Element("div.ql-editor").MustHandle(func(e *rod.Element) {
|
||
foundElement = e
|
||
found = true
|
||
}).
|
||
ElementFunc(func(page *rod.Page) (*rod.Element, error) {
|
||
return findTextboxByPlaceholder(page)
|
||
}).MustHandle(func(e *rod.Element) {
|
||
foundElement = e
|
||
found = true
|
||
}).
|
||
MustDo()
|
||
|
||
if found {
|
||
return foundElement, true
|
||
}
|
||
|
||
slog.Warn("no content element found by any method")
|
||
return nil, false
|
||
}
|
||
|
||
func inputTags(contentElem *rod.Element, tags []string) error {
|
||
if len(tags) == 0 {
|
||
return nil
|
||
}
|
||
|
||
time.Sleep(1 * time.Second)
|
||
|
||
for i := 0; i < 20; i++ {
|
||
ka, err := contentElem.KeyActions()
|
||
if err != nil {
|
||
return errors.Wrap(err, "创建键盘操作失败")
|
||
}
|
||
if err := ka.Type(input.ArrowDown).Do(); err != nil {
|
||
return errors.Wrap(err, "按下方向键失败")
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
|
||
ka, err := contentElem.KeyActions()
|
||
if err != nil {
|
||
return errors.Wrap(err, "创建键盘操作失败")
|
||
}
|
||
if err := ka.Press(input.Enter).Press(input.Enter).Do(); err != nil {
|
||
return errors.Wrap(err, "按下回车键失败")
|
||
}
|
||
|
||
time.Sleep(1 * time.Second)
|
||
|
||
for _, tag := range tags {
|
||
tag = strings.TrimLeft(tag, "#")
|
||
if err := inputTag(contentElem, tag); err != nil {
|
||
return errors.Wrapf(err, "输入标签[%s]失败", tag)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func inputTag(contentElem *rod.Element, tag string) error {
|
||
if err := contentElem.Input("#"); err != nil {
|
||
return errors.Wrap(err, "输入#失败")
|
||
}
|
||
time.Sleep(200 * time.Millisecond)
|
||
|
||
for _, char := range tag {
|
||
if err := contentElem.Input(string(char)); err != nil {
|
||
return errors.Wrapf(err, "输入字符[%c]失败", char)
|
||
}
|
||
time.Sleep(50 * time.Millisecond)
|
||
}
|
||
|
||
time.Sleep(1 * time.Second)
|
||
|
||
page := contentElem.Page()
|
||
topicContainer, err := page.Element("#creator-editor-topic-container")
|
||
if err != nil || topicContainer == nil {
|
||
slog.Warn("未找到标签联想下拉框,直接输入空格", "tag", tag)
|
||
return contentElem.Input(" ")
|
||
}
|
||
|
||
firstItem, err := topicContainer.Element(".item")
|
||
if err != nil || firstItem == nil {
|
||
slog.Warn("未找到标签联想选项,直接输入空格", "tag", tag)
|
||
return contentElem.Input(" ")
|
||
}
|
||
|
||
if err := firstItem.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击标签联想选项失败")
|
||
}
|
||
slog.Info("成功点击标签联想选项", "tag", tag)
|
||
time.Sleep(200 * time.Millisecond)
|
||
|
||
time.Sleep(500 * time.Millisecond) // 等待标签处理完成
|
||
return nil
|
||
}
|
||
|
||
func findTextboxByPlaceholder(page *rod.Page) (*rod.Element, error) {
|
||
elements := page.MustElements("p")
|
||
if elements == nil {
|
||
return nil, errors.New("no p elements found")
|
||
}
|
||
|
||
// 查找包含指定placeholder的元素
|
||
placeholderElem := findPlaceholderElement(elements, "输入正文描述")
|
||
if placeholderElem == nil {
|
||
return nil, errors.New("no placeholder element found")
|
||
}
|
||
|
||
// 向上查找textbox父元素
|
||
textboxElem := findTextboxParent(placeholderElem)
|
||
if textboxElem == nil {
|
||
return nil, errors.New("no textbox parent found")
|
||
}
|
||
|
||
return textboxElem, nil
|
||
}
|
||
|
||
func findPlaceholderElement(elements []*rod.Element, searchText string) *rod.Element {
|
||
for _, elem := range elements {
|
||
placeholder, err := elem.Attribute("data-placeholder")
|
||
if err != nil || placeholder == nil {
|
||
continue
|
||
}
|
||
|
||
if strings.Contains(*placeholder, searchText) {
|
||
return elem
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func findTextboxParent(elem *rod.Element) *rod.Element {
|
||
currentElem := elem
|
||
for i := 0; i < 5; i++ {
|
||
parent, err := currentElem.Parent()
|
||
if err != nil {
|
||
break
|
||
}
|
||
|
||
role, err := parent.Attribute("role")
|
||
if err != nil || role == nil {
|
||
currentElem = parent
|
||
continue
|
||
}
|
||
|
||
if *role == "textbox" {
|
||
return parent
|
||
}
|
||
|
||
currentElem = parent
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// isElementVisible 检查元素是否可见
|
||
func isElementVisible(elem *rod.Element) bool {
|
||
|
||
// 检查是否有隐藏样式
|
||
style, err := elem.Attribute("style")
|
||
if err == nil && style != nil {
|
||
styleStr := *style
|
||
|
||
if strings.Contains(styleStr, "left: -9999px") ||
|
||
strings.Contains(styleStr, "top: -9999px") ||
|
||
strings.Contains(styleStr, "position: absolute; left: -9999px") ||
|
||
strings.Contains(styleStr, "display: none") ||
|
||
strings.Contains(styleStr, "visibility: hidden") {
|
||
return false
|
||
}
|
||
}
|
||
|
||
visible, err := elem.Visible()
|
||
if err != nil {
|
||
slog.Warn("无法获取元素可见性", "error", err)
|
||
return true
|
||
}
|
||
|
||
return visible
|
||
}
|
||
|
||
// setVisibility 设置可见范围
|
||
// 支持: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
||
func setVisibility(page *rod.Page, visibility string) error {
|
||
if visibility == "" || visibility == "公开可见" {
|
||
slog.Info("可见范围使用默认:公开可见")
|
||
return nil
|
||
}
|
||
|
||
// 支持的选项校验
|
||
supported := map[string]bool{"仅自己可见": true, "仅互关好友可见": true}
|
||
if !supported[visibility] {
|
||
return errors.Errorf("不支持的可见范围: %s,支持: 公开可见、仅自己可见、仅互关好友可见", visibility)
|
||
}
|
||
|
||
// 点击可见范围下拉框
|
||
dropdown, err := page.Element("div.permission-card-wrapper div.d-select-content")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找可见范围下拉框失败")
|
||
}
|
||
if err := dropdown.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击可见范围下拉框失败")
|
||
}
|
||
time.Sleep(500 * time.Millisecond)
|
||
|
||
// 在弹窗中查找并点击目标选项
|
||
opts, err := page.Elements("div.d-options-wrapper div.d-grid-item div.custom-option")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找可见范围选项失败")
|
||
}
|
||
for _, opt := range opts {
|
||
text, err := opt.Text()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if strings.Contains(text, visibility) {
|
||
if err := opt.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "选择可见范围失败")
|
||
}
|
||
slog.Info("已设置可见范围", "visibility", visibility)
|
||
time.Sleep(200 * time.Millisecond)
|
||
return nil
|
||
}
|
||
}
|
||
return errors.Errorf("未找到可见范围选项: %s", visibility)
|
||
}
|
||
|
||
// setSchedulePublish 设置定时发布时间
|
||
func setSchedulePublish(page *rod.Page, t time.Time) error {
|
||
// 1. 点击定时发布开关
|
||
if err := clickScheduleSwitch(page); err != nil {
|
||
return err
|
||
}
|
||
time.Sleep(800 * time.Millisecond)
|
||
|
||
// 2. 设置日期时间
|
||
if err := setDateTime(page, t); err != nil {
|
||
return err
|
||
}
|
||
time.Sleep(500 * time.Millisecond)
|
||
|
||
return nil
|
||
}
|
||
|
||
// clickScheduleSwitch 点击定时发布开关
|
||
func clickScheduleSwitch(page *rod.Page) error {
|
||
switchElem, err := page.Element(".post-time-wrapper .d-switch")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找定时发布开关失败")
|
||
}
|
||
|
||
if err := switchElem.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击定时发布开关失败")
|
||
}
|
||
slog.Info("已点击定时发布开关")
|
||
return nil
|
||
}
|
||
|
||
// setDateTime 设置日期时间
|
||
func setDateTime(page *rod.Page, t time.Time) error {
|
||
dateTimeStr := t.Format("2006-01-02 15:04")
|
||
|
||
input, err := page.Element(".date-picker-container input")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找日期时间输入框失败")
|
||
}
|
||
|
||
if err := input.SelectAllText(); err != nil {
|
||
return errors.Wrap(err, "选择日期时间文本失败")
|
||
}
|
||
if err := input.Input(dateTimeStr); err != nil {
|
||
return errors.Wrap(err, "输入日期时间失败")
|
||
}
|
||
slog.Info("已设置日期时间", "datetime", dateTimeStr)
|
||
|
||
return nil
|
||
}
|
||
|
||
// setOriginal 设置原创声明
|
||
func setOriginal(page *rod.Page) error {
|
||
// 根据小红书创作者页面的实际结构:
|
||
// div.custom-switch-card 包含 span.has-tips 文本为"原创声明"
|
||
// 开关是 div.d-switch 组件
|
||
|
||
// 查找包含"原创声明"文本的 custom-switch-card
|
||
switchCards, err := page.Elements("div.custom-switch-card")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找原创声明卡片失败")
|
||
}
|
||
|
||
for _, card := range switchCards {
|
||
text, err := card.Text()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
// 检查是否是原创声明卡片
|
||
if !strings.Contains(text, "原创声明") {
|
||
continue
|
||
}
|
||
|
||
// 找到原创声明卡片,查找其中的 d-switch
|
||
switchElem, err := card.Element("div.d-switch")
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
// 检查开关是否已打开
|
||
checked, err := switchElem.Eval(`() => {
|
||
const input = this.querySelector('input[type="checkbox"]');
|
||
return input ? input.checked : false;
|
||
}`)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
if checked.Value.Bool() {
|
||
slog.Info("原创声明已开启")
|
||
return nil
|
||
}
|
||
|
||
// 点击开关
|
||
if err := switchElem.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击原创声明开关失败")
|
||
}
|
||
|
||
time.Sleep(500 * time.Millisecond)
|
||
|
||
// 处理原创声明确认弹窗
|
||
if err := confirmOriginalDeclaration(page); err != nil {
|
||
return errors.Wrap(err, "确认原创声明失败")
|
||
}
|
||
|
||
slog.Info("已开启原创声明")
|
||
return nil
|
||
}
|
||
|
||
return errors.New("未找到原创声明选项")
|
||
}
|
||
|
||
// confirmOriginalDeclaration 处理原创声明确认弹窗
|
||
func confirmOriginalDeclaration(page *rod.Page) error {
|
||
// 等待确认弹窗出现
|
||
time.Sleep(800 * time.Millisecond)
|
||
|
||
// 使用 JavaScript 直接处理弹窗,更可靠
|
||
result, err := page.Eval(`
|
||
() => {
|
||
// 查找包含"原创声明须知"的 footer 区域
|
||
const footers = document.querySelectorAll('div.footer');
|
||
for (const footer of footers) {
|
||
// 检查是否包含原创声明相关内容
|
||
if (!footer.textContent.includes('原创声明须知')) {
|
||
continue;
|
||
}
|
||
|
||
// 找到 checkbox 并勾选
|
||
const checkbox = footer.querySelector('div.d-checkbox input[type="checkbox"]');
|
||
if (checkbox && !checkbox.checked) {
|
||
checkbox.click();
|
||
console.log('已勾选原创声明须知 checkbox');
|
||
}
|
||
|
||
// 等待一下让按钮变为可用
|
||
return 'found_footer';
|
||
}
|
||
return 'footer_not_found';
|
||
}
|
||
`)
|
||
if err != nil {
|
||
slog.Warn("执行查找弹窗脚本失败", "error", err)
|
||
} else if result.Value.String() == "footer_not_found" {
|
||
slog.Warn("未找到原创声明确认弹窗的 footer")
|
||
}
|
||
|
||
time.Sleep(500 * time.Millisecond)
|
||
|
||
// 再次使用 JavaScript 点击声明原创按钮
|
||
result2, err := page.Eval(`
|
||
() => {
|
||
const footers = document.querySelectorAll('div.footer');
|
||
for (const footer of footers) {
|
||
if (!footer.textContent.includes('声明原创')) {
|
||
continue;
|
||
}
|
||
|
||
// 找到声明原创按钮
|
||
const btn = footer.querySelector('button.custom-button');
|
||
if (btn) {
|
||
// 检查是否禁用
|
||
if (btn.classList.contains('disabled') || btn.disabled) {
|
||
// 尝试再次勾选 checkbox
|
||
const checkbox = footer.querySelector('div.d-checkbox input[type="checkbox"]');
|
||
if (checkbox && !checkbox.checked) {
|
||
checkbox.click();
|
||
}
|
||
return 'button_disabled';
|
||
}
|
||
btn.click();
|
||
return 'clicked';
|
||
}
|
||
}
|
||
return 'button_not_found';
|
||
}
|
||
`)
|
||
if err != nil {
|
||
return errors.Wrap(err, "执行点击按钮脚本失败")
|
||
}
|
||
|
||
status := result2.Value.String()
|
||
slog.Info("原创声明确认结果", "status", status)
|
||
|
||
if status == "button_not_found" {
|
||
return errors.New("未找到声明原创按钮")
|
||
}
|
||
if status == "button_disabled" {
|
||
return errors.New("声明原创按钮仍处于禁用状态")
|
||
}
|
||
|
||
slog.Info("已成功点击声明原创按钮")
|
||
time.Sleep(300 * time.Millisecond)
|
||
|
||
return nil
|
||
}
|
||
|
||
// clickConfirmButton 点击确定按钮
|
||
func clickConfirmButton(page *rod.Page) error {
|
||
// 查找日期选择器弹窗中的确定按钮
|
||
buttons, err := page.Elements("button.el-picker-panel__link-btn")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找确定按钮失败")
|
||
}
|
||
|
||
for _, btn := range buttons {
|
||
text, err := btn.Text()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(text) == "确定" {
|
||
if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击确定按钮失败")
|
||
}
|
||
slog.Info("已点击确定按钮")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
return errors.New("未找到确定按钮")
|
||
}
|
||
|
||
// bindProducts 绑定商品到发布内容
|
||
func bindProducts(page *rod.Page, products []string) error {
|
||
if len(products) == 0 {
|
||
return nil
|
||
}
|
||
|
||
slog.Info("开始绑定商品", "products", products)
|
||
|
||
// 点击"添加商品"按钮
|
||
if err := clickAddProductButton(page); err != nil {
|
||
return errors.Wrap(err, "点击添加商品按钮失败")
|
||
}
|
||
time.Sleep(1 * time.Second)
|
||
|
||
// 等待商品选择弹窗出现
|
||
modal, err := waitForProductModal(page)
|
||
if err != nil {
|
||
return errors.Wrap(err, "等待商品弹窗失败")
|
||
}
|
||
slog.Info("商品选择弹窗已打开")
|
||
|
||
// 遍历搜索并选择商品
|
||
var failedProducts []string
|
||
for _, keyword := range products {
|
||
if err := searchAndSelectProduct(page, modal, keyword); err != nil {
|
||
slog.Warn("搜索选择商品失败", "keyword", keyword, "error", err)
|
||
failedProducts = append(failedProducts, keyword)
|
||
}
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
|
||
// 点击保存按钮
|
||
if err := clickModalSaveButton(page, modal); err != nil {
|
||
return errors.Wrap(err, "点击保存按钮失败")
|
||
}
|
||
|
||
// 等待弹窗关闭
|
||
if err := waitForModalClose(page); err != nil {
|
||
slog.Warn("等待弹窗关闭超时", "error", err)
|
||
}
|
||
|
||
if len(failedProducts) > 0 {
|
||
return errors.Errorf("部分商品未找到: %v", failedProducts)
|
||
}
|
||
|
||
slog.Info("商品绑定完成", "total", len(products))
|
||
return nil
|
||
}
|
||
|
||
// clickAddProductButton 点击"添加商品"按钮
|
||
func clickAddProductButton(page *rod.Page) error {
|
||
// 查找包含"添加商品"文本的元素
|
||
spans, err := page.Elements("span.d-text")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找商品按钮文本失败")
|
||
}
|
||
|
||
for _, span := range spans {
|
||
text, err := span.Text()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(text) == "添加商品" {
|
||
// 向上查找可点击的父元素
|
||
parent := span
|
||
for i := 0; i < 5; i++ {
|
||
p, err := parent.Parent()
|
||
if err != nil {
|
||
break
|
||
}
|
||
parent = p
|
||
|
||
tagName, err := parent.Eval(`() => this.tagName.toLowerCase()`)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
tag := tagName.Value.Str()
|
||
|
||
// 检查是否为 button 或含 d-button class
|
||
if tag == "button" {
|
||
if err := parent.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击添加商品按钮失败")
|
||
}
|
||
slog.Info("已点击添加商品按钮")
|
||
return nil
|
||
}
|
||
|
||
cls, _ := parent.Attribute("class")
|
||
if cls != nil && strings.Contains(*cls, "d-button") {
|
||
if err := parent.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击添加商品按钮失败")
|
||
}
|
||
slog.Info("已点击添加商品按钮")
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return errors.New("未找到添加商品按钮,账号可能未开通商品功能")
|
||
}
|
||
|
||
// waitForProductModal 等待商品选择弹窗出现
|
||
func waitForProductModal(page *rod.Page) (*rod.Element, error) {
|
||
deadline := time.Now().Add(10 * time.Second)
|
||
|
||
for time.Now().Before(deadline) {
|
||
modal, err := page.Element(".multi-goods-selector-modal")
|
||
if err == nil && modal != nil {
|
||
visible, _ := modal.Visible()
|
||
if visible {
|
||
return modal, nil
|
||
}
|
||
}
|
||
time.Sleep(200 * time.Millisecond)
|
||
}
|
||
|
||
return nil, errors.New("等待商品选择弹窗超时")
|
||
}
|
||
|
||
// searchAndSelectProduct 搜索并选择商品
|
||
func searchAndSelectProduct(page *rod.Page, modal *rod.Element, keyword string) error {
|
||
slog.Info("搜索商品", "keyword", keyword)
|
||
|
||
// 获取搜索框
|
||
searchInput, err := modal.Element(`input[placeholder="搜索商品ID 或 商品名称"]`)
|
||
if err != nil {
|
||
return errors.Wrap(err, "未找到商品搜索框")
|
||
}
|
||
|
||
// 清空并输入关键词
|
||
if err := searchInput.SelectAllText(); err != nil {
|
||
slog.Warn("选择搜索框文本失败", "error", err)
|
||
}
|
||
time.Sleep(100 * time.Millisecond)
|
||
|
||
if err := searchInput.Input(keyword); err != nil {
|
||
return errors.Wrap(err, "输入搜索关键词失败")
|
||
}
|
||
time.Sleep(300 * time.Millisecond)
|
||
|
||
// 模拟回车触发搜索
|
||
if err := searchInput.MustKeyActions().Press(input.Enter).Do(); err != nil {
|
||
return errors.Wrap(err, "触发搜索失败")
|
||
}
|
||
|
||
// 等待搜索结果
|
||
time.Sleep(2 * time.Second)
|
||
|
||
// 等待 loading 消失
|
||
deadline := time.Now().Add(10 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
loading, err := modal.Element(".d-loading")
|
||
if err != nil || loading == nil {
|
||
break
|
||
}
|
||
visible, _ := loading.Visible()
|
||
if !visible {
|
||
break
|
||
}
|
||
time.Sleep(300 * time.Millisecond)
|
||
}
|
||
time.Sleep(500 * time.Millisecond)
|
||
|
||
// 点击第一个商品的 checkbox
|
||
checkbox, err := modal.Element(".goods-item .d-checkbox")
|
||
if err != nil {
|
||
return errors.Wrap(err, "未找到商品选择框")
|
||
}
|
||
|
||
if err := checkbox.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击商品选择框失败")
|
||
}
|
||
|
||
slog.Info("已选择商品", "keyword", keyword)
|
||
return nil
|
||
}
|
||
|
||
// clickModalSaveButton 点击保存按钮
|
||
func clickModalSaveButton(page *rod.Page, modal *rod.Element) error {
|
||
// 查找保存按钮
|
||
buttons, err := modal.Elements(".goods-selected-footer button")
|
||
if err != nil {
|
||
return errors.Wrap(err, "查找保存按钮失败")
|
||
}
|
||
|
||
for _, btn := range buttons {
|
||
text, err := btn.Text()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
// 保存按钮通常包含"保存"或"确定"文字
|
||
if strings.Contains(text, "保存") || strings.Contains(text, "确定") {
|
||
if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击保存按钮失败")
|
||
}
|
||
slog.Info("已点击保存按钮")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// 尝试点击主按钮
|
||
primaryBtn, err := modal.Element(".goods-selected-footer .d-button--primary")
|
||
if err == nil && primaryBtn != nil {
|
||
if err := primaryBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||
return errors.Wrap(err, "点击主按钮失败")
|
||
}
|
||
slog.Info("已点击主按钮")
|
||
return nil
|
||
}
|
||
|
||
return errors.New("未找到保存按钮")
|
||
}
|
||
|
||
// waitForModalClose 等待弹窗关闭
|
||
func waitForModalClose(page *rod.Page) error {
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
|
||
for time.Now().Before(deadline) {
|
||
modal, err := page.Element(".multi-goods-selector-modal")
|
||
if err != nil {
|
||
return nil // 弹窗已关闭
|
||
}
|
||
if modal == nil {
|
||
return nil
|
||
}
|
||
visible, _ := modal.Visible()
|
||
if !visible {
|
||
return nil
|
||
}
|
||
time.Sleep(200 * time.Millisecond)
|
||
}
|
||
|
||
return errors.New("等待弹窗关闭超时")
|
||
}
|