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:
@@ -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列表")
|
||||
|
||||
@@ -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 的格式
|
||||
|
||||
81
service.go
81
service.go
@@ -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()
|
||||
|
||||
180
xiaohongshu/publish_video.go
Normal file
180
xiaohongshu/publish_video.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user