增强详情与评论MCP:支持URL自动解析并为子评论引入Keyset分页

This commit is contained in:
2026-03-03 13:10:14 +08:00
parent 68f42a9fd4
commit ed7fbdd5c2
6 changed files with 319 additions and 22 deletions
+3 -3
View File
@@ -93,9 +93,9 @@ Add this in `claude_desktop_config.json`:
| `xhs_delete_cookies` | Delete cookies and reset login session |
| `xhs_list_feeds` | Get explore page recommended feed list |
| `xhs_search` | Search notes by keyword with filters |
| `xhs_get_feed_detail` | Get note detail (content/media/stats/comments) |
| `xhs_get_sub_comments` | Load all sub-comments for a parent comment |
| `xhs_get_user_profile` | Get user profile with recent notes |
| `xhs_get_feed_detail` | Get note detail (supports passing note URL directly) |
| `xhs_get_sub_comments` | Load sub-comments for a parent comment (keyset cursor pagination) |
| `xhs_get_user_profile` | Get user profile with recent notes (supports passing profile URL directly) |
| `xhs_list_my_notes` | List current account's published notes |
| `xhs_publish_image` | Publish an image note |
| `xhs_publish_video` | Publish a video note |
+3 -3
View File
@@ -93,9 +93,9 @@ pnpm test
| `xhs_delete_cookies` | 删除 Cookie 并重置登录状态 |
| `xhs_list_feeds` | 获取推荐流 |
| `xhs_search` | 关键词搜索笔记(支持筛选) |
| `xhs_get_feed_detail` | 获取笔记详情(内容/媒体/统计/评论 |
| `xhs_get_sub_comments` | 拉取某条父评论的完整子评论 |
| `xhs_get_user_profile` | 获取用户主页及近期笔记 |
| `xhs_get_feed_detail` | 获取笔记详情(支持直接传笔记 URL |
| `xhs_get_sub_comments` | 拉取某条父评论的子评论Keyset 游标分页) |
| `xhs_get_user_profile` | 获取用户主页及近期笔记(支持直接传主页 URL |
| `xhs_list_my_notes` | 获取当前账号已发布笔记列表 |
| `xhs_publish_image` | 发布图文笔记 |
| `xhs_publish_video` | 发布视频笔记 |
+97 -6
View File
@@ -9,6 +9,7 @@ import {
getIdempotencyStore,
computeIdempotencyHash,
} from '../../utils/idempotency.js';
import { resolveFeedTarget, resolveUserTarget } from './target-resolver.js';
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
import { listFeeds } from './feeds.js';
import { searchFeeds } from './search.js';
@@ -158,6 +159,50 @@ function decodeNotificationCursor(cursor?: string): NotificationKeysetCursor | u
}
}
interface SubCommentKeysetCursor {
createTime: string;
replyId: string;
}
function compareSubCommentKey(a: { createTime: string; id: string }, b: { createTime: string; id: string }): number {
const timeCmp = a.createTime.localeCompare(b.createTime);
if (timeCmp !== 0) return timeCmp;
return a.id.localeCompare(b.id);
}
function encodeSubCommentCursor(cursor: SubCommentKeysetCursor): string {
return Buffer.from(
JSON.stringify({
create_time: cursor.createTime,
reply_id: cursor.replyId,
}),
'utf8',
).toString('base64url');
}
function decodeSubCommentCursor(cursor?: string): SubCommentKeysetCursor | undefined {
if (!cursor) return undefined;
try {
const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
create_time?: unknown;
reply_id?: unknown;
};
const createTime = raw.create_time;
const replyId = raw.reply_id;
if (
typeof createTime !== 'string' ||
createTime.length === 0 ||
typeof replyId !== 'string' ||
replyId.length === 0
) {
throw new Error('Invalid sub-comment cursor payload');
}
return { createTime, replyId };
} catch {
throw new Error('Invalid cursor for sub-comment keyset pagination');
}
}
async function runWithIdempotency<T>(
toolName: string,
requestId: string | undefined,
@@ -445,11 +490,16 @@ export const xiaohongshuPlugin: PlatformPlugin = {
async (args) => {
return withErrorHandling('xhs_get_feed_detail', async () => {
const timeoutMs = config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? 60_000;
const target = resolveFeedTarget({
url: args.url,
feed_id: args.feed_id,
xsec_token: args.xsec_token,
});
const detail = await browser.withPage(
PLATFORM,
async (page) =>
getFeedDetail(page, args.feed_id, args.xsec_token),
getFeedDetail(page, target.feedId, target.xsecToken),
timeoutMs,
);
return ok(detail);
@@ -463,7 +513,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
server.tool(
'xhs_get_sub_comments',
'Load all sub-comments (replies) for a specific parent comment on a Xiaohongshu note',
'Load sub-comments (replies) for a specific parent comment on a Xiaohongshu note with keyset cursor pagination',
GetSubCommentsSchema,
async (args) => {
return withErrorHandling('xhs_get_sub_comments', async () => {
@@ -471,14 +521,50 @@ export const xiaohongshuPlugin: PlatformPlugin = {
config.operationTimeouts['feed_detail'] ??
config.operationTimeouts['default'] ??
60_000;
const limit = clampPageSize(args.max_count);
const keysetCursor = decodeSubCommentCursor(args.cursor);
const result = await browser.withPage(
const allLoaded = await browser.withPage(
PLATFORM,
async (page) =>
getSubComments(page, args.feed_id, args.xsec_token, args.comment_id, args.max_count),
getSubComments(page, args.feed_id, args.xsec_token, args.comment_id, MAX_PAGE_SIZE),
timeoutMs,
);
return ok(result);
const sorted = [...allLoaded].sort((a, b) =>
compareSubCommentKey(
{ createTime: a.createTime, id: a.id },
{ createTime: b.createTime, id: b.id },
));
const startIndex = keysetCursor
? sorted.findIndex((item) =>
compareSubCommentKey(
{ createTime: item.createTime, id: item.id },
{ createTime: keysetCursor.createTime, id: keysetCursor.replyId },
) > 0)
: 0;
const start = startIndex < 0 ? sorted.length : startIndex;
const pageItems = sorted.slice(start, start + limit);
const hasMore = start + pageItems.length < sorted.length;
const nextCursor = hasMore && pageItems.length > 0
? encodeSubCommentCursor({
createTime: pageItems[pageItems.length - 1]!.createTime,
replyId: pageItems[pageItems.length - 1]!.id,
})
: undefined;
return ok(pageItems, {
pagination: {
mode: 'keyset',
cursor: args.cursor ?? null,
max_count: limit,
returned: pageItems.length,
has_more: hasMore,
...(nextCursor ? { next_cursor: nextCursor } : {}),
},
});
});
},
);
@@ -494,11 +580,16 @@ export const xiaohongshuPlugin: PlatformPlugin = {
async (args) => {
return withErrorHandling('xhs_get_user_profile', async () => {
const timeoutMs = config.operationTimeouts['user_profile'] ?? config.operationTimeouts['default'] ?? 60_000;
const target = resolveUserTarget({
url: args.url,
user_id: args.user_id,
xsec_token: args.xsec_token,
});
const profile = await browser.withPage(
PLATFORM,
async (page) =>
getUserProfile(page, args.user_id, args.xsec_token),
getUserProfile(page, target.userId, target.xsecToken),
timeoutMs,
);
return ok(profile);
+104 -5
View File
@@ -20,6 +20,7 @@ import { listMyNotes } from './my-notes.js';
import { postComment, replyComment } from './comment.js';
import { toggleLike, toggleFavorite } from './interaction.js';
import { getCommentNotifications, replyNotification } from './notification.js';
import { resolveFeedTarget, resolveUserTarget } from './target-resolver.js';
import {
getNotificationStateStore,
type NotificationTask,
@@ -72,6 +73,7 @@ const SearchBodySchema = z.object({
});
const FeedDetailBodySchema = z.object({
url: GetFeedDetailSchema.url,
feed_id: GetFeedDetailSchema.feed_id,
xsec_token: GetFeedDetailSchema.xsec_token,
});
@@ -81,9 +83,11 @@ const SubCommentsBodySchema = z.object({
xsec_token: GetSubCommentsSchema.xsec_token,
comment_id: GetSubCommentsSchema.comment_id,
max_count: GetSubCommentsSchema.max_count,
cursor: GetSubCommentsSchema.cursor,
});
const UserProfileBodySchema = z.object({
url: GetUserProfileSchema.url,
user_id: GetUserProfileSchema.user_id,
xsec_token: GetUserProfileSchema.xsec_token,
});
@@ -210,6 +214,45 @@ function decodeNotificationCursor(cursor?: string): { firstSeenAt: number; finge
}
}
function encodeSubCommentCursor(cursor: { createTime: string; replyId: string }): string {
return Buffer.from(
JSON.stringify({
create_time: cursor.createTime,
reply_id: cursor.replyId,
}),
'utf8',
).toString('base64url');
}
function decodeSubCommentCursor(cursor?: string): { createTime: string; replyId: string } | undefined {
if (!cursor) return undefined;
try {
const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
create_time?: unknown;
reply_id?: unknown;
};
const createTime = raw.create_time;
const replyId = raw.reply_id;
if (
typeof createTime !== 'string' ||
createTime.length === 0 ||
typeof replyId !== 'string' ||
replyId.length === 0
) {
throw new Error('Invalid sub-comment cursor payload');
}
return { createTime, replyId };
} catch {
throw new Error('Invalid cursor for sub-comment keyset pagination');
}
}
function compareSubCommentKey(a: { createTime: string; id: string }, b: { createTime: string; id: string }): number {
const timeCmp = a.createTime.localeCompare(b.createTime);
if (timeCmp !== 0) return timeCmp;
return a.id.localeCompare(b.id);
}
// ---------------------------------------------------------------------------
// Rate limiters
// ---------------------------------------------------------------------------
@@ -372,6 +415,11 @@ export function createXhsRoutes(browser: BrowserManager): Router {
void (async () => {
try {
const body = FeedDetailBodySchema.parse(req.body);
const target = resolveFeedTarget({
url: body.url,
feed_id: body.feed_id,
xsec_token: body.xsec_token,
});
const timeoutMs =
config.operationTimeouts['feed_detail'] ??
@@ -381,7 +429,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
const detail = await browser.withPage(
PLATFORM,
async (page) =>
getFeedDetail(page, body.feed_id, body.xsec_token),
getFeedDetail(page, target.feedId, target.xsecToken),
timeoutMs,
);
@@ -399,20 +447,66 @@ export function createXhsRoutes(browser: BrowserManager): Router {
void (async () => {
try {
const body = SubCommentsBodySchema.parse(req.body);
const limit = Math.min(200, Math.max(1, body.max_count));
const keysetCursor = decodeSubCommentCursor(body.cursor);
const timeoutMs =
config.operationTimeouts['feed_detail'] ??
config.operationTimeouts['default'] ??
60_000;
const result = await browser.withPage(
const allLoaded = await browser.withPage(
PLATFORM,
async (page) =>
getSubComments(page, body.feed_id, body.xsec_token, body.comment_id, body.max_count),
getSubComments(page, body.feed_id, body.xsec_token, body.comment_id, 200),
timeoutMs,
);
res.json(successResponse(result) as ApiResponse<typeof result>);
const sorted = [...allLoaded].sort((a, b) =>
compareSubCommentKey(
{ createTime: a.createTime, id: a.id },
{ createTime: b.createTime, id: b.id },
));
const startIndex = keysetCursor
? sorted.findIndex((item) =>
compareSubCommentKey(
{ createTime: item.createTime, id: item.id },
{ createTime: keysetCursor.createTime, id: keysetCursor.replyId },
) > 0)
: 0;
const start = startIndex < 0 ? sorted.length : startIndex;
const pageItems = sorted.slice(start, start + limit);
const hasMore = start + pageItems.length < sorted.length;
const nextCursor = hasMore && pageItems.length > 0
? encodeSubCommentCursor({
createTime: pageItems[pageItems.length - 1]!.createTime,
replyId: pageItems[pageItems.length - 1]!.id,
})
: undefined;
res.json(successResponse({
items: pageItems,
pagination: {
mode: 'keyset',
cursor: body.cursor ?? null,
max_count: limit,
returned: pageItems.length,
has_more: hasMore,
...(nextCursor ? { next_cursor: nextCursor } : {}),
},
}) as ApiResponse<{
items: typeof pageItems;
pagination: {
mode: 'keyset';
cursor: string | null;
max_count: number;
returned: number;
has_more: boolean;
next_cursor?: string;
};
}>);
} catch (err) {
handleError(res, err);
}
@@ -426,6 +520,11 @@ export function createXhsRoutes(browser: BrowserManager): Router {
void (async () => {
try {
const body = UserProfileBodySchema.parse(req.body);
const target = resolveUserTarget({
url: body.url,
user_id: body.user_id,
xsec_token: body.xsec_token,
});
const timeoutMs =
config.operationTimeouts['user_profile'] ??
@@ -435,7 +534,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
const profile = await browser.withPage(
PLATFORM,
async (page) =>
getUserProfile(page, body.user_id, body.xsec_token),
getUserProfile(page, target.userId, target.xsecToken),
timeoutMs,
);
+17 -5
View File
@@ -73,8 +73,12 @@ export const SearchSchema = {
/** xhs_get_feed_detail */
export const GetFeedDetailSchema = {
feed_id: z.string().describe('Feed (note) ID'),
xsec_token: z.string().describe('Security token for the feed'),
url: z
.string()
.optional()
.describe('Optional note URL (auto-parses feed_id and xsec_token)'),
feed_id: z.string().optional().describe('Feed (note) ID (required when url not provided)'),
xsec_token: z.string().optional().describe('Security token for the feed (required when url not provided)'),
};
/** xhs_get_sub_comments */
@@ -89,13 +93,21 @@ export const GetSubCommentsSchema = {
.max(200)
.optional()
.default(20)
.describe('Maximum number of sub-comments to load (1200, default 20)'),
.describe('Maximum number of sub-comments to return per page (1200, default 20)'),
cursor: z
.string()
.optional()
.describe('Keyset pagination cursor returned by previous call'),
};
/** xhs_get_user_profile */
export const GetUserProfileSchema = {
user_id: z.string().describe('User ID'),
xsec_token: z.string().describe('Security token for the user page'),
url: z
.string()
.optional()
.describe('Optional user profile URL (auto-parses user_id and xsec_token)'),
user_id: z.string().optional().describe('User ID (required when url not provided)'),
xsec_token: z.string().optional().describe('Security token for the user page (required when url not provided)'),
};
// -- Phase 4: Content publishing (2 tools) ---------------------------------
@@ -0,0 +1,95 @@
interface FeedTargetInput {
feed_id?: string;
xsec_token?: string;
url?: string;
}
interface UserTargetInput {
user_id?: string;
xsec_token?: string;
url?: string;
}
interface FeedTargetResolved {
feedId: string;
xsecToken: string;
}
interface UserTargetResolved {
userId: string;
xsecToken: string;
}
function parseXhsUrl(rawUrl: string): URL {
const trimmed = rawUrl.trim();
if (!trimmed) {
throw new Error('url cannot be empty');
}
if (/^https?:\/\//i.test(trimmed)) {
return new URL(trimmed);
}
if (trimmed.startsWith('/')) {
return new URL(`https://www.xiaohongshu.com${trimmed}`);
}
return new URL(`https://${trimmed}`);
}
function extractFeedIdFromPath(pathname: string): string | undefined {
const patterns = [
/\/explore\/([a-zA-Z0-9_-]+)/,
/\/discovery\/item\/([a-zA-Z0-9_-]+)/,
/\/note\/([a-zA-Z0-9_-]+)/,
];
for (const pattern of patterns) {
const match = pathname.match(pattern);
if (match?.[1]) return match[1];
}
return undefined;
}
function extractUserIdFromPath(pathname: string): string | undefined {
const match = pathname.match(/\/user\/profile\/([a-zA-Z0-9_-]+)/);
return match?.[1];
}
function extractXsecToken(url: URL): string | undefined {
return url.searchParams.get('xsec_token') ?? url.searchParams.get('xsecToken') ?? undefined;
}
export function resolveFeedTarget(input: FeedTargetInput): FeedTargetResolved {
let feedId = input.feed_id?.trim();
let xsecToken = input.xsec_token?.trim();
if (input.url) {
const parsed = parseXhsUrl(input.url);
feedId = feedId || extractFeedIdFromPath(parsed.pathname);
xsecToken = xsecToken || extractXsecToken(parsed);
}
if (!feedId || !xsecToken) {
throw new Error('xhs_get_feed_detail requires either url with feed_id/xsec_token, or both feed_id and xsec_token');
}
return { feedId, xsecToken };
}
export function resolveUserTarget(input: UserTargetInput): UserTargetResolved {
let userId = input.user_id?.trim();
let xsecToken = input.xsec_token?.trim();
if (input.url) {
const parsed = parseXhsUrl(input.url);
userId = userId || extractUserIdFromPath(parsed.pathname);
xsecToken = xsecToken || extractXsecToken(parsed);
}
if (!userId || !xsecToken) {
throw new Error('xhs_get_user_profile requires either url with user_id/xsec_token, or both user_id and xsec_token');
}
return { userId, xsecToken };
}