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列表
|
// handleListFeeds 处理获取Feeds列表
|
||||||
func (s *AppServer) handleListFeeds(ctx context.Context) *MCPToolResult {
|
func (s *AppServer) handleListFeeds(ctx context.Context) *MCPToolResult {
|
||||||
logrus.Info("MCP: 获取Feeds列表")
|
logrus.Info("MCP: 获取Feeds列表")
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ type PublishContentArgs struct {
|
|||||||
Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"`
|
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 搜索内容的参数
|
// SearchFeedsArgs 搜索内容的参数
|
||||||
type SearchFeedsArgs struct {
|
type SearchFeedsArgs struct {
|
||||||
Keyword string `json:"keyword" jsonschema:"搜索关键词"`
|
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 的格式
|
// convertToMCPResult 将自定义的 MCPToolResult 转换为官方 SDK 的格式
|
||||||
|
|||||||
81
service.go
81
service.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
"github.com/mattn/go-runewidth"
|
"github.com/mattn/go-runewidth"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@@ -47,11 +48,28 @@ type LoginQrcodeResponse struct {
|
|||||||
|
|
||||||
// PublishResponse 发布响应
|
// PublishResponse 发布响应
|
||||||
type PublishResponse struct {
|
type PublishResponse struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Images int `json:"images"`
|
Images int `json:"images"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
PostID string `json:"post_id,omitempty"`
|
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列表响应
|
// FeedsListResponse Feeds列表响应
|
||||||
@@ -199,6 +217,59 @@ func (s *XiaohongshuService) publishContent(ctx context.Context, content xiaohon
|
|||||||
return action.Publish(ctx, content)
|
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列表
|
// ListFeeds 获取Feeds列表
|
||||||
func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse, error) {
|
func (s *XiaohongshuService) ListFeeds(ctx context.Context) (*FeedsListResponse, error) {
|
||||||
b := newBrowser()
|
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