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
This commit is contained in:
Banghao Chi
2025-09-28 11:34:47 -05:00
committed by GitHub
parent fe82b14ba9
commit 8c3665a3de
4 changed files with 340 additions and 6 deletions

View File

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

View File

@@ -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 的格式

View File

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

View File

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