Files
xiaohongshu-mcp/mcp_handlers.go
haikow 13ac2e39c3 修复完善笔记详情内容加载 (#301)
* feat: add like and favorite functionality for feeds

- Implemented handleLikeFeed and handleFavoriteFeed methods in mcp_handlers.go to manage liking and favoriting feeds.
- Added LikeFavoriteArgs struct in mcp_server.go for handling parameters.
- Registered new MCP tools for liking and favoriting feeds in registerTools function.
- Introduced LikeFeed and FavoriteFeed methods in XiaohongshuService to interact with the respective actions.
- Created LikeFavoriteAction in a new file to encapsulate the logic for liking and favoriting feeds on the Xiaohongshu platform.

* "优化评论反馈逻辑:简化回复按钮查找和点击流程

* "Fix-build-errors"

* refactor: streamline like and favorite actions in LikeFavoriteAction

- Introduced a generic method `performInteractAction` to handle both liking and favoriting feeds, reducing code duplication.
- Updated logging to reflect the action type being performed (like or favorite).
- Enhanced state verification after interaction to ensure accurate feedback on success or failure.
- Removed the `clickLastMatch` function, simplifying the interaction logic.

* "Add-unlike-and-unfavorite-functionality"

* "Refactor-performInteractAction-function"

* "Refactor-split-LikeFavoriteAction-into-separate-actions"

* "Add-content-length-validation-for-publish"

* refactor: improve comment posting logic with enhanced error handling and stability checks

- Updated the PostComment method to include error handling for navigation and element interactions.
- Replaced sleep calls with more reliable wait mechanisms to ensure page stability.
- Added checks for the presence of input elements and improved logging for better debugging.

* feat: add reply comment functionality for Xiaohongshu feeds

- Implemented handleReplyComment method in mcp_handlers.go to manage replying to comments on feeds.
- Introduced ReplyCommentArgs struct in mcp_server.go for handling parameters related to comment replies.
- Registered a new MCP tool for replying to comments in the registerTools function.
- Added ReplyCommentToFeed method in service.go to interact with the Xiaohongshu platform for comment replies.
- Enhanced error handling for missing parameters in the reply process.

* refactor: enhance reply comment functionality with improved error handling and response structure

- Simplified error handling in handleReplyComment to check for both comment_id and user_id simultaneously.
- Updated response message to include both Comment ID and User ID upon successful reply.
- Modified ReplyCommentArgs struct to make comment_id and user_id optional.
- Renamed MCP tool for replying to comments for clarity.

* feat(feed): Migrate loadAllComments feature for GetFeedDetail

* fix

* fix

* fix

* fix

* fix: 添加更多自定义选项操作

* fix

* fix:优化代码结构

* chore: update dependencies and implement retry logic for page interactions

---------

Co-authored-by: chekayo <9827969+chekayo@user.noreply.gitee.com>
2025-12-07 15:04:14 +08:00

625 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/sirupsen/logrus"
"github.com/xpzouying/xiaohongshu-mcp/cookies"
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
)
// MCP 工具处理函数
// handleCheckLoginStatus 处理检查登录状态
func (s *AppServer) handleCheckLoginStatus(ctx context.Context) *MCPToolResult {
logrus.Info("MCP: 检查登录状态")
status, err := s.xiaohongshuService.CheckLoginStatus(ctx)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "检查登录状态失败: " + err.Error(),
}},
IsError: true,
}
}
// 根据 IsLoggedIn 判断并返回友好的提示
var resultText string
if status.IsLoggedIn {
resultText = fmt.Sprintf("✅ 已登录\n用户名: %s\n\n你可以使用其他功能了。", status.Username)
} else {
resultText = fmt.Sprintf("❌ 未登录\n\n请使用 get_login_qrcode 工具获取二维码进行登录。")
}
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: resultText,
}},
}
}
// handleGetLoginQrcode 处理获取登录二维码请求。
// 返回二维码图片的 Base64 编码和超时时间,供前端展示扫码登录。
func (s *AppServer) handleGetLoginQrcode(ctx context.Context) *MCPToolResult {
logrus.Info("MCP: 获取登录扫码图片")
result, err := s.xiaohongshuService.GetLoginQrcode(ctx)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{Type: "text", Text: "获取登录扫码图片失败: " + err.Error()}},
IsError: true,
}
}
if result.IsLoggedIn {
return &MCPToolResult{
Content: []MCPContent{{Type: "text", Text: "你当前已处于登录状态"}},
}
}
now := time.Now()
deadline := func() string {
d, err := time.ParseDuration(result.Timeout)
if err != nil {
return now.Format("2006-01-02 15:04:05")
}
return now.Add(d).Format("2006-01-02 15:04:05")
}()
// 已登录:文本 + 图片
contents := []MCPContent{
{Type: "text", Text: "请用小红书 App 在 " + deadline + " 前扫码登录 👇"},
{
Type: "image",
MimeType: "image/png",
Data: strings.TrimPrefix(result.Img, "data:image/png;base64,"),
},
}
return &MCPToolResult{Content: contents}
}
// handleDeleteCookies 处理删除 cookies 请求,用于登录重置
func (s *AppServer) handleDeleteCookies(ctx context.Context) *MCPToolResult {
logrus.Info("MCP: 删除 cookies重置登录状态")
err := s.xiaohongshuService.DeleteCookies(ctx)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{Type: "text", Text: "删除 cookies 失败: " + err.Error()}},
IsError: true,
}
}
cookiePath := cookies.GetCookiesFilePath()
resultText := fmt.Sprintf("Cookies 已成功删除,登录状态已重置。\n\n删除的文件路径: %s\n\n下次操作时需要重新登录。", cookiePath)
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: resultText,
}},
}
}
// handlePublishContent 处理发布内容
func (s *AppServer) handlePublishContent(ctx context.Context, args map[string]interface{}) *MCPToolResult {
logrus.Info("MCP: 发布内容")
// 解析参数
title, _ := args["title"].(string)
content, _ := args["content"].(string)
imagePathsInterface, _ := args["images"].([]interface{})
tagsInterface, _ := args["tags"].([]interface{})
var imagePaths []string
for _, path := range imagePathsInterface {
if pathStr, ok := path.(string); ok {
imagePaths = append(imagePaths, pathStr)
}
}
var tags []string
for _, tag := range tagsInterface {
if tagStr, ok := tag.(string); ok {
tags = append(tags, tagStr)
}
}
logrus.Infof("MCP: 发布内容 - 标题: %s, 图片数量: %d, 标签数量: %d", title, len(imagePaths), len(tags))
// 构建发布请求
req := &PublishRequest{
Title: title,
Content: content,
Images: imagePaths,
Tags: tags,
}
// 执行发布
result, err := s.xiaohongshuService.PublishContent(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,
}},
}
}
// 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列表
func (s *AppServer) handleListFeeds(ctx context.Context) *MCPToolResult {
logrus.Info("MCP: 获取Feeds列表")
result, err := s.xiaohongshuService.ListFeeds(ctx)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "获取Feeds列表失败: " + err.Error(),
}},
IsError: true,
}
}
// 格式化输出转换为JSON字符串
jsonData, err := json.MarshalIndent(result, "", " ")
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: fmt.Sprintf("获取Feeds列表成功但序列化失败: %v", err),
}},
IsError: true,
}
}
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: string(jsonData),
}},
}
}
// handleSearchFeeds 处理搜索Feeds
func (s *AppServer) handleSearchFeeds(ctx context.Context, args SearchFeedsArgs) *MCPToolResult {
logrus.Info("MCP: 搜索Feeds")
if args.Keyword == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "搜索Feeds失败: 缺少关键词参数",
}},
IsError: true,
}
}
logrus.Infof("MCP: 搜索Feeds - 关键词: %s", args.Keyword)
// 将 MCP 的 FilterOption 转换为 xiaohongshu.FilterOption
filter := xiaohongshu.FilterOption{
SortBy: args.Filters.SortBy,
NoteType: args.Filters.NoteType,
PublishTime: args.Filters.PublishTime,
SearchScope: args.Filters.SearchScope,
Location: args.Filters.Location,
}
result, err := s.xiaohongshuService.SearchFeeds(ctx, args.Keyword, filter)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "搜索Feeds失败: " + err.Error(),
}},
IsError: true,
}
}
// 格式化输出转换为JSON字符串
jsonData, err := json.MarshalIndent(result, "", " ")
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: fmt.Sprintf("搜索Feeds成功但序列化失败: %v", err),
}},
IsError: true,
}
}
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: string(jsonData),
}},
}
}
// handleGetFeedDetail 处理获取Feed详情
func (s *AppServer) handleGetFeedDetail(ctx context.Context, args map[string]any) *MCPToolResult {
logrus.Info("MCP: 获取Feed详情")
// 解析参数
feedID, ok := args["feed_id"].(string)
if !ok || feedID == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "获取Feed详情失败: 缺少feed_id参数",
}},
IsError: true,
}
}
xsecToken, ok := args["xsec_token"].(string)
if !ok || xsecToken == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "获取Feed详情失败: 缺少xsec_token参数",
}},
IsError: true,
}
}
loadAll := false
if raw, ok := args["load_all_comments"]; ok {
switch v := raw.(type) {
case bool:
loadAll = v
case string:
if parsed, err := strconv.ParseBool(v); err == nil {
loadAll = parsed
}
case float64:
loadAll = v != 0
}
}
// 解析评论配置参数,如果未提供则使用默认值
config := xiaohongshu.DefaultCommentLoadConfig()
if raw, ok := args["click_more_replies"]; ok {
switch v := raw.(type) {
case bool:
config.ClickMoreReplies = v
case string:
if parsed, err := strconv.ParseBool(v); err == nil {
config.ClickMoreReplies = parsed
}
}
}
if raw, ok := args["max_replies_threshold"]; ok {
switch v := raw.(type) {
case float64:
config.MaxRepliesThreshold = int(v)
case string:
if parsed, err := strconv.Atoi(v); err == nil {
config.MaxRepliesThreshold = parsed
}
case int:
config.MaxRepliesThreshold = v
}
}
if raw, ok := args["max_comment_items"]; ok {
switch v := raw.(type) {
case float64:
config.MaxCommentItems = int(v)
case string:
if parsed, err := strconv.Atoi(v); err == nil {
config.MaxCommentItems = parsed
}
case int:
config.MaxCommentItems = v
}
}
if raw, ok := args["scroll_speed"].(string); ok && raw != "" {
config.ScrollSpeed = raw
}
logrus.Infof("MCP: 获取Feed详情 - Feed ID: %s, loadAllComments=%v, config=%+v", feedID, loadAll, config)
result, err := s.xiaohongshuService.GetFeedDetailWithConfig(ctx, feedID, xsecToken, loadAll, config)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "获取Feed详情失败: " + err.Error(),
}},
IsError: true,
}
}
// 格式化输出转换为JSON字符串
jsonData, err := json.MarshalIndent(result, "", " ")
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: fmt.Sprintf("获取Feed详情成功但序列化失败: %v", err),
}},
IsError: true,
}
}
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: string(jsonData),
}},
}
}
// handleUserProfile 获取用户主页
func (s *AppServer) handleUserProfile(ctx context.Context, args map[string]any) *MCPToolResult {
logrus.Info("MCP: 获取用户主页")
// 解析参数
userID, ok := args["user_id"].(string)
if !ok || userID == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "获取用户主页失败: 缺少user_id参数",
}},
IsError: true,
}
}
xsecToken, ok := args["xsec_token"].(string)
if !ok || xsecToken == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "获取用户主页失败: 缺少xsec_token参数",
}},
IsError: true,
}
}
logrus.Infof("MCP: 获取用户主页 - User ID: %s", userID)
result, err := s.xiaohongshuService.UserProfile(ctx, userID, xsecToken)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "获取用户主页失败: " + err.Error(),
}},
IsError: true,
}
}
// 格式化输出转换为JSON字符串
jsonData, err := json.MarshalIndent(result, "", " ")
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: fmt.Sprintf("获取用户主页,但序列化失败: %v", err),
}},
IsError: true,
}
}
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: string(jsonData),
}},
}
}
// handleLikeFeed 处理点赞/取消点赞
func (s *AppServer) handleLikeFeed(ctx context.Context, args map[string]interface{}) *MCPToolResult {
feedID, ok := args["feed_id"].(string)
if !ok || feedID == "" {
return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: "操作失败: 缺少feed_id参数"}}, IsError: true}
}
xsecToken, ok := args["xsec_token"].(string)
if !ok || xsecToken == "" {
return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: "操作失败: 缺少xsec_token参数"}}, IsError: true}
}
unlike, _ := args["unlike"].(bool)
var res *ActionResult
var err error
if unlike {
res, err = s.xiaohongshuService.UnlikeFeed(ctx, feedID, xsecToken)
} else {
res, err = s.xiaohongshuService.LikeFeed(ctx, feedID, xsecToken)
}
if err != nil {
action := "点赞"
if unlike {
action = "取消点赞"
}
return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: action + "失败: " + err.Error()}}, IsError: true}
}
action := "点赞"
if unlike {
action = "取消点赞"
}
return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: fmt.Sprintf("%s成功 - Feed ID: %s", action, res.FeedID)}}}
}
// handleFavoriteFeed 处理收藏/取消收藏
func (s *AppServer) handleFavoriteFeed(ctx context.Context, args map[string]interface{}) *MCPToolResult {
feedID, ok := args["feed_id"].(string)
if !ok || feedID == "" {
return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: "操作失败: 缺少feed_id参数"}}, IsError: true}
}
xsecToken, ok := args["xsec_token"].(string)
if !ok || xsecToken == "" {
return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: "操作失败: 缺少xsec_token参数"}}, IsError: true}
}
unfavorite, _ := args["unfavorite"].(bool)
var res *ActionResult
var err error
if unfavorite {
res, err = s.xiaohongshuService.UnfavoriteFeed(ctx, feedID, xsecToken)
} else {
res, err = s.xiaohongshuService.FavoriteFeed(ctx, feedID, xsecToken)
}
if err != nil {
action := "收藏"
if unfavorite {
action = "取消收藏"
}
return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: action + "失败: " + err.Error()}}, IsError: true}
}
action := "收藏"
if unfavorite {
action = "取消收藏"
}
return &MCPToolResult{Content: []MCPContent{{Type: "text", Text: fmt.Sprintf("%s成功 - Feed ID: %s", action, res.FeedID)}}}
}
// handlePostComment 处理发表评论到Feed
func (s *AppServer) handlePostComment(ctx context.Context, args map[string]interface{}) *MCPToolResult {
logrus.Info("MCP: 发表评论到Feed")
// 解析参数
feedID, ok := args["feed_id"].(string)
if !ok || feedID == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "发表评论失败: 缺少feed_id参数",
}},
IsError: true,
}
}
xsecToken, ok := args["xsec_token"].(string)
if !ok || xsecToken == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "发表评论失败: 缺少xsec_token参数",
}},
IsError: true,
}
}
content, ok := args["content"].(string)
if !ok || content == "" {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "发表评论失败: 缺少content参数",
}},
IsError: true,
}
}
logrus.Infof("MCP: 发表评论 - Feed ID: %s, 内容长度: %d", feedID, len(content))
// 发表评论
result, err := s.xiaohongshuService.PostCommentToFeed(ctx, feedID, xsecToken, content)
if err != nil {
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: "发表评论失败: " + err.Error(),
}},
IsError: true,
}
}
// 返回成功结果只包含feed_id
resultText := fmt.Sprintf("评论发表成功 - Feed ID: %s", result.FeedID)
return &MCPToolResult{
Content: []MCPContent{{
Type: "text",
Text: resultText,
}},
}
}