feat: 为视频发布功能新增HTTP API接口和完善文档 (#179)

* 重构 publish tab 选择逻辑,把公共代码提取到同一个函数中

* feat: 为视频发布功能新增HTTP API接口和完善文档

- 新增 /api/v1/publish_video HTTP接口
- 添加 publishVideoHandler 处理函数
- 更新 API.md 增加视频发布接口文档
- 更新 README.md 和 README_EN.md 增加视频发布功能说明
- 在MCP工具列表中补充 publish_with_video 工具说明
This commit is contained in:
zy
2025-09-29 01:08:11 +08:00
committed by GitHub
parent 8c3665a3de
commit 0955723b19
7 changed files with 312 additions and 185 deletions

View File

@@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/bd9a9a4a-58cb-4421-b8f3-015f703ce1f9
<details>
<summary><b>2. 发布图文内容</b></summary>
支持发布图文内容到小红书,包括标题、内容描述和图片。后续支持更多的发布功能。
支持发布图文内容到小红书,包括标题、内容描述和图片。
**图片支持方式:**
@@ -67,7 +67,35 @@ https://github.com/user-attachments/assets/8aee0814-eb96-40af-b871-e66e6bbb6b06
</details>
<details>
<summary><b>3. 搜索内容</b></summary>
<summary><b>3. 发布视频内容</b></summary>
支持发布视频内容到小红书,包括标题、内容描述和本地视频文件。
**视频支持方式:**
仅支持本地视频文件绝对路径:
```
"/Users/username/Videos/video.mp4"
```
**功能特点:**
- ✅ 支持本地视频文件上传
- ✅ 自动处理视频格式转换
- ✅ 支持标题、内容描述和标签
- ✅ 等待视频处理完成后自动发布
**注意事项:**
- 仅支持本地视频文件,不支持 HTTP 链接
- 视频处理时间较长,请耐心等待
- 建议视频文件大小不超过 1GB
</details>
<details>
<summary><b>4. 搜索内容</b></summary>
根据关键词搜索小红书内容。
@@ -78,7 +106,7 @@ https://github.com/user-attachments/assets/03c5077d-6160-4b18-b629-2e40933a1fd3
</details>
<details>
<summary><b>4. 获取推荐列表</b></summary>
<summary><b>5. 获取推荐列表</b></summary>
获取小红书首页推荐内容列表。
@@ -89,7 +117,7 @@ https://github.com/user-attachments/assets/110fc15d-46f2-4cca-bdad-9de5b5b8cc28
</details>
<details>
<summary><b>5. 获取帖子详情(包括互动数据和评论)</b></summary>
<summary><b>6. 获取帖子详情(包括互动数据和评论)</b></summary>
获取小红书帖子的完整详情,包括:
@@ -111,7 +139,7 @@ https://github.com/user-attachments/assets/76a26130-a216-4371-a6b3-937b8fda092a
</details>
<details>
<summary><b>6. 发表评论到帖子</b></summary>
<summary><b>7. 发表评论到帖子</b></summary>
支持自动发表评论到小红书帖子。
@@ -134,7 +162,7 @@ https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80
</details>
<details>
<summary><b>7. 获取用户个人主页</b></summary>
<summary><b>8. 获取用户个人主页</b></summary>
获取小红书用户的个人主页信息,包括用户基本信息和笔记内容。
@@ -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)
**发布结果:**

View File

@@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/bd9a9a4a-58cb-4421-b8f3-015f703ce1f9
<details>
<summary><b>2. Publish Image and Text Content</b></summary>
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
</details>
<details>
<summary><b>3. Search Content</b></summary>
<summary><b>3. Publish Video Content</b></summary>
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
</details>
<details>
<summary><b>4. Search Content</b></summary>
Search RedNote content by keywords.
@@ -78,7 +106,7 @@ https://github.com/user-attachments/assets/03c5077d-6160-4b18-b629-2e40933a1fd3
</details>
<details>
<summary><b>4. Get Recommendation List</b></summary>
<summary><b>5. Get Recommendation List</b></summary>
Get RedNote homepage recommendation content list.
@@ -89,7 +117,7 @@ https://github.com/user-attachments/assets/110fc15d-46f2-4cca-bdad-9de5b5b8cc28
</details>
<details>
<summary><b>5. Get Post Details (Including Interaction Data and Comments)</b></summary>
<summary><b>6. Get Post Details (Including Interaction Data and Comments)</b></summary>
Get complete details of RedNote posts, including:
@@ -111,7 +139,7 @@ https://github.com/user-attachments/assets/76a26130-a216-4371-a6b3-937b8fda092a
</details>
<details>
<summary><b>6. Post Comments to Posts</b></summary>
<summary><b>7. Post Comments to Posts</b></summary>
Supports automatically posting comments to RedNote posts.
@@ -134,7 +162,7 @@ https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80
</details>
<details>
<summary><b>7. Get User Profile</b></summary>
<summary><b>8. Get User Profile</b></summary>
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:**

View File

@@ -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 管理

View File

@@ -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 列表

View File

@@ -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)

View File

@@ -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)

View File

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