优化运营MCP:新增状态型点赞收藏、评论幂等与通知游标分页

This commit is contained in:
2026-03-03 12:27:31 +08:00
parent a7672d0430
commit ceaad6f15b
6 changed files with 202 additions and 44 deletions
+2 -2
View File
@@ -101,8 +101,8 @@ Add this in `claude_desktop_config.json`:
| `xhs_publish_video` | Publish a video note |
| `xhs_post_comment` | Post a comment on a note |
| `xhs_reply_comment` | Reply to a comment |
| `xhs_like` | Toggle like state on a note |
| `xhs_favorite` | Toggle favorite state on a note |
| `xhs_set_like_state` | Set like state on a note (idempotent) |
| `xhs_set_favorite_state` | Set favorite state on a note (idempotent) |
| `xhs_get_unprocessed_notifications` | Get unprocessed notification tasks from local SQLite state |
| `xhs_mark_notification_task` | Manually mark notification task status (new/pending/ignored/replied/failed) |
| `xhs_mark_notification_tasks` | Batch mark notification task statuses |
+2 -2
View File
@@ -101,8 +101,8 @@ pnpm test
| `xhs_publish_video` | 发布视频笔记 |
| `xhs_post_comment` | 发表评论 |
| `xhs_reply_comment` | 回复评论 |
| `xhs_like` | 切换点赞状态 |
| `xhs_favorite` | 切换收藏状态 |
| `xhs_set_like_state` | 设置点赞状态(幂等) |
| `xhs_set_favorite_state` | 设置收藏状态(幂等) |
| `xhs_get_unprocessed_notifications` | 从本地 SQLite 获取“未处理”通知任务 |
| `xhs_mark_notification_task` | 手动标记通知任务状态(new/pending/ignored/replied/failed |
| `xhs_mark_notification_tasks` | 批量标记通知任务状态 |
+72 -29
View File
@@ -18,7 +18,7 @@ import { publishImageNote } from './publish.js';
import { publishVideoNote } from './publish-video.js';
import { listMyNotes } from './my-notes.js';
import { postComment, replyComment } from './comment.js';
import { toggleLike, toggleFavorite } from './interaction.js';
import { setLikeState, setFavoriteState } from './interaction.js';
import { replyNotification } from './notification.js';
import {
getNotificationStateStore,
@@ -41,8 +41,8 @@ import {
ListMyNotesSchema,
PostCommentSchema,
ReplyCommentSchema,
LikeSchema,
FavoriteSchema,
SetLikeStateSchema,
SetFavoriteStateSchema,
ReplyNotificationSchema,
GetUnprocessedNotificationsSchema,
MarkNotificationTaskSchema,
@@ -632,18 +632,29 @@ export const xiaohongshuPlugin: PlatformPlugin = {
PostCommentSchema,
async (args) => {
return withErrorHandling('xhs_post_comment', async () => {
const timeoutMs =
config.operationTimeouts['comment'] ??
config.operationTimeouts['default'] ??
20_000;
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;
const result = await browser.withPage(
PLATFORM,
async (page) =>
postComment(page, args.feed_id, args.xsec_token, args.content),
timeoutMs,
return await browser.withPage(
PLATFORM,
async (page) =>
postComment(page, args.feed_id, args.xsec_token, args.content),
timeoutMs,
);
},
);
return ok(result);
return ok(data, meta);
});
},
);
@@ -695,15 +706,15 @@ export const xiaohongshuPlugin: PlatformPlugin = {
);
// -----------------------------------------------------------------------
// xhs_like
// xhs_set_like_state
// -----------------------------------------------------------------------
server.tool(
'xhs_like',
'Toggle like on a Xiaohongshu note',
LikeSchema,
'xhs_set_like_state',
'Set like state on a Xiaohongshu note (idempotent)',
SetLikeStateSchema,
async (args) => {
return withErrorHandling('xhs_like', async () => {
return withErrorHandling('xhs_set_like_state', async () => {
const timeoutMs =
config.operationTimeouts['like'] ??
config.operationTimeouts['default'] ??
@@ -712,7 +723,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
const result = await browser.withPage(
PLATFORM,
async (page) =>
toggleLike(page, args.feed_id, args.xsec_token),
setLikeState(page, args.feed_id, args.xsec_token, args.liked),
timeoutMs,
);
return ok(result);
@@ -721,15 +732,15 @@ export const xiaohongshuPlugin: PlatformPlugin = {
);
// -----------------------------------------------------------------------
// xhs_favorite
// xhs_set_favorite_state
// -----------------------------------------------------------------------
server.tool(
'xhs_favorite',
'Toggle favorite on a Xiaohongshu note',
FavoriteSchema,
'xhs_set_favorite_state',
'Set favorite state on a Xiaohongshu note (idempotent)',
SetFavoriteStateSchema,
async (args) => {
return withErrorHandling('xhs_favorite', async () => {
return withErrorHandling('xhs_set_favorite_state', async () => {
const timeoutMs =
config.operationTimeouts['favorite'] ??
config.operationTimeouts['default'] ??
@@ -738,7 +749,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
const result = await browser.withPage(
PLATFORM,
async (page) =>
toggleFavorite(page, args.feed_id, args.xsec_token),
setFavoriteState(page, args.feed_id, args.xsec_token, args.favorited),
timeoutMs,
);
return ok(result);
@@ -770,8 +781,24 @@ export const xiaohongshuPlugin: PlatformPlugin = {
? args.statuses
: ['new', 'failed'];
const tasks = getNotificationStateStore().listByStatuses(statuses, args.max_count);
return ok(tasks, syncResult ? { synced: syncResult } : undefined);
const store = getNotificationStateStore();
const limit = clampPageSize(args.max_count);
const offset = parseCursor(args.cursor);
const total = store.countByStatuses(statuses);
const tasks = store.listByStatuses(statuses, limit, offset);
const nextOffset = offset + tasks.length;
const nextCursor = nextOffset < total ? String(nextOffset) : undefined;
return ok(tasks, {
...(syncResult ? { synced: syncResult } : {}),
pagination: {
cursor: args.cursor ?? '0',
max_count: limit,
returned: tasks.length,
total,
...(nextCursor ? { next_cursor: nextCursor } : {}),
},
});
});
},
);
@@ -858,8 +885,24 @@ export const xiaohongshuPlugin: PlatformPlugin = {
ListFailedNotificationTasksSchema,
async (args) => {
return withErrorHandling('xhs_list_failed_notification_tasks', async () => {
const tasks = getNotificationStateStore().listByStatuses(['failed'], args.max_count);
return ok(tasks);
const store = getNotificationStateStore();
const statuses: NotificationTaskStatus[] = ['failed'];
const limit = clampPageSize(args.max_count);
const offset = parseCursor(args.cursor);
const total = store.countByStatuses(statuses);
const tasks = store.listByStatuses(statuses, limit, offset);
const nextOffset = offset + tasks.length;
const nextCursor = nextOffset < total ? String(nextOffset) : undefined;
return ok(tasks, {
pagination: {
cursor: args.cursor ?? '0',
max_count: limit,
returned: tasks.length,
total,
...(nextCursor ? { next_cursor: nextCursor } : {}),
},
});
});
},
);
+75 -4
View File
@@ -48,6 +48,11 @@ async function readState(page: Page, btnSelector: string, activeHref: string): P
.catch(() => false);
}
async function openFeedOverlay(page: Page, feedId: string, xsecToken: string): Promise<void> {
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' });
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
}
// ---------------------------------------------------------------------------
// toggleLike — pure toggle, clicks the like button once
// ---------------------------------------------------------------------------
@@ -59,8 +64,7 @@ export async function toggleLike(
): Promise<{ success: boolean; liked: boolean }> {
log.info({ feedId }, 'Toggling like on note');
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' });
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
await openFeedOverlay(page, feedId, xsecToken);
const clicked = await clickLastMatch(page, '.engage-bar-style .like-wrapper');
if (!clicked) {
@@ -86,8 +90,7 @@ export async function toggleFavorite(
): Promise<{ success: boolean; favorited: boolean }> {
log.info({ feedId }, 'Toggling favorite on note');
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' });
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
await openFeedOverlay(page, feedId, xsecToken);
const clicked = await clickLastMatch(page, '.engage-bar-style .collect-wrapper');
if (!clicked) {
@@ -101,3 +104,71 @@ export async function toggleFavorite(
log.info({ feedId, favorited }, 'Favorite toggle complete');
return { success: true, favorited };
}
// ---------------------------------------------------------------------------
// setLikeState / setFavoriteState — idempotent state-setting operations
// ---------------------------------------------------------------------------
export async function setLikeState(
page: Page,
feedId: string,
xsecToken: string,
targetLiked: boolean,
): Promise<{ success: boolean; liked: boolean; changed: boolean }> {
log.info({ feedId, targetLiked }, 'Setting like state on note');
await openFeedOverlay(page, feedId, xsecToken);
const currentLiked = await readState(page, '.engage-bar-style .like-wrapper', '#liked');
if (currentLiked === targetLiked) {
return { success: true, liked: currentLiked, changed: false };
}
const clicked = await clickLastMatch(page, '.engage-bar-style .like-wrapper');
if (!clicked) {
log.warn('Like button not found in note detail overlay');
return { success: false, liked: currentLiked, changed: false };
}
await page.waitForTimeout(TOGGLE_SETTLE_MS);
const liked = await readState(page, '.engage-bar-style .like-wrapper', '#liked');
return {
success: liked === targetLiked,
liked,
changed: liked !== currentLiked,
};
}
export async function setFavoriteState(
page: Page,
feedId: string,
xsecToken: string,
targetFavorited: boolean,
): Promise<{ success: boolean; favorited: boolean; changed: boolean }> {
log.info({ feedId, targetFavorited }, 'Setting favorite state on note');
await openFeedOverlay(page, feedId, xsecToken);
const currentFavorited = await readState(
page,
'.engage-bar-style .collect-wrapper',
'#collected',
);
if (currentFavorited === targetFavorited) {
return { success: true, favorited: currentFavorited, changed: false };
}
const clicked = await clickLastMatch(page, '.engage-bar-style .collect-wrapper');
if (!clicked) {
log.warn('Favorite button not found in note detail overlay');
return { success: false, favorited: currentFavorited, changed: false };
}
await page.waitForTimeout(TOGGLE_SETTLE_MS);
const favorited = await readState(page, '.engage-bar-style .collect-wrapper', '#collected');
return {
success: favorited === targetFavorited,
favorited,
changed: favorited !== currentFavorited,
};
}
@@ -199,6 +199,7 @@ export class NotificationStateStore {
listByStatuses(
statuses: NotificationTaskStatus[],
maxCount: number,
offset = 0,
): NotificationTask[] {
if (statuses.length === 0) return [];
@@ -212,13 +213,28 @@ export class NotificationStateStore {
WHERE status IN (${placeholders})
ORDER BY first_seen_at ASC
LIMIT ?
OFFSET ?
`;
const stmt = this.db.prepare(query);
const rows = stmt.all(...statuses, maxCount) as unknown as NotificationRow[];
const rows = stmt.all(...statuses, maxCount, offset) as unknown as NotificationRow[];
return rows.map((r) => this.rowToTask(r));
}
countByStatuses(statuses: NotificationTaskStatus[]): number {
if (statuses.length === 0) return 0;
const placeholders = statuses.map(() => '?').join(', ');
const query = `
SELECT COUNT(1) AS count
FROM notification_tasks
WHERE status IN (${placeholders})
`;
const stmt = this.db.prepare(query);
const row = stmt.get(...statuses) as { count?: number } | undefined;
return row?.count ?? 0;
}
getByFingerprint(fingerprint: string): NotificationTask | null {
const stmt = this.db.prepare(`
SELECT
+34 -6
View File
@@ -159,6 +159,12 @@ export const PublishVideoSchema = {
/** xhs_post_comment */
export const PostCommentSchema = {
request_id: z
.string()
.min(1)
.max(128)
.optional()
.describe('Optional idempotency key for comment request'),
feed_id: z.string().describe('Feed ID to comment on'),
xsec_token: z.string().describe('Security token for the feed'),
content: z.string().min(1).describe('Comment text'),
@@ -179,10 +185,17 @@ export const ReplyCommentSchema = {
content: z.string().min(1).describe('Reply text'),
};
/** xhs_like */
export const LikeSchema = {
feed_id: z.string().describe('Feed ID to toggle like'),
/** xhs_set_like_state */
export const SetLikeStateSchema = {
feed_id: z.string().describe('Feed ID to set like state'),
xsec_token: z.string().describe('Security token for the feed'),
liked: z.boolean().describe('Target like state'),
};
/** Legacy schema used by REST toggle endpoint. */
export const LikeSchema = {
feed_id: SetLikeStateSchema.feed_id,
xsec_token: SetLikeStateSchema.xsec_token,
};
/** xhs_list_my_notes */
@@ -242,6 +255,10 @@ export const GetUnprocessedNotificationsSchema = {
.optional()
.default(20)
.describe('Maximum number of unprocessed notifications to return (1200, default 20)'),
cursor: z
.string()
.optional()
.describe('Pagination cursor returned by previous call'),
statuses: z
.array(z.enum(['new', 'pending', 'failed']))
.optional()
@@ -275,6 +292,10 @@ export const ListFailedNotificationTasksSchema = {
.optional()
.default(20)
.describe('Maximum number of failed tasks to return (1200, default 20)'),
cursor: z
.string()
.optional()
.describe('Pagination cursor returned by previous call'),
};
/** xhs_retry_notification_task */
@@ -329,8 +350,15 @@ export const RetryNotificationTasksSchema = {
.describe('Continue processing remaining tasks after one task fails'),
};
/** xhs_favorite */
export const FavoriteSchema = {
feed_id: z.string().describe('Feed ID to toggle favorite'),
/** xhs_set_favorite_state */
export const SetFavoriteStateSchema = {
feed_id: z.string().describe('Feed ID to set favorite state'),
xsec_token: z.string().describe('Security token for the feed'),
favorited: z.boolean().describe('Target favorite state'),
};
/** Legacy schema used by REST toggle endpoint. */
export const FavoriteSchema = {
feed_id: SetFavoriteStateSchema.feed_id,
xsec_token: SetFavoriteStateSchema.xsec_token,
};