diff --git a/src/platforms/xiaohongshu/feed-detail.ts b/src/platforms/xiaohongshu/feed-detail.ts index 8d7a69c..628c42e 100644 --- a/src/platforms/xiaohongshu/feed-detail.ts +++ b/src/platforms/xiaohongshu/feed-detail.ts @@ -3,7 +3,7 @@ import type { Page, ElementHandle } from 'rebrowser-playwright'; import { logger } from '../../utils/logger.js'; import { XHS_SELECTORS } from './selectors.js'; import { extractInitialState, parseCountString, ensureHttps } from './feeds.js'; -import type { FeedDetail, Comment } from './types.js'; +import type { FeedDetail, Comment, CommentsResult } from './types.js'; // --------------------------------------------------------------------------- // Constants @@ -11,8 +11,8 @@ import type { FeedDetail, Comment } from './types.js'; const FEED_DETAIL_BASE_URL = 'https://www.xiaohongshu.com/explore'; -/** Maximum number of "show more" clicks to load comments. */ -const MAX_LOAD_MORE_CLICKS = 20; +/** Maximum number of "show more" clicks to load comments (safety limit). */ +const MAX_LOAD_MORE_CLICKS = 50; /** Delay between "show more" clicks to let the page render. */ const LOAD_MORE_DELAY_MS = 1500; @@ -33,7 +33,7 @@ interface RawDetailState { noteData?: RawNoteData; }; note?: { - noteDetailMap?: Record; + noteDetailMap?: Record; note?: RawNoteData; noteData?: RawNoteData; }; @@ -153,20 +153,19 @@ interface RawCommentData { /** * Navigate to a Xiaohongshu note detail page and extract comprehensive - * information including title, content, images/video, stats, and comments. + * information including title, content, images/video, and stats. * - * @param page - A Playwright Page managed by BrowserManager. - * @param feedId - The note (feed) ID. - * @param xsecToken - Security token required to access the note. - * @param loadAllComments - If true, scrolls and clicks "load more" to fetch - * as many comments as possible. - * @returns A FeedDetail object with full note data and comments. + * Comments are NOT loaded here — use {@link getFeedComments} instead. + * + * @param page - A Playwright Page managed by BrowserManager. + * @param feedId - The note (feed) ID. + * @param xsecToken - Security token required to access the note. + * @returns A FeedDetail object with full note data (comments always `[]`). */ export async function getFeedDetail( page: Page, feedId: string, xsecToken: string, - loadAllComments = false, ): Promise { const url = `${FEED_DETAIL_BASE_URL}/${feedId}?xsec_token=${encodeURIComponent(xsecToken)}&xsec_source=pc_feed`; log.debug({ feedId, url }, 'Navigating to feed detail page'); @@ -206,12 +205,14 @@ export async function getFeedDetail( } // ----------------------------------------------------------------------- - // Load comments (from DOM — __INITIAL_STATE__ may not include them) + // Extract comments from the Vue store (loaded async after page render). + // Poll until firstRequestFinish === true, then read comments.list. // ----------------------------------------------------------------------- - if (detail.comments.length === 0 || loadAllComments) { - const comments = await scrapeComments(page, loadAllComments); - if (comments.length > 0) { - detail.comments = comments; + if (detail.comments.length === 0) { + const storeComments = await extractCommentsFromStore(page, feedId); + if (storeComments.length > 0) { + detail.comments = storeComments; + log.debug({ feedId, count: storeComments.length }, 'Extracted comments from Vue store'); } } @@ -236,13 +237,78 @@ export async function getFeedDetail( detail.isFavorited = interactionState.isFavorited ?? false; log.info( - { feedId, commentCount: detail.comments.length, imageCount: detail.images.length, isLiked: detail.isLiked, isFavorited: detail.isFavorited }, + { feedId, imageCount: detail.images.length, isLiked: detail.isLiked, isFavorited: detail.isFavorited }, 'Feed detail extraction complete', ); return detail; } +// --------------------------------------------------------------------------- +// getFeedComments +// --------------------------------------------------------------------------- + +/** Sort order type for comments. */ +export type CommentSort = 'default' | 'newest' | 'most_liked'; + +/** + * Navigate to a Xiaohongshu note detail page and scrape its comments. + * + * This is a standalone operation — it navigates to the feed URL on its own + * because each MCP / REST call gets an independent `withPage` session. + * + * @param page - A Playwright Page managed by BrowserManager. + * @param feedId - The note (feed) ID. + * @param xsecToken - Security token required to access the note. + * @param sort - Comment sort order (default | newest | most_liked). + * @param maxCount - Maximum number of top-level comments to load. + * @returns A CommentsResult with comments array, hasMore flag, and totalCount. + */ +export async function getFeedComments( + page: Page, + feedId: string, + xsecToken: string, + sort: CommentSort = 'default', + maxCount = 20, +): Promise { + 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'); + + await page.goto(url, { waitUntil: 'domcontentloaded' }); + + // Wait for any content to appear — whichever comes first. + await Promise.race([ + page.waitForSelector(SEL.commentItem, { timeout: 10_000 }), + page.waitForSelector(SEL.noteContainer, { timeout: 10_000 }), + ]).catch(() => { + log.warn({ feedId }, 'Page content not found within timeout, proceeding'); + }); + + // Switch sort tab if needed. + if (sort !== 'default') { + const sortSelector = sort === 'newest' + ? SEL.commentSortNewest + : SEL.commentSortHottest; + + const sortTab = await page.$(sortSelector); + if (sortTab) { + const isVisible = await sortTab.isVisible().catch(() => false); + if (isVisible) { + await sortTab.click().catch(() => {}); + // Wait for comment list to refresh after sort change. + await page.waitForTimeout(2000); + log.debug({ feedId, sort }, 'Clicked comment sort tab'); + } + } + } + + const result = await scrapeComments(page, maxCount); + + log.info({ feedId, commentCount: result.comments.length, hasMore: result.hasMore, totalCount: result.totalCount }, 'Feed comments extraction complete'); + + return result; +} + // --------------------------------------------------------------------------- // __INITIAL_STATE__ parsing // --------------------------------------------------------------------------- @@ -257,6 +323,7 @@ function parseDetailFromState( ): FeedDetail | null { // Try multiple possible locations for note data. let noteData: RawNoteData | undefined; + let mapComments: RawCommentData[] | undefined; // Location 1: state.noteData.data.noteData (common structure) noteData = state.noteData?.data?.noteData; @@ -266,10 +333,11 @@ function parseDetailFromState( noteData = state.noteData?.noteData; } - // Location 3: state.note.noteDetailMap[feedId].note + // Location 3: state.note.noteDetailMap[feedId] — note + comments are siblings if (!noteData && state.note?.noteDetailMap) { const mapEntry = state.note.noteDetailMap[feedId]; noteData = mapEntry?.note; + mapComments = mapEntry?.comments; } // Location 4: state.note.note or state.note.noteData @@ -353,8 +421,9 @@ function parseDetailFromState( avatar: rawUser?.avatar ?? rawUser?.avatarUrl ?? rawUser?.avatar_url ?? '', }; - // Comments from state (may be empty) - const rawComments = noteData.comments ?? []; + // Comments: prefer map-level comments (noteDetailMap[id].comments), + // fall back to noteData.comments. + const rawComments = mapComments ?? noteData.comments ?? []; const comments = rawComments.map(parseRawComment).filter((c): c is Comment => c !== null); const resolvedXsecToken = noteData.xsecToken ?? noteData.xsec_token ?? xsecToken; @@ -409,6 +478,11 @@ function parseRawComment(raw: RawCommentData): Comment | null { const rawSubs = raw.subComments ?? raw.sub_comments ?? []; const subComments = rawSubs.map(parseRawComment).filter((c): c is Comment => c !== null); + const rawSubCount = raw.subCommentCount ?? raw.sub_comment_count; + const subCommentCount = rawSubCount + ? (typeof rawSubCount === 'string' ? parseInt(rawSubCount, 10) || 0 : rawSubCount) + : subComments.length; + return { id, userId, @@ -418,6 +492,7 @@ function parseRawComment(raw: RawCommentData): Comment | null { likeCount, createTime, ipLocation, + subCommentCount, subComments, }; } @@ -539,6 +614,61 @@ async function scrapeDetailFromDom( }; } +// --------------------------------------------------------------------------- +// Comment extraction from Vue store (async-loaded data) +// --------------------------------------------------------------------------- + +/** + * Wait for comments to be loaded in the Vue store, then extract them. + * + * XHS loads comments asynchronously after page render. The store at + * `__INITIAL_STATE__.note.noteDetailMap[feedId].comments` starts with + * `{ list: [], firstRequestFinish: false }` and is populated by the + * frontend JS. We poll until `firstRequestFinish` becomes true. + */ +async function extractCommentsFromStore( + page: Page, + feedId: string, +): Promise { + const rawComments = await page.evaluate( + async (id: string) => { + const maxWaitMs = 5000; + const pollMs = 200; + let waited = 0; + + while (waited < maxWaitMs) { + const state = (window as unknown as Record).__INITIAL_STATE__ as + Record | undefined; + const note = state?.note as Record | undefined; + const map = note?.noteDetailMap as Record> | undefined; + const entry = map?.[id]; + const comments = entry?.comments as { list?: unknown[]; firstRequestFinish?: boolean } | undefined; + + if (comments?.firstRequestFinish && comments.list) { + return JSON.parse(JSON.stringify(comments.list)); + } + + await new Promise((r) => setTimeout(r, pollMs)); + waited += pollMs; + } + + // Timeout — return whatever is available. + const state = (window as unknown as Record).__INITIAL_STATE__ as + Record | undefined; + const note = state?.note as Record | undefined; + const map = note?.noteDetailMap as Record> | undefined; + const entry = map?.[feedId]; + const comments = entry?.comments as { list?: unknown[] } | undefined; + return comments?.list ? JSON.parse(JSON.stringify(comments.list)) : []; + }, + feedId, + ) as RawCommentData[]; + + return rawComments + .map(parseRawComment) + .filter((c): c is Comment => c !== null); +} + // --------------------------------------------------------------------------- // Comment scraping from DOM — uses Playwright Node-side API exclusively // --------------------------------------------------------------------------- @@ -546,16 +676,15 @@ async function scrapeDetailFromDom( /** * Scrape comments from the note detail page DOM. * - * @param page - The current Playwright page (already on the detail URL). - * @param loadAllComments - If true, clicks "show more" buttons repeatedly. - * @returns An array of Comment objects. + * @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( page: Page, - loadAllComments: boolean, -): Promise { + maxCount: number, +): Promise { // Scroll down to the comments section to trigger lazy loading. - // Use a string expression to avoid needing DOM types. await page.evaluate(` (() => { const commentsArea = document.querySelector('.comments-container'); @@ -566,27 +695,37 @@ async function scrapeComments( } })() `); - await page.waitForTimeout(1500); - // If loadAllComments, keep clicking "show more" until it disappears or - // we hit the maximum click limit. - if (loadAllComments) { - let clicks = 0; - while (clicks < MAX_LOAD_MORE_CLICKS) { - const showMoreBtn = await page.$(SEL.showMoreComments); - if (!showMoreBtn) break; + // 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'); + }); - const isVisible = await showMoreBtn.isVisible().catch(() => false); - if (!isVisible) break; + // 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); - await showMoreBtn.click().catch(() => {}); - await page.waitForTimeout(LOAD_MORE_DELAY_MS); - clicks++; - } + // 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; - if (clicks > 0) { - log.debug({ clicks }, 'Clicked "show more comments" button'); - } + 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. @@ -594,18 +733,24 @@ async function scrapeComments( 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 { - // Skip comments that fail to parse. continue; } } - return comments; + // 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 }; } /** @@ -640,13 +785,15 @@ async function parseCommentElement( .catch(() => ''); // Try to extract comment ID from the element's attributes. - const commentId = await commentEl.evaluate( + // 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 @@ -655,6 +802,13 @@ async function parseCommentElement( const userIdMatch = authorHref.match(/\/user\/profile\/([a-f0-9]+)/); const userId = userIdMatch?.[1] ?? ''; + // Extract sub-comment count from "展开 X 条回复" text. + const subCommentCountText = await commentEl + .$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[] = []; @@ -680,13 +834,14 @@ async function parseCommentElement( .$eval(SEL.commentIpLocation, (el) => el.textContent?.trim() ?? '') .catch(() => ''); - const subId = await subEl.evaluate( + const rawSubId = await subEl.evaluate( (el) => el.getAttribute('id') ?? el.getAttribute('data-id') ?? el.getAttribute('data-comment-id') ?? '', ); + const subId = rawSubId.replace(/^comment-/, ''); const subAuthorHref = await subEl .$eval('a[href*="/user/profile/"]', (el) => el.getAttribute('href') ?? '') @@ -702,6 +857,7 @@ async function parseCommentElement( likeCount: parseCountString(subLikeText), createTime: subTime, ipLocation: subIp, + subCommentCount: 0, subComments: [], }); } catch { @@ -709,6 +865,11 @@ async function parseCommentElement( } } + // 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, @@ -718,6 +879,7 @@ async function parseCommentElement( likeCount: parseCountString(likeText), createTime, ipLocation, + subCommentCount, subComments, }; } diff --git a/src/platforms/xiaohongshu/index.ts b/src/platforms/xiaohongshu/index.ts index a0330f3..6421d7e 100644 --- a/src/platforms/xiaohongshu/index.ts +++ b/src/platforms/xiaohongshu/index.ts @@ -8,7 +8,7 @@ import { resolveMediaInput, cleanupFile } from '../../utils/downloader.js'; import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js'; import { listFeeds } from './feeds.js'; import { searchFeeds } from './search.js'; -import { getFeedDetail } from './feed-detail.js'; +import { getFeedDetail, getFeedComments } from './feed-detail.js'; import { getUserProfile } from './user-profile.js'; import { publishImageNote } from './publish.js'; import { publishVideoNote } from './publish-video.js'; @@ -23,6 +23,7 @@ import { ListFeedsSchema, SearchSchema, GetFeedDetailSchema, + GetFeedCommentsSchema, GetUserProfileSchema, PublishImageSchema, PublishVideoSchema, @@ -228,7 +229,7 @@ export const xiaohongshuPlugin: PlatformPlugin = { server.tool( 'xhs_get_feed_detail', - 'Get Xiaohongshu note detail including content, images, stats, and comments', + 'Get Xiaohongshu note detail including content, images, and stats (comments fetched separately via xhs_get_comments)', GetFeedDetailSchema, async (args) => { return withErrorHandling('xhs_get_feed_detail', async () => { @@ -237,12 +238,7 @@ export const xiaohongshuPlugin: PlatformPlugin = { const detail = await browser.withPage( PLATFORM, async (page) => - getFeedDetail( - page, - args.feed_id, - args.xsec_token, - args.load_all_comments, - ), + getFeedDetail(page, args.feed_id, args.xsec_token), timeoutMs, ); @@ -258,6 +254,40 @@ export const xiaohongshuPlugin: PlatformPlugin = { }, ); + // ----------------------------------------------------------------------- + // xhs_get_comments + // ----------------------------------------------------------------------- + + server.tool( + 'xhs_get_comments', + 'Get comments for a Xiaohongshu note with sort and count control', + GetFeedCommentsSchema, + async (args) => { + return withErrorHandling('xhs_get_comments', async () => { + const timeoutMs = + config.operationTimeouts['feed_list'] ?? + config.operationTimeouts['default'] ?? + 30_000; + + const result = await browser.withPage( + PLATFORM, + async (page) => + getFeedComments(page, args.feed_id, args.xsec_token, args.sort, args.max_count), + timeoutMs, + ); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(result), + }, + ], + }; + }); + }, + ); + // ----------------------------------------------------------------------- // xhs_get_user_profile // ----------------------------------------------------------------------- diff --git a/src/platforms/xiaohongshu/routes.ts b/src/platforms/xiaohongshu/routes.ts index 9bdf611..b67f9da 100644 --- a/src/platforms/xiaohongshu/routes.ts +++ b/src/platforms/xiaohongshu/routes.ts @@ -12,7 +12,7 @@ import { cookieStore } from '../../cookie/store.js'; import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js'; import { listFeeds } from './feeds.js'; import { searchFeeds } from './search.js'; -import { getFeedDetail } from './feed-detail.js'; +import { getFeedDetail, getFeedComments } from './feed-detail.js'; import { getUserProfile } from './user-profile.js'; import { publishImageNote } from './publish.js'; import { publishVideoNote } from './publish-video.js'; @@ -23,6 +23,7 @@ import { toggleLike, toggleFavorite } from './interaction.js'; import { SearchSchema, GetFeedDetailSchema, + GetFeedCommentsSchema, GetUserProfileSchema, PublishImageSchema, PublishVideoSchema, @@ -63,7 +64,13 @@ const SearchBodySchema = z.object({ const FeedDetailBodySchema = z.object({ feed_id: GetFeedDetailSchema.feed_id, xsec_token: GetFeedDetailSchema.xsec_token, - load_all_comments: GetFeedDetailSchema.load_all_comments, +}); + +const FeedCommentsBodySchema = z.object({ + feed_id: GetFeedCommentsSchema.feed_id, + xsec_token: GetFeedCommentsSchema.xsec_token, + sort: GetFeedCommentsSchema.sort, + max_count: GetFeedCommentsSchema.max_count, }); const UserProfileBodySchema = z.object({ @@ -312,12 +319,7 @@ export function createXhsRoutes(browser: BrowserManager): Router { const detail = await browser.withPage( PLATFORM, async (page) => - getFeedDetail( - page, - body.feed_id, - body.xsec_token, - body.load_all_comments, - ), + getFeedDetail(page, body.feed_id, body.xsec_token), timeoutMs, ); @@ -328,6 +330,33 @@ export function createXhsRoutes(browser: BrowserManager): Router { })(); }); + // ----------------------------------------------------------------------- + // POST /feeds/comments + // ----------------------------------------------------------------------- + router.post('/feeds/comments', readRateLimiter, (req, res) => { + void (async () => { + try { + const body = FeedCommentsBodySchema.parse(req.body); + + const timeoutMs = + config.operationTimeouts['feed_list'] ?? + config.operationTimeouts['default'] ?? + 30_000; + + const result = await browser.withPage( + PLATFORM, + async (page) => + getFeedComments(page, body.feed_id, body.xsec_token, body.sort, body.max_count), + timeoutMs, + ); + + res.json(successResponse(result) as ApiResponse); + } catch (err) { + handleError(res, err); + } + })(); + }); + // ----------------------------------------------------------------------- // POST /user/profile // ----------------------------------------------------------------------- diff --git a/src/platforms/xiaohongshu/schemas.ts b/src/platforms/xiaohongshu/schemas.ts index 869ca92..9fa7510 100644 --- a/src/platforms/xiaohongshu/schemas.ts +++ b/src/platforms/xiaohongshu/schemas.ts @@ -50,11 +50,25 @@ export const SearchSchema = { export const GetFeedDetailSchema = { feed_id: z.string().describe('Feed (note) ID'), xsec_token: z.string().describe('Security token for the feed'), - load_all_comments: z - .boolean() +}; + +/** xhs_get_comments */ +export const GetFeedCommentsSchema = { + feed_id: z.string().describe('Feed (note) ID'), + xsec_token: z.string().describe('Security token for the feed'), + sort: z + .enum(['default', 'newest', 'most_liked']) .optional() - .default(false) - .describe('Whether to scroll and load all comments'), + .default('default') + .describe('Comment sort order: default, newest, or most_liked'), + max_count: z + .number() + .int() + .min(1) + .max(100) + .optional() + .default(20) + .describe('Maximum number of top-level comments to load (1–100)'), }; /** xhs_get_user_profile */ diff --git a/src/platforms/xiaohongshu/selectors.ts b/src/platforms/xiaohongshu/selectors.ts index f872de8..5898f9b 100644 --- a/src/platforms/xiaohongshu/selectors.ts +++ b/src/platforms/xiaohongshu/selectors.ts @@ -85,13 +85,13 @@ export const XHS_SELECTORS = { /** Comment list container. */ commentListContainer: '.comments-container .list-container', /** Individual top-level comment items. */ - commentItem: '.comments-container .list-container .list-item', + commentItem: '.comments-container .list-container > .parent-comment > .comment-item', /** Parent comment content text. */ commentContent: '.content', /** Comment author name. */ commentAuthor: '.author .name', /** Comment author avatar. */ - commentAvatar: '.author .avatar img', + commentAvatar: '.avatar img.avatar-item', /** Comment like count. */ commentLikeCount: '.like .count', /** Comment publish time. */ @@ -104,6 +104,14 @@ export const XHS_SELECTORS = { showMoreComments: '.comments-container .show-more', /** "Load more replies" button within a comment thread. */ 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 条回复"). */ + subCommentCountText: '.sub-comment-list .show-more, .reply-container .show-more', }, userProfile: { diff --git a/src/platforms/xiaohongshu/types.ts b/src/platforms/xiaohongshu/types.ts index acddc0d..528033c 100644 --- a/src/platforms/xiaohongshu/types.ts +++ b/src/platforms/xiaohongshu/types.ts @@ -73,9 +73,16 @@ export interface Comment { likeCount: number; createTime: string; ipLocation: string; + subCommentCount: number; subComments: Comment[]; } +export interface CommentsResult { + comments: Comment[]; + hasMore: boolean; + totalCount: number; +} + // -- User Profile --------------------------------------------------------- export interface UserProfile { diff --git a/web/src/api/endpoints.ts b/web/src/api/endpoints.ts index d3523fa..e5cc0c4 100644 --- a/web/src/api/endpoints.ts +++ b/web/src/api/endpoints.ts @@ -4,6 +4,7 @@ import type { QRCodeResult, Feed, FeedDetail, + CommentsResult, UserProfile, SearchFilters, HealthResponse, @@ -40,10 +41,21 @@ export const searchFeeds = (keyword: string, filters?: SearchFilters) => body: JSON.stringify({ keyword, filters }), }); -export const getFeedDetail = (feedId: string, xsecToken: string, loadAllComments = false) => +export const getFeedDetail = (feedId: string, xsecToken: string) => apiFetch>('/api/xhs/feeds/detail', { method: 'POST', - body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, load_all_comments: loadAllComments }), + body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken }), + }); + +export const getFeedComments = ( + feedId: string, + xsecToken: string, + sort: 'default' | 'newest' | 'most_liked' = 'default', + maxCount = 20, +) => + apiFetch>('/api/xhs/feeds/comments', { + method: 'POST', + body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, sort, max_count: maxCount }), }); // User diff --git a/web/src/api/types.ts b/web/src/api/types.ts index ddf860b..b8ca71f 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -60,9 +60,16 @@ export interface Comment { likeCount: number; createTime: string; ipLocation: string; + subCommentCount: number; subComments: Comment[]; } +export interface CommentsResult { + comments: Comment[]; + hasMore: boolean; + totalCount: number; +} + export interface UserProfile { id: string; nickname: string; diff --git a/web/src/components/feed/CommentTree.tsx b/web/src/components/feed/CommentTree.tsx index f1f6458..e6b16e7 100644 --- a/web/src/components/feed/CommentTree.tsx +++ b/web/src/components/feed/CommentTree.tsx @@ -41,6 +41,11 @@ export function CommentTree({ comments, depth = 0, onReply }: Props) { {comment.subComments.length > 0 && ( )} + {comment.subCommentCount > comment.subComments.length && ( +

+ 还有 {comment.subCommentCount - comment.subComments.length} 条回复 +

+ )} ))} diff --git a/web/src/components/feed/FeedDetail.tsx b/web/src/components/feed/FeedDetail.tsx index bd0117e..02abab4 100644 --- a/web/src/components/feed/FeedDetail.tsx +++ b/web/src/components/feed/FeedDetail.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; -import type { FeedDetail as FeedDetailType } from '@/api/types'; -import { getFeedDetail, toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints'; +import type { FeedDetail as FeedDetailType, Comment } from '@/api/types'; +import { getFeedDetail, getFeedComments, toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints'; import { Badge } from '@/components/ui/Badge'; import { Spinner } from '@/components/ui/Spinner'; import { Button } from '@/components/ui/Button'; @@ -23,6 +23,12 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) { const [error, setError] = useState(null); const [currentImage, setCurrentImage] = useState(0); + const [comments, setComments] = useState([]); + const [commentsLoading, setCommentsLoading] = useState(false); + const [commentsHasMore, setCommentsHasMore] = useState(false); + const [commentsTotalCount, setCommentsTotalCount] = useState(0); + const [commentsMaxCount, setCommentsMaxCount] = useState(20); + const [liked, setLiked] = useState(false); const [favorited, setFavorited] = useState(false); const [actionLoading, setActionLoading] = useState(null); @@ -33,20 +39,50 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) { useEffect(() => { setLoading(true); setError(null); + setComments([]); void getFeedDetail(feedId, xsecToken) .then((res) => { if (res.success && res.data) { setDetail(res.data); setLiked(res.data.isLiked); setFavorited(res.data.isFavorited); + // Use comments from __INITIAL_STATE__ (first page, ~10-20). + if (res.data.comments.length > 0) { + setComments(res.data.comments); + setCommentsTotalCount(res.data.commentCount); + setCommentsHasMore(res.data.commentCount > res.data.comments.length); + } } else { setError(res.error?.message || 'Failed to load detail'); } }) .catch((err) => setError(err instanceof Error ? err.message : 'Error')) .finally(() => setLoading(false)); + }, [feedId, xsecToken]); + const handleLoadComments = async (maxCount = commentsMaxCount) => { + setCommentsLoading(true); + try { + const res = await getFeedComments(feedId, xsecToken, 'default', maxCount); + if (res.success && res.data) { + setComments(res.data.comments); + setCommentsHasMore(res.data.hasMore); + setCommentsTotalCount(res.data.totalCount); + } + } catch { + toast('error', '加载评论失败'); + } finally { + setCommentsLoading(false); + } + }; + + const handleLoadMore = () => { + const nextCount = commentsMaxCount * 2; + setCommentsMaxCount(nextCount); + void handleLoadComments(nextCount); + }; + const handleToggleLike = async () => { setActionLoading('like'); try { @@ -232,7 +268,7 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) { )}

{detail.user.nickname}

-

{detail.ipLocation} · {formatTime(detail.createTime)}

+

{[detail.ipLocation, formatTime(detail.createTime)].filter(Boolean).join(' · ') || '暂无信息'}

@@ -287,18 +323,43 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) { {/* Comments */} - {detail.comments.length > 0 && ( + {comments.length === 0 && !commentsLoading && ( + + )} + {commentsLoading && ( +
+ + 加载评论中... +
+ )} + {comments.length > 0 && (

- 评论 ({detail.comments.length}) + 评论 ({commentsTotalCount > 0 ? commentsTotalCount : comments.length})

{ setReplyTarget({ commentId, userId, nickname }); setReplyText(''); }} /> + {commentsHasMore && !commentsLoading && ( + + )}
)} diff --git a/web/src/lib/formatters.ts b/web/src/lib/formatters.ts index 60cae14..378986e 100644 --- a/web/src/lib/formatters.ts +++ b/web/src/lib/formatters.ts @@ -18,8 +18,11 @@ export function formatNumber(n: number): string { } export function formatTime(iso: string): string { + if (!iso) return ''; try { - return new Date(iso).toLocaleString(); + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString(); } catch { return iso; }