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:
52
README.md
52
README.md
@@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/bd9a9a4a-58cb-4421-b8f3-015f703ce1f9
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>2. 发布图文内容</b></summary>
|
<summary><b>2. 发布图文内容</b></summary>
|
||||||
|
|
||||||
支持发布图文内容到小红书,包括标题、内容描述和图片。后续支持更多的发布功能。
|
支持发布图文内容到小红书,包括标题、内容描述和图片。
|
||||||
|
|
||||||
**图片支持方式:**
|
**图片支持方式:**
|
||||||
|
|
||||||
@@ -67,7 +67,35 @@ https://github.com/user-attachments/assets/8aee0814-eb96-40af-b871-e66e6bbb6b06
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>7. 获取用户个人主页</b></summary>
|
<summary><b>8. 获取用户个人主页</b></summary>
|
||||||
|
|
||||||
获取小红书用户的个人主页信息,包括用户基本信息和笔记内容。
|
获取小红书用户的个人主页信息,包括用户基本信息和笔记内容。
|
||||||
|
|
||||||
@@ -637,6 +665,8 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。
|
|||||||
- `check_login_status` - 检查小红书登录状态(无参数)
|
- `check_login_status` - 检查小红书登录状态(无参数)
|
||||||
- `publish_content` - 发布图文内容到小红书(必需:title, content, images)
|
- `publish_content` - 发布图文内容到小红书(必需:title, content, images)
|
||||||
- `images`: 支持 HTTP 链接或本地绝对路径,推荐使用本地路径
|
- `images`: 支持 HTTP 链接或本地绝对路径,推荐使用本地路径
|
||||||
|
- `publish_with_video` - 发布视频内容到小红书(必需:title, content, video)
|
||||||
|
- `video`: 仅支持本地视频文件绝对路径
|
||||||
- `list_feeds` - 获取小红书首页推荐列表(无参数)
|
- `list_feeds` - 获取小红书首页推荐列表(无参数)
|
||||||
- `search_feeds` - 搜索小红书内容(需要:keyword)
|
- `search_feeds` - 搜索小红书内容(需要:keyword)
|
||||||
- `get_feed_detail` - 获取帖子详情(需要:feed_id, xsec_token)
|
- `get_feed_detail` - 获取帖子详情(需要:feed_id, xsec_token)
|
||||||
@@ -668,6 +698,16 @@ Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。
|
|||||||
使用 xiaohongshu-mcp 进行发布。
|
使用 xiaohongshu-mcp 进行发布。
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**示例 3:发布视频内容**
|
||||||
|
|
||||||
|
```
|
||||||
|
帮我写一篇关于美食制作的视频发布到小红书上,
|
||||||
|
使用这个本地视频文件:
|
||||||
|
- /Users/username/Videos/cooking_tutorial.mp4
|
||||||
|
|
||||||
|
使用 xiaohongshu-mcp 的视频发布功能。
|
||||||
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**发布结果:**
|
**发布结果:**
|
||||||
|
|||||||
52
README_EN.md
52
README_EN.md
@@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/bd9a9a4a-58cb-4421-b8f3-015f703ce1f9
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>2. Publish Image and Text Content</b></summary>
|
<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:**
|
**Image Support Methods:**
|
||||||
|
|
||||||
@@ -67,7 +67,35 @@ https://github.com/user-attachments/assets/8aee0814-eb96-40af-b871-e66e6bbb6b06
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<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.
|
Search RedNote content by keywords.
|
||||||
|
|
||||||
@@ -78,7 +106,7 @@ https://github.com/user-attachments/assets/03c5077d-6160-4b18-b629-2e40933a1fd3
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>4. Get Recommendation List</b></summary>
|
<summary><b>5. Get Recommendation List</b></summary>
|
||||||
|
|
||||||
Get RedNote homepage recommendation content list.
|
Get RedNote homepage recommendation content list.
|
||||||
|
|
||||||
@@ -89,7 +117,7 @@ https://github.com/user-attachments/assets/110fc15d-46f2-4cca-bdad-9de5b5b8cc28
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<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:
|
Get complete details of RedNote posts, including:
|
||||||
|
|
||||||
@@ -111,7 +139,7 @@ https://github.com/user-attachments/assets/76a26130-a216-4371-a6b3-937b8fda092a
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<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.
|
Supports automatically posting comments to RedNote posts.
|
||||||
|
|
||||||
@@ -134,7 +162,7 @@ https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<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.
|
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)
|
- `check_login_status` - Check RedNote login status (no parameters)
|
||||||
- `publish_content` - Publish image-text content to RedNote (required: title, content, images)
|
- `publish_content` - Publish image-text content to RedNote (required: title, content, images)
|
||||||
- `images`: Supports HTTP links or local absolute paths, local paths recommended
|
- `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)
|
- `list_feeds` - Get RedNote homepage recommendation list (no parameters)
|
||||||
- `search_feeds` - Search RedNote content (required: keyword)
|
- `search_feeds` - Search RedNote content (required: keyword)
|
||||||
- `get_feed_detail` - Get post details (required: feed_id, xsec_token)
|
- `get_feed_detail` - Get post details (required: feed_id, xsec_token)
|
||||||
@@ -664,6 +694,16 @@ using these local images:
|
|||||||
Use xiaohongshu-mcp for publishing.
|
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:**
|
**Publishing Result:**
|
||||||
|
|||||||
50
docs/API.md
50
docs/API.md
@@ -111,7 +111,9 @@ GET /api/v1/login/qrcode
|
|||||||
|
|
||||||
### 3. 内容发布
|
### 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 管理
|
### 4. Feed 管理
|
||||||
|
|||||||
@@ -81,6 +81,26 @@ func (s *AppServer) publishHandler(c *gin.Context) {
|
|||||||
respondSuccess(c, result, "发布成功")
|
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列表
|
// listFeedsHandler 获取Feeds列表
|
||||||
func (s *AppServer) listFeedsHandler(c *gin.Context) {
|
func (s *AppServer) listFeedsHandler(c *gin.Context) {
|
||||||
// 获取 Feeds 列表
|
// 获取 Feeds 列表
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine {
|
|||||||
api.GET("/login/status", appServer.checkLoginStatusHandler)
|
api.GET("/login/status", appServer.checkLoginStatusHandler)
|
||||||
api.GET("/login/qrcode", appServer.getLoginQrcodeHandler)
|
api.GET("/login/qrcode", appServer.getLoginQrcodeHandler)
|
||||||
api.POST("/publish", appServer.publishHandler)
|
api.POST("/publish", appServer.publishHandler)
|
||||||
|
api.POST("/publish_video", appServer.publishVideoHandler)
|
||||||
api.GET("/feeds/list", appServer.listFeedsHandler)
|
api.GET("/feeds/list", appServer.listFeedsHandler)
|
||||||
api.GET("/feeds/search", appServer.searchFeedsHandler)
|
api.GET("/feeds/search", appServer.searchFeedsHandler)
|
||||||
api.POST("/feeds/detail", appServer.getFeedDetailHandler)
|
api.POST("/feeds/detail", appServer.getFeedDetailHandler)
|
||||||
|
|||||||
@@ -37,45 +37,7 @@ func NewPublishImageAction(page *rod.Page) (*PublishAction, error) {
|
|||||||
pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable()
|
pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable()
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
logrus.Info("navigate to publish page success")
|
mustClickPublishTab(page, "上传图文")
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(1 * time.Second)
|
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 {
|
func uploadImages(page *rod.Page, imagesPaths []string) error {
|
||||||
pp := page.Timeout(30 * time.Second)
|
pp := page.Timeout(30 * time.Second)
|
||||||
|
|
||||||
|
|||||||
@@ -1,180 +1,148 @@
|
|||||||
package xiaohongshu
|
package xiaohongshu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
"github.com/go-rod/rod/lib/proto"
|
"github.com/go-rod/rod/lib/proto"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PublishVideoContent 发布视频内容
|
// PublishVideoContent 发布视频内容
|
||||||
type PublishVideoContent struct {
|
type PublishVideoContent struct {
|
||||||
Title string
|
Title string
|
||||||
Content string
|
Content string
|
||||||
Tags []string
|
Tags []string
|
||||||
VideoPath string
|
VideoPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPublishVideoAction 进入发布页并切换到“上传视频”
|
// NewPublishVideoAction 进入发布页并切换到“上传视频”
|
||||||
func NewPublishVideoAction(page *rod.Page) (*PublishAction, error) {
|
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()
|
time.Sleep(1 * time.Second)
|
||||||
slog.Info("wait for upload-content visible success (video)")
|
|
||||||
|
|
||||||
time.Sleep(1 * time.Second)
|
return &PublishAction{page: pp}, nil
|
||||||
|
|
||||||
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 上传视频并提交
|
// PublishVideo 上传视频并提交
|
||||||
func (p *PublishAction) PublishVideo(ctx context.Context, content PublishVideoContent) error {
|
func (p *PublishAction) PublishVideo(ctx context.Context, content PublishVideoContent) error {
|
||||||
if content.VideoPath == "" {
|
if content.VideoPath == "" {
|
||||||
return errors.New("视频不能为空")
|
return errors.New("视频不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
page := p.page.Context(ctx)
|
page := p.page.Context(ctx)
|
||||||
|
|
||||||
if err := uploadVideo(page, content.VideoPath); err != nil {
|
if err := uploadVideo(page, content.VideoPath); err != nil {
|
||||||
return errors.Wrap(err, "小红书上传视频失败")
|
return errors.Wrap(err, "小红书上传视频失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := submitPublishVideo(page, content.Title, content.Content, content.Tags); err != nil {
|
if err := submitPublishVideo(page, content.Title, content.Content, content.Tags); err != nil {
|
||||||
return errors.Wrap(err, "小红书发布失败")
|
return errors.Wrap(err, "小红书发布失败")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// uploadVideo 上传单个本地视频
|
// uploadVideo 上传单个本地视频
|
||||||
func uploadVideo(page *rod.Page, videoPath string) error {
|
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) {
|
if _, err := os.Stat(videoPath); os.IsNotExist(err) {
|
||||||
return errors.Wrapf(err, "视频文件不存在: %s", videoPath)
|
return errors.Wrapf(err, "视频文件不存在: %s", videoPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 寻找文件上传输入框(与图文一致的 class,或退回到 input[type=file])
|
// 寻找文件上传输入框(与图文一致的 class,或退回到 input[type=file])
|
||||||
var fileInput *rod.Element
|
var fileInput *rod.Element
|
||||||
var err error
|
var err error
|
||||||
fileInput, err = pp.Element(".upload-input")
|
fileInput, err = pp.Element(".upload-input")
|
||||||
if err != nil || fileInput == nil {
|
if err != nil || fileInput == nil {
|
||||||
fileInput, err = pp.Element("input[type='file']")
|
fileInput, err = pp.Element("input[type='file']")
|
||||||
if err != nil || fileInput == nil {
|
if err != nil || fileInput == nil {
|
||||||
return errors.New("未找到视频上传输入框")
|
return errors.New("未找到视频上传输入框")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fileInput.MustSetFiles(videoPath)
|
fileInput.MustSetFiles(videoPath)
|
||||||
|
|
||||||
// 对于视频,等待发布按钮变为可点击即表示处理完成
|
// 对于视频,等待发布按钮变为可点击即表示处理完成
|
||||||
btn, err := waitForPublishButtonClickable(pp)
|
btn, err := waitForPublishButtonClickable(pp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
slog.Info("视频上传/处理完成,发布按钮可点击", "btn", btn)
|
slog.Info("视频上传/处理完成,发布按钮可点击", "btn", btn)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForPublishButtonClickable 等待发布按钮可点击
|
// waitForPublishButtonClickable 等待发布按钮可点击
|
||||||
func waitForPublishButtonClickable(page *rod.Page) (*rod.Element, error) {
|
func waitForPublishButtonClickable(page *rod.Page) (*rod.Element, error) {
|
||||||
maxWait := 10 * time.Minute
|
maxWait := 10 * time.Minute
|
||||||
interval := 1 * time.Second
|
interval := 1 * time.Second
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
selector := "button.publishBtn"
|
selector := "button.publishBtn"
|
||||||
|
|
||||||
slog.Info("开始等待发布按钮可点击(视频)")
|
slog.Info("开始等待发布按钮可点击(视频)")
|
||||||
|
|
||||||
for time.Since(start) < maxWait {
|
for time.Since(start) < maxWait {
|
||||||
btn, err := page.Element(selector)
|
btn, err := page.Element(selector)
|
||||||
if err == nil && btn != nil {
|
if err == nil && btn != nil {
|
||||||
// 可见性
|
// 可见性
|
||||||
vis, verr := btn.Visible()
|
vis, verr := btn.Visible()
|
||||||
if verr == nil && vis {
|
if verr == nil && vis {
|
||||||
// 检查 disabled 属性
|
// 检查 disabled 属性
|
||||||
if disabled, _ := btn.Attribute("disabled"); disabled == nil {
|
if disabled, _ := btn.Attribute("disabled"); disabled == nil {
|
||||||
// 再通过 class 名粗略判断不在禁用态
|
// 再通过 class 名粗略判断不在禁用态
|
||||||
if cls, _ := btn.Attribute("class"); cls != nil && !strings.Contains(*cls, "disabled") {
|
if cls, _ := btn.Attribute("class"); cls != nil && !strings.Contains(*cls, "disabled") {
|
||||||
return btn, nil
|
return btn, nil
|
||||||
}
|
}
|
||||||
// 即使 class 包含 disabled,只要没有 disabled 属性,也尝试点击一次以确认
|
// 即使 class 包含 disabled,只要没有 disabled 属性,也尝试点击一次以确认
|
||||||
return btn, nil
|
return btn, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(interval)
|
time.Sleep(interval)
|
||||||
}
|
}
|
||||||
return nil, errors.New("等待发布按钮可点击超时")
|
return nil, errors.New("等待发布按钮可点击超时")
|
||||||
}
|
}
|
||||||
|
|
||||||
// submitPublishVideo 填写标题、正文、标签并点击发布(等待按钮可点击后再提交)
|
// submitPublishVideo 填写标题、正文、标签并点击发布(等待按钮可点击后再提交)
|
||||||
func submitPublishVideo(page *rod.Page, title, content string, tags []string) error {
|
func submitPublishVideo(page *rod.Page, title, content string, tags []string) error {
|
||||||
// 标题
|
// 标题
|
||||||
titleElem := page.MustElement("div.d-input input")
|
titleElem := page.MustElement("div.d-input input")
|
||||||
titleElem.MustInput(title)
|
titleElem.MustInput(title)
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
// 正文 + 标签
|
// 正文 + 标签
|
||||||
if contentElem, ok := getContentElement(page); ok {
|
if contentElem, ok := getContentElement(page); ok {
|
||||||
contentElem.MustInput(content)
|
contentElem.MustInput(content)
|
||||||
inputTags(contentElem, tags)
|
inputTags(contentElem, tags)
|
||||||
} else {
|
} else {
|
||||||
return errors.New("没有找到内容输入框")
|
return errors.New("没有找到内容输入框")
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
// 等待发布按钮可点击
|
// 等待发布按钮可点击
|
||||||
btn, err := waitForPublishButtonClickable(page)
|
btn, err := waitForPublishButtonClickable(page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击发布
|
// 点击发布
|
||||||
if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||||
return errors.Wrap(err, "点击发布按钮失败")
|
return errors.Wrap(err, "点击发布按钮失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user