feat: 添加商品绑定功能
- 在图文发布和视频发布流程中集成商品绑定功能 - 新增 Products 字段到发布请求结构体 - 实现 go-rod 原生商品绑定函数(bindProducts) - 商品绑定失败将阻断发布流程并返回具体错误信息 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,7 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in
|
|||||||
content, _ := args["content"].(string)
|
content, _ := args["content"].(string)
|
||||||
imagePathsInterface, _ := args["images"].([]interface{})
|
imagePathsInterface, _ := args["images"].([]interface{})
|
||||||
tagsInterface, _ := args["tags"].([]interface{})
|
tagsInterface, _ := args["tags"].([]interface{})
|
||||||
|
productsInterface, _ := args["products"].([]interface{})
|
||||||
|
|
||||||
var imagePaths []string
|
var imagePaths []string
|
||||||
for _, path := range imagePathsInterface {
|
for _, path := range imagePathsInterface {
|
||||||
@@ -144,6 +145,13 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var products []string
|
||||||
|
for _, p := range productsInterface {
|
||||||
|
if pStr, ok := p.(string); ok {
|
||||||
|
products = append(products, pStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析定时发布参数
|
// 解析定时发布参数
|
||||||
scheduleAt, _ := args["schedule_at"].(string)
|
scheduleAt, _ := args["schedule_at"].(string)
|
||||||
visibility := parseVisibility(args)
|
visibility := parseVisibility(args)
|
||||||
@@ -151,7 +159,7 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in
|
|||||||
// 解析原创参数
|
// 解析原创参数
|
||||||
isOriginal, _ := args["is_original"].(bool)
|
isOriginal, _ := args["is_original"].(bool)
|
||||||
|
|
||||||
logrus.Infof("MCP: 发布内容 - 标题: %s, 图片数量: %d, 标签数量: %d, 定时: %s, 原创: %v, visibility: %s", title, len(imagePaths), len(tags), scheduleAt, isOriginal, visibility)
|
logrus.Infof("MCP: 发布内容 - 标题: %s, 图片数量: %d, 标签数量: %d, 定时: %s, 原创: %v, visibility: %s, 商品: %v", title, len(imagePaths), len(tags), scheduleAt, isOriginal, visibility, products)
|
||||||
|
|
||||||
// 构建发布请求
|
// 构建发布请求
|
||||||
req := &PublishRequest{
|
req := &PublishRequest{
|
||||||
@@ -162,6 +170,7 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in
|
|||||||
ScheduleAt: scheduleAt,
|
ScheduleAt: scheduleAt,
|
||||||
IsOriginal: isOriginal,
|
IsOriginal: isOriginal,
|
||||||
Visibility: visibility,
|
Visibility: visibility,
|
||||||
|
Products: products,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行发布
|
// 执行发布
|
||||||
@@ -193,6 +202,7 @@ func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]inte
|
|||||||
content, _ := args["content"].(string)
|
content, _ := args["content"].(string)
|
||||||
videoPath, _ := args["video"].(string)
|
videoPath, _ := args["video"].(string)
|
||||||
tagsInterface, _ := args["tags"].([]interface{})
|
tagsInterface, _ := args["tags"].([]interface{})
|
||||||
|
productsInterface, _ := args["products"].([]interface{})
|
||||||
|
|
||||||
var tags []string
|
var tags []string
|
||||||
for _, tag := range tagsInterface {
|
for _, tag := range tagsInterface {
|
||||||
@@ -201,6 +211,13 @@ func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]inte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var products []string
|
||||||
|
for _, p := range productsInterface {
|
||||||
|
if pStr, ok := p.(string); ok {
|
||||||
|
products = append(products, pStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if videoPath == "" {
|
if videoPath == "" {
|
||||||
return &MCPToolResult{
|
return &MCPToolResult{
|
||||||
Content: []MCPContent{{
|
Content: []MCPContent{{
|
||||||
@@ -215,7 +232,7 @@ func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]inte
|
|||||||
scheduleAt, _ := args["schedule_at"].(string)
|
scheduleAt, _ := args["schedule_at"].(string)
|
||||||
visibility := parseVisibility(args)
|
visibility := parseVisibility(args)
|
||||||
|
|
||||||
logrus.Infof("MCP: 发布视频 - 标题: %s, 标签数量: %d, 定时: %s, visibility: %s", title, len(tags), scheduleAt, visibility)
|
logrus.Infof("MCP: 发布视频 - 标题: %s, 标签数量: %d, 定时: %s, visibility: %s, 商品: %v", title, len(tags), scheduleAt, visibility, products)
|
||||||
|
|
||||||
// 构建发布请求
|
// 构建发布请求
|
||||||
req := &PublishVideoRequest{
|
req := &PublishVideoRequest{
|
||||||
@@ -225,6 +242,7 @@ func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]inte
|
|||||||
Tags: tags,
|
Tags: tags,
|
||||||
ScheduleAt: scheduleAt,
|
ScheduleAt: scheduleAt,
|
||||||
Visibility: visibility,
|
Visibility: visibility,
|
||||||
|
Products: products,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行发布
|
// 执行发布
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type PublishContentArgs struct {
|
|||||||
ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间(可选),ISO8601格式如 2024-01-20T10:30:00+08:00,支持1小时至14天内。不填则立即发布"`
|
ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间(可选),ISO8601格式如 2024-01-20T10:30:00+08:00,支持1小时至14天内。不填则立即发布"`
|
||||||
IsOriginal bool `json:"is_original,omitempty" jsonschema:"是否声明原创(可选),true为声明原创,false或不填则不声明"`
|
IsOriginal bool `json:"is_original,omitempty" jsonschema:"是否声明原创(可选),true为声明原创,false或不填则不声明"`
|
||||||
Visibility string `json:"visibility,omitempty" jsonschema:"可见范围(可选),支持: 公开可见(默认)、仅自己可见、仅互关好友可见。不填则默认公开可见"`
|
Visibility string `json:"visibility,omitempty" jsonschema:"可见范围(可选),支持: 公开可见(默认)、仅自己可见、仅互关好友可见。不填则默认公开可见"`
|
||||||
|
Products []string `json:"products,omitempty" jsonschema:"商品关键词列表(可选),用于绑定带货商品。填写商品名称或商品ID,系统会自动搜索并选择第一个匹配结果。需账号已开通商品功能。示例: [面膜, 防晒霜SPF50]"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishVideoArgs 发布视频的参数(仅支持本地单个视频文件)
|
// PublishVideoArgs 发布视频的参数(仅支持本地单个视频文件)
|
||||||
@@ -34,6 +35,7 @@ type PublishVideoArgs struct {
|
|||||||
Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"`
|
Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"`
|
||||||
ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间(可选),ISO8601格式如 2024-01-20T10:30:00+08:00,支持1小时至14天内。不填则立即发布"`
|
ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间(可选),ISO8601格式如 2024-01-20T10:30:00+08:00,支持1小时至14天内。不填则立即发布"`
|
||||||
Visibility string `json:"visibility,omitempty" jsonschema:"可见范围(可选),支持: 公开可见(默认)、仅自己可见、仅互关好友可见。不填则默认公开可见"`
|
Visibility string `json:"visibility,omitempty" jsonschema:"可见范围(可选),支持: 公开可见(默认)、仅自己可见、仅互关好友可见。不填则默认公开可见"`
|
||||||
|
Products []string `json:"products,omitempty" jsonschema:"商品关键词列表(可选),用于绑定带货商品。填写商品名称或商品ID,系统会自动搜索并选择第一个匹配结果。需账号已开通商品功能。示例: [面膜, 防晒霜SPF50]"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchFeedsArgs 搜索内容的参数
|
// SearchFeedsArgs 搜索内容的参数
|
||||||
@@ -219,6 +221,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
"schedule_at": args.ScheduleAt,
|
"schedule_at": args.ScheduleAt,
|
||||||
"is_original": args.IsOriginal,
|
"is_original": args.IsOriginal,
|
||||||
"visibility": args.Visibility,
|
"visibility": args.Visibility,
|
||||||
|
"products": convertStringsToInterfaces(args.Products),
|
||||||
}
|
}
|
||||||
result := appServer.handlePublishContent(ctx, argsMap)
|
result := appServer.handlePublishContent(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
@@ -391,6 +394,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
|
|||||||
"tags": convertStringsToInterfaces(args.Tags),
|
"tags": convertStringsToInterfaces(args.Tags),
|
||||||
"schedule_at": args.ScheduleAt,
|
"schedule_at": args.ScheduleAt,
|
||||||
"visibility": args.Visibility,
|
"visibility": args.Visibility,
|
||||||
|
"products": convertStringsToInterfaces(args.Products),
|
||||||
}
|
}
|
||||||
result := appServer.handlePublishVideo(ctx, argsMap)
|
result := appServer.handlePublishVideo(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type PublishRequest struct {
|
|||||||
ScheduleAt string `json:"schedule_at,omitempty"` // 定时发布时间,ISO8601格式,为空则立即发布
|
ScheduleAt string `json:"schedule_at,omitempty"` // 定时发布时间,ISO8601格式,为空则立即发布
|
||||||
IsOriginal bool `json:"is_original,omitempty"` // 是否声明原创
|
IsOriginal bool `json:"is_original,omitempty"` // 是否声明原创
|
||||||
Visibility string `json:"visibility,omitempty"` // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
Visibility string `json:"visibility,omitempty"` // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
||||||
|
Products []string `json:"products,omitempty"` // 商品关键词列表,用于绑定带货商品
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginStatusResponse 登录状态响应
|
// LoginStatusResponse 登录状态响应
|
||||||
@@ -67,6 +68,7 @@ type PublishVideoRequest struct {
|
|||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
ScheduleAt string `json:"schedule_at,omitempty"` // 定时发布时间,ISO8601格式,为空则立即发布
|
ScheduleAt string `json:"schedule_at,omitempty"` // 定时发布时间,ISO8601格式,为空则立即发布
|
||||||
Visibility string `json:"visibility,omitempty"` // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
Visibility string `json:"visibility,omitempty"` // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
||||||
|
Products []string `json:"products,omitempty"` // 商品关键词列表,用于绑定带货商品
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishVideoResponse 发布视频响应
|
// PublishVideoResponse 发布视频响应
|
||||||
@@ -217,6 +219,7 @@ func (s *XiaohongshuService) PublishContent(ctx context.Context, req *PublishReq
|
|||||||
ScheduleTime: scheduleTime,
|
ScheduleTime: scheduleTime,
|
||||||
IsOriginal: req.IsOriginal,
|
IsOriginal: req.IsOriginal,
|
||||||
Visibility: req.Visibility,
|
Visibility: req.Visibility,
|
||||||
|
Products: req.Products,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行发布
|
// 执行发布
|
||||||
@@ -307,6 +310,7 @@ func (s *XiaohongshuService) PublishVideo(ctx context.Context, req *PublishVideo
|
|||||||
VideoPath: req.Video,
|
VideoPath: req.Video,
|
||||||
ScheduleTime: scheduleTime,
|
ScheduleTime: scheduleTime,
|
||||||
Visibility: req.Visibility,
|
Visibility: req.Visibility,
|
||||||
|
Products: req.Products,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行发布
|
// 执行发布
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type PublishImageContent struct {
|
|||||||
ScheduleTime *time.Time // 定时发布时间,nil 表示立即发布
|
ScheduleTime *time.Time // 定时发布时间,nil 表示立即发布
|
||||||
IsOriginal bool // 是否声明原创
|
IsOriginal bool // 是否声明原创
|
||||||
Visibility string // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
Visibility string // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
||||||
|
Products []string // 商品关键词列表,用于绑定带货商品
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublishAction struct {
|
type PublishAction struct {
|
||||||
@@ -84,9 +85,9 @@ func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent
|
|||||||
tags = tags[:10]
|
tags = tags[:10]
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Infof("发布内容: title=%s, images=%v, tags=%v, schedule=%v, original=%v, visibility=%s", content.Title, len(content.ImagePaths), tags, content.ScheduleTime, content.IsOriginal, content.Visibility)
|
logrus.Infof("发布内容: title=%s, images=%v, tags=%v, schedule=%v, original=%v, visibility=%s, products=%v", content.Title, len(content.ImagePaths), tags, content.ScheduleTime, content.IsOriginal, content.Visibility, content.Products)
|
||||||
|
|
||||||
if err := submitPublish(page, content.Title, content.Content, tags, content.ScheduleTime, content.IsOriginal, content.Visibility); err != nil {
|
if err := submitPublish(page, content.Title, content.Content, tags, content.ScheduleTime, content.IsOriginal, content.Visibility, content.Products); err != nil {
|
||||||
return errors.Wrap(err, "小红书发布失败")
|
return errors.Wrap(err, "小红书发布失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +271,7 @@ func waitForUploadComplete(page *rod.Page, expectedCount int) error {
|
|||||||
return errors.Errorf("第%d张图片上传超时(60s),请检查网络连接和图片大小", expectedCount)
|
return errors.Errorf("第%d张图片上传超时(60s),请检查网络连接和图片大小", expectedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func submitPublish(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, isOriginal bool, visibility string) error {
|
func submitPublish(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, isOriginal bool, visibility string, products []string) error {
|
||||||
titleElem, err := page.Element("div.d-input input")
|
titleElem, err := page.Element("div.d-input input")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "查找标题输入框失败")
|
return errors.Wrap(err, "查找标题输入框失败")
|
||||||
@@ -332,6 +333,11 @@ func submitPublish(page *rod.Page, title, content string, tags []string, schedul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绑定商品
|
||||||
|
if err := bindProducts(page, products); err != nil {
|
||||||
|
return errors.Wrap(err, "绑定商品失败")
|
||||||
|
}
|
||||||
|
|
||||||
submitButton, err := page.Element(".publish-page-publish-btn button.bg-red")
|
submitButton, err := page.Element(".publish-page-publish-btn button.bg-red")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "查找发布按钮失败")
|
return errors.Wrap(err, "查找发布按钮失败")
|
||||||
@@ -835,3 +841,264 @@ func confirmOriginalDeclaration(page *rod.Page) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clickConfirmButton 点击确定按钮
|
||||||
|
func clickConfirmButton(page *rod.Page) error {
|
||||||
|
// 查找日期选择器弹窗中的确定按钮
|
||||||
|
buttons, err := page.Elements("button.el-picker-panel__link-btn")
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "查找确定按钮失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, btn := range buttons {
|
||||||
|
text, err := btn.Text()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(text) == "确定" {
|
||||||
|
if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||||
|
return errors.Wrap(err, "点击确定按钮失败")
|
||||||
|
}
|
||||||
|
slog.Info("已点击确定按钮")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("未找到确定按钮")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindProducts 绑定商品到发布内容
|
||||||
|
func bindProducts(page *rod.Page, products []string) error {
|
||||||
|
if len(products) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("开始绑定商品", "products", products)
|
||||||
|
|
||||||
|
// 点击"添加商品"按钮
|
||||||
|
if err := clickAddProductButton(page); err != nil {
|
||||||
|
return errors.Wrap(err, "点击添加商品按钮失败")
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// 等待商品选择弹窗出现
|
||||||
|
modal, err := waitForProductModal(page)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "等待商品弹窗失败")
|
||||||
|
}
|
||||||
|
slog.Info("商品选择弹窗已打开")
|
||||||
|
|
||||||
|
// 遍历搜索并选择商品
|
||||||
|
var failedProducts []string
|
||||||
|
for _, keyword := range products {
|
||||||
|
if err := searchAndSelectProduct(page, modal, keyword); err != nil {
|
||||||
|
slog.Warn("搜索选择商品失败", "keyword", keyword, "error", err)
|
||||||
|
failedProducts = append(failedProducts, keyword)
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击保存按钮
|
||||||
|
if err := clickModalSaveButton(page, modal); err != nil {
|
||||||
|
return errors.Wrap(err, "点击保存按钮失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待弹窗关闭
|
||||||
|
if err := waitForModalClose(page); err != nil {
|
||||||
|
slog.Warn("等待弹窗关闭超时", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failedProducts) > 0 {
|
||||||
|
return errors.Errorf("部分商品未找到: %v", failedProducts)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("商品绑定完成", "total", len(products))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clickAddProductButton 点击"添加商品"按钮
|
||||||
|
func clickAddProductButton(page *rod.Page) error {
|
||||||
|
// 查找包含"添加商品"文本的元素
|
||||||
|
spans, err := page.Elements("span.d-text")
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "查找商品按钮文本失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, span := range spans {
|
||||||
|
text, err := span.Text()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(text) == "添加商品" {
|
||||||
|
// 向上查找可点击的父元素
|
||||||
|
parent := span
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
p, err := parent.Parent()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parent = p
|
||||||
|
|
||||||
|
tagName, err := parent.Eval(`() => this.tagName.toLowerCase()`)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tag := tagName.Value.Str()
|
||||||
|
|
||||||
|
// 检查是否为 button 或含 d-button class
|
||||||
|
if tag == "button" {
|
||||||
|
if err := parent.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||||
|
return errors.Wrap(err, "点击添加商品按钮失败")
|
||||||
|
}
|
||||||
|
slog.Info("已点击添加商品按钮")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cls, _ := parent.Attribute("class")
|
||||||
|
if cls != nil && strings.Contains(*cls, "d-button") {
|
||||||
|
if err := parent.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||||
|
return errors.Wrap(err, "点击添加商品按钮失败")
|
||||||
|
}
|
||||||
|
slog.Info("已点击添加商品按钮")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("未找到添加商品按钮,账号可能未开通商品功能")
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForProductModal 等待商品选择弹窗出现
|
||||||
|
func waitForProductModal(page *rod.Page) (*rod.Element, error) {
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
modal, err := page.Element(".multi-goods-selector-modal")
|
||||||
|
if err == nil && modal != nil {
|
||||||
|
visible, _ := modal.Visible()
|
||||||
|
if visible {
|
||||||
|
return modal, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("等待商品选择弹窗超时")
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchAndSelectProduct 搜索并选择商品
|
||||||
|
func searchAndSelectProduct(page *rod.Page, modal *rod.Element, keyword string) error {
|
||||||
|
slog.Info("搜索商品", "keyword", keyword)
|
||||||
|
|
||||||
|
// 获取搜索框
|
||||||
|
searchInput, err := modal.Element(`input[placeholder="搜索商品ID 或 商品名称"]`)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "未找到商品搜索框")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空并输入关键词
|
||||||
|
if err := searchInput.SelectAllText(); err != nil {
|
||||||
|
slog.Warn("选择搜索框文本失败", "error", err)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
if err := searchInput.Input(keyword); err != nil {
|
||||||
|
return errors.Wrap(err, "输入搜索关键词失败")
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
// 模拟回车触发搜索
|
||||||
|
if err := searchInput.MustKeyActions().Press(input.Enter).Do(); err != nil {
|
||||||
|
return errors.Wrap(err, "触发搜索失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待搜索结果
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// 等待 loading 消失
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
loading, err := modal.Element(".d-loading")
|
||||||
|
if err != nil || loading == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
visible, _ := loading.Visible()
|
||||||
|
if !visible {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// 点击第一个商品的 checkbox
|
||||||
|
checkbox, err := modal.Element(".goods-item .d-checkbox")
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "未找到商品选择框")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkbox.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||||
|
return errors.Wrap(err, "点击商品选择框失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("已选择商品", "keyword", keyword)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clickModalSaveButton 点击保存按钮
|
||||||
|
func clickModalSaveButton(page *rod.Page, modal *rod.Element) error {
|
||||||
|
// 查找保存按钮
|
||||||
|
buttons, err := modal.Elements(".goods-selected-footer button")
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "查找保存按钮失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, btn := range buttons {
|
||||||
|
text, err := btn.Text()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 保存按钮通常包含"保存"或"确定"文字
|
||||||
|
if strings.Contains(text, "保存") || strings.Contains(text, "确定") {
|
||||||
|
if err := btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||||
|
return errors.Wrap(err, "点击保存按钮失败")
|
||||||
|
}
|
||||||
|
slog.Info("已点击保存按钮")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试点击主按钮
|
||||||
|
primaryBtn, err := modal.Element(".goods-selected-footer .d-button--primary")
|
||||||
|
if err == nil && primaryBtn != nil {
|
||||||
|
if err := primaryBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||||
|
return errors.Wrap(err, "点击主按钮失败")
|
||||||
|
}
|
||||||
|
slog.Info("已点击主按钮")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("未找到保存按钮")
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForModalClose 等待弹窗关闭
|
||||||
|
func waitForModalClose(page *rod.Page) error {
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
modal, err := page.Element(".multi-goods-selector-modal")
|
||||||
|
if err != nil {
|
||||||
|
return nil // 弹窗已关闭
|
||||||
|
}
|
||||||
|
if modal == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
visible, _ := modal.Visible()
|
||||||
|
if !visible {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("等待弹窗关闭超时")
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type PublishVideoContent struct {
|
|||||||
VideoPath string
|
VideoPath string
|
||||||
ScheduleTime *time.Time // 定时发布时间,nil 表示立即发布
|
ScheduleTime *time.Time // 定时发布时间,nil 表示立即发布
|
||||||
Visibility string // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
Visibility string // 可见范围: "公开可见"(默认), "仅自己可见", "仅互关好友可见"
|
||||||
|
Products []string // 商品关键词列表,用于绑定带货商品
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPublishVideoAction 进入发布页并切换到"上传视频"
|
// NewPublishVideoAction 进入发布页并切换到"上传视频"
|
||||||
@@ -63,7 +64,7 @@ func (p *PublishAction) PublishVideo(ctx context.Context, content PublishVideoCo
|
|||||||
return errors.Wrap(err, "小红书上传视频失败")
|
return errors.Wrap(err, "小红书上传视频失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := submitPublishVideo(page, content.Title, content.Content, content.Tags, content.ScheduleTime, content.Visibility); err != nil {
|
if err := submitPublishVideo(page, content.Title, content.Content, content.Tags, content.ScheduleTime, content.Visibility, content.Products); err != nil {
|
||||||
return errors.Wrap(err, "小红书发布失败")
|
return errors.Wrap(err, "小红书发布失败")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -131,7 +132,7 @@ func waitForPublishButtonClickable(page *rod.Page) (*rod.Element, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// submitPublishVideo 填写标题、正文、标签并点击发布(等待按钮可点击后再提交)
|
// submitPublishVideo 填写标题、正文、标签并点击发布(等待按钮可点击后再提交)
|
||||||
func submitPublishVideo(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, visibility string) error {
|
func submitPublishVideo(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time, visibility string, products []string) error {
|
||||||
// 标题
|
// 标题
|
||||||
titleElem, err := page.Element("div.d-input input")
|
titleElem, err := page.Element("div.d-input input")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,6 +173,11 @@ func submitPublishVideo(page *rod.Page, title, content string, tags []string, sc
|
|||||||
return errors.Wrap(err, "设置可见范围失败")
|
return errors.Wrap(err, "设置可见范围失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绑定商品
|
||||||
|
if err := bindProducts(page, products); err != nil {
|
||||||
|
return errors.Wrap(err, "绑定商品失败")
|
||||||
|
}
|
||||||
|
|
||||||
// 等待发布按钮可点击
|
// 等待发布按钮可点击
|
||||||
btn, err := waitForPublishButtonClickable(page)
|
btn, err := waitForPublishButtonClickable(page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user