diff --git a/go.mod b/go.mod index 0f9e121..f47d5b2 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/xpzouying/xiaohongshu-mcp go 1.24.0 require ( + github.com/avast/retry-go/v4 v4.7.0 github.com/gin-gonic/gin v1.10.1 github.com/go-rod/rod v0.116.2 github.com/h2non/filetype v1.1.3 @@ -15,7 +16,6 @@ require ( ) require ( - github.com/avast/retry-go/v4 v4.7.0 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect diff --git a/go.sum b/go.sum index a6c928f..e0d41e3 100644 --- a/go.sum +++ b/go.sum @@ -79,8 +79,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= diff --git a/handlers_api.go b/handlers_api.go index 31b6afc..26f8893 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -250,6 +250,26 @@ func (s *AppServer) postCommentHandler(c *gin.Context) { respondSuccess(c, result, result.Message) } +// replyCommentHandler 回复指定评论 +func (s *AppServer) replyCommentHandler(c *gin.Context) { + var req ReplyCommentRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "INVALID_REQUEST", + "请求参数错误", err.Error()) + return + } + + result, err := s.xiaohongshuService.ReplyCommentToFeed(c.Request.Context(), req.FeedID, req.XsecToken, req.CommentID, req.UserID, req.Content) + if err != nil { + respondError(c, http.StatusInternalServerError, "REPLY_COMMENT_FAILED", + "回复评论失败", err.Error()) + return + } + + c.Set("account", "ai-report") + respondSuccess(c, result, result.Message) +} + // healthHandler 健康检查 func healthHandler(c *gin.Context) { respondSuccess(c, map[string]any{ diff --git a/mcp_handlers.go b/mcp_handlers.go index a99073d..12ee3da 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -622,3 +622,77 @@ func (s *AppServer) handlePostComment(ctx context.Context, args map[string]inter }}, } } + +// handleReplyComment 处理回复评论 +func (s *AppServer) handleReplyComment(ctx context.Context, args map[string]interface{}) *MCPToolResult { + logrus.Info("MCP: 回复评论") + + // 解析参数 + 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, + } + } + + commentID, _ := args["comment_id"].(string) + userID, _ := args["user_id"].(string) + if commentID == "" && userID == "" { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "回复评论失败: 缺少comment_id或user_id参数", + }}, + 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, Comment ID: %s, User ID: %s, 内容长度: %d", feedID, commentID, userID, len(content)) + + // 回复评论 + result, err := s.xiaohongshuService.ReplyCommentToFeed(ctx, feedID, xsecToken, commentID, userID, content) + if err != nil { + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: "回复评论失败: " + err.Error(), + }}, + IsError: true, + } + } + + // 返回成功结果 + responseText := fmt.Sprintf("评论回复成功 - Feed ID: %s, Comment ID: %s, User ID: %s", result.FeedID, result.TargetCommentID, result.TargetUserID) + return &MCPToolResult{ + Content: []MCPContent{{ + Type: "text", + Text: responseText, + }}, + } +} diff --git a/mcp_server.go b/mcp_server.go index 19cda7d..6d920f0 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -47,10 +47,10 @@ type FilterOption struct { type FeedDetailArgs struct { FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"` XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"` - LoadAllComments bool `json:"load_all_comments,omitempty" jsonschema:"是否加载全部评论(默认false,仅返回首批评论)"` + LoadAllComments bool `json:"load_all_comments,omitempty" jsonschema:"是否加载全部评论(默认false,仅返回首批前十条一级评论)"` ClickMoreReplies bool `json:"click_more_replies,omitempty" jsonschema:"是否点击'更多回复'按钮 (默认: false)"` MaxRepliesThreshold int `json:"max_replies_threshold,omitempty" jsonschema:"回复数量阈值,超过此数量的'更多'按钮将被跳过 (0表示不跳过任何, 默认: 10)"` - MaxCommentItems int `json:"max_comment_items,omitempty" jsonschema:"最大加载评论数(0表示加载所有, 默认: 0)"` + MaxCommentItems int `json:"max_comment_items,omitempty" jsonschema:"最大加载一级评论数(0表示加载所有一级评论, 默认: 0)"` ScrollSpeed string `json:"scroll_speed,omitempty" jsonschema:"滚动速度: 'slow'|'normal'|'fast' (默认: 'normal')"` } @@ -67,6 +67,15 @@ type PostCommentArgs struct { Content string `json:"content" jsonschema:"评论内容"` } +// ReplyCommentArgs 回复评论的参数 +type ReplyCommentArgs struct { + FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"` + XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"` + CommentID string `json:"comment_id,omitempty" jsonschema:"目标评论ID,从评论列表获取"` + UserID string `json:"user_id,omitempty" jsonschema:"目标评论用户ID,从评论列表获取"` + Content string `json:"content" jsonschema:"回复内容"` +} + // LikeFeedArgs 点赞参数 type LikeFeedArgs struct { FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"` @@ -267,7 +276,33 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 10: 发布视频(仅本地文件) + // 工具 10: 回复评论 + mcp.AddTool(server, + &mcp.Tool{ + Name: "reply_comment_in_feed", + Description: "回复小红书笔记下的指定评论", + }, + func(ctx context.Context, req *mcp.CallToolRequest, args ReplyCommentArgs) (*mcp.CallToolResult, any, error) { + if args.CommentID == "" && args.UserID == "" { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: "缺少 comment_id 或 user_id"}}, + }, nil, nil + } + + argsMap := map[string]interface{}{ + "feed_id": args.FeedID, + "xsec_token": args.XsecToken, + "comment_id": args.CommentID, + "user_id": args.UserID, + "content": args.Content, + } + result := appServer.handleReplyComment(ctx, argsMap) + return convertToMCPResult(result), nil, nil + }, + ) + + // 工具 11: 发布视频(仅本地文件) mcp.AddTool(server, &mcp.Tool{ Name: "publish_with_video", @@ -285,7 +320,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 11: 点赞笔记 + // 工具 12: 点赞笔记 mcp.AddTool(server, &mcp.Tool{ Name: "like_feed", @@ -302,7 +337,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }), ) - // 工具 12: 收藏笔记 + // 工具 13: 收藏笔记 mcp.AddTool(server, &mcp.Tool{ Name: "favorite_feed", diff --git a/routes.go b/routes.go index f8b31ed..b17d87b 100644 --- a/routes.go +++ b/routes.go @@ -49,6 +49,7 @@ func setupRoutes(appServer *AppServer) *gin.Engine { api.POST("/feeds/detail", appServer.getFeedDetailHandler) api.POST("/user/profile", appServer.userProfileHandler) api.POST("/feeds/comment", appServer.postCommentHandler) + api.POST("/feeds/comment/reply", appServer.replyCommentHandler) api.GET("/user/me", appServer.myProfileHandler) } diff --git a/service.go b/service.go index c5e07b6..c6f1e83 100644 --- a/service.go +++ b/service.go @@ -458,6 +458,29 @@ func (s *XiaohongshuService) UnfavoriteFeed(ctx context.Context, feedID, xsecTok return &ActionResult{FeedID: feedID, Success: true, Message: "取消收藏成功或未收藏"}, nil } +// ReplyCommentToFeed 回复指定评论 +func (s *XiaohongshuService) ReplyCommentToFeed(ctx context.Context, feedID, xsecToken, commentID, userID, content string) (*ReplyCommentResponse, error) { + b := newBrowser() + defer b.Close() + + page := b.NewPage() + defer page.Close() + + action := xiaohongshu.NewCommentFeedAction(page) + + if err := action.ReplyToComment(ctx, feedID, xsecToken, commentID, userID, content); err != nil { + return nil, err + } + + return &ReplyCommentResponse{ + FeedID: feedID, + TargetCommentID: commentID, + TargetUserID: userID, + Success: true, + Message: "评论回复成功", + }, nil +} + func newBrowser() *headless_browser.Browser { return browser.NewBrowser(configs.IsHeadless(), browser.WithBinPath(configs.GetBinPath())) } diff --git a/types.go b/types.go index 0ea075f..f729bf7 100644 --- a/types.go +++ b/types.go @@ -40,7 +40,7 @@ type CommentLoadConfig struct { ClickMoreReplies bool `json:"click_more_replies,omitempty"` // 回复数量阈值,超过这个数量的"更多"按钮将被跳过(0表示不跳过任何) MaxRepliesThreshold int `json:"max_replies_threshold,omitempty"` - // 最大加载评论数(comment-item数量),0表示加载所有 + // 最大加载评论数(.parent-comment数量),0表示加载所有 MaxCommentItems int `json:"max_comment_items,omitempty"` // 滚动速度等级: slow(慢速), normal(正常), fast(快速) ScrollSpeed string `json:"scroll_speed,omitempty"` @@ -79,6 +79,24 @@ type PostCommentResponse struct { Message string `json:"message"` } +// ReplyCommentRequest 回复评论请求 +type ReplyCommentRequest struct { + FeedID string `json:"feed_id" binding:"required"` + XsecToken string `json:"xsec_token" binding:"required"` + CommentID string `json:"comment_id" binding:"required_without=UserID"` + UserID string `json:"user_id" binding:"required_without=CommentID"` + Content string `json:"content" binding:"required"` +} + +// ReplyCommentResponse 回复评论响应 +type ReplyCommentResponse struct { + FeedID string `json:"feed_id"` + TargetCommentID string `json:"target_comment_id,omitempty"` + TargetUserID string `json:"target_user_id,omitempty"` + Success bool `json:"success"` + Message string `json:"message"` +} + // UserProfileRequest 用户主页请求 type UserProfileRequest struct { UserID string `json:"user_id" binding:"required"` diff --git a/xiaohongshu/comment_feed.go b/xiaohongshu/comment_feed.go index eb953e2..198b069 100644 --- a/xiaohongshu/comment_feed.go +++ b/xiaohongshu/comment_feed.go @@ -2,9 +2,11 @@ package xiaohongshu import ( "context" + "fmt" "time" "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" "github.com/sirupsen/logrus" ) @@ -20,31 +22,252 @@ func NewCommentFeedAction(page *rod.Page) *CommentFeedAction { // PostComment 发表评论到 Feed func (f *CommentFeedAction) PostComment(ctx context.Context, feedID, xsecToken, content string) error { - page := f.page.Context(ctx).Timeout(60 * time.Second) + // 不使用 Context(ctx),避免继承外部 context 的超时 + page := f.page.Timeout(60 * time.Second) - // 构建详情页 URL url := makeFeedDetailURL(feedID, xsecToken) - - logrus.Infof("Opening feed detail page: %s", url) + logrus.Infof("打开 feed 详情页: %s", url) // 导航到详情页 page.MustNavigate(url) page.MustWaitDOMStable() + time.Sleep(1 * time.Second) + + // 检测页面是否可访问 + if err := checkPageAccessible(page); err != nil { + return err + } + + elem, err := page.Element("div.input-box div.content-edit span") + if err != nil { + logrus.Warnf("Failed to find comment input box: %v", err) + return fmt.Errorf("未找到评论输入框,该帖子可能不支持评论或网页端不可访问: %w", err) + } + + if err := elem.Click(proto.InputMouseButtonLeft, 1); err != nil { + logrus.Warnf("Failed to click comment input box: %v", err) + return fmt.Errorf("无法点击评论输入框: %w", err) + } + + elem2, err := page.Element("div.input-box div.content-edit p.content-input") + if err != nil { + logrus.Warnf("Failed to find comment input field: %v", err) + return fmt.Errorf("未找到评论输入区域: %w", err) + } + + if err := elem2.Input(content); err != nil { + logrus.Warnf("Failed to input comment content: %v", err) + return fmt.Errorf("无法输入评论内容: %w", err) + } time.Sleep(1 * time.Second) - elem := page.MustElement("div.input-box div.content-edit span") - elem.MustClick() + submitButton, err := page.Element("div.bottom button.submit") + if err != nil { + logrus.Warnf("Failed to find submit button: %v", err) + return fmt.Errorf("未找到提交按钮: %w", err) + } - elem2 := page.MustElement("div.input-box div.content-edit p.content-input") - elem2.MustInput(content) - - time.Sleep(1 * time.Second) - - submitButton := page.MustElement("div.bottom button.submit") - submitButton.MustClick() + if err := submitButton.Click(proto.InputMouseButtonLeft, 1); err != nil { + logrus.Warnf("Failed to click submit button: %v", err) + return fmt.Errorf("无法点击提交按钮: %w", err) + } time.Sleep(1 * time.Second) + logrus.Infof("Comment posted successfully to feed: %s", feedID) return nil } + +// ReplyToComment 回复指定评论 +func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToken, commentID, userID, content string) error { + // 增加超时时间,因为需要滚动查找评论 + // 注意:不使用 Context(ctx),避免继承外部 context 的超时 + page := f.page.Timeout(5 * time.Minute) + url := makeFeedDetailURL(feedID, xsecToken) + logrus.Infof("打开 feed 详情页进行回复: %s", url) + + // 导航到详情页 + page.MustNavigate(url) + page.MustWaitDOMStable() + time.Sleep(1 * time.Second) + + // 检测页面是否可访问 + if err := checkPageAccessible(page); err != nil { + return err + } + + // 等待评论容器加载 + time.Sleep(2 * time.Second) + + // 使用 Go 实现的查找逻辑 + commentEl, err := findCommentElement(page, commentID, userID) + if err != nil { + return fmt.Errorf("无法找到评论: %w", err) + } + + // 滚动到评论位置 + logrus.Info("滚动到评论位置...") + commentEl.MustScrollIntoView() + time.Sleep(1 * time.Second) + + logrus.Info("准备点击回复按钮") + + // 查找并点击回复按钮 + replyBtn, err := commentEl.Element(".right .interactions .reply") + if err != nil { + return fmt.Errorf("无法找到回复按钮: %w", err) + } + + if err := replyBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + return fmt.Errorf("点击回复按钮失败: %w", err) + } + + time.Sleep(1 * time.Second) + + // 查找回复输入框 + inputEl, err := page.Element("div.input-box div.content-edit p.content-input") + if err != nil { + return fmt.Errorf("无法找到回复输入框: %w", err) + } + + // 输入内容 + if err := inputEl.Input(content); err != nil { + return fmt.Errorf("输入回复内容失败: %w", err) + } + + time.Sleep(500 * time.Millisecond) + + // 查找并点击提交按钮 + submitBtn, err := page.Element("div.bottom button.submit") + if err != nil { + return fmt.Errorf("无法找到提交按钮: %w", err) + } + + if err := submitBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + return fmt.Errorf("点击提交按钮失败: %w", err) + } + + time.Sleep(2 * time.Second) + logrus.Infof("回复评论成功") + return nil +} + +// findCommentElement 查找指定评论元素(参考 feed_detail.go 的滚动逻辑) +func findCommentElement(page *rod.Page, commentID, userID string) (*rod.Element, error) { + logrus.Infof("开始查找评论 - commentID: %s, userID: %s", commentID, userID) + + const maxAttempts = 100 + const scrollInterval = 800 * time.Millisecond + + // 先滚动到评论区 + scrollToCommentsArea(page) + time.Sleep(1 * time.Second) + + var lastCommentCount = 0 + stagnantChecks := 0 + + logrus.Infof("开始循环查找,最大尝试次数: %d", maxAttempts) + + for attempt := 0; attempt < maxAttempts; attempt++ { + logrus.Infof("=== 查找尝试 %d/%d ===", attempt+1, maxAttempts) + + // === 1. 检查是否到达底部 === + if checkEndContainer(page) { + logrus.Info("已到达评论底部,未找到目标评论") + break + } + + // === 2. 获取当前评论数量 === + currentCount := getCommentCount(page) + logrus.Infof("当前评论数: %d", currentCount) + + if currentCount != lastCommentCount { + logrus.Infof("✓ 评论数增加: %d -> %d", lastCommentCount, currentCount) + lastCommentCount = currentCount + stagnantChecks = 0 + } else { + stagnantChecks++ + if stagnantChecks%5 == 0 { + logrus.Infof("评论数停滞 %d 次", stagnantChecks) + } + } + + // === 3. 停滞检测 === + if stagnantChecks >= 10 { + logrus.Info("评论数量停滞超过10次,可能已加载完所有评论") + break + } + + // === 4. 先滚动到最后一个评论(触发懒加载)=== + if currentCount > 0 { + logrus.Infof("滚动到最后一个评论(共 %d 条)", currentCount) + + // 使用 Go 获取所有评论元素 + elements, err := page.Timeout(2 * time.Second).Elements(".parent-comment, .comment-item, .comment") + if err == nil && len(elements) > 0 { + // 滚动到最后一个评论 + lastComment := elements[len(elements)-1] + err := lastComment.ScrollIntoView() + if err != nil { + logrus.Warnf("滚动到最后一个评论失败: %v", err) + } + } else { + logrus.Warnf("未找到评论元素: %v", err) + } + time.Sleep(300 * time.Millisecond) + } + + // === 5. 继续向下滚动 === + logrus.Infof("继续向下滚动...") + _, err := page.Eval(`() => { window.scrollBy(0, window.innerHeight * 0.8); return true; }`) + if err != nil { + logrus.Warnf("滚动失败: %v", err) + } + time.Sleep(500 * time.Millisecond) + + // === 6. 滚动后立即查找(边滚动边查找)=== + // 优先通过 commentID 查找(使用 Timeout 避免长时间等待) + if commentID != "" { + selector := fmt.Sprintf("#comment-%s", commentID) + logrus.Infof("尝试通过 commentID 查找: %s", selector) + + // 使用 Timeout 避免长时间等待 + el, err := page.Timeout(2 * time.Second).Element(selector) + if err == nil && el != nil { + logrus.Infof("✓ 通过 commentID 找到评论: %s (尝试 %d 次)", commentID, attempt+1) + return el, nil + } + logrus.Infof("未找到 commentID (2秒超时)") + } + + // 通过 userID 查找 + if userID != "" { + logrus.Infof("尝试通过 userID 查找: %s", userID) + + // 使用 Timeout 避免长时间等待 + elements, err := page.Timeout(2 * time.Second).Elements(".comment-item, .comment, .parent-comment") + if err == nil && len(elements) > 0 { + logrus.Infof("找到 %d 个评论元素", len(elements)) + for i, el := range elements { + // 快速检查,不等待 + userEl, err := el.Timeout(500 * time.Millisecond).Element(fmt.Sprintf(`[data-user-id="%s"]`, userID)) + if err == nil && userEl != nil { + logrus.Infof("✓ 通过 userID 在第 %d 个元素中找到评论: %s (尝试 %d 次)", i+1, userID, attempt+1) + return el, nil + } + } + logrus.Infof("在 %d 个元素中未找到匹配的 userID", len(elements)) + } else { + logrus.Infof("获取评论元素失败或超时: %v", err) + } + } + + logrus.Infof("本次尝试未找到目标评论,继续下一轮...") + + // === 7. 等待内容加载 === + time.Sleep(scrollInterval) + } + + return nil, fmt.Errorf("未找到评论 (commentID: %s, userID: %s), 尝试次数: %d", commentID, userID, maxAttempts) +} diff --git a/xiaohongshu/publish.go b/xiaohongshu/publish.go index 25a004c..fd7a29c 100644 --- a/xiaohongshu/publish.go +++ b/xiaohongshu/publish.go @@ -433,4 +433,4 @@ func isElementVisible(elem *rod.Element) bool { } return visible -} +} \ No newline at end of file