diff --git a/go.mod b/go.mod index be1375c..f47d5b2 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/xpzouying/xiaohongshu-mcp go 1.24.0 require ( - github.com/avast/retry-go/v4 v4.6.0 + 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 @@ -16,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/mcp_handlers.go b/mcp_handlers.go index 9dc3cef..12ee3da 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -8,10 +8,6 @@ import ( "strings" "time" - "strconv" - "strings" - "time" - "github.com/sirupsen/logrus" "github.com/xpzouying/xiaohongshu-mcp/cookies" "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" @@ -401,67 +397,7 @@ func (s *AppServer) handleGetFeedDetail(ctx context.Context, args map[string]any } logrus.Infof("MCP: 获取Feed详情 - Feed ID: %s, loadAllComments=%v, config=%+v", feedID, loadAll, config) - 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) result, err := s.xiaohongshuService.GetFeedDetailWithConfig(ctx, feedID, xsecToken, loadAll, config) if err != nil { return &MCPToolResult{ diff --git a/mcp_server.go b/mcp_server.go index 577750e..6d920f0 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -237,13 +237,6 @@ func registerTools(server *mcp.Server, appServer *AppServer) { "max_replies_threshold": args.MaxRepliesThreshold, "max_comment_items": args.MaxCommentItems, "scroll_speed": args.ScrollSpeed, - "feed_id": args.FeedID, - "xsec_token": args.XsecToken, - "load_all_comments": args.LoadAllComments, - "click_more_replies": args.ClickMoreReplies, - "max_replies_threshold": args.MaxRepliesThreshold, - "max_comment_items": args.MaxCommentItems, - "scroll_speed": args.ScrollSpeed, } result := appServer.handleGetFeedDetail(ctx, argsMap) return convertToMCPResult(result), nil, nil diff --git a/types.go b/types.go index 2147208..f729bf7 100644 --- a/types.go +++ b/types.go @@ -46,26 +46,12 @@ type CommentLoadConfig struct { ScrollSpeed string `json:"scroll_speed,omitempty"` } -// CommentLoadConfig 评论加载配置 -type CommentLoadConfig struct { - // 是否点击"更多回复"按钮 - ClickMoreReplies bool `json:"click_more_replies,omitempty"` - // 回复数量阈值,超过这个数量的"更多"按钮将被跳过(0表示不跳过任何) - MaxRepliesThreshold int `json:"max_replies_threshold,omitempty"` - // 最大加载评论数(comment-item数量),0表示加载所有 - MaxCommentItems int `json:"max_comment_items,omitempty"` - // 滚动速度等级: slow(慢速), normal(正常), fast(快速) - ScrollSpeed string `json:"scroll_speed,omitempty"` -} - // FeedDetailRequest Feed详情请求 type FeedDetailRequest struct { FeedID string `json:"feed_id" binding:"required"` XsecToken string `json:"xsec_token" binding:"required"` LoadAllComments bool `json:"load_all_comments,omitempty"` CommentConfig *CommentLoadConfig `json:"comment_config,omitempty"` - LoadAllComments bool `json:"load_all_comments,omitempty"` - CommentConfig *CommentLoadConfig `json:"comment_config,omitempty"` } type SearchFeedsRequest struct { diff --git a/xiaohongshu/comment_feed.go b/xiaohongshu/comment_feed.go index f0e9e96..a2f0b4e 100644 --- a/xiaohongshu/comment_feed.go +++ b/xiaohongshu/comment_feed.go @@ -22,7 +22,8 @@ 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 := makeFeedDetailURL(feedID, xsecToken) logrus.Infof("打开 feed 详情页: %s", url) @@ -81,7 +82,8 @@ func (f *CommentFeedAction) PostComment(ctx context.Context, feedID, xsecToken, // ReplyToComment 回复指定评论 func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToken, commentID, userID, content string) error { // 增加超时时间,因为需要滚动查找评论 - page := f.page.Context(ctx).Timeout(5 * time.Minute) + // 注意:不使用 Context(ctx),避免继承外部 context 的超时 + page := f.page.Timeout(5 * time.Minute) url := makeFeedDetailURL(feedID, xsecToken) logrus.Infof("打开 feed 详情页进行回复: %s", url) @@ -151,50 +153,122 @@ func (f *CommentFeedAction) ReplyToComment(ctx context.Context, feedID, xsecToke return nil } -// findCommentElement 查找指定评论元素(Go 实现,减少 JS 代码) +// 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 = 50 + const maxAttempts = 100 const scrollInterval = 800 * time.Millisecond // 先滚动到评论区 - page.MustEval(`() => { - const container = document.querySelector('.comments-container'); - if (container) { - container.scrollIntoView({behavior: 'smooth', block: 'start'}); - } - }`) + scrollToCommentsArea(page) time.Sleep(1 * time.Second) - for attempt := 0; attempt < maxAttempts; attempt++ { - logrus.Debugf("查找尝试 %d/%d", attempt+1, maxAttempts) + var lastCommentCount = 0 + stagnantChecks := 0 - // 优先通过 commentID 查找 + 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) + _, err := page.Eval(`() => { + const container = document.querySelector('.comments-container'); + if (!container) return false; + + // 查找最后一个评论 + const comments = container.querySelectorAll('.parent-comment, .comment-item, .comment'); + if (comments.length > 0) { + const lastComment = comments[comments.length - 1]; + lastComment.scrollIntoView({behavior: 'smooth', block: 'center'}); + return true; + } + return false; + }`) + if err != nil { + 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) - if el, err := page.Element(selector); err == nil { - logrus.Infof("✓ 通过 commentID 找到评论: %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 != "" { - elements, err := page.Elements(".comment-item, .comment") - if err == nil { - for _, el := range elements { - userEl, err := el.Element(fmt.Sprintf(`[data-user-id="%s"]`, 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 找到评论: %s", userID) + 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("本次尝试未找到目标评论,继续下一轮...") - // 滚动页面 - page.MustEval(`() => window.scrollBy(0, window.innerHeight * 0.8)`) + // === 7. 等待内容加载 === time.Sleep(scrollInterval) }