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 的视频发布功能。
+```
+

**发布结果:**
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.
+```
+

**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
}