diff --git a/handlers_api.go b/handlers_api.go index cbd0ebd..646ccaa 100644 --- a/handlers_api.go +++ b/handlers_api.go @@ -165,7 +165,7 @@ func (s *AppServer) getFeedDetailHandler(c *gin.Context) { } // 获取 Feed 详情 - result, err := s.xiaohongshuService.GetFeedDetail(c.Request.Context(), req.FeedID, req.XsecToken) + result, err := s.xiaohongshuService.GetFeedDetail(c.Request.Context(), req.FeedID, req.XsecToken, req.LoadAllComments) if err != nil { respondError(c, http.StatusInternalServerError, "GET_FEED_DETAIL_FAILED", "获取Feed详情失败", err.Error()) diff --git a/mcp_handlers.go b/mcp_handlers.go index ff4ff58..be1e03f 100644 --- a/mcp_handlers.go +++ b/mcp_handlers.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "fmt" - "github.com/sirupsen/logrus" - "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" + "strconv" "strings" "time" + + "github.com/sirupsen/logrus" + "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" ) // MCP 工具处理函数 @@ -306,9 +308,23 @@ func (s *AppServer) handleGetFeedDetail(ctx context.Context, args map[string]any } } - logrus.Infof("MCP: 获取Feed详情 - Feed ID: %s", feedID) + 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 + } + } - result, err := s.xiaohongshuService.GetFeedDetail(ctx, feedID, xsecToken) + logrus.Infof("MCP: 获取Feed详情 - Feed ID: %s, loadAllComments=%v", feedID, loadAll) + + result, err := s.xiaohongshuService.GetFeedDetail(ctx, feedID, xsecToken, loadAll) if err != nil { return &MCPToolResult{ Content: []MCPContent{{ diff --git a/mcp_server.go b/mcp_server.go index 71a39bd..3c127e8 100644 --- a/mcp_server.go +++ b/mcp_server.go @@ -45,8 +45,9 @@ type FilterOption struct { // FeedDetailArgs 获取Feed详情的参数 type FeedDetailArgs struct { - FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"` - XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"` + 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,仅返回首批评论)"` } // UserProfileArgs 获取用户主页的参数 @@ -213,8 +214,9 @@ func registerTools(server *mcp.Server, appServer *AppServer) { }, withPanicRecovery("get_feed_detail", func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) { argsMap := map[string]interface{}{ - "feed_id": args.FeedID, - "xsec_token": args.XsecToken, + "feed_id": args.FeedID, + "xsec_token": args.XsecToken, + "load_all_comments": args.LoadAllComments, } result := appServer.handleGetFeedDetail(ctx, argsMap) return convertToMCPResult(result), nil, nil diff --git a/service.go b/service.go index a3fa71e..2c7c1a3 100644 --- a/service.go +++ b/service.go @@ -321,7 +321,7 @@ func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string, fi } // GetFeedDetail 获取Feed详情 -func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToken string) (*FeedDetailResponse, error) { +func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToken string, loadAllComments bool) (*FeedDetailResponse, error) { b := newBrowser() defer b.Close() @@ -332,7 +332,7 @@ func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToke action := xiaohongshu.NewFeedDetailAction(page) // 获取 Feed 详情 - result, err := action.GetFeedDetail(ctx, feedID, xsecToken) + result, err := action.GetFeedDetail(ctx, feedID, xsecToken, loadAllComments) if err != nil { return nil, err } diff --git a/types.go b/types.go index f622cfb..cfd3a8c 100644 --- a/types.go +++ b/types.go @@ -36,8 +36,9 @@ type MCPContent struct { // FeedDetailRequest Feed详情请求 type FeedDetailRequest struct { - FeedID string `json:"feed_id" binding:"required"` - XsecToken string `json:"xsec_token" binding:"required"` + FeedID string `json:"feed_id" binding:"required"` + XsecToken string `json:"xsec_token" binding:"required"` + LoadAllComments bool `json:"load_all_comments,omitempty"` } type SearchFeedsRequest struct { diff --git a/xiaohongshu/feed_detail.go b/xiaohongshu/feed_detail.go index 8921498..6adcfb2 100644 --- a/xiaohongshu/feed_detail.go +++ b/xiaohongshu/feed_detail.go @@ -22,8 +22,8 @@ func NewFeedDetailAction(page *rod.Page) *FeedDetailAction { } // GetFeedDetail 获取 Feed 详情页数据 -func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken string) (*FeedDetailResponse, error) { - page := f.page.Context(ctx).Timeout(60 * time.Second) +func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken string, loadAllComments bool) (*FeedDetailResponse, error) { + page := f.page.Context(ctx).Timeout(5 * time.Minute) // 构建详情页 URL url := makeFeedDetailURL(feedID, xsecToken) @@ -35,6 +35,217 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken page.MustWaitDOMStable() time.Sleep(1 * time.Second) + var domCommentsPayload string + if loadAllComments { + scrollToEndJS := `() => { + const END_SELECTOR = '.end-container'; + const DELTA_MIN = 520; + const MAX_ATTEMPTS = 60; + const WAIT_AFTER_SCROLL = 420; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const scrollRoot = document.scrollingElement || document.documentElement || document.body; + + const reachedEnd = () => { + const endEl = document.querySelector(END_SELECTOR); + if (!endEl) return false; + const text = (endEl.textContent || '').toUpperCase(); + if (text.includes('THE END')) return true; + const rect = endEl.getBoundingClientRect(); + return rect.top >= 0 && rect.top <= (window.innerHeight || document.documentElement.clientHeight || 0); + }; + + const collectCandidates = () => { + const container = document.querySelector('.comments-container'); + const set = new Set(); + + const push = (node) => { + if (node && node instanceof HTMLElement) { + set.add(node); + } + }; + + push(document.body); + push(document.documentElement); + push(scrollRoot); + + if (container) { + let current = container; + while (current) { + push(current); + if (current === document.body || current === document.documentElement) { + break; + } + current = current.parentElement; + } + container.querySelectorAll('.comments-el, .list-container, [data-v-4a19279a][name="list"]').forEach(push); + } + + const ranked = Array.from(set).map((node) => { + const style = window.getComputedStyle(node); + const scrollable = node.scrollHeight - node.clientHeight > 40; + const hasScroll = /auto|scroll|overlay/i.test(style.overflowY || ''); + const weight = + (node === scrollRoot ? 800 : 0) + + (container && node === container ? 1200 : 0) + + (container && node.contains && node.contains(container) ? 600 : 0) + + (hasScroll ? 300 : 0) + + (scrollable ? 300 : 0) - + (node === document.body || node === document.documentElement ? 80 : 0); + return { node, weight }; + }).sort((a, b) => b.weight - a.weight); + + return ranked.slice(0, 8).map((item) => item.node); + }; + + const metrics = (el) => { + if (!el || el === document || el === window) { + const root = scrollRoot; + return { + top: root.scrollTop, + max: Math.max(root.scrollHeight - root.clientHeight, 0), + client: root.clientHeight || window.innerHeight + }; + } + return { + top: el.scrollTop, + max: Math.max(el.scrollHeight - el.clientHeight, 0), + client: el.clientHeight + }; + }; + + const setScrollTop = (el, value) => { + if (!el) return; + if (el === document.body || el === document.documentElement || el === scrollRoot || el === document || el === window) { + scrollRoot.scrollTop = value; + } else { + el.scrollTop = value; + } + }; + + const dispatchWheel = (el, delta) => { + if (!el) return; + try { + el.dispatchEvent(new Event('scroll', { bubbles: true })); + if (typeof WheelEvent === 'function' && delta !== 0) { + const wheel = new WheelEvent('wheel', { deltaY: delta, bubbles: true, cancelable: true }); + el.dispatchEvent(wheel); + } + } catch (err) { + console.debug('dispatchWheel error', err); + } + }; + + const waitForMove = (el, beforeTop) => { + let tries = 0; + return new Promise((resolve) => { + const tick = () => { + tries++; + const now = metrics(el).top; + if (Math.abs(now - beforeTop) >= 6 || tries >= 6) { + resolve(Math.abs(now - beforeTop) >= 6); + return; + } + setTimeout(tick, 60); + }; + setTimeout(tick, 60); + }); + }; + + const scrollOnce = async (node) => { + const before = metrics(node); + const delta = Math.max(before.client * 0.85, DELTA_MIN); + const desired = before.max > 0 ? Math.min(before.top + delta, before.max) : before.top + delta; + const applied = Math.max(0, desired - before.top); + setScrollTop(node, desired); + dispatchWheel(node, applied); + const moved = await waitForMove(node, before.top); + if (!moved && node !== scrollRoot) { + const rootBefore = metrics(scrollRoot).top; + setScrollTop(scrollRoot, rootBefore + applied); + dispatchWheel(scrollRoot, applied); + return waitForMove(scrollRoot, rootBefore); + } + return moved; + }; + + return (async () => { + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const candidates = collectCandidates(); + for (const node of candidates) { + const moved = await scrollOnce(node); + if (moved) { + await sleep(WAIT_AFTER_SCROLL); + break; + } + } + if (reachedEnd()) { + return JSON.stringify({ status: 'end', attempts: attempt + 1 }); + } + } + return JSON.stringify({ status: 'timeout' }); + })().catch((err) => JSON.stringify({ status: 'error', message: err && err.message ? err.message : String(err) })); + }` + + if res, err := page.Eval(scrollToEndJS); err != nil { + logrus.Warnf("加载全部评论失败: %v", err) + } else if res != nil { + logrus.Infof("评论滚动结果: %v", res.Value) + } + + collectCommentsJS := `() => { + try { + const container = document.querySelector('.comments-container'); + if (!container) { + return JSON.stringify({ list: [], reachedEnd: false, error: 'comments container not found' }); + } + + const items = Array.from(container.querySelectorAll('.comment-item')); + const seen = new Set(); + const list = []; + + const textContent = (node) => (node && node.textContent ? node.textContent.trim() : ''); + + for (const item of items) { + let rawId = item.getAttribute('id') || ''; + if (!rawId && item.dataset) { + rawId = item.dataset.commentId || item.dataset.id || ''; + } + const commentId = rawId.replace(/^comment-/, '') || rawId; + if (!commentId || seen.has(commentId)) { + continue; + } + seen.add(commentId); + + const contentEl = item.querySelector('.comment-content, .content, .content-text, .text, .word'); + const nicknameEl = item.querySelector('.user-name, .nickname, .name, .author-name, .title'); + const userNode = item.querySelector('[data-user-id]'); + const likeEl = item.querySelector('.like .count, .interaction .like span, .interaction-bar .like span, [class*="like"] span'); + + list.push({ + id: commentId, + content: textContent(contentEl), + nickname: textContent(nicknameEl), + userId: userNode ? (userNode.getAttribute('data-user-id') || '') : '', + likeCount: textContent(likeEl), + }); + } + + const endEl = document.querySelector('.end-container'); + const reachedEnd = !!(endEl && (endEl.textContent || '').toUpperCase().includes('THE END')); + return JSON.stringify({ list, reachedEnd }); + } catch (err) { + return JSON.stringify({ list: [], reachedEnd: false, error: err && err.message ? err.message : String(err) }); + } + }` + + if res, err := page.Eval(collectCommentsJS); err != nil { + logrus.Warnf("收集评论失败: %v", err) + } else if res != nil { + domCommentsPayload = res.Value.Str() + } + } + result := page.MustEval(`() => { if (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.note && @@ -63,6 +274,47 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken return nil, fmt.Errorf("feed %s not found in noteDetailMap", feedID) } + if loadAllComments && domCommentsPayload != "" { + var payload struct { + List []struct { + ID string `json:"id"` + Content string `json:"content"` + Nickname string `json:"nickname"` + UserID string `json:"userId"` + LikeCount string `json:"likeCount"` + } + ReachedEnd bool `json:"reachedEnd"` + Error string `json:"error"` + } + + if err := json.Unmarshal([]byte(domCommentsPayload), &payload); err != nil { + logrus.Warnf("解析 DOM 评论数据失败: %v", err) + } else if payload.Error != "" { + logrus.Warnf("DOM 评论数据返回错误: %s", payload.Error) + } else if len(payload.List) > 0 { + comments := make([]Comment, 0, len(payload.List)) + for _, item := range payload.List { + comments = append(comments, Comment{ + ID: item.ID, + NoteID: feedID, + Content: item.Content, + LikeCount: item.LikeCount, + UserInfo: User{ + UserID: item.UserID, + Nickname: item.Nickname, + NickName: item.Nickname, + }, + SubComments: nil, + SubCommentCount: "0", + }) + } + + noteDetail.Comments.List = comments + noteDetail.Comments.Cursor = "" + noteDetail.Comments.HasMore = !payload.ReachedEnd + } + } + return &FeedDetailResponse{ Note: noteDetail.Note, Comments: noteDetail.Comments,