1210 lines
40 KiB
TypeScript
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);
|
|
});
|
|
},
|
|
);
|
|
},
|
|
};
|