From ed7fbdd5c25867947e9387665df7844c64fb20eb Mon Sep 17 00:00:00 2001 From: kurihada Date: Tue, 3 Mar 2026 13:10:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E8=AF=A6=E6=83=85=E4=B8=8E?= =?UTF-8?q?=E8=AF=84=E8=AE=BAMCP=EF=BC=9A=E6=94=AF=E6=8C=81URL=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=A7=A3=E6=9E=90=E5=B9=B6=E4=B8=BA=E5=AD=90=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=E5=BC=95=E5=85=A5Keyset=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- README.zh-CN.md | 6 +- src/platforms/xiaohongshu/index.ts | 103 +++++++++++++++++- src/platforms/xiaohongshu/routes.ts | 109 ++++++++++++++++++- src/platforms/xiaohongshu/schemas.ts | 22 +++- src/platforms/xiaohongshu/target-resolver.ts | 95 ++++++++++++++++ 6 files changed, 319 insertions(+), 22 deletions(-) create mode 100644 src/platforms/xiaohongshu/target-resolver.ts diff --git a/README.md b/README.md index c7e179e..54a3333 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index a50dc36..a52f540 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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` | 发布视频笔记 | diff --git a/src/platforms/xiaohongshu/index.ts b/src/platforms/xiaohongshu/index.ts index 2a87d61..6e35a36 100644 --- a/src/platforms/xiaohongshu/index.ts +++ b/src/platforms/xiaohongshu/index.ts @@ -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( 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); diff --git a/src/platforms/xiaohongshu/routes.ts b/src/platforms/xiaohongshu/routes.ts index c18339d..257dfee 100644 --- a/src/platforms/xiaohongshu/routes.ts +++ b/src/platforms/xiaohongshu/routes.ts @@ -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); + 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, ); diff --git a/src/platforms/xiaohongshu/schemas.ts b/src/platforms/xiaohongshu/schemas.ts index 4e4a31b..6522c6d 100644 --- a/src/platforms/xiaohongshu/schemas.ts +++ b/src/platforms/xiaohongshu/schemas.ts @@ -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) --------------------------------- diff --git a/src/platforms/xiaohongshu/target-resolver.ts b/src/platforms/xiaohongshu/target-resolver.ts new file mode 100644 index 0000000..3d6d455 --- /dev/null +++ b/src/platforms/xiaohongshu/target-resolver.ts @@ -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 }; +}