xhs_get_comments → xhs_get_sub_comments:针对单条评论加载子评论

getFeedDetail 已返回首屏一级评论(含 1-2 条子评论预览),不再需要独立的
评论加载工具。新增 xhs_get_sub_comments 针对指定一级评论加载完整子评论,
支持 max_count 参数(默认 20)控制加载量,避免超时和上下文溢出。

后端:
- schemas: GetFeedCommentsSchema → GetSubCommentsSchema (feed_id, xsec_token, comment_id, max_count)
- types: 删除 CommentsResult
- feed-detail: 删除 getFeedComments/scrapeComments/CommentSort/parseCommentElement,
  新增 getSubComments(导航→store就绪→定位评论→点击展开→读store)
- selectors: 删除 commentSort* 选择器
- index/routes: 注册新工具和路由,超时改用 feed_detail(60s)

前端:
- types/endpoints: 删除 CommentsResult,新增 getSubComments API
- FeedDetail: 删除独立评论加载逻辑,评论随详情显示,新增 handleLoadSubComments
- CommentTree: "还有 X 条回复" 改为可点击按钮,带加载状态
This commit is contained in:
2026-03-02 17:52:35 +08:00
parent a0f3a3cbac
commit 54a3d9708a
10 changed files with 273 additions and 375 deletions
+168 -237
View File
@@ -1,9 +1,9 @@
import type { Page, ElementHandle } from 'rebrowser-playwright'; import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { XHS_SELECTORS } from './selectors.js'; import { XHS_SELECTORS } from './selectors.js';
import { extractInitialState, parseCountString, ensureHttps } from './feeds.js'; import { extractInitialState, parseCountString, ensureHttps } from './feeds.js';
import type { FeedDetail, Comment, CommentsResult } from './types.js'; import type { FeedDetail, Comment } from './types.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -155,12 +155,14 @@ interface RawCommentData {
* Navigate to a Xiaohongshu note detail page and extract comprehensive * Navigate to a Xiaohongshu note detail page and extract comprehensive
* information including title, content, images/video, and stats. * information including title, content, images/video, and stats.
* *
* Comments are NOT loaded here — use {@link getFeedComments} instead. * First-screen comments (10-20 top-level, each with 1-2 sub-comment
* previews) are included. Use {@link getSubComments} to load complete
* sub-comments for a specific parent comment.
* *
* @param page - A Playwright Page managed by BrowserManager. * @param page - A Playwright Page managed by BrowserManager.
* @param feedId - The note (feed) ID. * @param feedId - The note (feed) ID.
* @param xsecToken - Security token required to access the note. * @param xsecToken - Security token required to access the note.
* @returns A FeedDetail object with full note data (comments always `[]`). * @returns A FeedDetail object with full note data including first-screen comments.
*/ */
export async function getFeedDetail( export async function getFeedDetail(
page: Page, page: Page,
@@ -245,68 +247,115 @@ export async function getFeedDetail(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// getFeedComments // getSubComments
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Sort order type for comments. */
export type CommentSort = 'default' | 'newest' | 'most_liked';
/** /**
* Navigate to a Xiaohongshu note detail page and scrape its comments. * Navigate to a Xiaohongshu note detail page, find a specific top-level
* comment, and load its sub-comments (replies) by clicking "展开更多回复"
* until we have at least `maxCount` or no more to load.
* *
* This is a standalone operation — it navigates to the feed URL on its own * The first-screen comments (with 1-2 sub-comment previews) are already
* because each MCP / REST call gets an independent `withPage` session. * returned by {@link getFeedDetail}. This function is for loading more
* sub-comments for a specific parent comment.
* *
* @param page - A Playwright Page managed by BrowserManager. * @param page - A Playwright Page managed by BrowserManager.
* @param feedId - The note (feed) ID. * @param feedId - The note (feed) ID.
* @param xsecToken - Security token required to access the note. * @param xsecToken - Security token required to access the note.
* @param sort - Comment sort order (default | newest | most_liked). * @param commentId - The parent comment ID whose sub-comments to load.
* @param maxCount - Maximum number of top-level comments to load. * @param maxCount - Stop loading once we have at least this many (default 20).
* @returns A CommentsResult with comments array, hasMore flag, and totalCount. * @returns An array of Comment objects (the sub-comments).
*/ */
export async function getFeedComments( export async function getSubComments(
page: Page, page: Page,
feedId: string, feedId: string,
xsecToken: string, xsecToken: string,
sort: CommentSort = 'default', commentId: string,
maxCount = 20, maxCount = 20,
): Promise<CommentsResult> { ): Promise<Comment[]> {
const url = `${FEED_DETAIL_BASE_URL}/${feedId}?xsec_token=${encodeURIComponent(xsecToken)}&xsec_source=pc_feed`; const url = `${FEED_DETAIL_BASE_URL}/${feedId}?xsec_token=${encodeURIComponent(xsecToken)}&xsec_source=pc_feed`;
log.debug({ feedId, url, sort, maxCount }, 'Navigating to feed page for comments'); log.debug({ feedId, commentId, url, maxCount }, 'Navigating to feed page for sub-comments');
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
// Wait for any content to appear — whichever comes first. // Wait for the note container, then immediately poll the store —
await Promise.race([ // no extra fixed delay needed, the store poll covers timing.
page.waitForSelector(SEL.commentItem, { timeout: 10_000 }), await page.waitForSelector(SEL.noteContainer, { timeout: 15_000 }).catch(() => {
page.waitForSelector(SEL.noteContainer, { timeout: 10_000 }), log.warn({ feedId }, 'Note container not found within timeout, proceeding');
]).catch(() => {
log.warn({ feedId }, 'Page content not found within timeout, proceeding');
}); });
// Switch sort tab if needed. // Wait for comments store to finish initial load.
if (sort !== 'default') { await waitForCommentsStoreReady(page, feedId);
const sortSelector = sort === 'newest'
? SEL.commentSortNewest
: SEL.commentSortHottest;
const sortTab = await page.$(sortSelector); // -----------------------------------------------------------------------
if (sortTab) { // Locate the parent-comment DOM wrapper for the target comment and
const isVisible = await sortTab.isVisible().catch(() => false); // click "展开更多回复" repeatedly to load all sub-comments.
if (isVisible) { // -----------------------------------------------------------------------
await sortTab.click().catch(() => {}); // Find the index of the .parent-comment that owns our commentId so we
// Wait for comment list to refresh after sort change. // can get a real ElementHandle (evaluateHandle returns JSHandle which
await page.waitForTimeout(2000); // lacks $ / isVisible).
log.debug({ feedId, sort }, 'Clicked comment sort tab'); const parentIndex = await page.evaluate((cid: string) => {
const parents = document.querySelectorAll('.parent-comment');
for (let i = 0; i < parents.length; i++) {
const item = parents[i].querySelector('.comment-item');
if (!item) continue;
const id =
item.getAttribute('id')?.replace(/^comment-/, '') ??
item.getAttribute('data-id') ??
item.getAttribute('data-comment-id') ??
'';
if (id === cid) return i;
}
return -1;
}, commentId);
let clicks = 0;
if (parentIndex >= 0) {
const parentEls = await page.$$('.parent-comment');
const parentEl = parentEls[parentIndex];
if (parentEl) {
// Scroll the comment into view first.
await parentEl.scrollIntoViewIfNeeded().catch(() => {});
await page.waitForTimeout(300);
while (clicks < MAX_LOAD_MORE_CLICKS) {
// Check if we already have enough sub-comments in the store.
const currentCount = await getStoreSubCommentCount(page, feedId, commentId);
if (currentCount >= maxCount) break;
// Look for "load more replies" button inside this comment thread.
const loadMoreBtn = await parentEl.$('.show-more').catch(() => null);
if (!loadMoreBtn) break;
const isVisible = await loadMoreBtn.isVisible().catch(() => false);
if (!isVisible) break;
await loadMoreBtn.click().catch(() => {});
await page.waitForTimeout(LOAD_MORE_DELAY_MS);
clicks++;
} }
} }
} else {
log.warn({ feedId, commentId }, 'Target parent-comment not found in DOM');
} }
const result = await scrapeComments(page, maxCount); if (clicks > 0) {
log.debug({ commentId, clicks }, 'Clicked "load more replies" button');
}
log.info({ feedId, commentCount: result.comments.length, hasMore: result.hasMore, totalCount: result.totalCount }, 'Feed comments extraction complete'); // -----------------------------------------------------------------------
// Read sub-comments from the Vue store for this specific comment.
// -----------------------------------------------------------------------
const subComments = await extractSubCommentsFromStore(page, feedId, commentId, maxCount);
return result; log.info(
{ feedId, commentId, subCommentCount: subComments.length, maxCount },
'Sub-comments extraction complete',
);
return subComments;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -670,218 +719,100 @@ async function extractCommentsFromStore(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Comment scraping from DOM — uses Playwright Node-side API exclusively // Sub-comment extraction from Vue store
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Scrape comments from the note detail page DOM. * Wait for the comments store to finish its initial request for a given feed.
*
* @param page - The current Playwright page (already on the detail URL).
* @param maxCount - Maximum number of top-level comments to collect.
* @returns A CommentsResult with comments, hasMore flag, and totalCount.
*/ */
async function scrapeComments( async function waitForCommentsStoreReady(page: Page, feedId: string): Promise<void> {
page: Page, await page.evaluate(
maxCount: number, async (id: string) => {
): Promise<CommentsResult> { const maxWaitMs = 5000;
// Scroll down to the comments section to trigger lazy loading. const pollMs = 200;
await page.evaluate(` let waited = 0;
(() => { while (waited < maxWaitMs) {
const commentsArea = document.querySelector('.comments-container'); const state = (window as unknown as Record<string, unknown>).__INITIAL_STATE__ as
if (commentsArea) { Record<string, unknown> | undefined;
commentsArea.scrollIntoView({ behavior: 'smooth' }); const note = state?.note as Record<string, unknown> | undefined;
} else { const map = note?.noteDetailMap as Record<string, Record<string, unknown>> | undefined;
window.scrollTo(0, document.body.scrollHeight); const entry = map?.[id];
const comments = entry?.comments as { firstRequestFinish?: boolean } | undefined;
if (comments?.firstRequestFinish) return;
await new Promise((r) => setTimeout(r, pollMs));
waited += pollMs;
} }
})() },
`); feedId,
);
// Wait briefly for comment items to render.
await page.waitForSelector(SEL.commentItem, { timeout: 2_000 }).catch(() => {
log.debug('No comment items appeared within 2s, proceeding with empty list');
});
// Try to extract total comment count from the page (engage bar).
const totalCountText = await page
.$eval(SEL.commentCount, (el) => el.textContent?.trim() ?? '0')
.catch(() => '0');
const totalCount = parseCountString(totalCountText);
// Load more comments until we have enough or no more "show more" button.
let clicks = 0;
while (clicks < MAX_LOAD_MORE_CLICKS) {
const currentCount = (await page.$$(SEL.commentItem)).length;
if (currentCount >= maxCount) break;
const showMoreBtn = await page.$(SEL.showMoreComments);
if (!showMoreBtn) break;
const isVisible = await showMoreBtn.isVisible().catch(() => false);
if (!isVisible) break;
await showMoreBtn.click().catch(() => {});
await page.waitForTimeout(LOAD_MORE_DELAY_MS);
clicks++;
}
if (clicks > 0) {
log.debug({ clicks }, 'Clicked "show more comments" button');
}
// Now extract all visible comments using Playwright Node-side API.
const commentElements = await page.$$(SEL.commentItem);
const comments: Comment[] = [];
for (const commentEl of commentElements) {
if (comments.length >= maxCount) break;
try {
const comment = await parseCommentElement(commentEl);
if (comment) {
comments.push(comment);
}
} catch {
continue;
}
}
// Determine if there are more comments beyond what we collected.
const showMoreStillExists = await page.$(SEL.showMoreComments).then((btn) => btn !== null).catch(() => false);
const hasMore = commentElements.length > maxCount ||
showMoreStillExists ||
(totalCount > 0 && totalCount > comments.length);
return { comments, hasMore, totalCount };
} }
/** /**
* Parse a single comment element into a Comment object using Playwright * Quick check: how many sub-comments does the store currently have for
* Node-side API. * a given parent comment? Used to decide whether to keep clicking.
*/ */
async function parseCommentElement( async function getStoreSubCommentCount(
commentEl: ElementHandle, page: Page,
): Promise<Comment | null> { feedId: string,
const content = await commentEl commentId: string,
.$eval(SEL.commentContent, (el) => el.textContent?.trim() ?? '') ): Promise<number> {
.catch(() => ''); return page.evaluate(
(args: { feedId: string; commentId: string }) => {
const nickname = await commentEl const state = (window as unknown as Record<string, unknown>).__INITIAL_STATE__ as
.$eval(SEL.commentAuthor, (el) => el.textContent?.trim() ?? '') Record<string, unknown> | undefined;
.catch(() => ''); const note = state?.note as Record<string, unknown> | undefined;
const map = note?.noteDetailMap as Record<string, Record<string, unknown>> | undefined;
const avatar = await commentEl const entry = map?.[args.feedId];
.$eval(SEL.commentAvatar, (el) => el.getAttribute('src') ?? '') const comments = entry?.comments as { list?: Array<Record<string, unknown>> } | undefined;
.catch(() => ''); if (!comments?.list) return 0;
const parent = comments.list.find((c) => c.id === args.commentId);
const likeText = await commentEl if (!parent) return 0;
.$eval(SEL.commentLikeCount, (el) => el.textContent?.trim() ?? '0') const subs = (parent.subComments ?? parent.sub_comments ?? []) as unknown[];
.catch(() => '0'); return subs.length;
},
const createTime = await commentEl { feedId, commentId },
.$eval(SEL.commentTime, (el) => el.textContent?.trim() ?? '')
.catch(() => '');
const ipLocation = await commentEl
.$eval(SEL.commentIpLocation, (el) => el.textContent?.trim() ?? '')
.catch(() => '');
// Try to extract comment ID from the element's attributes.
// DOM uses id="comment-{id}", strip the prefix.
const rawCommentId = await commentEl.evaluate(
(el) =>
el.getAttribute('id') ??
el.getAttribute('data-id') ??
el.getAttribute('data-comment-id') ??
'',
); );
const commentId = rawCommentId.replace(/^comment-/, ''); }
// Try to extract user ID from an author link. /**
const authorHref = await commentEl * Read sub-comments for a specific parent comment from the Vue store,
.$eval('a[href*="/user/profile/"]', (el) => el.getAttribute('href') ?? '') * capped at `maxCount`.
.catch(() => ''); *
const userIdMatch = authorHref.match(/\/user\/profile\/([a-f0-9]+)/); * The store structure is:
const userId = userIdMatch?.[1] ?? ''; * `__INITIAL_STATE__.note.noteDetailMap[feedId].comments.list[]`
* Each item in `list` has `subComments[]`, `subCommentCount`,
* `subCommentHasMore`, and `subCommentCursor`.
*/
async function extractSubCommentsFromStore(
page: Page,
feedId: string,
commentId: string,
maxCount: number,
): Promise<Comment[]> {
const rawSubComments = await page.evaluate(
(args: { feedId: string; commentId: string; maxCount: number }) => {
const state = (window as unknown as Record<string, unknown>).__INITIAL_STATE__ as
Record<string, unknown> | undefined;
const note = state?.note as Record<string, unknown> | undefined;
const map = note?.noteDetailMap as Record<string, Record<string, unknown>> | undefined;
const entry = map?.[args.feedId];
const comments = entry?.comments as { list?: Array<Record<string, unknown>> } | undefined;
if (!comments?.list) return [];
// Extract sub-comment count from "展开 X 条回复" text. const parent = comments.list.find(
const subCommentCountText = await commentEl (c) => c.id === args.commentId,
.$eval(SEL.subCommentCountText, (el) => el.textContent?.trim() ?? '')
.catch(() => '');
const subCountMatch = subCommentCountText.match(/(\d+)/);
let subCommentCount = subCountMatch ? parseInt(subCountMatch[1], 10) : 0;
// Sub-comments (replies)
const subCommentElements = await commentEl.$$(SEL.subCommentItem);
const subComments: Comment[] = [];
for (const subEl of subCommentElements) {
try {
const subContent = await subEl
.$eval(SEL.commentContent, (el) => el.textContent?.trim() ?? '')
.catch(() => '');
const subAuthor = await subEl
.$eval(SEL.commentAuthor, (el) => el.textContent?.trim() ?? '')
.catch(() => '');
const subAvatar = await subEl
.$eval(SEL.commentAvatar, (el) => el.getAttribute('src') ?? '')
.catch(() => '');
const subLikeText = await subEl
.$eval(SEL.commentLikeCount, (el) => el.textContent?.trim() ?? '0')
.catch(() => '0');
const subTime = await subEl
.$eval(SEL.commentTime, (el) => el.textContent?.trim() ?? '')
.catch(() => '');
const subIp = await subEl
.$eval(SEL.commentIpLocation, (el) => el.textContent?.trim() ?? '')
.catch(() => '');
const rawSubId = await subEl.evaluate(
(el) =>
el.getAttribute('id') ??
el.getAttribute('data-id') ??
el.getAttribute('data-comment-id') ??
'',
); );
const subId = rawSubId.replace(/^comment-/, ''); if (!parent) return [];
const subAuthorHref = await subEl const subs = (parent.subComments ?? parent.sub_comments ?? []) as unknown[];
.$eval('a[href*="/user/profile/"]', (el) => el.getAttribute('href') ?? '') return JSON.parse(JSON.stringify(subs.slice(0, args.maxCount)));
.catch(() => ''); },
const subUserIdMatch = subAuthorHref.match(/\/user\/profile\/([a-f0-9]+)/); { feedId, commentId, maxCount },
) as RawCommentData[];
subComments.push({ return rawSubComments
id: subId, .map(parseRawComment)
userId: subUserIdMatch?.[1] ?? '', .filter((c): c is Comment => c !== null);
nickname: subAuthor,
avatar: subAvatar,
content: subContent,
likeCount: parseCountString(subLikeText),
createTime: subTime,
ipLocation: subIp,
subCommentCount: 0,
subComments: [],
});
} catch {
continue;
}
}
// If we parsed sub-comments but had no count from the button, use the parsed count.
if (subCommentCount === 0 && subComments.length > 0) {
subCommentCount = subComments.length;
}
return {
id: commentId,
userId,
nickname,
avatar,
content,
likeCount: parseCountString(likeText),
createTime,
ipLocation,
subCommentCount,
subComments,
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+11 -11
View File
@@ -8,7 +8,7 @@ import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js';
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js'; import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
import { listFeeds } from './feeds.js'; import { listFeeds } from './feeds.js';
import { searchFeeds } from './search.js'; import { searchFeeds } from './search.js';
import { getFeedDetail, getFeedComments } from './feed-detail.js'; import { getFeedDetail, getSubComments } from './feed-detail.js';
import { getUserProfile } from './user-profile.js'; import { getUserProfile } from './user-profile.js';
import { publishImageNote } from './publish.js'; import { publishImageNote } from './publish.js';
import { publishVideoNote } from './publish-video.js'; import { publishVideoNote } from './publish-video.js';
@@ -23,7 +23,7 @@ import {
ListFeedsSchema, ListFeedsSchema,
SearchSchema, SearchSchema,
GetFeedDetailSchema, GetFeedDetailSchema,
GetFeedCommentsSchema, GetSubCommentsSchema,
GetUserProfileSchema, GetUserProfileSchema,
PublishImageSchema, PublishImageSchema,
PublishVideoSchema, PublishVideoSchema,
@@ -229,7 +229,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
server.tool( server.tool(
'xhs_get_feed_detail', 'xhs_get_feed_detail',
'Get Xiaohongshu note detail including content, images, and stats (comments fetched separately via xhs_get_comments)', 'Get Xiaohongshu note detail including content, images, stats, and first-screen comments (use xhs_get_sub_comments to load full replies)',
GetFeedDetailSchema, GetFeedDetailSchema,
async (args) => { async (args) => {
return withErrorHandling('xhs_get_feed_detail', async () => { return withErrorHandling('xhs_get_feed_detail', async () => {
@@ -255,24 +255,24 @@ export const xiaohongshuPlugin: PlatformPlugin = {
); );
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// xhs_get_comments // xhs_get_sub_comments
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
server.tool( server.tool(
'xhs_get_comments', 'xhs_get_sub_comments',
'Get comments for a Xiaohongshu note with sort and count control', 'Load all sub-comments (replies) for a specific parent comment on a Xiaohongshu note',
GetFeedCommentsSchema, GetSubCommentsSchema,
async (args) => { async (args) => {
return withErrorHandling('xhs_get_comments', async () => { return withErrorHandling('xhs_get_sub_comments', async () => {
const timeoutMs = const timeoutMs =
config.operationTimeouts['feed_list'] ?? config.operationTimeouts['feed_detail'] ??
config.operationTimeouts['default'] ?? config.operationTimeouts['default'] ??
30_000; 60_000;
const result = await browser.withPage( const result = await browser.withPage(
PLATFORM, PLATFORM,
async (page) => async (page) =>
getFeedComments(page, args.feed_id, args.xsec_token, args.sort, args.max_count), getSubComments(page, args.feed_id, args.xsec_token, args.comment_id, args.max_count),
timeoutMs, timeoutMs,
); );
+13 -13
View File
@@ -12,7 +12,7 @@ import { cookieStore } from '../../cookie/store.js';
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js'; import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
import { listFeeds } from './feeds.js'; import { listFeeds } from './feeds.js';
import { searchFeeds } from './search.js'; import { searchFeeds } from './search.js';
import { getFeedDetail, getFeedComments } from './feed-detail.js'; import { getFeedDetail, getSubComments } from './feed-detail.js';
import { getUserProfile } from './user-profile.js'; import { getUserProfile } from './user-profile.js';
import { publishImageNote } from './publish.js'; import { publishImageNote } from './publish.js';
import { publishVideoNote } from './publish-video.js'; import { publishVideoNote } from './publish-video.js';
@@ -23,7 +23,7 @@ import { toggleLike, toggleFavorite } from './interaction.js';
import { import {
SearchSchema, SearchSchema,
GetFeedDetailSchema, GetFeedDetailSchema,
GetFeedCommentsSchema, GetSubCommentsSchema,
GetUserProfileSchema, GetUserProfileSchema,
PublishImageSchema, PublishImageSchema,
PublishVideoSchema, PublishVideoSchema,
@@ -66,11 +66,11 @@ const FeedDetailBodySchema = z.object({
xsec_token: GetFeedDetailSchema.xsec_token, xsec_token: GetFeedDetailSchema.xsec_token,
}); });
const FeedCommentsBodySchema = z.object({ const SubCommentsBodySchema = z.object({
feed_id: GetFeedCommentsSchema.feed_id, feed_id: GetSubCommentsSchema.feed_id,
xsec_token: GetFeedCommentsSchema.xsec_token, xsec_token: GetSubCommentsSchema.xsec_token,
sort: GetFeedCommentsSchema.sort, comment_id: GetSubCommentsSchema.comment_id,
max_count: GetFeedCommentsSchema.max_count, max_count: GetSubCommentsSchema.max_count,
}); });
const UserProfileBodySchema = z.object({ const UserProfileBodySchema = z.object({
@@ -331,22 +331,22 @@ export function createXhsRoutes(browser: BrowserManager): Router {
}); });
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// POST /feeds/comments // POST /feeds/sub-comments
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
router.post('/feeds/comments', readRateLimiter, (req, res) => { router.post('/feeds/sub-comments', readRateLimiter, (req, res) => {
void (async () => { void (async () => {
try { try {
const body = FeedCommentsBodySchema.parse(req.body); const body = SubCommentsBodySchema.parse(req.body);
const timeoutMs = const timeoutMs =
config.operationTimeouts['feed_list'] ?? config.operationTimeouts['feed_detail'] ??
config.operationTimeouts['default'] ?? config.operationTimeouts['default'] ??
30_000; 60_000;
const result = await browser.withPage( const result = await browser.withPage(
PLATFORM, PLATFORM,
async (page) => async (page) =>
getFeedComments(page, body.feed_id, body.xsec_token, body.sort, body.max_count), getSubComments(page, body.feed_id, body.xsec_token, body.comment_id, body.max_count),
timeoutMs, timeoutMs,
); );
+5 -9
View File
@@ -52,23 +52,19 @@ export const GetFeedDetailSchema = {
xsec_token: z.string().describe('Security token for the feed'), xsec_token: z.string().describe('Security token for the feed'),
}; };
/** xhs_get_comments */ /** xhs_get_sub_comments */
export const GetFeedCommentsSchema = { export const GetSubCommentsSchema = {
feed_id: z.string().describe('Feed (note) ID'), feed_id: z.string().describe('Feed (note) ID'),
xsec_token: z.string().describe('Security token for the feed'), xsec_token: z.string().describe('Security token for the feed'),
sort: z comment_id: z.string().describe('Parent comment ID whose sub-comments to load'),
.enum(['default', 'newest', 'most_liked'])
.optional()
.default('default')
.describe('Comment sort order: default, newest, or most_liked'),
max_count: z max_count: z
.number() .number()
.int() .int()
.min(1) .min(1)
.max(100) .max(200)
.optional() .optional()
.default(20) .default(20)
.describe('Maximum number of top-level comments to load (1100)'), .describe('Maximum number of sub-comments to load (1200, default 20)'),
}; };
/** xhs_get_user_profile */ /** xhs_get_user_profile */
-6
View File
@@ -104,12 +104,6 @@ export const XHS_SELECTORS = {
showMoreComments: '.comments-container .show-more', showMoreComments: '.comments-container .show-more',
/** "Load more replies" button within a comment thread. */ /** "Load more replies" button within a comment thread. */
loadMoreReplies: '.sub-comment-list .show-more', loadMoreReplies: '.sub-comment-list .show-more',
/** Comment sort tab — default (热度). */
commentSortDefault: '.comments-container .tab:first-child, .comments-container .sort-tab:first-child',
/** Comment sort tab — newest (最新). */
commentSortNewest: '.comments-container .tab:nth-child(2), .comments-container .sort-tab:nth-child(2)',
/** Comment sort tab — most liked (最热). */
commentSortHottest: '.comments-container .tab:nth-child(3), .comments-container .sort-tab:nth-child(3)',
/** Sub-comment count text element (e.g. "展开 X 条回复"). */ /** Sub-comment count text element (e.g. "展开 X 条回复"). */
subCommentCountText: '.sub-comment-list .show-more, .reply-container .show-more', subCommentCountText: '.sub-comment-list .show-more, .reply-container .show-more',
}, },
-6
View File
@@ -77,12 +77,6 @@ export interface Comment {
subComments: Comment[]; subComments: Comment[];
} }
export interface CommentsResult {
comments: Comment[];
hasMore: boolean;
totalCount: number;
}
// -- User Profile --------------------------------------------------------- // -- User Profile ---------------------------------------------------------
export interface UserProfile { export interface UserProfile {
+5 -5
View File
@@ -4,7 +4,7 @@ import type {
QRCodeResult, QRCodeResult,
Feed, Feed,
FeedDetail, FeedDetail,
CommentsResult, Comment,
UserProfile, UserProfile,
SearchFilters, SearchFilters,
HealthResponse, HealthResponse,
@@ -47,15 +47,15 @@ export const getFeedDetail = (feedId: string, xsecToken: string) =>
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken }), body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken }),
}); });
export const getFeedComments = ( export const getSubComments = (
feedId: string, feedId: string,
xsecToken: string, xsecToken: string,
sort: 'default' | 'newest' | 'most_liked' = 'default', commentId: string,
maxCount = 20, maxCount = 20,
) => ) =>
apiFetch<ApiResponse<CommentsResult>>('/api/xhs/feeds/comments', { apiFetch<ApiResponse<Comment[]>>('/api/xhs/feeds/sub-comments', {
method: 'POST', method: 'POST',
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, sort, max_count: maxCount }), body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, comment_id: commentId, max_count: maxCount }),
}); });
// User // User
-6
View File
@@ -64,12 +64,6 @@ export interface Comment {
subComments: Comment[]; subComments: Comment[];
} }
export interface CommentsResult {
comments: Comment[];
hasMore: boolean;
totalCount: number;
}
export interface UserProfile { export interface UserProfile {
id: string; id: string;
nickname: string; nickname: string;
+26 -7
View File
@@ -1,16 +1,23 @@
import type { Comment } from '@/api/types'; import type { Comment } from '@/api/types';
import { Spinner } from '@/components/ui/Spinner';
import { formatTime } from '@/lib/formatters'; import { formatTime } from '@/lib/formatters';
interface Props { interface Props {
comments: Comment[]; comments: Comment[];
depth?: number; depth?: number;
onReply?: (commentId: string, userId: string, nickname: string) => void; onReply?: (commentId: string, userId: string, nickname: string) => void;
onLoadSubComments?: (commentId: string) => void;
subCommentsLoadingId?: string | null;
} }
export function CommentTree({ comments, depth = 0, onReply }: Props) { export function CommentTree({ comments, depth = 0, onReply, onLoadSubComments, subCommentsLoadingId }: Props) {
return ( return (
<div className={depth > 0 ? 'ml-6 border-l border-dark-border pl-4' : ''}> <div className={depth > 0 ? 'ml-6 border-l border-dark-border pl-4' : ''}>
{comments.map((comment) => ( {comments.map((comment) => {
const remainingCount = comment.subCommentCount - comment.subComments.length;
const isLoadingSubs = subCommentsLoadingId === comment.id;
return (
<div key={comment.id} className="py-3 border-b border-dark-border/50 last:border-0"> <div key={comment.id} className="py-3 border-b border-dark-border/50 last:border-0">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{comment.avatar && ( {comment.avatar && (
@@ -41,13 +48,25 @@ export function CommentTree({ comments, depth = 0, onReply }: Props) {
{comment.subComments.length > 0 && ( {comment.subComments.length > 0 && (
<CommentTree comments={comment.subComments} depth={depth + 1} onReply={onReply} /> <CommentTree comments={comment.subComments} depth={depth + 1} onReply={onReply} />
)} )}
{comment.subCommentCount > comment.subComments.length && ( {remainingCount > 0 && depth === 0 && (
<p className="ml-8 mt-1 text-xs text-dark-muted"> <button
{comment.subCommentCount - comment.subComments.length} onClick={() => onLoadSubComments?.(comment.id)}
</p> disabled={isLoadingSubs}
className="ml-8 mt-1 text-xs text-dark-accent hover:text-dark-accent/80 disabled:opacity-50 flex items-center gap-1"
>
{isLoadingSubs ? (
<>
<Spinner size="sm" />
<span>...</span>
</>
) : (
<span> {remainingCount} </span>
)}
</button>
)} )}
</div> </div>
))} );
})}
</div> </div>
); );
} }
+17 -47
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { FeedDetail as FeedDetailType, Comment } from '@/api/types'; import type { FeedDetail as FeedDetailType, Comment } from '@/api/types';
import { getFeedDetail, getFeedComments, toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints'; import { getFeedDetail, getSubComments, toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Spinner } from '@/components/ui/Spinner'; import { Spinner } from '@/components/ui/Spinner';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -24,10 +24,7 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
const [currentImage, setCurrentImage] = useState(0); const [currentImage, setCurrentImage] = useState(0);
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [commentsLoading, setCommentsLoading] = useState(false); const [subCommentsLoading, setSubCommentsLoading] = useState<string | null>(null);
const [commentsHasMore, setCommentsHasMore] = useState(false);
const [commentsTotalCount, setCommentsTotalCount] = useState(0);
const [commentsMaxCount, setCommentsMaxCount] = useState(20);
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(false);
const [favorited, setFavorited] = useState(false); const [favorited, setFavorited] = useState(false);
@@ -49,8 +46,6 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
// Use comments from __INITIAL_STATE__ (first page, ~10-20). // Use comments from __INITIAL_STATE__ (first page, ~10-20).
if (res.data.comments.length > 0) { if (res.data.comments.length > 0) {
setComments(res.data.comments); setComments(res.data.comments);
setCommentsTotalCount(res.data.commentCount);
setCommentsHasMore(res.data.commentCount > res.data.comments.length);
} }
} else { } else {
setError(res.error?.message || 'Failed to load detail'); setError(res.error?.message || 'Failed to load detail');
@@ -61,28 +56,26 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
}, [feedId, xsecToken]); }, [feedId, xsecToken]);
const handleLoadComments = async (maxCount = commentsMaxCount) => { const handleLoadSubComments = async (commentId: string) => {
setCommentsLoading(true); setSubCommentsLoading(commentId);
try { try {
const res = await getFeedComments(feedId, xsecToken, 'default', maxCount); const res = await getSubComments(feedId, xsecToken, commentId);
if (res.success && res.data) { if (res.success && res.data) {
setComments(res.data.comments); setComments((prev) =>
setCommentsHasMore(res.data.hasMore); prev.map((c) =>
setCommentsTotalCount(res.data.totalCount); c.id === commentId
? { ...c, subComments: res.data!, subCommentCount: res.data!.length }
: c,
),
);
} }
} catch { } catch {
toast('error', '加载评论失败'); toast('error', '加载评论失败');
} finally { } finally {
setCommentsLoading(false); setSubCommentsLoading(null);
} }
}; };
const handleLoadMore = () => {
const nextCount = commentsMaxCount * 2;
setCommentsMaxCount(nextCount);
void handleLoadComments(nextCount);
};
const handleToggleLike = async () => { const handleToggleLike = async () => {
setActionLoading('like'); setActionLoading('like');
try { try {
@@ -323,25 +316,10 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
</div> </div>
{/* Comments */} {/* Comments */}
{comments.length === 0 && !commentsLoading && (
<Button
size="sm"
variant="secondary"
onClick={() => void handleLoadComments()}
>
</Button>
)}
{commentsLoading && (
<div className="flex items-center gap-2 py-4 text-dark-muted text-sm">
<Spinner size="sm" />
<span>...</span>
</div>
)}
{comments.length > 0 && ( {comments.length > 0 && (
<div> <div>
<h3 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3"> <h3 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">
({commentsTotalCount > 0 ? commentsTotalCount : comments.length}) ({detail.commentCount > 0 ? detail.commentCount : comments.length})
</h3> </h3>
<CommentTree <CommentTree
comments={comments} comments={comments}
@@ -349,17 +327,9 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
setReplyTarget({ commentId, userId, nickname }); setReplyTarget({ commentId, userId, nickname });
setReplyText(''); setReplyText('');
}} }}
onLoadSubComments={(commentId) => void handleLoadSubComments(commentId)}
subCommentsLoadingId={subCommentsLoading}
/> />
{commentsHasMore && !commentsLoading && (
<Button
size="sm"
variant="secondary"
onClick={handleLoadMore}
className="mt-3 w-full"
>
</Button>
)}
</div> </div>
)} )}
</div> </div>