From 0955723b1921ef1c1521062506ba170b9209f154 Mon Sep 17 00:00:00 2001 From: zy Date: Mon, 29 Sep 2025 01:08:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=E8=A7=86=E9=A2=91=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=8A=9F=E8=83=BD=E6=96=B0=E5=A2=9EHTTP=20API?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=92=8C=E5=AE=8C=E5=96=84=E6=96=87=E6=A1=A3?= =?UTF-8?q?=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 重构 publish tab 选择逻辑,把公共代码提取到同一个函数中 * feat: 为视频发布功能新增HTTP API接口和完善文档 - 新增 /api/v1/publish_video HTTP接口 - 添加 publishVideoHandler 处理函数 - 更新 API.md 增加视频发布接口文档 - 更新 README.md 和 README_EN.md 增加视频发布功能说明 - 在MCP工具列表中补充 publish_with_video 工具说明 --- README.md | 52 +++++++- README_EN.md | 52 +++++++- docs/API.md | 50 +++++++- handlers_api.go | 20 +++ routes.go | 1 + xiaohongshu/publish.go | 88 +++++++------ xiaohongshu/publish_video.go | 234 +++++++++++++++-------------------- 7 files changed, 312 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index 7329c04..9501a5c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/bd9a9a4a-58cb-4421-b8f3-015f703ce1f9
2. 发布图文内容 -支持发布图文内容到小红书,包括标题、内容描述和图片。后续支持更多的发布功能。 +支持发布图文内容到小红书,包括标题、内容描述和图片。 **图片支持方式:** @@ -67,7 +67,35 @@ https://github.com/user-attachments/assets/8aee0814-eb96-40af-b871-e66e6bbb6b06
-3. 搜索内容 +3. 发布视频内容 + +支持发布视频内容到小红书,包括标题、内容描述和本地视频文件。 + +**视频支持方式:** + +仅支持本地视频文件绝对路径: + +``` +"/Users/username/Videos/video.mp4" +``` + +**功能特点:** + +- ✅ 支持本地视频文件上传 +- ✅ 自动处理视频格式转换 +- ✅ 支持标题、内容描述和标签 +- ✅ 等待视频处理完成后自动发布 + +**注意事项:** + +- 仅支持本地视频文件,不支持 HTTP 链接 +- 视频处理时间较长,请耐心等待 +- 建议视频文件大小不超过 1GB + +
+ +
+4. 搜索内容 根据关键词搜索小红书内容。 @@ -78,7 +106,7 @@ https://github.com/user-attachments/assets/03c5077d-6160-4b18-b629-2e40933a1fd3
-4. 获取推荐列表 +5. 获取推荐列表 获取小红书首页推荐内容列表。 @@ -89,7 +117,7 @@ https://github.com/user-attachments/assets/110fc15d-46f2-4cca-bdad-9de5b5b8cc28
-5. 获取帖子详情(包括互动数据和评论) +6. 获取帖子详情(包括互动数据和评论) 获取小红书帖子的完整详情,包括: @@ -111,7 +139,7 @@ https://github.com/user-attachments/assets/76a26130-a216-4371-a6b3-937b8fda092a
-6. 发表评论到帖子 +7. 发表评论到帖子 支持自动发表评论到小红书帖子。 @@ -134,7 +162,7 @@ https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80
-7. 获取用户个人主页 +8. 获取用户个人主页 获取小红书用户的个人主页信息,包括用户基本信息和笔记内容。 @@ -637,6 +665,8 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 - `check_login_status` - 检查小红书登录状态(无参数) - `publish_content` - 发布图文内容到小红书(必需:title, content, images) - `images`: 支持 HTTP 链接或本地绝对路径,推荐使用本地路径 +- `publish_with_video` - 发布视频内容到小红书(必需:title, content, video) + - `video`: 仅支持本地视频文件绝对路径 - `list_feeds` - 获取小红书首页推荐列表(无参数) - `search_feeds` - 搜索小红书内容(需要:keyword) - `get_feed_detail` - 获取帖子详情(需要:feed_id, xsec_token) @@ -668,6 +698,16 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 使用 xiaohongshu-mcp 进行发布。 ``` +**示例 3:发布视频内容** + +``` +帮我写一篇关于美食制作的视频发布到小红书上, +使用这个本地视频文件: +- /Users/username/Videos/cooking_tutorial.mp4 + +使用 xiaohongshu-mcp 的视频发布功能。 +``` + ![claude-cli 进行发布](./assets/claude_push.gif) **发布结果:** diff --git a/README_EN.md b/README_EN.md index 43f01a9..80df687 100644 --- a/README_EN.md +++ b/README_EN.md @@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/bd9a9a4a-58cb-4421-b8f3-015f703ce1f9
2. Publish Image and Text Content -Supports publishing image and text content to RedNote, including title, content description, and images. More publishing features will be supported later. +Supports publishing image and text content to RedNote, including title, content description, and images. **Image Support Methods:** @@ -67,7 +67,35 @@ https://github.com/user-attachments/assets/8aee0814-eb96-40af-b871-e66e6bbb6b06
-3. Search Content +3. Publish Video Content + +Supports publishing video content to RedNote, including title, content description, and local video files. + +**Video Support Methods:** + +Only supports local video file absolute paths: + +``` +"/Users/username/Videos/video.mp4" +``` + +**Features:** + +- ✅ Supports local video file upload +- ✅ Automatic video format processing +- ✅ Supports title, content description, and tags +- ✅ Automatically publishes after video processing is complete + +**Important Notes:** + +- Only supports local video files, not HTTP links +- Video processing takes longer, please be patient +- Recommended video file size should not exceed 1GB + +
+ +
+4. Search Content Search RedNote content by keywords. @@ -78,7 +106,7 @@ https://github.com/user-attachments/assets/03c5077d-6160-4b18-b629-2e40933a1fd3
-4. Get Recommendation List +5. Get Recommendation List Get RedNote homepage recommendation content list. @@ -89,7 +117,7 @@ https://github.com/user-attachments/assets/110fc15d-46f2-4cca-bdad-9de5b5b8cc28
-5. Get Post Details (Including Interaction Data and Comments) +6. Get Post Details (Including Interaction Data and Comments) Get complete details of RedNote posts, including: @@ -111,7 +139,7 @@ https://github.com/user-attachments/assets/76a26130-a216-4371-a6b3-937b8fda092a
-6. Post Comments to Posts +7. Post Comments to Posts Supports automatically posting comments to RedNote posts. @@ -134,7 +162,7 @@ https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80
-7. Get User Profile +8. Get User Profile Get RedNote user's personal profile information, including basic user information and note content. @@ -633,6 +661,8 @@ After successful connection, you can use the following MCP tools: - `check_login_status` - Check RedNote login status (no parameters) - `publish_content` - Publish image-text content to RedNote (required: title, content, images) - `images`: Supports HTTP links or local absolute paths, local paths recommended +- `publish_with_video` - Publish video content to RedNote (required: title, content, video) + - `video`: Only supports local video file absolute paths - `list_feeds` - Get RedNote homepage recommendation list (no parameters) - `search_feeds` - Search RedNote content (required: keyword) - `get_feed_detail` - Get post details (required: feed_id, xsec_token) @@ -664,6 +694,16 @@ using these local images: Use xiaohongshu-mcp for publishing. ``` +**Example 3: Publishing Video Content** + +``` +Help me write a video post about cooking tutorials to publish on RedNote, +using this local video file: +- /Users/username/Videos/cooking_tutorial.mp4 + +Use xiaohongshu-mcp's video publishing feature. +``` + ![claude-cli publishing](./assets/claude_push.gif) **Publishing Result:** diff --git a/docs/API.md b/docs/API.md index e53f0ff..384ad67 100644 --- a/docs/API.md +++ b/docs/API.md @@ -111,7 +111,9 @@ GET /api/v1/login/qrcode ### 3. 内容发布 -发布笔记内容到小红书。 +#### 3.1 发布图文内容 + +发布图文笔记内容到小红书。 **请求** ``` @@ -153,6 +155,52 @@ Content-Type: application/json } ``` +#### 3.2 发布视频内容 + +发布视频内容到小红书(仅支持本地视频文件)。 + +**请求** +``` +POST /api/v1/publish_video +Content-Type: application/json +``` + +**请求体** +```json +{ + "title": "视频标题", + "content": "视频内容描述", + "video": "/Users/username/Videos/video.mp4", + "tags": ["标签1", "标签2"] +} +``` + +**请求参数说明:** +- `title` (string, required): 视频标题 +- `content` (string, required): 视频内容描述 +- `video` (string, required): 本地视频文件绝对路径 +- `tags` (array, optional): 标签数组 + +**响应** +```json +{ + "success": true, + "data": { + "title": "视频标题", + "content": "视频内容描述", + "video": "/Users/username/Videos/video.mp4", + "status": "发布完成", + "post_id": "64f1a2b3c4d5e6f7a8b9c0d1" + }, + "message": "视频发布成功" +} +``` + +**注意事项:** +- 仅支持本地视频文件路径,不支持 HTTP 链接 +- 视频处理时间较长,请耐心等待 +- 建议视频文件大小不超过 1GB + --- ### 4. Feed 管理 diff --git a/handlers_api.go b/handlers_api.go index 3366fb3..a7dcd04 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -81,6 +81,26 @@ func (s *AppServer) publishHandler(c *gin.Context) { respondSuccess(c, result, "发布成功") } +// publishVideoHandler 发布视频内容 +func (s *AppServer) publishVideoHandler(c *gin.Context) { + var req PublishVideoRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "INVALID_REQUEST", + "请求参数错误", err.Error()) + return + } + + // 执行视频发布 + result, err := s.xiaohongshuService.PublishVideo(c.Request.Context(), &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "PUBLISH_VIDEO_FAILED", + "视频发布失败", err.Error()) + return + } + + respondSuccess(c, result, "视频发布成功") +} + // listFeedsHandler 获取Feeds列表 func (s *AppServer) listFeedsHandler(c *gin.Context) { // 获取 Feeds 列表 diff --git a/routes.go b/routes.go index ea6b9d9..c709943 100644 --- a/routes.go +++ b/routes.go @@ -41,6 +41,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine { api.GET("/login/status", appServer.checkLoginStatusHandler) api.GET("/login/qrcode", appServer.getLoginQrcodeHandler) api.POST("/publish", appServer.publishHandler) + api.POST("/publish_video", appServer.publishVideoHandler) api.GET("/feeds/list", appServer.listFeedsHandler) api.GET("/feeds/search", appServer.searchFeedsHandler) api.POST("/feeds/detail", appServer.getFeedDetailHandler) diff --git a/xiaohongshu/publish.go b/xiaohongshu/publish.go index b53003c..21eb26a 100644 --- a/xiaohongshu/publish.go +++ b/xiaohongshu/publish.go @@ -37,45 +37,7 @@ func NewPublishImageAction(page *rod.Page) (*PublishAction, error) { pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable() time.Sleep(1 * time.Second) - logrus.Info("navigate to publish page success") - - removePopCover(page) // 移除弹窗封面 - - 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") - - // 过滤掉隐藏的元素 - 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 - } - } + mustClickPublishTab(page, "上传图文") time.Sleep(1 * time.Second) @@ -115,6 +77,54 @@ func removePopCover(page *rod.Page) { } +func mustClickPublishTab(page *rod.Page, tabname string) error { + + removePopCover(page) // 移除弹窗封面 + + page.MustElement(`div.upload-content`).MustWaitVisible() + + time.Sleep(1 * time.Second) + + createElems := page.MustElements("div.creator-tab") + + // 过滤掉隐藏的元素 + var visibleElems []*rod.Element + for _, elem := range createElems { + if isElementVisible(elem) { + visibleElems = append(visibleElems, elem) + } + } + + if len(visibleElems) == 0 { + return errors.Errorf("没有找到发布 TAB - %s", tabname) + } + + var clicked bool + + for _, elem := range visibleElems { + text, err := elem.Text() + if err != nil { + logrus.Errorf("获取元素文本失败: %v", err) + continue + } + if text == tabname { + if err := elem.Click(proto.InputMouseButtonLeft, 1); err != nil { + logrus.Errorf("点击元素失败: %v", err) + continue + } + + clicked = true + break + } + } + + if !clicked { + return errors.Errorf("没有找到发布 TAB - %s", tabname) + } + + return nil +} + func uploadImages(page *rod.Page, imagesPaths []string) error { pp := page.Timeout(30 * time.Second) diff --git a/xiaohongshu/publish_video.go b/xiaohongshu/publish_video.go index 8785c84..bebd03f 100644 --- a/xiaohongshu/publish_video.go +++ b/xiaohongshu/publish_video.go @@ -1,180 +1,148 @@ package xiaohongshu import ( - "context" - "log/slog" - "os" - "strings" - "time" + "context" + "log/slog" + "os" + "strings" + "time" - "github.com/go-rod/rod" - "github.com/go-rod/rod/lib/proto" - "github.com/pkg/errors" + "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 + Title string + Content string + Tags []string + VideoPath string } // NewPublishVideoAction 进入发布页并切换到“上传视频” func NewPublishVideoAction(page *rod.Page) (*PublishAction, error) { - pp := page.Timeout(300 * time.Second) + pp := page.Timeout(300 * time.Second) - pp.MustNavigate(urlOfPublic) + pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable() + time.Sleep(1 * time.Second) - removePopCover(page) // 移除弹窗封面 + if err := mustClickPublishTab(page, "上传视频"); err != nil { + return nil, errors.Wrap(err, "切换到上传视频失败") + } - pp.MustElement(`div.upload-content`).MustWaitVisible() - slog.Info("wait for upload-content visible success (video)") + time.Sleep(1 * time.Second) - 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 + return &PublishAction{page: pp}, nil } // PublishVideo 上传视频并提交 func (p *PublishAction) PublishVideo(ctx context.Context, content PublishVideoContent) error { - if content.VideoPath == "" { - return errors.New("视频不能为空") - } + if content.VideoPath == "" { + return errors.New("视频不能为空") + } - page := p.page.Context(ctx) + page := p.page.Context(ctx) - if err := uploadVideo(page, content.VideoPath); err != nil { - return errors.Wrap(err, "小红书上传视频失败") - } + 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 + 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) // 视频处理耗时更长 + pp := page.Timeout(5 * time.Minute) // 视频处理耗时更长 - if _, err := os.Stat(videoPath); os.IsNotExist(err) { - return errors.Wrapf(err, "视频文件不存在: %s", videoPath) - } + 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("未找到视频上传输入框") - } - } + // 寻找文件上传输入框(与图文一致的 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) + fileInput.MustSetFiles(videoPath) - // 对于视频,等待发布按钮变为可点击即表示处理完成 - btn, err := waitForPublishButtonClickable(pp) - if err != nil { - return err - } - slog.Info("视频上传/处理完成,发布按钮可点击", "btn", btn) - return nil + // 对于视频,等待发布按钮变为可点击即表示处理完成 + 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" + maxWait := 10 * time.Minute + interval := 1 * time.Second + start := time.Now() + selector := "button.publishBtn" - slog.Info("开始等待发布按钮可点击(视频)") + 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("等待发布按钮可点击超时") + 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) + // 标题 + 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("没有找到内容输入框") - } + // 正文 + 标签 + if contentElem, ok := getContentElement(page); ok { + contentElem.MustInput(content) + inputTags(contentElem, tags) + } else { + return errors.New("没有找到内容输入框") + } - time.Sleep(1 * time.Second) + time.Sleep(1 * time.Second) - // 等待发布按钮可点击 - btn, err := waitForPublishButtonClickable(page) - if err != nil { - return err - } + // 等待发布按钮可点击 + btn, err := waitForPublishButtonClickable(page) + if err != nil { + return err + } - // 点击发布 - if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil { - return errors.Wrap(err, "点击发布按钮失败") - } + // 点击发布 + if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil { + return errors.Wrap(err, "点击发布按钮失败") + } - time.Sleep(3 * time.Second) - return nil + time.Sleep(3 * time.Second) + return nil }