Files
xiaohongshu-mcp/xiaohongshu/publish.go
zy 47aba20735 feat: 添加小红书标签功能支持 (#65)
* feat: 实现小红书标签输入和自动关联功能

- 在 PublishImageContent 结构体中添加 Tags 字段
- 实现 inputTags 函数处理多个标签输入
- 实现 inputTag 函数自动点击标签联想下拉框
- 通过 #creator-editor-topic-container 选择器定位标签下拉框
- 自动点击第一个 .item 元素完成标签关联
- 添加错误处理和日志记录

* feat: 为 MCP 和 HTTP API 添加标签(tags)支持

- 在 PublishRequest 结构体中添加 Tags 字段
- 更新 MCP handler 处理标签参数
- 更新 MCP 工具定义,添加 tags 参数说明
- HTTP API 自动支持 tags 字段(通过 PublishRequest)
- 保持向后兼容,tags 为可选参数
2025-09-14 15:06:23 +08:00

255 lines
5.8 KiB
Go

package xiaohongshu
import (
"context"
"log/slog"
"strings"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/pkg/errors"
)
// 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(60 * time.Second)
pp.MustNavigate(urlOfPublic)
pp.MustElement(`div.upload-content`).MustWaitVisible()
slog.Info("wait for upload-content visible success")
// 等待一段时间确保页面完全加载
time.Sleep(1 * time.Second)
createElems := pp.MustElements("div.creator-tab")
slog.Info("foundcreator-tab elements", "count", len(createElems))
for _, elem := range createElems {
text, err := elem.Text()
if err != nil {
slog.Error("获取元素文本失败", "error", err)
continue
}
if text == "上传图文" {
if err := elem.Click(proto.InputMouseButtonLeft, 1); err != nil {
slog.Error("点击元素失败", "error", err)
continue
}
break
}
}
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, "小红书上传图片失败")
}
if err := submitPublish(page, content.Title, content.Content, content.Tags); err != nil {
return errors.Wrap(err, "小红书发布失败")
}
return nil
}
func uploadImages(page *rod.Page, imagesPaths []string) error {
pp := page.Timeout(30 * time.Second)
// 等待上传输入框出现
uploadInput := pp.MustElement(".upload-input")
// 上传多个文件
uploadInput.MustSetFiles(imagesPaths...)
// 等待上传完成
time.Sleep(3 * time.Second)
return nil
}
func submitPublish(page *rod.Page, title, content string, tags []string) error {
titleElem := page.MustElement("div.d-input input")
titleElem.MustInput(title)
time.Sleep(1 * time.Second)
if contentElem, ok := getContentElement(page); ok {
contentElem.MustInput(content)
inputTags(contentElem, tags)
} else {
return errors.New("没有找到内容输入框")
}
submitButton := page.MustElement("div.submit div.d-button-content")
submitButton.MustClick()
time.Sleep(3 * time.Second)
return nil
}
// 查找内容输入框 - 使用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
}
contentElem.MustInput("\n\n")
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(500 * time.Millisecond)
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
}