Files
social-mcp/apps/xhs-mcp/src/platforms/xiaohongshu/index.ts
T

1210 lines
40 KiB
TypeScript

import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Router } from 'express';
import type { BrowserManager } from '@social/core/browser/manager.js';
import { config } from '@social/core/config/index.js';
import { withErrorHandling, type McpToolResult } from '@social/core/utils/errors.js';
import { resolveMediaInput, cleanupFile } from '@social/core/utils/downloader.js';
import {
getIdempotencyStore,
computeIdempotencyHash,
} from '@social/core/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';
import { getFeedDetail, getSubComments } from './feed-detail.js';
import { getUserProfile } from './user-profile.js';
import { publishImageNote } from './publish.js';
import { publishVideoNote } from './publish-video.js';
import { listMyNotes } from './my-notes.js';
import { postComment, replyComment } from './comment.js';
import { setLikeState, setFavoriteState } from './interaction.js';
import { replyNotification } from './notification.js';
import {
getNotificationStateStore,
type NotificationKeysetCursor,
type NotificationTask,
type NotificationTaskStatus,
} from './notification-state.js';
import { syncCommentNotifications, xhsNotificationPoller } from './notification-sync.js';
import { createXhsRoutes } from './routes.js';
import {
CheckLoginSchema,
GetLoginQRCodeSchema,
DeleteCookiesSchema,
ListFeedsSchema,
SearchSchema,
GetFeedDetailSchema,
GetSubCommentsSchema,
GetUserProfileSchema,
PublishImageSchema,
PublishVideoSchema,
ListMyNotesSchema,
PostCommentSchema,
ReplyCommentSchema,
SetLikeStateSchema,
SetFavoriteStateSchema,
ReplyNotificationSchema,
GetUnprocessedNotificationsSchema,
MarkNotificationTaskSchema,
MarkNotificationTasksSchema,
ListFailedNotificationTasksSchema,
RetryNotificationTaskSchema,
RetryNotificationTasksSchema,
} from './schemas.js';
import type { SearchFilters } from './types.js';
import type { PlatformPlugin } from '@social/core/server/app.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PLATFORM = 'xiaohongshu';
/** Maximum file size for video uploads (500 MB). */
const VIDEO_MAX_SIZE_MB = 500;
/** Maximum file size for image uploads (20 MB — default in validateMediaPath). */
const IMAGE_MAX_SIZE_MB = 20;
/** Default page size for list-like MCP tools. */
const DEFAULT_PAGE_SIZE = 20;
/** Maximum page size for list-like MCP tools. */
const MAX_PAGE_SIZE = 200;
type McpMeta = Record<string, unknown>;
function ok(data: unknown, meta?: McpMeta): McpToolResult {
return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
data,
meta: meta ?? {},
}),
}],
};
}
function parseCursor(cursor?: string): number {
if (!cursor) return 0;
if (!/^\d+$/.test(cursor)) {
throw new Error('Invalid cursor: must be a non-negative integer string');
}
return Number.parseInt(cursor, 10);
}
function clampPageSize(maxCount?: number): number {
return Math.min(MAX_PAGE_SIZE, Math.max(1, maxCount ?? DEFAULT_PAGE_SIZE));
}
function paginateArray<T>(
items: T[],
opts?: { max_count?: number; cursor?: string },
): { items: T[]; meta: McpMeta } {
const limit = clampPageSize(opts?.max_count);
const offset = parseCursor(opts?.cursor);
const sliced = items.slice(offset, offset + limit);
const nextOffset = offset + sliced.length;
const nextCursor = nextOffset < items.length ? String(nextOffset) : undefined;
return {
items: sliced,
meta: {
pagination: {
cursor: opts?.cursor ?? '0',
max_count: limit,
returned: sliced.length,
total: items.length,
...(nextCursor ? { next_cursor: nextCursor } : {}),
},
},
};
}
function encodeNotificationCursor(cursor: NotificationKeysetCursor): string {
return Buffer.from(
JSON.stringify({
first_seen_at: cursor.firstSeenAt,
fingerprint: cursor.fingerprint,
}),
'utf8',
).toString('base64url');
}
function decodeNotificationCursor(cursor?: string): NotificationKeysetCursor | undefined {
if (!cursor) return undefined;
try {
const raw = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
first_seen_at?: unknown;
fingerprint?: unknown;
};
const firstSeenAt = raw.first_seen_at;
const fingerprint = raw.fingerprint;
if (
typeof firstSeenAt !== 'number' ||
!Number.isInteger(firstSeenAt) ||
firstSeenAt < 0 ||
typeof fingerprint !== 'string' ||
fingerprint.length === 0
) {
throw new Error('Invalid notification cursor payload');
}
return { firstSeenAt, fingerprint };
} catch {
throw new Error('Invalid cursor for notification keyset pagination');
}
}
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,
inputForHash: unknown,
execute: () => Promise<T>,
): Promise<{ data: T; meta?: McpMeta }> {
if (!requestId) {
return { data: await execute() };
}
const store = getIdempotencyStore();
const inputHash = computeIdempotencyHash(inputForHash);
const existing = store.get(toolName, requestId);
if (existing) {
if (existing.inputHash !== inputHash) {
throw new Error('request_id already used with different parameters');
}
return {
data: existing.responseData as T,
meta: {
request_id: requestId,
idempotent_replay: true,
first_processed_at: existing.createdAt,
},
};
}
const data = await execute();
store.put(toolName, requestId, inputHash, data);
return {
data,
meta: {
request_id: requestId,
idempotent_replay: false,
},
};
}
async function retryFailedNotificationTaskByFingerprint(
browser: BrowserManager,
fingerprint: string,
replyContentOverride?: string,
): Promise<{ success: boolean; fingerprint: string }> {
const store = getNotificationStateStore();
const task = store.getByFingerprint(fingerprint);
if (!task) {
throw new Error(`Notification task not found: ${fingerprint}`);
}
if (task.status !== 'failed') {
throw new Error(`Task status must be failed to retry, got: ${task.status}`);
}
const replyContent = replyContentOverride ?? task.replyContent;
if (!replyContent) {
throw new Error('Retry requires reply_content when task has no stored replyContent');
}
store.markPending(fingerprint);
const timeoutMs =
config.operationTimeouts['reply'] ??
config.operationTimeouts['default'] ??
20_000;
try {
const result = await browser.withPage(
PLATFORM,
async (page) =>
replyNotification(
page,
task.notification.userId,
task.notification.content,
replyContent,
),
timeoutMs,
);
if (result.success) {
store.markReplied(fingerprint, replyContent);
} else {
store.markFailed(fingerprint, 'Retry reply returned success=false');
}
return {
success: result.success,
fingerprint,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
store.markFailed(fingerprint, message);
throw err;
}
}
function resolveReplyTarget(args: {
fingerprint?: string;
user_id?: string;
comment_content?: string;
}): { fingerprint?: string; userId: string; commentContent: string } {
const store = getNotificationStateStore();
if (args.fingerprint) {
const task = store.getByFingerprint(args.fingerprint);
if (!task) {
throw new Error(`Notification task not found: ${args.fingerprint}`);
}
return {
fingerprint: args.fingerprint,
userId: task.notification.userId,
commentContent: task.notification.content,
};
}
if (!args.user_id || !args.comment_content) {
throw new Error('Either fingerprint or both user_id and comment_content are required');
}
const fp = store.findOpenFingerprint(args.user_id, args.comment_content) ?? undefined;
return {
...(fp ? { fingerprint: fp } : {}),
userId: args.user_id,
commentContent: args.comment_content,
};
}
// ---------------------------------------------------------------------------
// PlatformPlugin implementation
// ---------------------------------------------------------------------------
export const xiaohongshuPlugin: PlatformPlugin = {
name: PLATFORM,
apiNamespace: 'xhs',
// =========================================================================
// REST API routes (Phase 5)
// =========================================================================
registerRoutes(router: Router, browser: BrowserManager): void {
xhsNotificationPoller.start(browser);
const xhsRouter = createXhsRoutes(browser);
router.use('/', xhsRouter);
},
shutdown(): Promise<void> {
xhsNotificationPoller.stop();
return Promise.resolve();
},
// =========================================================================
// MCP tools
// =========================================================================
registerTools(server: McpServer, browser: BrowserManager): void {
// =====================================================================
// Phase 2: Login management (3 tools)
// =====================================================================
// -----------------------------------------------------------------------
// xhs_check_login
// -----------------------------------------------------------------------
server.tool(
'xhs_check_login',
'Check Xiaohongshu login status',
CheckLoginSchema,
async () => {
return withErrorHandling('xhs_check_login', async () => {
const timeoutMs = config.operationTimeouts['login'] ?? config.operationTimeouts['default'] ?? 60_000;
const status = await browser.withPage(
PLATFORM,
async (page) => checkLoginStatus(page),
timeoutMs,
);
return ok(status);
});
},
);
// -----------------------------------------------------------------------
// xhs_get_login_qrcode
// -----------------------------------------------------------------------
server.tool(
'xhs_get_login_qrcode',
'Get Xiaohongshu login QR code (user scans with phone)',
GetLoginQRCodeSchema,
async () => {
return withErrorHandling('xhs_get_login_qrcode', async () => {
const result = await getLoginQRCode(browser);
return ok(result);
});
},
);
// -----------------------------------------------------------------------
// xhs_delete_cookies
// -----------------------------------------------------------------------
server.tool(
'xhs_delete_cookies',
'Delete Xiaohongshu cookies and reset login session',
DeleteCookiesSchema,
async () => {
return withErrorHandling('xhs_delete_cookies', async () => {
await deleteCookies(browser);
return ok({ message: 'Cookies deleted' });
});
},
);
// =====================================================================
// Phase 3: Content browsing (4 tools)
// =====================================================================
// -----------------------------------------------------------------------
// xhs_list_feeds
// -----------------------------------------------------------------------
server.tool(
'xhs_list_feeds',
'Get Xiaohongshu explore page recommended feed list',
ListFeedsSchema,
async (args) => {
return withErrorHandling('xhs_list_feeds', async () => {
const timeoutMs = config.operationTimeouts['feed_list'] ?? config.operationTimeouts['default'] ?? 60_000;
const feeds = await browser.withPage(
PLATFORM,
async (page) => listFeeds(page),
timeoutMs,
);
const pageResult = paginateArray(feeds, {
max_count: args.max_count,
cursor: args.cursor,
});
return ok(pageResult.items, pageResult.meta);
});
},
);
// -----------------------------------------------------------------------
// xhs_search
// -----------------------------------------------------------------------
server.tool(
'xhs_search',
'Search Xiaohongshu notes by keyword with optional filters (sort, type, time range)',
SearchSchema,
async (args) => {
return withErrorHandling('xhs_search', async () => {
const timeoutMs = config.operationTimeouts['search'] ?? config.operationTimeouts['default'] ?? 60_000;
const filters: SearchFilters | undefined = args.filters
? {
sort: args.filters.sort,
type: args.filters.type,
time: args.filters.time,
}
: undefined;
const feeds = await browser.withPage(
PLATFORM,
async (page) => searchFeeds(page, args.keyword, filters),
timeoutMs,
);
const pageResult = paginateArray(feeds, {
max_count: args.max_count,
cursor: args.cursor,
});
return ok(pageResult.items, pageResult.meta);
});
},
);
// -----------------------------------------------------------------------
// xhs_get_feed_detail
// -----------------------------------------------------------------------
server.tool(
'xhs_get_feed_detail',
'Get Xiaohongshu note detail including content, images, stats, and first-screen comments (use xhs_get_sub_comments to load full replies)',
GetFeedDetailSchema,
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, target.feedId, target.xsecToken),
timeoutMs,
);
return ok(detail);
});
},
);
// -----------------------------------------------------------------------
// xhs_get_sub_comments
// -----------------------------------------------------------------------
server.tool(
'xhs_get_sub_comments',
'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 () => {
const timeoutMs =
config.operationTimeouts['feed_detail'] ??
config.operationTimeouts['default'] ??
60_000;
const limit = clampPageSize(args.max_count);
const keysetCursor = decodeSubCommentCursor(args.cursor);
const allLoaded = await browser.withPage(
PLATFORM,
async (page) =>
getSubComments(page, args.feed_id, args.xsec_token, args.comment_id, MAX_PAGE_SIZE),
timeoutMs,
);
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 } : {}),
},
});
});
},
);
// -----------------------------------------------------------------------
// xhs_get_user_profile
// -----------------------------------------------------------------------
server.tool(
'xhs_get_user_profile',
'Get Xiaohongshu user profile information including bio, stats, and recent notes',
GetUserProfileSchema,
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, target.userId, target.xsecToken),
timeoutMs,
);
return ok(profile);
});
},
);
// =====================================================================
// My published notes (1 tool)
// =====================================================================
// -----------------------------------------------------------------------
// xhs_list_my_notes
// -----------------------------------------------------------------------
server.tool(
'xhs_list_my_notes',
'List your published notes on Xiaohongshu from the creator center',
ListMyNotesSchema,
async (args) => {
return withErrorHandling('xhs_list_my_notes', async () => {
const timeoutMs =
config.operationTimeouts['feed_list'] ??
config.operationTimeouts['default'] ??
60_000;
const notes = await browser.withPage(
PLATFORM,
async (page) => listMyNotes(page),
timeoutMs,
);
const pageResult = paginateArray(notes, {
max_count: args.max_count,
cursor: args.cursor,
});
return ok(pageResult.items, pageResult.meta);
});
},
);
// =====================================================================
// Phase 4: Content publishing (2 tools)
// =====================================================================
// -----------------------------------------------------------------------
// xhs_publish_image
// -----------------------------------------------------------------------
server.tool(
'xhs_publish_image',
'Publish an image note on Xiaohongshu. Provide local file paths for images.',
PublishImageSchema,
async (args) => {
return withErrorHandling('xhs_publish_image', async () => {
const { data, meta } = await runWithIdempotency(
'xhs_publish_image',
args.request_id,
{
title: args.title,
content: args.content,
images: args.images,
tags: args.tags,
schedule_at: args.schedule_at,
is_original: args.is_original,
visibility: args.visibility,
},
async () => {
// Resolve all images (local path or URL download) before acquiring browser.
const resolved: Array<{ path: string; temporary: boolean }> = [];
for (const img of args.images) {
resolved.push(await resolveMediaInput(img, { maxSizeMB: IMAGE_MAX_SIZE_MB }));
}
const validatedPaths = resolved.map((r) => r.path);
const timeoutMs =
config.operationTimeouts['publish'] ??
config.operationTimeouts['default'] ??
300_000;
try {
return await browser.withPage(
PLATFORM,
async (page) =>
publishImageNote(page, args.title, args.content, validatedPaths, {
tags: args.tags,
scheduleAt: args.schedule_at,
isOriginal: args.is_original,
visibility: args.visibility,
}),
timeoutMs,
);
} finally {
for (const r of resolved) {
if (r.temporary) await cleanupFile(r.path).catch(() => undefined);
}
}
},
);
return ok(data, meta);
});
},
);
// -----------------------------------------------------------------------
// xhs_publish_video
// -----------------------------------------------------------------------
server.tool(
'xhs_publish_video',
'Publish a video note on Xiaohongshu. Provide a local file path for the video.',
PublishVideoSchema,
async (args) => {
return withErrorHandling('xhs_publish_video', async () => {
const { data, meta } = await runWithIdempotency(
'xhs_publish_video',
args.request_id,
{
title: args.title,
content: args.content,
video: args.video,
tags: args.tags,
schedule_at: args.schedule_at,
visibility: args.visibility,
},
async () => {
// Resolve video (local path or URL download) before acquiring browser.
const resolvedVideo = await resolveMediaInput(args.video, { maxSizeMB: VIDEO_MAX_SIZE_MB });
const timeoutMs =
config.operationTimeouts['publish'] ??
config.operationTimeouts['default'] ??
300_000;
try {
return await browser.withPage(
PLATFORM,
async (page) =>
publishVideoNote(page, args.title, args.content, resolvedVideo.path, {
tags: args.tags,
scheduleAt: args.schedule_at,
visibility: args.visibility,
}),
timeoutMs,
);
} finally {
if (resolvedVideo.temporary) await cleanupFile(resolvedVideo.path).catch(() => undefined);
}
},
);
return ok(data, meta);
});
},
);
// =====================================================================
// Phase 4: Interactions (4 tools)
// =====================================================================
// -----------------------------------------------------------------------
// xhs_post_comment
// -----------------------------------------------------------------------
server.tool(
'xhs_post_comment',
'Post a comment on a Xiaohongshu note',
PostCommentSchema,
async (args) => {
return withErrorHandling('xhs_post_comment', async () => {
const { data, meta } = await runWithIdempotency(
'xhs_post_comment',
args.request_id,
{
feed_id: args.feed_id,
xsec_token: args.xsec_token,
content: args.content,
},
async () => {
const timeoutMs =
config.operationTimeouts['comment'] ??
config.operationTimeouts['default'] ??
20_000;
return await browser.withPage(
PLATFORM,
async (page) =>
postComment(page, args.feed_id, args.xsec_token, args.content),
timeoutMs,
);
},
);
return ok(data, meta);
});
},
);
// -----------------------------------------------------------------------
// xhs_reply_comment
// -----------------------------------------------------------------------
server.tool(
'xhs_reply_comment',
'Reply to a comment on a Xiaohongshu note',
ReplyCommentSchema,
async (args) => {
return withErrorHandling('xhs_reply_comment', async () => {
const { data, meta } = await runWithIdempotency(
'xhs_reply_comment',
args.request_id,
{
feed_id: args.feed_id,
xsec_token: args.xsec_token,
content: args.content,
comment_id: args.comment_id,
user_id: args.user_id,
},
async () => {
const timeoutMs =
config.operationTimeouts['reply'] ??
config.operationTimeouts['default'] ??
20_000;
return await browser.withPage(
PLATFORM,
async (page) =>
replyComment(
page,
args.feed_id,
args.xsec_token,
args.content,
args.comment_id,
args.user_id,
),
timeoutMs,
);
},
);
return ok(data, meta);
});
},
);
// -----------------------------------------------------------------------
// xhs_set_like_state
// -----------------------------------------------------------------------
server.tool(
'xhs_set_like_state',
'Set like state on a Xiaohongshu note (idempotent)',
SetLikeStateSchema,
async (args) => {
return withErrorHandling('xhs_set_like_state', async () => {
const timeoutMs =
config.operationTimeouts['like'] ??
config.operationTimeouts['default'] ??
15_000;
const result = await browser.withPage(
PLATFORM,
async (page) =>
setLikeState(page, args.feed_id, args.xsec_token, args.liked),
timeoutMs,
);
return ok(result);
});
},
);
// -----------------------------------------------------------------------
// xhs_set_favorite_state
// -----------------------------------------------------------------------
server.tool(
'xhs_set_favorite_state',
'Set favorite state on a Xiaohongshu note (idempotent)',
SetFavoriteStateSchema,
async (args) => {
return withErrorHandling('xhs_set_favorite_state', async () => {
const timeoutMs =
config.operationTimeouts['favorite'] ??
config.operationTimeouts['default'] ??
15_000;
const result = await browser.withPage(
PLATFORM,
async (page) =>
setFavoriteState(page, args.feed_id, args.xsec_token, args.favorited),
timeoutMs,
);
return ok(result);
});
},
);
// =====================================================================
// Notifications (7 tools)
// =====================================================================
// -----------------------------------------------------------------------
// xhs_get_unprocessed_notifications
// -----------------------------------------------------------------------
server.tool(
'xhs_get_unprocessed_notifications',
'Get unprocessed notification tasks from local state (SQLite). Optionally sync latest notifications first.',
GetUnprocessedNotificationsSchema,
async (args) => {
return withErrorHandling('xhs_get_unprocessed_notifications', async () => {
let syncResult: { fetched: number; inserted: number; updated: number } | null = null;
if (args.sync) {
syncResult = await syncCommentNotifications(browser, args.max_count);
}
const statuses: NotificationTaskStatus[] =
args.statuses && args.statuses.length > 0
? args.statuses
: ['new', 'failed'];
const store = getNotificationStateStore();
const limit = clampPageSize(args.max_count);
const keysetCursor = decodeNotificationCursor(args.cursor);
const page = store.listByStatusesKeyset(statuses, limit, keysetCursor);
const nextCursor = page.nextCursor
? encodeNotificationCursor(page.nextCursor)
: undefined;
return ok(page.tasks, {
...(syncResult ? { synced: syncResult } : {}),
pagination: {
mode: 'keyset',
cursor: args.cursor ?? null,
max_count: limit,
returned: page.tasks.length,
has_more: page.hasMore,
...(nextCursor ? { next_cursor: nextCursor } : {}),
},
});
});
},
);
// -----------------------------------------------------------------------
// xhs_mark_notification_task
// -----------------------------------------------------------------------
server.tool(
'xhs_mark_notification_task',
'Manually mark a notification task status (new/pending/ignored/replied/failed)',
MarkNotificationTaskSchema,
async (args) => {
return withErrorHandling('xhs_mark_notification_task', async () => {
const store = getNotificationStateStore();
const existing = store.getByFingerprint(args.fingerprint);
if (!existing) {
throw new Error(`Notification task not found: ${args.fingerprint}`);
}
store.setStatus(args.fingerprint, args.status, args.note);
const updated = store.getByFingerprint(args.fingerprint);
return ok(updated);
});
},
);
// -----------------------------------------------------------------------
// xhs_mark_notification_tasks
// -----------------------------------------------------------------------
server.tool(
'xhs_mark_notification_tasks',
'Batch mark notification task statuses',
MarkNotificationTasksSchema,
async (args) => {
return withErrorHandling('xhs_mark_notification_tasks', async () => {
const store = getNotificationStateStore();
const results: Array<{
fingerprint: string;
success: boolean;
task?: NotificationTask | null;
error?: string;
}> = [];
for (const item of args.tasks) {
const existing = store.getByFingerprint(item.fingerprint);
if (!existing) {
results.push({
fingerprint: item.fingerprint,
success: false,
error: 'Notification task not found',
});
continue;
}
store.setStatus(item.fingerprint, item.status, item.note);
results.push({
fingerprint: item.fingerprint,
success: true,
task: store.getByFingerprint(item.fingerprint),
});
}
const successCount = results.filter((r) => r.success).length;
return ok(results, {
batch: {
total: results.length,
success: successCount,
failed: results.length - successCount,
},
});
});
},
);
// -----------------------------------------------------------------------
// xhs_list_failed_notification_tasks
// -----------------------------------------------------------------------
server.tool(
'xhs_list_failed_notification_tasks',
'List failed notification tasks from local state store for retry/triage',
ListFailedNotificationTasksSchema,
async (args) => {
return withErrorHandling('xhs_list_failed_notification_tasks', async () => {
const store = getNotificationStateStore();
const statuses: NotificationTaskStatus[] = ['failed'];
const limit = clampPageSize(args.max_count);
const keysetCursor = decodeNotificationCursor(args.cursor);
const page = store.listByStatusesKeyset(statuses, limit, keysetCursor);
const nextCursor = page.nextCursor
? encodeNotificationCursor(page.nextCursor)
: undefined;
return ok(page.tasks, {
pagination: {
mode: 'keyset',
cursor: args.cursor ?? null,
max_count: limit,
returned: page.tasks.length,
has_more: page.hasMore,
...(nextCursor ? { next_cursor: nextCursor } : {}),
},
});
});
},
);
// -----------------------------------------------------------------------
// xhs_retry_notification_task
// -----------------------------------------------------------------------
server.tool(
'xhs_retry_notification_task',
'Retry a failed notification task by fingerprint, optionally overriding reply content',
RetryNotificationTaskSchema,
async (args) => {
return withErrorHandling('xhs_retry_notification_task', async () => {
const { data, meta } = await runWithIdempotency(
'xhs_retry_notification_task',
args.request_id,
{
fingerprint: args.fingerprint,
reply_content: args.reply_content,
},
async () =>
retryFailedNotificationTaskByFingerprint(
browser,
args.fingerprint,
args.reply_content,
),
);
return ok(data, meta);
});
},
);
// -----------------------------------------------------------------------
// xhs_retry_notification_tasks
// -----------------------------------------------------------------------
server.tool(
'xhs_retry_notification_tasks',
'Batch retry failed notification tasks',
RetryNotificationTasksSchema,
async (args) => {
return withErrorHandling('xhs_retry_notification_tasks', async () => {
const { data, meta } = await runWithIdempotency(
'xhs_retry_notification_tasks',
args.request_id,
{
tasks: args.tasks,
continue_on_error: args.continue_on_error,
},
async () => {
const results: Array<{
fingerprint: string;
success: boolean;
error?: string;
}> = [];
for (const item of args.tasks) {
try {
const one = await retryFailedNotificationTaskByFingerprint(
browser,
item.fingerprint,
item.reply_content,
);
results.push(one);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
results.push({
fingerprint: item.fingerprint,
success: false,
error: message,
});
if (!args.continue_on_error) {
throw err;
}
}
}
const successCount = results.filter((r) => r.success).length;
return {
results,
summary: {
total: results.length,
success: successCount,
failed: results.length - successCount,
},
};
},
);
return ok(data, meta);
});
},
);
// -----------------------------------------------------------------------
// xhs_reply_notification
// -----------------------------------------------------------------------
server.tool(
'xhs_reply_notification',
'Reply to a comment notification inline on the Xiaohongshu notification page',
ReplyNotificationSchema,
async (args) => {
return withErrorHandling('xhs_reply_notification', async () => {
const target = resolveReplyTarget({
fingerprint: args.fingerprint,
user_id: args.user_id,
comment_content: args.comment_content,
});
const targetFingerprint = target.fingerprint;
const { data, meta } = await runWithIdempotency(
'xhs_reply_notification',
args.request_id,
{
fingerprint: args.fingerprint,
user_id: target.userId,
comment_content: target.commentContent,
reply_content: args.reply_content,
},
async () => {
const timeoutMs =
config.operationTimeouts['reply'] ??
config.operationTimeouts['default'] ??
20_000;
try {
if (targetFingerprint) {
getNotificationStateStore().markPending(targetFingerprint);
}
const result = await browser.withPage(
PLATFORM,
async (page) =>
replyNotification(
page,
target.userId,
target.commentContent,
args.reply_content,
),
timeoutMs,
);
if (targetFingerprint) {
if (result.success) {
getNotificationStateStore().markReplied(targetFingerprint, args.reply_content);
} else {
getNotificationStateStore().markFailed(
targetFingerprint,
'Reply action returned success=false',
);
}
}
return {
...result,
...(targetFingerprint ? { fingerprint: targetFingerprint } : {}),
};
} catch (err) {
if (targetFingerprint) {
const message = err instanceof Error ? err.message : String(err);
getNotificationStateStore().markFailed(targetFingerprint, message);
}
throw err;
}
},
);
return ok(data, meta);
});
},
);
},
};