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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user