From 54a3d9708a38cbc4d76b3d922710df6cc7df7704 Mon Sep 17 00:00:00 2001 From: kurihada Date: Mon, 2 Mar 2026 17:52:35 +0800 Subject: [PATCH] =?UTF-8?q?xhs=5Fget=5Fcomments=20=E2=86=92=20xhs=5Fget=5F?= =?UTF-8?q?sub=5Fcomments=EF=BC=9A=E9=92=88=E5=AF=B9=E5=8D=95=E6=9D=A1?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E5=8A=A0=E8=BD=BD=E5=AD=90=E8=AF=84=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 条回复" 改为可点击按钮,带加载状态 --- src/platforms/xiaohongshu/feed-detail.ts | 405 ++++++++++------------- src/platforms/xiaohongshu/index.ts | 22 +- src/platforms/xiaohongshu/routes.ts | 26 +- src/platforms/xiaohongshu/schemas.ts | 14 +- src/platforms/xiaohongshu/selectors.ts | 6 - src/platforms/xiaohongshu/types.ts | 6 - web/src/api/endpoints.ts | 10 +- web/src/api/types.ts | 6 - web/src/components/feed/CommentTree.tsx | 89 +++-- web/src/components/feed/FeedDetail.tsx | 64 +--- 10 files changed, 273 insertions(+), 375 deletions(-) diff --git a/src/platforms/xiaohongshu/feed-detail.ts b/src/platforms/xiaohongshu/feed-detail.ts index 628c42e..1a29245 100644 --- a/src/platforms/xiaohongshu/feed-detail.ts +++ b/src/platforms/xiaohongshu/feed-detail.ts @@ -1,9 +1,9 @@ -import type { Page, ElementHandle } from 'rebrowser-playwright'; +import type { Page } 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, CommentsResult } from './types.js'; +import type { FeedDetail, Comment } from './types.js'; // --------------------------------------------------------------------------- // Constants @@ -155,12 +155,14 @@ interface RawCommentData { * Navigate to a Xiaohongshu note detail page and extract comprehensive * 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 feedId - The note (feed) ID. * @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( 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 - * because each MCP / REST call gets an independent `withPage` session. + * The first-screen comments (with 1-2 sub-comment previews) are already + * 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 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. + * @param commentId - The parent comment ID whose sub-comments to load. + * @param maxCount - Stop loading once we have at least this many (default 20). + * @returns An array of Comment objects (the sub-comments). */ -export async function getFeedComments( +export async function getSubComments( page: Page, feedId: string, xsecToken: string, - sort: CommentSort = 'default', + commentId: string, maxCount = 20, -): Promise { +): 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'); + log.debug({ feedId, commentId, url, maxCount }, 'Navigating to feed page for sub-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'); + // Wait for the note container, then immediately poll the store — + // no extra fixed delay needed, the store poll covers timing. + await page.waitForSelector(SEL.noteContainer, { timeout: 15_000 }).catch(() => { + log.warn({ feedId }, 'Note container not found within timeout, proceeding'); }); - // Switch sort tab if needed. - if (sort !== 'default') { - const sortSelector = sort === 'newest' - ? SEL.commentSortNewest - : SEL.commentSortHottest; + // Wait for comments store to finish initial load. + await waitForCommentsStoreReady(page, feedId); - 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'); + // ----------------------------------------------------------------------- + // Locate the parent-comment DOM wrapper for the target comment and + // click "展开更多回复" repeatedly to load all sub-comments. + // ----------------------------------------------------------------------- + // Find the index of the .parent-comment that owns our commentId so we + // can get a real ElementHandle (evaluateHandle returns JSHandle which + // lacks $ / isVisible). + 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. - * - * @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. + * Wait for the comments store to finish its initial request for a given feed. */ -async function scrapeComments( - page: Page, - maxCount: number, -): Promise { - // Scroll down to the comments section to trigger lazy loading. - await page.evaluate(` - (() => { - const commentsArea = document.querySelector('.comments-container'); - if (commentsArea) { - commentsArea.scrollIntoView({ behavior: 'smooth' }); - } else { - window.scrollTo(0, document.body.scrollHeight); +async function waitForCommentsStoreReady(page: Page, feedId: string): Promise { + 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 { firstRequestFinish?: boolean } | undefined; + if (comments?.firstRequestFinish) return; + await new Promise((r) => setTimeout(r, pollMs)); + waited += pollMs; } - })() - `); - - // 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 }; + }, + feedId, + ); } /** - * Parse a single comment element into a Comment object using Playwright - * Node-side API. + * Quick check: how many sub-comments does the store currently have for + * a given parent comment? Used to decide whether to keep clicking. */ -async function parseCommentElement( - commentEl: ElementHandle, -): Promise { - const content = await commentEl - .$eval(SEL.commentContent, (el) => el.textContent?.trim() ?? '') - .catch(() => ''); - - const nickname = await commentEl - .$eval(SEL.commentAuthor, (el) => el.textContent?.trim() ?? '') - .catch(() => ''); - - const avatar = await commentEl - .$eval(SEL.commentAvatar, (el) => el.getAttribute('src') ?? '') - .catch(() => ''); - - const likeText = await commentEl - .$eval(SEL.commentLikeCount, (el) => el.textContent?.trim() ?? '0') - .catch(() => '0'); - - const createTime = await commentEl - .$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') ?? - '', +async function getStoreSubCommentCount( + page: Page, + feedId: string, + commentId: string, +): Promise { + return page.evaluate( + (args: { feedId: string; commentId: string }) => { + 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?.[args.feedId]; + const comments = entry?.comments as { list?: Array> } | undefined; + if (!comments?.list) return 0; + const parent = comments.list.find((c) => c.id === args.commentId); + if (!parent) return 0; + const subs = (parent.subComments ?? parent.sub_comments ?? []) as unknown[]; + return subs.length; + }, + { feedId, commentId }, ); - const commentId = rawCommentId.replace(/^comment-/, ''); +} - // Try to extract user ID from an author link. - const authorHref = await commentEl - .$eval('a[href*="/user/profile/"]', (el) => el.getAttribute('href') ?? '') - .catch(() => ''); - const userIdMatch = authorHref.match(/\/user\/profile\/([a-f0-9]+)/); - const userId = userIdMatch?.[1] ?? ''; +/** + * Read sub-comments for a specific parent comment from the Vue store, + * capped at `maxCount`. + * + * The store structure is: + * `__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 { + const rawSubComments = await page.evaluate( + (args: { feedId: string; commentId: string; maxCount: number }) => { + 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?.[args.feedId]; + const comments = entry?.comments as { list?: Array> } | undefined; + if (!comments?.list) return []; - // 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[] = []; - - 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 parent = comments.list.find( + (c) => c.id === args.commentId, ); - const subId = rawSubId.replace(/^comment-/, ''); + if (!parent) return []; - const subAuthorHref = await subEl - .$eval('a[href*="/user/profile/"]', (el) => el.getAttribute('href') ?? '') - .catch(() => ''); - const subUserIdMatch = subAuthorHref.match(/\/user\/profile\/([a-f0-9]+)/); + const subs = (parent.subComments ?? parent.sub_comments ?? []) as unknown[]; + return JSON.parse(JSON.stringify(subs.slice(0, args.maxCount))); + }, + { feedId, commentId, maxCount }, + ) as RawCommentData[]; - subComments.push({ - id: subId, - userId: subUserIdMatch?.[1] ?? '', - 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, - }; + return rawSubComments + .map(parseRawComment) + .filter((c): c is Comment => c !== null); } // --------------------------------------------------------------------------- diff --git a/src/platforms/xiaohongshu/index.ts b/src/platforms/xiaohongshu/index.ts index 6421d7e..f4f6ee4 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, getFeedComments } from './feed-detail.js'; +import { getFeedDetail, getSubComments } from './feed-detail.js'; import { getUserProfile } from './user-profile.js'; import { publishImageNote } from './publish.js'; import { publishVideoNote } from './publish-video.js'; @@ -23,7 +23,7 @@ import { ListFeedsSchema, SearchSchema, GetFeedDetailSchema, - GetFeedCommentsSchema, + GetSubCommentsSchema, GetUserProfileSchema, PublishImageSchema, PublishVideoSchema, @@ -229,7 +229,7 @@ export const xiaohongshuPlugin: PlatformPlugin = { server.tool( '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, async (args) => { return withErrorHandling('xhs_get_feed_detail', async () => { @@ -255,24 +255,24 @@ export const xiaohongshuPlugin: PlatformPlugin = { ); // ----------------------------------------------------------------------- - // xhs_get_comments + // xhs_get_sub_comments // ----------------------------------------------------------------------- server.tool( - 'xhs_get_comments', - 'Get comments for a Xiaohongshu note with sort and count control', - GetFeedCommentsSchema, + 'xhs_get_sub_comments', + 'Load all sub-comments (replies) for a specific parent comment on a Xiaohongshu note', + GetSubCommentsSchema, async (args) => { - return withErrorHandling('xhs_get_comments', async () => { + return withErrorHandling('xhs_get_sub_comments', async () => { const timeoutMs = - config.operationTimeouts['feed_list'] ?? + config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? - 30_000; + 60_000; const result = await browser.withPage( PLATFORM, 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, ); diff --git a/src/platforms/xiaohongshu/routes.ts b/src/platforms/xiaohongshu/routes.ts index b67f9da..41d504a 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, getFeedComments } from './feed-detail.js'; +import { getFeedDetail, getSubComments } from './feed-detail.js'; import { getUserProfile } from './user-profile.js'; import { publishImageNote } from './publish.js'; import { publishVideoNote } from './publish-video.js'; @@ -23,7 +23,7 @@ import { toggleLike, toggleFavorite } from './interaction.js'; import { SearchSchema, GetFeedDetailSchema, - GetFeedCommentsSchema, + GetSubCommentsSchema, GetUserProfileSchema, PublishImageSchema, PublishVideoSchema, @@ -66,11 +66,11 @@ const FeedDetailBodySchema = z.object({ xsec_token: GetFeedDetailSchema.xsec_token, }); -const FeedCommentsBodySchema = z.object({ - feed_id: GetFeedCommentsSchema.feed_id, - xsec_token: GetFeedCommentsSchema.xsec_token, - sort: GetFeedCommentsSchema.sort, - max_count: GetFeedCommentsSchema.max_count, +const SubCommentsBodySchema = z.object({ + feed_id: GetSubCommentsSchema.feed_id, + xsec_token: GetSubCommentsSchema.xsec_token, + comment_id: GetSubCommentsSchema.comment_id, + max_count: GetSubCommentsSchema.max_count, }); 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 () => { try { - const body = FeedCommentsBodySchema.parse(req.body); + const body = SubCommentsBodySchema.parse(req.body); const timeoutMs = - config.operationTimeouts['feed_list'] ?? + config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? - 30_000; + 60_000; const result = await browser.withPage( PLATFORM, 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, ); diff --git a/src/platforms/xiaohongshu/schemas.ts b/src/platforms/xiaohongshu/schemas.ts index 9fa7510..656e705 100644 --- a/src/platforms/xiaohongshu/schemas.ts +++ b/src/platforms/xiaohongshu/schemas.ts @@ -52,23 +52,19 @@ export const GetFeedDetailSchema = { xsec_token: z.string().describe('Security token for the feed'), }; -/** xhs_get_comments */ -export const GetFeedCommentsSchema = { +/** xhs_get_sub_comments */ +export const GetSubCommentsSchema = { 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('default') - .describe('Comment sort order: default, newest, or most_liked'), + comment_id: z.string().describe('Parent comment ID whose sub-comments to load'), max_count: z .number() .int() .min(1) - .max(100) + .max(200) .optional() .default(20) - .describe('Maximum number of top-level comments to load (1–100)'), + .describe('Maximum number of sub-comments to load (1–200, default 20)'), }; /** xhs_get_user_profile */ diff --git a/src/platforms/xiaohongshu/selectors.ts b/src/platforms/xiaohongshu/selectors.ts index 5898f9b..c000495 100644 --- a/src/platforms/xiaohongshu/selectors.ts +++ b/src/platforms/xiaohongshu/selectors.ts @@ -104,12 +104,6 @@ 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', }, diff --git a/src/platforms/xiaohongshu/types.ts b/src/platforms/xiaohongshu/types.ts index 528033c..8ea154b 100644 --- a/src/platforms/xiaohongshu/types.ts +++ b/src/platforms/xiaohongshu/types.ts @@ -77,12 +77,6 @@ export interface Comment { 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 e5cc0c4..c93024b 100644 --- a/web/src/api/endpoints.ts +++ b/web/src/api/endpoints.ts @@ -4,7 +4,7 @@ import type { QRCodeResult, Feed, FeedDetail, - CommentsResult, + Comment, UserProfile, SearchFilters, HealthResponse, @@ -47,15 +47,15 @@ export const getFeedDetail = (feedId: string, xsecToken: string) => body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken }), }); -export const getFeedComments = ( +export const getSubComments = ( feedId: string, xsecToken: string, - sort: 'default' | 'newest' | 'most_liked' = 'default', + commentId: string, maxCount = 20, ) => - apiFetch>('/api/xhs/feeds/comments', { + apiFetch>('/api/xhs/feeds/sub-comments', { 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 diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b8ca71f..08225f0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -64,12 +64,6 @@ export interface Comment { 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 e6b16e7..ce85cb3 100644 --- a/web/src/components/feed/CommentTree.tsx +++ b/web/src/components/feed/CommentTree.tsx @@ -1,53 +1,72 @@ import type { Comment } from '@/api/types'; +import { Spinner } from '@/components/ui/Spinner'; import { formatTime } from '@/lib/formatters'; interface Props { comments: Comment[]; depth?: number; 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 (
0 ? 'ml-6 border-l border-dark-border pl-4' : ''}> - {comments.map((comment) => ( -
-
- {comment.avatar && ( - - )} -
-
- {comment.nickname} - {formatTime(comment.createTime)} - {comment.ipLocation && ( - {comment.ipLocation} - )} - {onReply && ( - + {comments.map((comment) => { + const remainingCount = comment.subCommentCount - comment.subComments.length; + const isLoadingSubs = subCommentsLoadingId === comment.id; + + return ( +
+
+ {comment.avatar && ( + + )} +
+
+ {comment.nickname} + {formatTime(comment.createTime)} + {comment.ipLocation && ( + {comment.ipLocation} + )} + {onReply && ( + + )} +
+

{comment.content}

+ {comment.likeCount > 0 && ( + {comment.likeCount} 赞 )}
-

{comment.content}

- {comment.likeCount > 0 && ( - {comment.likeCount} 赞 - )}
+ {comment.subComments.length > 0 && ( + + )} + {remainingCount > 0 && depth === 0 && ( + + )}
- {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 02abab4..997cc48 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, 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 { Spinner } from '@/components/ui/Spinner'; import { Button } from '@/components/ui/Button'; @@ -24,10 +24,7 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) { 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 [subCommentsLoading, setSubCommentsLoading] = useState(null); const [liked, setLiked] = 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). 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'); @@ -61,28 +56,26 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) { }, [feedId, xsecToken]); - const handleLoadComments = async (maxCount = commentsMaxCount) => { - setCommentsLoading(true); + const handleLoadSubComments = async (commentId: string) => { + setSubCommentsLoading(commentId); try { - const res = await getFeedComments(feedId, xsecToken, 'default', maxCount); + const res = await getSubComments(feedId, xsecToken, commentId); if (res.success && res.data) { - setComments(res.data.comments); - setCommentsHasMore(res.data.hasMore); - setCommentsTotalCount(res.data.totalCount); + setComments((prev) => + prev.map((c) => + c.id === commentId + ? { ...c, subComments: res.data!, subCommentCount: res.data!.length } + : c, + ), + ); } } catch { - toast('error', '加载评论失败'); + toast('error', '加载子评论失败'); } finally { - setCommentsLoading(false); + setSubCommentsLoading(null); } }; - const handleLoadMore = () => { - const nextCount = commentsMaxCount * 2; - setCommentsMaxCount(nextCount); - void handleLoadComments(nextCount); - }; - const handleToggleLike = async () => { setActionLoading('like'); try { @@ -323,25 +316,10 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
{/* Comments */} - {comments.length === 0 && !commentsLoading && ( - - )} - {commentsLoading && ( -
- - 加载评论中... -
- )} {comments.length > 0 && (

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

void handleLoadSubComments(commentId)} + subCommentsLoadingId={subCommentsLoading} /> - {commentsHasMore && !commentsLoading && ( - - )}
)}