增强详情与评论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_delete_cookies` | Delete cookies and reset login session |
|
||||||
| `xhs_list_feeds` | Get explore page recommended feed list |
|
| `xhs_list_feeds` | Get explore page recommended feed list |
|
||||||
| `xhs_search` | Search notes by keyword with filters |
|
| `xhs_search` | Search notes by keyword with filters |
|
||||||
| `xhs_get_feed_detail` | Get note detail (content/media/stats/comments) |
|
| `xhs_get_feed_detail` | Get note detail (supports passing note URL directly) |
|
||||||
| `xhs_get_sub_comments` | Load all sub-comments for a parent comment |
|
| `xhs_get_sub_comments` | Load sub-comments for a parent comment (keyset cursor pagination) |
|
||||||
| `xhs_get_user_profile` | Get user profile with recent notes |
|
| `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_list_my_notes` | List current account's published notes |
|
||||||
| `xhs_publish_image` | Publish an image note |
|
| `xhs_publish_image` | Publish an image note |
|
||||||
| `xhs_publish_video` | Publish a video note |
|
| `xhs_publish_video` | Publish a video note |
|
||||||
|
|||||||
+3
-3
@@ -93,9 +93,9 @@ pnpm test
|
|||||||
| `xhs_delete_cookies` | 删除 Cookie 并重置登录状态 |
|
| `xhs_delete_cookies` | 删除 Cookie 并重置登录状态 |
|
||||||
| `xhs_list_feeds` | 获取推荐流 |
|
| `xhs_list_feeds` | 获取推荐流 |
|
||||||
| `xhs_search` | 关键词搜索笔记(支持筛选) |
|
| `xhs_search` | 关键词搜索笔记(支持筛选) |
|
||||||
| `xhs_get_feed_detail` | 获取笔记详情(内容/媒体/统计/评论) |
|
| `xhs_get_feed_detail` | 获取笔记详情(支持直接传笔记 URL) |
|
||||||
| `xhs_get_sub_comments` | 拉取某条父评论的完整子评论 |
|
| `xhs_get_sub_comments` | 拉取某条父评论的子评论(Keyset 游标分页) |
|
||||||
| `xhs_get_user_profile` | 获取用户主页及近期笔记 |
|
| `xhs_get_user_profile` | 获取用户主页及近期笔记(支持直接传主页 URL) |
|
||||||
| `xhs_list_my_notes` | 获取当前账号已发布笔记列表 |
|
| `xhs_list_my_notes` | 获取当前账号已发布笔记列表 |
|
||||||
| `xhs_publish_image` | 发布图文笔记 |
|
| `xhs_publish_image` | 发布图文笔记 |
|
||||||
| `xhs_publish_video` | 发布视频笔记 |
|
| `xhs_publish_video` | 发布视频笔记 |
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getIdempotencyStore,
|
getIdempotencyStore,
|
||||||
computeIdempotencyHash,
|
computeIdempotencyHash,
|
||||||
} from '../../utils/idempotency.js';
|
} from '../../utils/idempotency.js';
|
||||||
|
import { resolveFeedTarget, resolveUserTarget } from './target-resolver.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';
|
||||||
@@ -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>(
|
async function runWithIdempotency<T>(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
requestId: string | undefined,
|
requestId: string | undefined,
|
||||||
@@ -445,11 +490,16 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
|||||||
async (args) => {
|
async (args) => {
|
||||||
return withErrorHandling('xhs_get_feed_detail', async () => {
|
return withErrorHandling('xhs_get_feed_detail', async () => {
|
||||||
const timeoutMs = config.operationTimeouts['feed_detail'] ?? config.operationTimeouts['default'] ?? 60_000;
|
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(
|
const detail = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
async (page) =>
|
||||||
getFeedDetail(page, args.feed_id, args.xsec_token),
|
getFeedDetail(page, target.feedId, target.xsecToken),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
return ok(detail);
|
return ok(detail);
|
||||||
@@ -463,7 +513,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
|||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'xhs_get_sub_comments',
|
'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,
|
GetSubCommentsSchema,
|
||||||
async (args) => {
|
async (args) => {
|
||||||
return withErrorHandling('xhs_get_sub_comments', async () => {
|
return withErrorHandling('xhs_get_sub_comments', async () => {
|
||||||
@@ -471,14 +521,50 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
|||||||
config.operationTimeouts['feed_detail'] ??
|
config.operationTimeouts['feed_detail'] ??
|
||||||
config.operationTimeouts['default'] ??
|
config.operationTimeouts['default'] ??
|
||||||
60_000;
|
60_000;
|
||||||
|
const limit = clampPageSize(args.max_count);
|
||||||
|
const keysetCursor = decodeSubCommentCursor(args.cursor);
|
||||||
|
|
||||||
const result = await browser.withPage(
|
const allLoaded = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
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,
|
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) => {
|
async (args) => {
|
||||||
return withErrorHandling('xhs_get_user_profile', async () => {
|
return withErrorHandling('xhs_get_user_profile', async () => {
|
||||||
const timeoutMs = config.operationTimeouts['user_profile'] ?? config.operationTimeouts['default'] ?? 60_000;
|
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(
|
const profile = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
async (page) =>
|
||||||
getUserProfile(page, args.user_id, args.xsec_token),
|
getUserProfile(page, target.userId, target.xsecToken),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
return ok(profile);
|
return ok(profile);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { listMyNotes } from './my-notes.js';
|
|||||||
import { postComment, replyComment } from './comment.js';
|
import { postComment, replyComment } from './comment.js';
|
||||||
import { toggleLike, toggleFavorite } from './interaction.js';
|
import { toggleLike, toggleFavorite } from './interaction.js';
|
||||||
import { getCommentNotifications, replyNotification } from './notification.js';
|
import { getCommentNotifications, replyNotification } from './notification.js';
|
||||||
|
import { resolveFeedTarget, resolveUserTarget } from './target-resolver.js';
|
||||||
import {
|
import {
|
||||||
getNotificationStateStore,
|
getNotificationStateStore,
|
||||||
type NotificationTask,
|
type NotificationTask,
|
||||||
@@ -72,6 +73,7 @@ const SearchBodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const FeedDetailBodySchema = z.object({
|
const FeedDetailBodySchema = z.object({
|
||||||
|
url: GetFeedDetailSchema.url,
|
||||||
feed_id: GetFeedDetailSchema.feed_id,
|
feed_id: GetFeedDetailSchema.feed_id,
|
||||||
xsec_token: GetFeedDetailSchema.xsec_token,
|
xsec_token: GetFeedDetailSchema.xsec_token,
|
||||||
});
|
});
|
||||||
@@ -81,9 +83,11 @@ const SubCommentsBodySchema = z.object({
|
|||||||
xsec_token: GetSubCommentsSchema.xsec_token,
|
xsec_token: GetSubCommentsSchema.xsec_token,
|
||||||
comment_id: GetSubCommentsSchema.comment_id,
|
comment_id: GetSubCommentsSchema.comment_id,
|
||||||
max_count: GetSubCommentsSchema.max_count,
|
max_count: GetSubCommentsSchema.max_count,
|
||||||
|
cursor: GetSubCommentsSchema.cursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
const UserProfileBodySchema = z.object({
|
const UserProfileBodySchema = z.object({
|
||||||
|
url: GetUserProfileSchema.url,
|
||||||
user_id: GetUserProfileSchema.user_id,
|
user_id: GetUserProfileSchema.user_id,
|
||||||
xsec_token: GetUserProfileSchema.xsec_token,
|
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
|
// Rate limiters
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -372,6 +415,11 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const body = FeedDetailBodySchema.parse(req.body);
|
const body = FeedDetailBodySchema.parse(req.body);
|
||||||
|
const target = resolveFeedTarget({
|
||||||
|
url: body.url,
|
||||||
|
feed_id: body.feed_id,
|
||||||
|
xsec_token: body.xsec_token,
|
||||||
|
});
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['feed_detail'] ??
|
config.operationTimeouts['feed_detail'] ??
|
||||||
@@ -381,7 +429,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
const detail = await browser.withPage(
|
const detail = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
async (page) =>
|
||||||
getFeedDetail(page, body.feed_id, body.xsec_token),
|
getFeedDetail(page, target.feedId, target.xsecToken),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -399,20 +447,66 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const body = SubCommentsBodySchema.parse(req.body);
|
const body = SubCommentsBodySchema.parse(req.body);
|
||||||
|
const limit = Math.min(200, Math.max(1, body.max_count));
|
||||||
|
const keysetCursor = decodeSubCommentCursor(body.cursor);
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['feed_detail'] ??
|
config.operationTimeouts['feed_detail'] ??
|
||||||
config.operationTimeouts['default'] ??
|
config.operationTimeouts['default'] ??
|
||||||
60_000;
|
60_000;
|
||||||
|
|
||||||
const result = await browser.withPage(
|
const allLoaded = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
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,
|
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) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
}
|
}
|
||||||
@@ -426,6 +520,11 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const body = UserProfileBodySchema.parse(req.body);
|
const body = UserProfileBodySchema.parse(req.body);
|
||||||
|
const target = resolveUserTarget({
|
||||||
|
url: body.url,
|
||||||
|
user_id: body.user_id,
|
||||||
|
xsec_token: body.xsec_token,
|
||||||
|
});
|
||||||
|
|
||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
config.operationTimeouts['user_profile'] ??
|
config.operationTimeouts['user_profile'] ??
|
||||||
@@ -435,7 +534,7 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
const profile = await browser.withPage(
|
const profile = await browser.withPage(
|
||||||
PLATFORM,
|
PLATFORM,
|
||||||
async (page) =>
|
async (page) =>
|
||||||
getUserProfile(page, body.user_id, body.xsec_token),
|
getUserProfile(page, target.userId, target.xsecToken),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,12 @@ export const SearchSchema = {
|
|||||||
|
|
||||||
/** xhs_get_feed_detail */
|
/** xhs_get_feed_detail */
|
||||||
export const GetFeedDetailSchema = {
|
export const GetFeedDetailSchema = {
|
||||||
feed_id: z.string().describe('Feed (note) ID'),
|
url: z
|
||||||
xsec_token: z.string().describe('Security token for the feed'),
|
.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 */
|
/** xhs_get_sub_comments */
|
||||||
@@ -89,13 +93,21 @@ export const GetSubCommentsSchema = {
|
|||||||
.max(200)
|
.max(200)
|
||||||
.optional()
|
.optional()
|
||||||
.default(20)
|
.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 */
|
/** xhs_get_user_profile */
|
||||||
export const GetUserProfileSchema = {
|
export const GetUserProfileSchema = {
|
||||||
user_id: z.string().describe('User ID'),
|
url: z
|
||||||
xsec_token: z.string().describe('Security token for the user page'),
|
.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) ---------------------------------
|
// -- 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