feat: 添加商品绑定功能

- 在图文发布和视频发布流程中集成商品绑定功能
- 新增 Products 字段到发布请求结构体
- 实现 go-rod 原生商品绑定函数(bindProducts)
- 商品绑定失败将阻断发布流程并返回具体错误信息

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
tanjun
2026-01-24 19:30:59 +08:00
committed by tan jun
parent bc7fc864b5
commit d092830b67
5 changed files with 306 additions and 7 deletions

View File

@@ -129,6 +129,7 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in
content, _ := args["content"].(string)
imagePathsInterface, _ := args["images"].([]interface{})
tagsInterface, _ := args["tags"].([]interface{})
productsInterface, _ := args["products"].([]interface{})
var imagePaths []string
for _, path := range imagePathsInterface {
@@ -144,6 +145,13 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in
}
}
var products []string
for _, p := range productsInterface {
if pStr, ok := p.(string); ok {
products = append(products, pStr)
}
}
// 解析定时发布参数
scheduleAt, _ := args["schedule_at"].(string)
visibility := parseVisibility(args)
@@ -151,7 +159,7 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in
// 解析原创参数
isOriginal, _ := args["is_original"].(bool)
logrus.Infof("MCP: 发布内容 - 标题: %s, 图片数量: %d, 标签数量: %d, 定时: %s, 原创: %v, visibility: %s", title, len(imagePaths), len(tags), scheduleAt, isOriginal, visibility)
logrus.Infof("MCP: 发布内容 - 标题: %s, 图片数量: %d, 标签数量: %d, 定时: %s, 原创: %v, visibility: %s, 商品: %v", title, len(imagePaths), len(tags), scheduleAt, isOriginal, visibility, products)
// 构建发布请求
req := &PublishRequest{
@@ -162,6 +170,7 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in
ScheduleAt: scheduleAt,
IsOriginal: isOriginal,
Visibility: visibility,
Products: products,
}
// 执行发布
@@ -193,6 +202,7 @@ func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]inte
content, _ := args["content"].(string)
videoPath, _ := args["video"].(string)
tagsInterface, _ := args["tags"].([]interface{})
productsInterface, _ := args["products"].([]interface{})
var tags []string
for _, tag := range tagsInterface {
@@ -201,6 +211,13 @@ func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]inte
}
}
var products []string
for _, p := range productsInterface {
if pStr, ok := p.(string); ok {
products = append(products, pStr)
}
}
if videoPath == "" {
return &MCPToolResult{
Content: []MCPContent{{
@@ -215,7 +232,7 @@ func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]inte
scheduleAt, _ := args["schedule_at"].(string)
visibility := parseVisibility(args)
logrus.Infof("MCP: 发布视频 - 标题: %s, 标签数量: %d, 定时: %s, visibility: %s", title, len(tags), scheduleAt, visibility)
logrus.Infof("MCP: 发布视频 - 标题: %s, 标签数量: %d, 定时: %s, visibility: %s, 商品: %v", title, len(tags), scheduleAt, visibility, products)
// 构建发布请求
req := &PublishVideoRequest{
@@ -225,6 +242,7 @@ func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]inte
Tags: tags,
ScheduleAt: scheduleAt,
Visibility: visibility,
Products: products,
}
// 执行发布

View File

@@ -24,6 +24,7 @@ type PublishContentArgs struct {
ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间可选ISO8601格式如 2024-01-20T10:30:00+08:00支持1小时至14天内。不填则立即发布"`
IsOriginal bool `json:"is_original,omitempty" jsonschema:"是否声明原创可选true为声明原创false或不填则不声明"`
Visibility string `json:"visibility,omitempty" jsonschema:"可见范围(可选),支持: 公开可见(默认)、仅自己可见、仅互关好友可见。不填则默认公开可见"`
Products []string `json:"products,omitempty" jsonschema:"商品关键词列表可选用于绑定带货商品。填写商品名称或商品ID系统会自动搜索并选择第一个匹配结果。需账号已开通商品功能。示例: [面膜, 防晒霜SPF50]"`
}
// PublishVideoArgs 发布视频的参数(仅支持本地单个视频文件)
@@ -34,6 +35,7 @@ type PublishVideoArgs struct {
Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"`
ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间可选ISO8601格式如 2024-01-20T10:30:00+08:00支持1小时至14天内。不填则立即发布"`
Visibility string `json:"visibility,omitempty" jsonschema:"可见范围(可选),支持: 公开可见(默认)、仅自己可见、仅互关好友可见。不填则默认公开可见"`
Products []string `json:"products,omitempty" jsonschema:"商品关键词列表可选用于绑定带货商品。填写商品名称或商品ID系统会自动搜索并选择第一个匹配结果。需账号已开通商品功能。示例: [面膜, 防晒霜SPF50]"`
}
// SearchFeedsArgs 搜索内容的参数
@@ -219,6 +221,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
"schedule_at": args.ScheduleAt,
"is_original": args.IsOriginal,
"visibility": args.Visibility,
"products": convertStringsToInterfaces(args.Products),
}
result := appServer.handlePublishContent(ctx, argsMap)
return convertToMCPResult(result), nil, nil
@@ -391,6 +394,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
"tags": convertStringsToInterfaces(args.Tags),
"schedule_at": args.ScheduleAt,
"visibility": args.Visibility,
"products": convertStringsToInterfaces(args.Products),
}
result := appServer.handlePublishVideo(ctx, argsMap)
return convertToMCPResult(result), nil, nil

View File

@@ -35,6 +35,7 @@ type PublishRequest struct {
ScheduleAt string `json:"schedule_at,omitempty"` // 定时发布时间ISO8601格式为空则立即发布
IsOriginal bool `json:"is_original,omitempty"` // 是否声明原创
Visibility string `json:"visibility,omitempty"` // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
Products []string `json:"products,omitempty"` // 商品关键词列表,用于绑定带货商品
}
// LoginStatusResponse 登录状态响应
@@ -67,6 +68,7 @@ type PublishVideoRequest struct {
Tags []string `json:"tags,omitempty"`
ScheduleAt string `json:"schedule_at,omitempty"` // 定时发布时间ISO8601格式为空则立即发布
Visibility string `json:"visibility,omitempty"` // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
Products []string `json:"products,omitempty"` // 商品关键词列表,用于绑定带货商品
}
// PublishVideoResponse 发布视频响应
@@ -217,6 +219,7 @@ func (s *XiaohongshuService) PublishContent(ctx context.Context, req *PublishReq
ScheduleTime: scheduleTime,
IsOriginal: req.IsOriginal,
Visibility: req.Visibility,
Products: req.Products,
}
// 执行发布
@@ -307,6 +310,7 @@ func (s *XiaohongshuService) PublishVideo(ctx context.Context, req *PublishVideo
VideoPath: req.Video,
ScheduleTime: scheduleTime,
Visibility: req.Visibility,
Products: req.Products,
}
// 执行发布

View File

@@ -24,6 +24,7 @@ type PublishImageContent struct {
ScheduleTime *time.Time // 定时发布时间nil 表示立即发布
IsOriginal bool // 是否声明原创
Visibility string // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
Products []string // 商品关键词列表,用于绑定带货商品
}
type PublishAction struct {
@@ -84,9 +85,9 @@ func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent
tags = tags[:10]
}
logrus.Infof("发布内容: title=%s, images=%v, tags=%v, schedule=%v, original=%v, visibility=%s", content.Title, len(content.ImagePaths), tags, content.ScheduleTime, content.IsOriginal, content.Visibility)
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); err != nil {
if err := submitPublish(page, content.Title, content.Content, tags, content.ScheduleTime, content.IsOriginal, content.Visibility, content.Products); err != nil {
return errors.Wrap(err, "小红书发布失败")
}
@@ -270,7 +271,7 @@ func waitForUploadComplete(page *rod.Page, expectedCount int) error {
return errors.Errorf("第%d张图片上传超时(60s),请检查网络连接和图片大小", expectedCount)
}
func submitPublish(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, isOriginal bool, visibility string) error {
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, "查找标题输入框失败")
@@ -332,6 +333,11 @@ func submitPublish(page *rod.Page, title, content string, tags []string, schedul
}
}
// 绑定商品
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, "查找发布按钮失败")
@@ -835,3 +841,264 @@ func confirmOriginalDeclaration(page *rod.Page) error {
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("等待弹窗关闭超时")
}

View File

@@ -21,6 +21,7 @@ type PublishVideoContent struct {
VideoPath string
ScheduleTime *time.Time // 定时发布时间nil 表示立即发布
Visibility string // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
Products []string // 商品关键词列表,用于绑定带货商品
}
// NewPublishVideoAction 进入发布页并切换到"上传视频"
@@ -63,7 +64,7 @@ func (p *PublishAction) PublishVideo(ctx context.Context, content PublishVideoCo
return errors.Wrap(err, "小红书上传视频失败")
}
if err := submitPublishVideo(page, content.Title, content.Content, content.Tags, content.ScheduleTime, content.Visibility); err != nil {
if err := submitPublishVideo(page, content.Title, content.Content, content.Tags, content.ScheduleTime, content.Visibility, content.Products); err != nil {
return errors.Wrap(err, "小红书发布失败")
}
return nil
@@ -131,7 +132,7 @@ func waitForPublishButtonClickable(page *rod.Page) (*rod.Element, error) {
}
// submitPublishVideo 填写标题、正文、标签并点击发布(等待按钮可点击后再提交)
func submitPublishVideo(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, visibility string) error {
func submitPublishVideo(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, visibility string, products []string) error {
// 标题
titleElem, err := page.Element("div.d-input input")
if err != nil {
@@ -172,6 +173,11 @@ func submitPublishVideo(page *rod.Page, title, content string, tags []string, sc
return errors.Wrap(err, "设置可见范围失败")
}
// 绑定商品
if err := bindProducts(page, products); err != nil {
return errors.Wrap(err, "绑定商品失败")
}
// 等待发布按钮可点击
btn, err := waitForPublishButtonClickable(page)
if err != nil {