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

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