xhs_get_comments 增加 sort + max_count 控制,评论随详情一起返回
- Comment 新增 subCommentCount,新增 CommentsResult 接口 - GetFeedCommentsSchema 替换 load_all 为 sort (default/newest/most_liked) + max_count (1-100) - getFeedDetail 不再清空评论,从 Vue store 异步提取首屏评论(轮询 firstRequestFinish) - getFeedComments 重写:支持排序切换、按 maxCount 加载、返回 hasMore/totalCount - 前端详情加载后直接显示评论,无需单独请求;底部显示"加载更多评论"按钮 - CommentTree 显示"还有 X 条回复"提示 - 修复 formatTime 对空字符串和无效日期的处理
This commit is contained in:
@@ -3,7 +3,7 @@ import type { Page, ElementHandle } 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 } from './types.js';
|
import type { FeedDetail, Comment, CommentsResult } from './types.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -11,8 +11,8 @@ import type { FeedDetail, Comment } from './types.js';
|
|||||||
|
|
||||||
const FEED_DETAIL_BASE_URL = 'https://www.xiaohongshu.com/explore';
|
const FEED_DETAIL_BASE_URL = 'https://www.xiaohongshu.com/explore';
|
||||||
|
|
||||||
/** Maximum number of "show more" clicks to load comments. */
|
/** Maximum number of "show more" clicks to load comments (safety limit). */
|
||||||
const MAX_LOAD_MORE_CLICKS = 20;
|
const MAX_LOAD_MORE_CLICKS = 50;
|
||||||
|
|
||||||
/** Delay between "show more" clicks to let the page render. */
|
/** Delay between "show more" clicks to let the page render. */
|
||||||
const LOAD_MORE_DELAY_MS = 1500;
|
const LOAD_MORE_DELAY_MS = 1500;
|
||||||
@@ -33,7 +33,7 @@ interface RawDetailState {
|
|||||||
noteData?: RawNoteData;
|
noteData?: RawNoteData;
|
||||||
};
|
};
|
||||||
note?: {
|
note?: {
|
||||||
noteDetailMap?: Record<string, { note?: RawNoteData }>;
|
noteDetailMap?: Record<string, { note?: RawNoteData; comments?: RawCommentData[] }>;
|
||||||
note?: RawNoteData;
|
note?: RawNoteData;
|
||||||
noteData?: RawNoteData;
|
noteData?: RawNoteData;
|
||||||
};
|
};
|
||||||
@@ -153,20 +153,19 @@ 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, stats, and comments.
|
* information including title, content, images/video, and stats.
|
||||||
*
|
*
|
||||||
* @param page - A Playwright Page managed by BrowserManager.
|
* Comments are NOT loaded here — use {@link getFeedComments} instead.
|
||||||
* @param feedId - The note (feed) ID.
|
*
|
||||||
* @param xsecToken - Security token required to access the note.
|
* @param page - A Playwright Page managed by BrowserManager.
|
||||||
* @param loadAllComments - If true, scrolls and clicks "load more" to fetch
|
* @param feedId - The note (feed) ID.
|
||||||
* as many comments as possible.
|
* @param xsecToken - Security token required to access the note.
|
||||||
* @returns A FeedDetail object with full note data and comments.
|
* @returns A FeedDetail object with full note data (comments always `[]`).
|
||||||
*/
|
*/
|
||||||
export async function getFeedDetail(
|
export async function getFeedDetail(
|
||||||
page: Page,
|
page: Page,
|
||||||
feedId: string,
|
feedId: string,
|
||||||
xsecToken: string,
|
xsecToken: string,
|
||||||
loadAllComments = false,
|
|
||||||
): Promise<FeedDetail> {
|
): Promise<FeedDetail> {
|
||||||
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 }, 'Navigating to feed detail page');
|
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) {
|
if (detail.comments.length === 0) {
|
||||||
const comments = await scrapeComments(page, loadAllComments);
|
const storeComments = await extractCommentsFromStore(page, feedId);
|
||||||
if (comments.length > 0) {
|
if (storeComments.length > 0) {
|
||||||
detail.comments = comments;
|
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;
|
detail.isFavorited = interactionState.isFavorited ?? false;
|
||||||
|
|
||||||
log.info(
|
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',
|
'Feed detail extraction complete',
|
||||||
);
|
);
|
||||||
|
|
||||||
return detail;
|
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<CommentsResult> {
|
||||||
|
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
|
// __INITIAL_STATE__ parsing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -257,6 +323,7 @@ function parseDetailFromState(
|
|||||||
): FeedDetail | null {
|
): FeedDetail | null {
|
||||||
// Try multiple possible locations for note data.
|
// Try multiple possible locations for note data.
|
||||||
let noteData: RawNoteData | undefined;
|
let noteData: RawNoteData | undefined;
|
||||||
|
let mapComments: RawCommentData[] | undefined;
|
||||||
|
|
||||||
// Location 1: state.noteData.data.noteData (common structure)
|
// Location 1: state.noteData.data.noteData (common structure)
|
||||||
noteData = state.noteData?.data?.noteData;
|
noteData = state.noteData?.data?.noteData;
|
||||||
@@ -266,10 +333,11 @@ function parseDetailFromState(
|
|||||||
noteData = state.noteData?.noteData;
|
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) {
|
if (!noteData && state.note?.noteDetailMap) {
|
||||||
const mapEntry = state.note.noteDetailMap[feedId];
|
const mapEntry = state.note.noteDetailMap[feedId];
|
||||||
noteData = mapEntry?.note;
|
noteData = mapEntry?.note;
|
||||||
|
mapComments = mapEntry?.comments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location 4: state.note.note or state.note.noteData
|
// Location 4: state.note.note or state.note.noteData
|
||||||
@@ -353,8 +421,9 @@ function parseDetailFromState(
|
|||||||
avatar: rawUser?.avatar ?? rawUser?.avatarUrl ?? rawUser?.avatar_url ?? '',
|
avatar: rawUser?.avatar ?? rawUser?.avatarUrl ?? rawUser?.avatar_url ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Comments from state (may be empty)
|
// Comments: prefer map-level comments (noteDetailMap[id].comments),
|
||||||
const rawComments = noteData.comments ?? [];
|
// fall back to noteData.comments.
|
||||||
|
const rawComments = mapComments ?? noteData.comments ?? [];
|
||||||
const comments = rawComments.map(parseRawComment).filter((c): c is Comment => c !== null);
|
const comments = rawComments.map(parseRawComment).filter((c): c is Comment => c !== null);
|
||||||
|
|
||||||
const resolvedXsecToken = noteData.xsecToken ?? noteData.xsec_token ?? xsecToken;
|
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 rawSubs = raw.subComments ?? raw.sub_comments ?? [];
|
||||||
const subComments = rawSubs.map(parseRawComment).filter((c): c is Comment => c !== null);
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
@@ -418,6 +492,7 @@ function parseRawComment(raw: RawCommentData): Comment | null {
|
|||||||
likeCount,
|
likeCount,
|
||||||
createTime,
|
createTime,
|
||||||
ipLocation,
|
ipLocation,
|
||||||
|
subCommentCount,
|
||||||
subComments,
|
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<Comment[]> {
|
||||||
|
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<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?.[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<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?.[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
|
// 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.
|
* Scrape comments from the note detail page DOM.
|
||||||
*
|
*
|
||||||
* @param page - The current Playwright page (already on the detail URL).
|
* @param page - The current Playwright page (already on the detail URL).
|
||||||
* @param loadAllComments - If true, clicks "show more" buttons repeatedly.
|
* @param maxCount - Maximum number of top-level comments to collect.
|
||||||
* @returns An array of Comment objects.
|
* @returns A CommentsResult with comments, hasMore flag, and totalCount.
|
||||||
*/
|
*/
|
||||||
async function scrapeComments(
|
async function scrapeComments(
|
||||||
page: Page,
|
page: Page,
|
||||||
loadAllComments: boolean,
|
maxCount: number,
|
||||||
): Promise<Comment[]> {
|
): Promise<CommentsResult> {
|
||||||
// Scroll down to the comments section to trigger lazy loading.
|
// Scroll down to the comments section to trigger lazy loading.
|
||||||
// Use a string expression to avoid needing DOM types.
|
|
||||||
await page.evaluate(`
|
await page.evaluate(`
|
||||||
(() => {
|
(() => {
|
||||||
const commentsArea = document.querySelector('.comments-container');
|
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
|
// Wait briefly for comment items to render.
|
||||||
// we hit the maximum click limit.
|
await page.waitForSelector(SEL.commentItem, { timeout: 2_000 }).catch(() => {
|
||||||
if (loadAllComments) {
|
log.debug('No comment items appeared within 2s, proceeding with empty list');
|
||||||
let clicks = 0;
|
});
|
||||||
while (clicks < MAX_LOAD_MORE_CLICKS) {
|
|
||||||
const showMoreBtn = await page.$(SEL.showMoreComments);
|
|
||||||
if (!showMoreBtn) break;
|
|
||||||
|
|
||||||
const isVisible = await showMoreBtn.isVisible().catch(() => false);
|
// Try to extract total comment count from the page (engage bar).
|
||||||
if (!isVisible) break;
|
const totalCountText = await page
|
||||||
|
.$eval(SEL.commentCount, (el) => el.textContent?.trim() ?? '0')
|
||||||
|
.catch(() => '0');
|
||||||
|
const totalCount = parseCountString(totalCountText);
|
||||||
|
|
||||||
await showMoreBtn.click().catch(() => {});
|
// Load more comments until we have enough or no more "show more" button.
|
||||||
await page.waitForTimeout(LOAD_MORE_DELAY_MS);
|
let clicks = 0;
|
||||||
clicks++;
|
while (clicks < MAX_LOAD_MORE_CLICKS) {
|
||||||
}
|
const currentCount = (await page.$$(SEL.commentItem)).length;
|
||||||
|
if (currentCount >= maxCount) break;
|
||||||
|
|
||||||
if (clicks > 0) {
|
const showMoreBtn = await page.$(SEL.showMoreComments);
|
||||||
log.debug({ clicks }, 'Clicked "show more comments" button');
|
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.
|
// Now extract all visible comments using Playwright Node-side API.
|
||||||
@@ -594,18 +733,24 @@ async function scrapeComments(
|
|||||||
const comments: Comment[] = [];
|
const comments: Comment[] = [];
|
||||||
|
|
||||||
for (const commentEl of commentElements) {
|
for (const commentEl of commentElements) {
|
||||||
|
if (comments.length >= maxCount) break;
|
||||||
try {
|
try {
|
||||||
const comment = await parseCommentElement(commentEl);
|
const comment = await parseCommentElement(commentEl);
|
||||||
if (comment) {
|
if (comment) {
|
||||||
comments.push(comment);
|
comments.push(comment);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip comments that fail to parse.
|
|
||||||
continue;
|
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(() => '');
|
.catch(() => '');
|
||||||
|
|
||||||
// Try to extract comment ID from the element's attributes.
|
// 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) =>
|
||||||
el.getAttribute('id') ??
|
el.getAttribute('id') ??
|
||||||
el.getAttribute('data-id') ??
|
el.getAttribute('data-id') ??
|
||||||
el.getAttribute('data-comment-id') ??
|
el.getAttribute('data-comment-id') ??
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
|
const commentId = rawCommentId.replace(/^comment-/, '');
|
||||||
|
|
||||||
// Try to extract user ID from an author link.
|
// Try to extract user ID from an author link.
|
||||||
const authorHref = await commentEl
|
const authorHref = await commentEl
|
||||||
@@ -655,6 +802,13 @@ async function parseCommentElement(
|
|||||||
const userIdMatch = authorHref.match(/\/user\/profile\/([a-f0-9]+)/);
|
const userIdMatch = authorHref.match(/\/user\/profile\/([a-f0-9]+)/);
|
||||||
const userId = userIdMatch?.[1] ?? '';
|
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)
|
// Sub-comments (replies)
|
||||||
const subCommentElements = await commentEl.$$(SEL.subCommentItem);
|
const subCommentElements = await commentEl.$$(SEL.subCommentItem);
|
||||||
const subComments: Comment[] = [];
|
const subComments: Comment[] = [];
|
||||||
@@ -680,13 +834,14 @@ async function parseCommentElement(
|
|||||||
.$eval(SEL.commentIpLocation, (el) => el.textContent?.trim() ?? '')
|
.$eval(SEL.commentIpLocation, (el) => el.textContent?.trim() ?? '')
|
||||||
.catch(() => '');
|
.catch(() => '');
|
||||||
|
|
||||||
const subId = await subEl.evaluate(
|
const rawSubId = await subEl.evaluate(
|
||||||
(el) =>
|
(el) =>
|
||||||
el.getAttribute('id') ??
|
el.getAttribute('id') ??
|
||||||
el.getAttribute('data-id') ??
|
el.getAttribute('data-id') ??
|
||||||
el.getAttribute('data-comment-id') ??
|
el.getAttribute('data-comment-id') ??
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
|
const subId = rawSubId.replace(/^comment-/, '');
|
||||||
|
|
||||||
const subAuthorHref = await subEl
|
const subAuthorHref = await subEl
|
||||||
.$eval('a[href*="/user/profile/"]', (el) => el.getAttribute('href') ?? '')
|
.$eval('a[href*="/user/profile/"]', (el) => el.getAttribute('href') ?? '')
|
||||||
@@ -702,6 +857,7 @@ async function parseCommentElement(
|
|||||||
likeCount: parseCountString(subLikeText),
|
likeCount: parseCountString(subLikeText),
|
||||||
createTime: subTime,
|
createTime: subTime,
|
||||||
ipLocation: subIp,
|
ipLocation: subIp,
|
||||||
|
subCommentCount: 0,
|
||||||
subComments: [],
|
subComments: [],
|
||||||
});
|
});
|
||||||
} catch {
|
} 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 {
|
return {
|
||||||
id: commentId,
|
id: commentId,
|
||||||
userId,
|
userId,
|
||||||
@@ -718,6 +879,7 @@ async function parseCommentElement(
|
|||||||
likeCount: parseCountString(likeText),
|
likeCount: parseCountString(likeText),
|
||||||
createTime,
|
createTime,
|
||||||
ipLocation,
|
ipLocation,
|
||||||
|
subCommentCount,
|
||||||
subComments,
|
subComments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } from './feed-detail.js';
|
import { getFeedDetail, getFeedComments } 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,6 +23,7 @@ import {
|
|||||||
ListFeedsSchema,
|
ListFeedsSchema,
|
||||||
SearchSchema,
|
SearchSchema,
|
||||||
GetFeedDetailSchema,
|
GetFeedDetailSchema,
|
||||||
|
GetFeedCommentsSchema,
|
||||||
GetUserProfileSchema,
|
GetUserProfileSchema,
|
||||||
PublishImageSchema,
|
PublishImageSchema,
|
||||||
PublishVideoSchema,
|
PublishVideoSchema,
|
||||||
@@ -228,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, stats, and comments',
|
'Get Xiaohongshu note detail including content, images, and stats (comments fetched separately via xhs_get_comments)',
|
||||||
GetFeedDetailSchema,
|
GetFeedDetailSchema,
|
||||||
async (args) => {
|
async (args) => {
|
||||||
return withErrorHandling('xhs_get_feed_detail', async () => {
|
return withErrorHandling('xhs_get_feed_detail', async () => {
|
||||||
@@ -237,12 +238,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
|||||||
const detail = await browser.withPage(
|
const detail = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
async (page) =>
|
||||||
getFeedDetail(
|
getFeedDetail(page, args.feed_id, args.xsec_token),
|
||||||
page,
|
|
||||||
args.feed_id,
|
|
||||||
args.xsec_token,
|
|
||||||
args.load_all_comments,
|
|
||||||
),
|
|
||||||
timeoutMs,
|
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
|
// xhs_get_user_profile
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 } from './feed-detail.js';
|
import { getFeedDetail, getFeedComments } 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,6 +23,7 @@ import { toggleLike, toggleFavorite } from './interaction.js';
|
|||||||
import {
|
import {
|
||||||
SearchSchema,
|
SearchSchema,
|
||||||
GetFeedDetailSchema,
|
GetFeedDetailSchema,
|
||||||
|
GetFeedCommentsSchema,
|
||||||
GetUserProfileSchema,
|
GetUserProfileSchema,
|
||||||
PublishImageSchema,
|
PublishImageSchema,
|
||||||
PublishVideoSchema,
|
PublishVideoSchema,
|
||||||
@@ -63,7 +64,13 @@ const SearchBodySchema = z.object({
|
|||||||
const FeedDetailBodySchema = z.object({
|
const FeedDetailBodySchema = z.object({
|
||||||
feed_id: GetFeedDetailSchema.feed_id,
|
feed_id: GetFeedDetailSchema.feed_id,
|
||||||
xsec_token: GetFeedDetailSchema.xsec_token,
|
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({
|
const UserProfileBodySchema = z.object({
|
||||||
@@ -312,12 +319,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
const detail = await browser.withPage(
|
const detail = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
async (page) =>
|
||||||
getFeedDetail(
|
getFeedDetail(page, body.feed_id, body.xsec_token),
|
||||||
page,
|
|
||||||
body.feed_id,
|
|
||||||
body.xsec_token,
|
|
||||||
body.load_all_comments,
|
|
||||||
),
|
|
||||||
timeoutMs,
|
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<typeof result>);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// POST /user/profile
|
// POST /user/profile
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -50,11 +50,25 @@ export const SearchSchema = {
|
|||||||
export const GetFeedDetailSchema = {
|
export const GetFeedDetailSchema = {
|
||||||
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'),
|
||||||
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()
|
.optional()
|
||||||
.default(false)
|
.default('default')
|
||||||
.describe('Whether to scroll and load all comments'),
|
.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 */
|
/** xhs_get_user_profile */
|
||||||
|
|||||||
@@ -85,13 +85,13 @@ export const XHS_SELECTORS = {
|
|||||||
/** Comment list container. */
|
/** Comment list container. */
|
||||||
commentListContainer: '.comments-container .list-container',
|
commentListContainer: '.comments-container .list-container',
|
||||||
/** Individual top-level comment items. */
|
/** 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. */
|
/** Parent comment content text. */
|
||||||
commentContent: '.content',
|
commentContent: '.content',
|
||||||
/** Comment author name. */
|
/** Comment author name. */
|
||||||
commentAuthor: '.author .name',
|
commentAuthor: '.author .name',
|
||||||
/** Comment author avatar. */
|
/** Comment author avatar. */
|
||||||
commentAvatar: '.author .avatar img',
|
commentAvatar: '.avatar img.avatar-item',
|
||||||
/** Comment like count. */
|
/** Comment like count. */
|
||||||
commentLikeCount: '.like .count',
|
commentLikeCount: '.like .count',
|
||||||
/** Comment publish time. */
|
/** Comment publish time. */
|
||||||
@@ -104,6 +104,14 @@ 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 条回复"). */
|
||||||
|
subCommentCountText: '.sub-comment-list .show-more, .reply-container .show-more',
|
||||||
},
|
},
|
||||||
|
|
||||||
userProfile: {
|
userProfile: {
|
||||||
|
|||||||
@@ -73,9 +73,16 @@ export interface Comment {
|
|||||||
likeCount: number;
|
likeCount: number;
|
||||||
createTime: string;
|
createTime: string;
|
||||||
ipLocation: string;
|
ipLocation: string;
|
||||||
|
subCommentCount: number;
|
||||||
subComments: Comment[];
|
subComments: Comment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommentsResult {
|
||||||
|
comments: Comment[];
|
||||||
|
hasMore: boolean;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
// -- User Profile ---------------------------------------------------------
|
// -- User Profile ---------------------------------------------------------
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
QRCodeResult,
|
QRCodeResult,
|
||||||
Feed,
|
Feed,
|
||||||
FeedDetail,
|
FeedDetail,
|
||||||
|
CommentsResult,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
SearchFilters,
|
SearchFilters,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
@@ -40,10 +41,21 @@ export const searchFeeds = (keyword: string, filters?: SearchFilters) =>
|
|||||||
body: JSON.stringify({ keyword, filters }),
|
body: JSON.stringify({ keyword, filters }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getFeedDetail = (feedId: string, xsecToken: string, loadAllComments = false) =>
|
export const getFeedDetail = (feedId: string, xsecToken: string) =>
|
||||||
apiFetch<ApiResponse<FeedDetail>>('/api/xhs/feeds/detail', {
|
apiFetch<ApiResponse<FeedDetail>>('/api/xhs/feeds/detail', {
|
||||||
method: 'POST',
|
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<ApiResponse<CommentsResult>>('/api/xhs/feeds/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ feed_id: feedId, xsec_token: xsecToken, sort, max_count: maxCount }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// User
|
// User
|
||||||
|
|||||||
@@ -60,9 +60,16 @@ export interface Comment {
|
|||||||
likeCount: number;
|
likeCount: number;
|
||||||
createTime: string;
|
createTime: string;
|
||||||
ipLocation: string;
|
ipLocation: string;
|
||||||
|
subCommentCount: number;
|
||||||
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;
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ 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 && (
|
||||||
|
<p className="ml-8 mt-1 text-xs text-dark-muted">
|
||||||
|
还有 {comment.subCommentCount - comment.subComments.length} 条回复
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { FeedDetail as FeedDetailType } from '@/api/types';
|
import type { FeedDetail as FeedDetailType, Comment } from '@/api/types';
|
||||||
import { getFeedDetail, toggleLike, toggleFavorite, postComment, replyComment } from '@/api/endpoints';
|
import { getFeedDetail, getFeedComments, 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';
|
||||||
@@ -23,6 +23,12 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentImage, setCurrentImage] = useState(0);
|
const [currentImage, setCurrentImage] = useState(0);
|
||||||
|
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
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 [liked, setLiked] = useState(false);
|
||||||
const [favorited, setFavorited] = useState(false);
|
const [favorited, setFavorited] = useState(false);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
@@ -33,20 +39,50 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setComments([]);
|
||||||
void getFeedDetail(feedId, xsecToken)
|
void getFeedDetail(feedId, xsecToken)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setDetail(res.data);
|
setDetail(res.data);
|
||||||
setLiked(res.data.isLiked);
|
setLiked(res.data.isLiked);
|
||||||
setFavorited(res.data.isFavorited);
|
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 {
|
} else {
|
||||||
setError(res.error?.message || 'Failed to load detail');
|
setError(res.error?.message || 'Failed to load detail');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : 'Error'))
|
.catch((err) => setError(err instanceof Error ? err.message : 'Error'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
}, [feedId, xsecToken]);
|
}, [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 () => {
|
const handleToggleLike = async () => {
|
||||||
setActionLoading('like');
|
setActionLoading('like');
|
||||||
try {
|
try {
|
||||||
@@ -232,7 +268,7 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{detail.user.nickname}</p>
|
<p className="text-sm font-medium">{detail.user.nickname}</p>
|
||||||
<p className="text-xs text-dark-muted">{detail.ipLocation} · {formatTime(detail.createTime)}</p>
|
<p className="text-xs text-dark-muted">{[detail.ipLocation, formatTime(detail.createTime)].filter(Boolean).join(' · ') || '暂无信息'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -287,18 +323,43 @@ export function FeedDetail({ feedId, xsecToken, onClose, onUserClick }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
{detail.comments.length > 0 && (
|
{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 && (
|
||||||
<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">
|
||||||
评论 ({detail.comments.length})
|
评论 ({commentsTotalCount > 0 ? commentsTotalCount : comments.length})
|
||||||
</h3>
|
</h3>
|
||||||
<CommentTree
|
<CommentTree
|
||||||
comments={detail.comments}
|
comments={comments}
|
||||||
onReply={(commentId, userId, nickname) => {
|
onReply={(commentId, userId, nickname) => {
|
||||||
setReplyTarget({ commentId, userId, nickname });
|
setReplyTarget({ commentId, userId, nickname });
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{commentsHasMore && !commentsLoading && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
className="mt-3 w-full"
|
||||||
|
>
|
||||||
|
加载更多评论
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ export function formatNumber(n: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(iso: string): string {
|
export function formatTime(iso: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString();
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString();
|
||||||
} catch {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user