Files
xiaohongshu-mcp/xiaohongshu/publish.go
zy 8916dacdab publish: check title/content max length
检查 “标题”/“正文” 是否超出小红书要求的最大长度
2025-12-17 00:19:11 +08:00

502 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 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
}
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)
pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable()
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", content.Title, len(content.ImagePaths), tags)
if err := submitPublish(page, content.Title, content.Content, tags); 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 {
pp := page.Timeout(30 * time.Second)
// 验证文件路径有效性
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)
}
// 等待上传输入框出现
uploadInput := pp.MustElement(".upload-input")
// 上传多个文件
uploadInput.MustSetFiles(validPaths...)
// 等待并验证上传完成
return waitForUploadComplete(pp, len(validPaths))
}
// waitForUploadComplete 等待并验证上传完成
func waitForUploadComplete(page *rod.Page, expectedCount int) error {
maxWaitTime := 60 * time.Second
checkInterval := 500 * time.Millisecond
start := time.Now()
slog.Info("开始等待图片上传完成", "expected_count", expectedCount)
for time.Since(start) < maxWaitTime {
// 使用具体的pr类名检查已上传的图片
uploadedImages, err := page.Elements(".img-preview-area .pr")
slog.Info("uploadedImages", "uploadedImages", uploadedImages)
if err == nil {
currentCount := len(uploadedImages)
slog.Info("检测到已上传图片", "current_count", currentCount, "expected_count", expectedCount)
if currentCount >= expectedCount {
slog.Info("所有图片上传完成", "count", currentCount)
return nil
}
} else {
slog.Debug("未找到已上传图片元素")
}
time.Sleep(checkInterval)
}
return errors.New("上传超时,请检查网络连接和图片大小")
}
func submitPublish(page *rod.Page, title, content string, tags []string) error {
titleElem := page.MustElement("div.d-input input")
titleElem.MustInput(title)
// 检查一下 title 的长度
time.Sleep(500 * time.Millisecond) // 等待页面渲染长度提示
if err := checkTitleMaxLength(page); err != nil {
return err
}
slog.Info("检查标题长度:通过")
time.Sleep(1 * time.Second)
if contentElem, ok := getContentElement(page); ok {
contentElem.MustInput(content)
inputTags(contentElem, tags)
} else {
return errors.New("没有找到内容输入框")
}
time.Sleep(1 * time.Second)
// 正文的长度的判定:
if err := checkContentMaxLength(page); err != nil {
return err
}
slog.Info("检查正文长度:通过")
submitButton := page.MustElement("div.submit div.d-button-content")
submitButton.MustClick()
time.Sleep(3 * time.Second)
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) {
if len(tags) == 0 {
return
}
time.Sleep(1 * time.Second)
for i := 0; i < 20; i++ {
contentElem.MustKeyActions().
Type(input.ArrowDown).
MustDo()
time.Sleep(10 * time.Millisecond)
}
contentElem.MustKeyActions().
Press(input.Enter).
Press(input.Enter).
MustDo()
time.Sleep(1 * time.Second)
for _, tag := range tags {
tag = strings.TrimLeft(tag, "#")
inputTag(contentElem, tag)
}
}
func inputTag(contentElem *rod.Element, tag string) {
contentElem.MustInput("#")
time.Sleep(200 * time.Millisecond)
for _, char := range tag {
contentElem.MustInput(string(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 {
firstItem, err := topicContainer.Element(".item")
if err == nil && firstItem != nil {
firstItem.MustClick()
slog.Info("成功点击标签联想选项", "tag", tag)
time.Sleep(200 * time.Millisecond)
} else {
slog.Warn("未找到标签联想选项,直接输入空格", "tag", tag)
// 如果没有找到联想选项,输入空格结束
contentElem.MustInput(" ")
}
} else {
slog.Warn("未找到标签联想下拉框,直接输入空格", "tag", tag)
// 如果没有找到下拉框,输入空格结束
contentElem.MustInput(" ")
}
time.Sleep(500 * time.Millisecond) // 等待标签处理完成
}
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
}