增强详情与评论MCP:支持URL自动解析并为子评论引入Keyset分页
This commit is contained in:
@@ -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
@@ -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` | 发布视频笔记 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 (1–200, default 20)'),
|
||||
.describe('Maximum number of sub-comments to return per page (1–200, 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user