feat(feed_detail): add loadAllComments parameter to GetFeedDetail functionality
- Enhanced GetFeedDetail method to support loading all comments based on the new loadAllComments parameter. - Updated related handlers and request structures to accommodate the new parameter. - Improved logging to reflect the loading of all comments during feed detail retrieval. - Implemented JavaScript logic to scroll and collect comments when loadAllComments is true.
This commit is contained in:
@@ -165,7 +165,7 @@ func (s *AppServer) getFeedDetailHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取 Feed 详情
|
// 获取 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 {
|
if err != nil {
|
||||||
respondError(c, http.StatusInternalServerError, "GET_FEED_DETAIL_FAILED",
|
respondError(c, http.StatusInternalServerError, "GET_FEED_DETAIL_FAILED",
|
||||||
"获取Feed详情失败", err.Error())
|
"获取Feed详情失败", err.Error())
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/sirupsen/logrus"
|
"strconv"
|
||||||
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/xpzouying/xiaohongshu-mcp/xiaohongshu"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MCP 工具处理函数
|
// 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 {
|
if err != nil {
|
||||||
return &MCPToolResult{
|
return &MCPToolResult{
|
||||||
Content: []MCPContent{{
|
Content: []MCPContent{{
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ type FilterOption struct {
|
|||||||
|
|
||||||
// FeedDetailArgs 获取Feed详情的参数
|
// FeedDetailArgs 获取Feed详情的参数
|
||||||
type FeedDetailArgs struct {
|
type FeedDetailArgs struct {
|
||||||
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
|
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
|
||||||
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
|
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
|
||||||
|
LoadAllComments bool `json:"load_all_comments,omitempty" jsonschema:"是否加载全部评论(默认false,仅返回首批评论)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserProfileArgs 获取用户主页的参数
|
// 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) {
|
withPanicRecovery("get_feed_detail", func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) {
|
||||||
argsMap := map[string]interface{}{
|
argsMap := map[string]interface{}{
|
||||||
"feed_id": args.FeedID,
|
"feed_id": args.FeedID,
|
||||||
"xsec_token": args.XsecToken,
|
"xsec_token": args.XsecToken,
|
||||||
|
"load_all_comments": args.LoadAllComments,
|
||||||
}
|
}
|
||||||
result := appServer.handleGetFeedDetail(ctx, argsMap)
|
result := appServer.handleGetFeedDetail(ctx, argsMap)
|
||||||
return convertToMCPResult(result), nil, nil
|
return convertToMCPResult(result), nil, nil
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ func (s *XiaohongshuService) SearchFeeds(ctx context.Context, keyword string, fi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetFeedDetail 获取Feed详情
|
// 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()
|
b := newBrowser()
|
||||||
defer b.Close()
|
defer b.Close()
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ func (s *XiaohongshuService) GetFeedDetail(ctx context.Context, feedID, xsecToke
|
|||||||
action := xiaohongshu.NewFeedDetailAction(page)
|
action := xiaohongshu.NewFeedDetailAction(page)
|
||||||
|
|
||||||
// 获取 Feed 详情
|
// 获取 Feed 详情
|
||||||
result, err := action.GetFeedDetail(ctx, feedID, xsecToken)
|
result, err := action.GetFeedDetail(ctx, feedID, xsecToken, loadAllComments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
5
types.go
5
types.go
@@ -36,8 +36,9 @@ type MCPContent struct {
|
|||||||
|
|
||||||
// FeedDetailRequest Feed详情请求
|
// FeedDetailRequest Feed详情请求
|
||||||
type FeedDetailRequest struct {
|
type FeedDetailRequest struct {
|
||||||
FeedID string `json:"feed_id" binding:"required"`
|
FeedID string `json:"feed_id" binding:"required"`
|
||||||
XsecToken string `json:"xsec_token" binding:"required"`
|
XsecToken string `json:"xsec_token" binding:"required"`
|
||||||
|
LoadAllComments bool `json:"load_all_comments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchFeedsRequest struct {
|
type SearchFeedsRequest struct {
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ func NewFeedDetailAction(page *rod.Page) *FeedDetailAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetFeedDetail 获取 Feed 详情页数据
|
// GetFeedDetail 获取 Feed 详情页数据
|
||||||
func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken string) (*FeedDetailResponse, error) {
|
func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken string, loadAllComments bool) (*FeedDetailResponse, error) {
|
||||||
page := f.page.Context(ctx).Timeout(60 * time.Second)
|
page := f.page.Context(ctx).Timeout(5 * time.Minute)
|
||||||
|
|
||||||
// 构建详情页 URL
|
// 构建详情页 URL
|
||||||
url := makeFeedDetailURL(feedID, xsecToken)
|
url := makeFeedDetailURL(feedID, xsecToken)
|
||||||
@@ -35,6 +35,217 @@ func (f *FeedDetailAction) GetFeedDetail(ctx context.Context, feedID, xsecToken
|
|||||||
page.MustWaitDOMStable()
|
page.MustWaitDOMStable()
|
||||||
time.Sleep(1 * time.Second)
|
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(`() => {
|
result := page.MustEval(`() => {
|
||||||
if (window.__INITIAL_STATE__ &&
|
if (window.__INITIAL_STATE__ &&
|
||||||
window.__INITIAL_STATE__.note &&
|
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)
|
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{
|
return &FeedDetailResponse{
|
||||||
Note: noteDetail.Note,
|
Note: noteDetail.Note,
|
||||||
Comments: noteDetail.Comments,
|
Comments: noteDetail.Comments,
|
||||||
|
|||||||
Reference in New Issue
Block a user