Files
xiaohongshu-mcp/xiaohongshu/publish.go
tanjun db81fd8f80 fix: 修复商品绑定后卡住在等待弹窗关闭的问题
- 使用 page.Has 替代 page.Element 检测弹窗关闭,避免阻塞等待
- 简化 clickModalSaveButton,找不到按钮时不返回错误,继续执行
- 删除未使用的 clickConfirmButton 函数
- 添加详细日志输出便于调试
- 商品绑定完成后增加 1 秒等待时间

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:24:32 +08:00

1103 lines
30 KiB
Go
Raw Permalink 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 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
}
// 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)
}
// 点击保存按钮
slog.Info("准备点击保存按钮")
if err := clickModalSaveButton(page, modal); err != nil {
return errors.Wrap(err, "点击保存按钮失败")
}
slog.Info("保存按钮点击完成,开始等待弹窗关闭")
// 等待弹窗关闭
if err := waitForModalClose(page); err != nil {
slog.Warn("等待弹窗关闭超时", "error", err)
} else {
slog.Info("弹窗已关闭")
}
if len(failedProducts) > 0 {
return errors.Errorf("部分商品未找到: %v", failedProducts)
}
slog.Info("商品绑定完成", "total", len(products))
time.Sleep(1000 * time.Millisecond)
return nil
}
// clickAddProductButton 点击"添加商品"按钮
func clickAddProductButton(page *rod.Page) error {
slog.Info("开始查找添加商品按钮")
// 查找包含"添加商品"文本的元素
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) == "添加商品" {
slog.Info("找到添加商品文本,向上查找可点击父元素")
// 向上查找可点击的父元素
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("已点击添加商品按钮")
time.Sleep(300 * time.Millisecond) // 确保弹窗动画开始
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("已点击添加商品按钮")
time.Sleep(300 * time.Millisecond) // 确保弹窗动画开始
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 {
slog.Info("商品选择弹窗已出现")
return modal, nil
}
}
time.Sleep(100 * time.Millisecond) // 缩短轮询间隔,更快响应
}
return nil, errors.New("等待商品选择弹窗超时")
}
// searchAndSelectProduct 搜索并选择商品
func searchAndSelectProduct(page *rod.Page, modal *rod.Element, keyword string) error {
slog.Info("搜索商品", "keyword", keyword)
// 1. 获取搜索框
searchInput, err := modal.Element(`input[placeholder="搜索商品ID 或 商品名称"]`)
if err != nil {
return errors.Wrap(err, "未找到商品搜索框")
}
// 2. 清空并输入关键词(使用原生 JS setter + 完整事件)
if err := searchInput.SelectAllText(); err != nil {
slog.Warn("选择搜索框文本失败", "error", err)
}
time.Sleep(100 * time.Millisecond)
// 使用 rod Input 输入关键词
if err := searchInput.Input(keyword); err != nil {
return errors.Wrap(err, "输入搜索关键词失败")
}
time.Sleep(300 * time.Millisecond)
// 3. 触发搜索(模拟键盘 Enter
if err := page.Keyboard.Press(input.Enter); err != nil {
return errors.Wrap(err, "触发搜索失败")
}
// 4. 等待搜索结果加载
time.Sleep(1 * time.Second)
// 等待 loading 消失(使用与工作代码相同的选择器)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
loading, err := modal.Element(".goods-list-loading")
if err != nil || loading == nil {
break
}
visible, _ := loading.Visible()
if !visible {
break
}
time.Sleep(100 * time.Millisecond)
}
// 等待商品列表渲染完成(使用与工作代码相同的选择器)
for time.Now().Before(deadline) {
productList, err := modal.Element(".goods-list-normal .good-card-container")
if err == nil && productList != nil {
break
}
time.Sleep(100 * time.Millisecond)
}
time.Sleep(500 * time.Millisecond) // 额外等待确保渲染完成
// 5. 点击第一个商品的 checkbox使用与工作代码相同的选择器
checkbox, err := modal.Element(".goods-list-normal .good-card-container .d-checkbox")
if err != nil {
return errors.Wrap(err, "未找到商品选择框")
}
// 检查是否已经选中
isChecked, err := checkbox.Eval(`(el) => {
return el.querySelector('.d-checkbox-simulator.checked') !== null ||
el.querySelector('input[type="checkbox"]:checked') !== null;
}`)
if err == nil && isChecked.Value.Bool() {
slog.Info("商品已选中,跳过", "keyword", keyword)
return nil
}
if err := checkbox.Click(proto.InputMouseButtonLeft, 1); err != nil {
return errors.Wrap(err, "点击商品选择框失败")
}
// 6. 随机延迟模拟人为操作800-1500ms
randomDelay := 800 + rand.Intn(700)
time.Sleep(time.Duration(randomDelay) * time.Millisecond)
slog.Info("已选择商品", "keyword", keyword)
return nil
}
// clickModalSaveButton 点击保存按钮
func clickModalSaveButton(page *rod.Page, modal *rod.Element) error {
// 查找保存按钮(参考工作代码:直接查找并点击,不强制要求找到)
btn, err := modal.Element(".goods-selected-footer button")
if err == nil && btn != nil {
if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
slog.Warn("点击保存按钮失败", "error", err)
} else {
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 {
slog.Warn("点击主按钮失败", "error", err)
} else {
slog.Info("已点击主按钮")
return nil
}
}
slog.Warn("未找到保存按钮,继续执行")
return nil
}
// waitForModalClose 等待弹窗关闭
func waitForModalClose(page *rod.Page) error {
deadline := time.Now().Add(5 * time.Second)
slog.Info("开始等待弹窗关闭")
for time.Now().Before(deadline) {
// 使用 Has 代替 Element避免等待元素出现的阻塞
has, _, err := page.Has(".multi-goods-selector-modal")
if err != nil || !has {
slog.Info("弹窗已关闭")
return nil
}
time.Sleep(200 * time.Millisecond)
}
return errors.New("等待弹窗关闭超时")
}