diff --git a/mcp_handlers.go b/mcp_handlers.go index 02b4703..52151ca 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -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, } // 执行发布 diff --git a/mcp_server.go b/mcp_server.go index c6466de..19a214e 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -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 diff --git a/service.go b/service.go index e414bcf..8ef5735 100644 --- a/service.go +++ b/service.go @@ -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, } // 执行发布 diff --git a/xiaohongshu/publish.go b/xiaohongshu/publish.go index bcb6628..fd6085c 100644 --- a/xiaohongshu/publish.go +++ b/xiaohongshu/publish.go @@ -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("等待弹窗关闭超时") +} diff --git a/xiaohongshu/publish_video.go b/xiaohongshu/publish_video.go index 54508fc..53ebe09 100644 --- a/xiaohongshu/publish_video.go +++ b/xiaohongshu/publish_video.go @@ -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 {