diff --git a/mcp_handlers.go b/mcp_handlers.go index 12ee3da..2ce9e8b 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -132,14 +132,18 @@ func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]in } } - logrus.Infof("MCP: 发布内容 - 标题: %s, 图片数量: %d, 标签数量: %d", title, len(imagePaths), len(tags)) + // 解析定时发布参数 + scheduleAt, _ := args["schedule_at"].(string) + + logrus.Infof("MCP: 发布内容 - 标题: %s, 图片数量: %d, 标签数量: %d, 定时: %s", title, len(imagePaths), len(tags), scheduleAt) // 构建发布请求 req := &PublishRequest{ - Title: title, - Content: content, - Images: imagePaths, - Tags: tags, + Title: title, + Content: content, + Images: imagePaths, + Tags: tags, + ScheduleAt: scheduleAt, } // 执行发布 @@ -189,14 +193,18 @@ func (s *AppServer) handlePublishVideo(ctx context.Context, args map[string]inte } } - logrus.Infof("MCP: 发布视频 - 标题: %s, 标签数量: %d", title, len(tags)) + // 解析定时发布参数 + scheduleAt, _ := args["schedule_at"].(string) + + logrus.Infof("MCP: 发布视频 - 标题: %s, 标签数量: %d, 定时: %s", title, len(tags), scheduleAt) // 构建发布请求 req := &PublishVideoRequest{ - Title: title, - Content: content, - Video: videoPath, - Tags: tags, + Title: title, + Content: content, + Video: videoPath, + Tags: tags, + ScheduleAt: scheduleAt, } // 执行发布 diff --git a/mcp_server.go b/mcp_server.go index b894a46..4c40607 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -17,18 +17,20 @@ func boolPtr(b bool) *bool { return &b } // PublishContentArgs 发布内容的参数 type PublishContentArgs struct { - Title string `json:"title" jsonschema:"内容标题(小红书限制:最多20个中文字或英文单词)"` - Content string `json:"content" jsonschema:"正文内容,不包含以#开头的标签内容,所有话题标签都用tags参数来生成和提供即可"` - Images []string `json:"images" jsonschema:"图片路径列表(至少需要1张图片)。支持两种方式:1. HTTP/HTTPS图片链接(自动下载);2. 本地图片绝对路径(推荐,如:/Users/user/image.jpg)"` - Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"` + Title string `json:"title" jsonschema:"内容标题(小红书限制:最多20个中文字或英文单词)"` + Content string `json:"content" jsonschema:"正文内容,不包含以#开头的标签内容,所有话题标签都用tags参数来生成和提供即可"` + Images []string `json:"images" jsonschema:"图片路径列表(至少需要1张图片)。支持两种方式:1. HTTP/HTTPS图片链接(自动下载);2. 本地图片绝对路径(推荐,如:/Users/user/image.jpg)"` + Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"` + ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间(可选),ISO8601格式如 2024-01-20T10:30:00+08:00,支持1小时至14天内。不填则立即发布"` } // 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:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"` + 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:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"` + ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间(可选),ISO8601格式如 2024-01-20T10:30:00+08:00,支持1小时至14天内。不填则立即发布"` } // SearchFeedsArgs 搜索内容的参数 @@ -207,10 +209,11 @@ func registerTools(server *mcp.Server, appServer *AppServer) { withPanicRecovery("publish_content", func(ctx context.Context, req *mcp.CallToolRequest, args PublishContentArgs) (*mcp.CallToolResult, any, error) { // 转换参数格式到现有的 handler argsMap := map[string]interface{}{ - "title": args.Title, - "content": args.Content, - "images": convertStringsToInterfaces(args.Images), - "tags": convertStringsToInterfaces(args.Tags), + "title": args.Title, + "content": args.Content, + "images": convertStringsToInterfaces(args.Images), + "tags": convertStringsToInterfaces(args.Tags), + "schedule_at": args.ScheduleAt, } result := appServer.handlePublishContent(ctx, argsMap) return convertToMCPResult(result), nil, nil @@ -377,10 +380,11 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }, withPanicRecovery("publish_with_video", 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), + "title": args.Title, + "content": args.Content, + "video": args.Video, + "tags": convertStringsToInterfaces(args.Tags), + "schedule_at": args.ScheduleAt, } result := appServer.handlePublishVideo(ctx, argsMap) return convertToMCPResult(result), nil, nil diff --git a/service.go b/service.go index c6f1e83..6f116f4 100644 --- a/service.go +++ b/service.go @@ -28,10 +28,11 @@ func NewXiaohongshuService() *XiaohongshuService { // PublishRequest 发布请求 type PublishRequest struct { - Title string `json:"title" binding:"required"` - Content string `json:"content" binding:"required"` - Images []string `json:"images" binding:"required,min=1"` - Tags []string `json:"tags,omitempty"` + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Images []string `json:"images" binding:"required,min=1"` + Tags []string `json:"tags,omitempty"` + ScheduleAt string `json:"schedule_at,omitempty"` // 定时发布时间,ISO8601格式,为空则立即发布 } // LoginStatusResponse 登录状态响应 @@ -58,10 +59,11 @@ type PublishResponse struct { // 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"` + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Video string `json:"video" binding:"required"` + Tags []string `json:"tags,omitempty"` + ScheduleAt string `json:"schedule_at,omitempty"` // 定时发布时间,ISO8601格式,为空则立即发布 } // PublishVideoResponse 发布视频响应 @@ -179,12 +181,39 @@ func (s *XiaohongshuService) PublishContent(ctx context.Context, req *PublishReq return nil, err } + // 解析定时发布时间 + var scheduleTime *time.Time + if req.ScheduleAt != "" { + t, err := time.Parse(time.RFC3339, req.ScheduleAt) + if err != nil { + return nil, fmt.Errorf("定时发布时间格式错误,请使用 ISO8601 格式: %v", err) + } + + // 校验定时发布时间范围:1小时至14天 + now := time.Now() + minTime := now.Add(1 * time.Hour) + maxTime := now.Add(14 * 24 * time.Hour) + + if t.Before(minTime) { + return nil, fmt.Errorf("定时发布时间必须至少在1小时后,当前设置: %s,最早可选: %s", + t.Format("2006-01-02 15:04"), minTime.Format("2006-01-02 15:04")) + } + if t.After(maxTime) { + return nil, fmt.Errorf("定时发布时间不能超过14天,当前设置: %s,最晚可选: %s", + t.Format("2006-01-02 15:04"), maxTime.Format("2006-01-02 15:04")) + } + + scheduleTime = &t + logrus.Infof("设置定时发布时间: %s", t.Format("2006-01-02 15:04")) + } + // 构建发布内容 content := xiaohongshu.PublishImageContent{ - Title: req.Title, - Content: req.Content, - Tags: req.Tags, - ImagePaths: imagePaths, + Title: req.Title, + Content: req.Content, + Tags: req.Tags, + ImagePaths: imagePaths, + ScheduleTime: scheduleTime, } // 执行发布 @@ -241,12 +270,39 @@ func (s *XiaohongshuService) PublishVideo(ctx context.Context, req *PublishVideo return nil, fmt.Errorf("视频文件不存在或不可访问: %v", err) } + // 解析定时发布时间 + var scheduleTime *time.Time + if req.ScheduleAt != "" { + t, err := time.Parse(time.RFC3339, req.ScheduleAt) + if err != nil { + return nil, fmt.Errorf("定时发布时间格式错误,请使用 ISO8601 格式: %v", err) + } + + // 校验定时发布时间范围:1小时至14天 + now := time.Now() + minTime := now.Add(1 * time.Hour) + maxTime := now.Add(14 * 24 * time.Hour) + + if t.Before(minTime) { + return nil, fmt.Errorf("定时发布时间必须至少在1小时后,当前设置: %s,最早可选: %s", + t.Format("2006-01-02 15:04"), minTime.Format("2006-01-02 15:04")) + } + if t.After(maxTime) { + return nil, fmt.Errorf("定时发布时间不能超过14天,当前设置: %s,最晚可选: %s", + t.Format("2006-01-02 15:04"), maxTime.Format("2006-01-02 15:04")) + } + + scheduleTime = &t + logrus.Infof("设置定时发布时间: %s", t.Format("2006-01-02 15:04")) + } + // 构建发布内容 content := xiaohongshu.PublishVideoContent{ - Title: req.Title, - Content: req.Content, - Tags: req.Tags, - VideoPath: req.Video, + Title: req.Title, + Content: req.Content, + Tags: req.Tags, + VideoPath: req.Video, + ScheduleTime: scheduleTime, } // 执行发布 diff --git a/xiaohongshu/publish.go b/xiaohongshu/publish.go index 9d70f4b..0484f40 100644 --- a/xiaohongshu/publish.go +++ b/xiaohongshu/publish.go @@ -17,10 +17,11 @@ import ( // PublishImageContent 发布图文内容 type PublishImageContent struct { - Title string - Content string - Tags []string - ImagePaths []string + Title string + Content string + Tags []string + ImagePaths []string + ScheduleTime *time.Time // 定时发布时间,nil 表示立即发布 } type PublishAction struct { @@ -35,7 +36,21 @@ func NewPublishImageAction(page *rod.Page) (*PublishAction, error) { pp := page.Timeout(300 * time.Second) - pp.MustNavigate(urlOfPublic).MustWaitIdle().MustWaitDOMStable() + // 使用更稳健的导航和等待策略 + if err := pp.Navigate(urlOfPublic); err != nil { + return nil, errors.Wrap(err, "导航到发布页面失败") + } + + // 等待页面加载,使用 WaitLoad 代替 WaitIdle(更宽松) + if err := pp.WaitLoad(); err != nil { + logrus.Warnf("等待页面加载出现问题: %v,继续尝试", err) + } + time.Sleep(2 * time.Second) + + // 等待页面稳定 + if err := pp.WaitDOMStable(time.Second, 0.1); err != nil { + logrus.Warnf("等待 DOM 稳定出现问题: %v,继续尝试", err) + } time.Sleep(1 * time.Second) if err := mustClickPublishTab(pp, "上传图文"); err != nil { @@ -67,9 +82,9 @@ func (p *PublishAction) Publish(ctx context.Context, content PublishImageContent tags = tags[:10] } - logrus.Infof("发布内容: title=%s, images=%v, tags=%v", content.Title, len(content.ImagePaths), tags) + logrus.Infof("发布内容: title=%s, images=%v, tags=%v, schedule=%v", content.Title, len(content.ImagePaths), tags, content.ScheduleTime) - if err := submitPublish(page, content.Title, content.Content, tags); err != nil { + if err := submitPublish(page, content.Title, content.Content, tags, content.ScheduleTime); err != nil { return errors.Wrap(err, "小红书发布失败") } @@ -239,7 +254,7 @@ func waitForUploadComplete(page *rod.Page, expectedCount int) error { return errors.New("上传超时,请检查网络连接和图片大小") } -func submitPublish(page *rod.Page, title, content string, tags []string) error { +func submitPublish(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time) error { titleElem := page.MustElement("div.d-input input") titleElem.MustInput(title) @@ -270,6 +285,14 @@ func submitPublish(page *rod.Page, title, content string, tags []string) error { } slog.Info("检查正文长度:通过") + // 处理定时发布 + if scheduleTime != nil { + if err := setSchedulePublish(page, *scheduleTime); err != nil { + return errors.Wrap(err, "设置定时发布失败") + } + slog.Info("定时发布设置完成", "schedule_time", scheduleTime.Format("2006-01-02 15:04")) + } + submitButton := page.MustElement("div.submit div.d-button-content") submitButton.MustClick() @@ -499,3 +522,132 @@ func isElementVisible(elem *rod.Element) bool { return visible } + +// setSchedulePublish 设置定时发布时间 +func setSchedulePublish(page *rod.Page, t time.Time) error { + // 1. 点击"定时发布" radio button + if err := clickScheduleRadio(page); err != nil { + return err + } + time.Sleep(500 * time.Millisecond) + + // 2. 点击时间选择器打开面板 + if err := clickDateTimePicker(page); err != nil { + return err + } + time.Sleep(500 * time.Millisecond) + + // 3. 设置日期和时间 + if err := setDateTime(page, t); err != nil { + return err + } + time.Sleep(300 * time.Millisecond) + + // 4. 点击确定按钮 + if err := clickConfirmButton(page); err != nil { + return err + } + time.Sleep(500 * time.Millisecond) + + return nil +} + +// clickScheduleRadio 点击定时发布 radio +func clickScheduleRadio(page *rod.Page) error { + labels, err := page.Elements("span.el-radio__label") + if err != nil { + return errors.Wrap(err, "查找 radio label 失败") + } + + for _, label := range labels { + text, err := label.Text() + if err != nil { + continue + } + if strings.TrimSpace(text) == "定时发布" { + if err := label.Click(proto.InputMouseButtonLeft, 1); err != nil { + return errors.Wrap(err, "点击定时发布按钮失败") + } + slog.Info("已点击定时发布按钮") + return nil + } + } + + return errors.New("未找到定时发布按钮") +} + +// clickDateTimePicker 点击时间选择器 +func clickDateTimePicker(page *rod.Page) error { + // 查找日期时间选择器输入框 + picker, err := page.Element("input.el-input__inner[placeholder='选择日期和时间']") + if err != nil { + return errors.Wrap(err, "查找时间选择器失败") + } + + if err := picker.Click(proto.InputMouseButtonLeft, 1); err != nil { + return errors.Wrap(err, "点击时间选择器失败") + } + slog.Info("已点击时间选择器") + return nil +} + +// setDateTime 设置日期和时间 +func setDateTime(page *rod.Page, t time.Time) error { + dateStr := t.Format("2006-01-02") + timeStr := t.Format("15:04") + + // 设置日期 + dateInput, err := page.Element("input.el-input__inner[placeholder='选择日期']") + if err != nil { + return errors.Wrap(err, "查找日期输入框失败") + } + if err := dateInput.SelectAllText(); err != nil { + return errors.Wrap(err, "选择日期文本失败") + } + if err := dateInput.Input(dateStr); err != nil { + return errors.Wrap(err, "输入日期失败") + } + slog.Info("已设置日期", "date", dateStr) + + time.Sleep(300 * time.Millisecond) + + // 设置时间 + timeInput, err := page.Element("input.el-input__inner[placeholder='选择时间']") + if err != nil { + return errors.Wrap(err, "查找时间输入框失败") + } + if err := timeInput.SelectAllText(); err != nil { + return errors.Wrap(err, "选择时间文本失败") + } + if err := timeInput.Input(timeStr); err != nil { + return errors.Wrap(err, "输入时间失败") + } + slog.Info("已设置时间", "time", timeStr) + + 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("未找到确定按钮") +} diff --git a/xiaohongshu/publish_video.go b/xiaohongshu/publish_video.go index bebd03f..13e8f5c 100644 --- a/xiaohongshu/publish_video.go +++ b/xiaohongshu/publish_video.go @@ -14,10 +14,11 @@ import ( // PublishVideoContent 发布视频内容 type PublishVideoContent struct { - Title string - Content string - Tags []string - VideoPath string + Title string + Content string + Tags []string + VideoPath string + ScheduleTime *time.Time // 定时发布时间,nil 表示立即发布 } // NewPublishVideoAction 进入发布页并切换到“上传视频” @@ -48,7 +49,7 @@ func (p *PublishAction) PublishVideo(ctx context.Context, content PublishVideoCo return errors.Wrap(err, "小红书上传视频失败") } - if err := submitPublishVideo(page, content.Title, content.Content, content.Tags); err != nil { + if err := submitPublishVideo(page, content.Title, content.Content, content.Tags, content.ScheduleTime); err != nil { return errors.Wrap(err, "小红书发布失败") } return nil @@ -116,7 +117,7 @@ func waitForPublishButtonClickable(page *rod.Page) (*rod.Element, error) { } // submitPublishVideo 填写标题、正文、标签并点击发布(等待按钮可点击后再提交) -func submitPublishVideo(page *rod.Page, title, content string, tags []string) error { +func submitPublishVideo(page *rod.Page, title, content string, tags []string, scheduleTime *time.Time) error { // 标题 titleElem := page.MustElement("div.d-input input") titleElem.MustInput(title) @@ -132,6 +133,14 @@ func submitPublishVideo(page *rod.Page, title, content string, tags []string) er time.Sleep(1 * time.Second) + // 处理定时发布 + if scheduleTime != nil { + if err := setSchedulePublish(page, *scheduleTime); err != nil { + return errors.Wrap(err, "设置定时发布失败") + } + slog.Info("定时发布设置完成", "schedule_time", scheduleTime.Format("2006-01-02 15:04")) + } + // 等待发布按钮可点击 btn, err := waitForPublishButtonClickable(page) if err != nil {