From 8c3665a3de64a226c7061697542f743072d64b62 Mon Sep 17 00:00:00 2001 From: Banghao Chi <125724218+BiboyQG@users.noreply.github.com> Date: Sun, 28 Sep 2025 11:34:47 -0500 Subject: [PATCH] feat: publish with video (#171) * feat: publish with video * fix: add more timeout (network bandwidth + large files) and remove pop-up * fix: remove excessive remove pop-up function --- mcp_handlers.go | 57 +++++++++++ mcp_server.go | 28 +++++- service.go | 81 +++++++++++++++- xiaohongshu/publish_video.go | 180 +++++++++++++++++++++++++++++++++++ 4 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 xiaohongshu/publish_video.go diff --git a/mcp_handlers.go b/mcp_handlers.go index f6932c0..0b5adc7 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -130,6 +130,63 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in } } +// handlePublishVideo 处理发布视频内容(仅本地单个视频文件) +func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]interface{}) *MCPToolResult { + logrus.Info("MCP: 发布视频内容(本地)") + + title, _ := args["title"].(string) + content, _ := args["content"].(string) + videoPath, _ := args["video"].(string) + tagsInterface, _ := args["tags"].([]interface{}) + + var tags []string + for _, tag := range tagsInterface { + if tagStr, ok := tag.(string); ok { + tags = append(tags, tagStr) + } + } + + if videoPath == "" { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "发布失败: 缺少本地视频文件路径", + }}, + IsError: true, + } + } + + logrus.Infof("MCP: 发布视频 - 标题: %s, 标签数量: %d", title, len(tags)) + + // 构建发布请求 + req := &PublishVideoRequest{ + Title: title, + Content: content, + Video: videoPath, + Tags: tags, + } + + // 执行发布 + result, err := s.xiaohongshuService.PublishVideo(ctx, req) + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "发布失败: " + err.Error(), + }}, + IsError: true, + } + } + + resultText := fmt.Sprintf("视频发布成功: %+v", result) + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: resultText, + }}, + } +} + // handleListFeeds 处理获取Feeds列表 func (s *AppServer) handleListFeeds(ctx context.Context) *MCPToolResult { logrus.Info("MCP: 获取Feeds列表") diff --git a/mcp_server.go b/mcp_server.go index b407bd4..35ebb20 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -18,6 +18,14 @@ type PublishContentArgs struct { Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"` } +// PublishVideoArgs 发布视频的参数(仅支持本地单个视频文件) +type PublishVideoArgs struct { + Title string `json:"title" jsonschema:"内容标题(小红书限制:最多20个中文字或英文单词)"` + Content string `json:"content" jsonschema:"正文内容,不包含以#开头的标签内容,所有话题标签都用tags参数来生成和提供即可"` + Video string `json:"video" jsonschema:"本地视频绝对路径(仅支持单个视频文件,如:/Users/user/video.mp4)"` + Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"` +} + // SearchFeedsArgs 搜索内容的参数 type SearchFeedsArgs struct { Keyword string `json:"keyword" jsonschema:"搜索关键词"` @@ -182,7 +190,25 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }, ) - logrus.Infof("Registered %d MCP tools", 8) + // 工具 9: 发布视频(仅本地文件) + mcp.AddTool(server, + &mcp.Tool{ + Name: "publish_with_video", + Description: "发布小红书视频内容(仅支持本地单个视频文件)", + }, + func(ctx context.Context, req *mcp.CallToolRequest, args PublishVideoArgs) (*mcp.CallToolResult, any, error) { + argsMap := map[string]interface{}{ + "title": args.Title, + "content": args.Content, + "video": args.Video, + "tags": convertStringsToInterfaces(args.Tags), + } + result := appServer.handlePublishVideo(ctx, argsMap) + return convertToMCPResult(result), nil, nil + }, + ) + + logrus.Infof("Registered %d MCP tools", 9) } // convertToMCPResult 将自定义的 MCPToolResult 转换为官方 SDK 的格式 diff --git a/service.go b/service.go index f6e6965..f25ebeb 100644 --- a/service.go +++ b/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "github.com/go-rod/rod" "github.com/mattn/go-runewidth" "github.com/sirupsen/logrus" @@ -47,11 +48,28 @@ type LoginQrcodeResponse struct { // PublishResponse 发布响应 type PublishResponse struct { - Title string `json:"title"` - Content string `json:"content"` - Images int `json:"images"` - Status string `json:"status"` - PostID string `json:"post_id,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + Images int `json:"images"` + Status string `json:"status"` + PostID string `json:"post_id,omitempty"` +} + +// PublishVideoRequest 发布视频请求(仅支持本地单个视频文件) +type PublishVideoRequest struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Video string `json:"video" binding:"required"` + Tags []string `json:"tags,omitempty"` +} + +// PublishVideoResponse 发布视频响应 +type PublishVideoResponse struct { + Title string `json:"title"` + Content string `json:"content"` + Video string `json:"video"` + Status string `json:"status"` + PostID string `json:"post_id,omitempty"` } // FeedsListResponse Feeds列表响应 @@ -199,6 +217,59 @@ func (s *XiaohongshuService) publishContent(ctx context.Context, content xiaohon return action.Publish(ctx, content) } +// PublishVideo 发布视频(本地文件) +func (s *XiaohongshuService) PublishVideo(ctx context.Context, req *PublishVideoRequest) (*PublishVideoResponse, error) { + // 标题长度校验 + if titleWidth := runewidth.StringWidth(req.Title); titleWidth > 40 { + return nil, fmt.Errorf("标题长度超过限制") + } + + // 本地视频文件校验 + if req.Video == "" { + return nil, fmt.Errorf("必须提供本地视频文件") + } + if _, err := os.Stat(req.Video); err != nil { + return nil, fmt.Errorf("视频文件不存在或不可访问: %v", err) + } + + // 构建发布内容 + content := xiaohongshu.PublishVideoContent{ + Title: req.Title, + Content: req.Content, + Tags: req.Tags, + VideoPath: req.Video, + } + + // 执行发布 + if err := s.publishVideo(ctx, content); err != nil { + return nil, err + } + + resp := &PublishVideoResponse{ + Title: req.Title, + Content: req.Content, + Video: req.Video, + Status: "发布完成", + } + return resp, nil +} + +// publishVideo 执行视频发布 +func (s *XiaohongshuService) publishVideo(ctx context.Context, content xiaohongshu.PublishVideoContent) error { + b := newBrowser() + defer b.Close() + + page := b.NewPage() + defer page.Close() + + action, err := xiaohongshu.NewPublishVideoAction(page) + if err != nil { + return err + } + + return action.PublishVideo(ctx, content) +} + // ListFeeds 获取Feeds列表 func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse, error) { b := newBrowser() diff --git a/xiaohongshu/publish_video.go b/xiaohongshu/publish_video.go new file mode 100644 index 0000000..8785c84 --- /dev/null +++ b/xiaohongshu/publish_video.go @@ -0,0 +1,180 @@ +package xiaohongshu + +import ( + "context" + "log/slog" + "os" + "strings" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" + "github.com/pkg/errors" +) + +// PublishVideoContent 发布视频内容 +type PublishVideoContent struct { + Title string + Content string + Tags []string + VideoPath string +} + +// NewPublishVideoAction 进入发布页并切换到“上传视频” +func NewPublishVideoAction(page *rod.Page) (*PublishAction, error) { + pp := page.Timeout(300 * time.Second) + + pp.MustNavigate(urlOfPublic) + + removePopCover(page) // 移除弹窗封面 + + pp.MustElement(`div.upload-content`).MustWaitVisible() + slog.Info("wait for upload-content visible success (video)") + + time.Sleep(1 * time.Second) + + createElems := pp.MustElements("div.creator-tab") + + // 过滤掉隐藏的元素 + var visibleElems []*rod.Element + for _, elem := range createElems { + if isElementVisible(elem) { + visibleElems = append(visibleElems, elem) + } + } + + if len(visibleElems) == 0 { + return nil, errors.New("没有找到上传视频元素") + } + + // 点击“上传视频” + for _, elem := range visibleElems { + 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 +} + +// PublishVideo 上传视频并提交 +func (p *PublishAction) PublishVideo(ctx context.Context, content PublishVideoContent) error { + if content.VideoPath == "" { + return errors.New("视频不能为空") + } + + page := p.page.Context(ctx) + + if err := uploadVideo(page, content.VideoPath); err != nil { + return errors.Wrap(err, "小红书上传视频失败") + } + + if err := submitPublishVideo(page, content.Title, content.Content, content.Tags); err != nil { + return errors.Wrap(err, "小红书发布失败") + } + return nil +} + +// uploadVideo 上传单个本地视频 +func uploadVideo(page *rod.Page, videoPath string) error { + pp := page.Timeout(5 * time.Minute) // 视频处理耗时更长 + + if _, err := os.Stat(videoPath); os.IsNotExist(err) { + return errors.Wrapf(err, "视频文件不存在: %s", videoPath) + } + + // 寻找文件上传输入框(与图文一致的 class,或退回到 input[type=file]) + var fileInput *rod.Element + var err error + fileInput, err = pp.Element(".upload-input") + if err != nil || fileInput == nil { + fileInput, err = pp.Element("input[type='file']") + if err != nil || fileInput == nil { + return errors.New("未找到视频上传输入框") + } + } + + fileInput.MustSetFiles(videoPath) + + // 对于视频,等待发布按钮变为可点击即表示处理完成 + btn, err := waitForPublishButtonClickable(pp) + if err != nil { + return err + } + slog.Info("视频上传/处理完成,发布按钮可点击", "btn", btn) + return nil +} + +// waitForPublishButtonClickable 等待发布按钮可点击 +func waitForPublishButtonClickable(page *rod.Page) (*rod.Element, error) { + maxWait := 10 * time.Minute + interval := 1 * time.Second + start := time.Now() + selector := "button.publishBtn" + + slog.Info("开始等待发布按钮可点击(视频)") + + for time.Since(start) < maxWait { + btn, err := page.Element(selector) + if err == nil && btn != nil { + // 可见性 + vis, verr := btn.Visible() + if verr == nil && vis { + // 检查 disabled 属性 + if disabled, _ := btn.Attribute("disabled"); disabled == nil { + // 再通过 class 名粗略判断不在禁用态 + if cls, _ := btn.Attribute("class"); cls != nil && !strings.Contains(*cls, "disabled") { + return btn, nil + } + // 即使 class 包含 disabled,只要没有 disabled 属性,也尝试点击一次以确认 + return btn, nil + } + } + } + time.Sleep(interval) + } + return nil, errors.New("等待发布按钮可点击超时") +} + +// submitPublishVideo 填写标题、正文、标签并点击发布(等待按钮可点击后再提交) +func submitPublishVideo(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("没有找到内容输入框") + } + + time.Sleep(1 * time.Second) + + // 等待发布按钮可点击 + btn, err := waitForPublishButtonClickable(page) + if err != nil { + return err + } + + // 点击发布 + if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil { + return errors.Wrap(err, "点击发布按钮失败") + } + + time.Sleep(3 * time.Second) + return nil +}