优化运营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_publish_video` | Publish a video note |
| `xhs_post_comment` | Post a comment on a note | | `xhs_post_comment` | Post a comment on a note |
| `xhs_reply_comment` | Reply to a comment | | `xhs_reply_comment` | Reply to a comment |
| `xhs_like` | Toggle like state on a note | | `xhs_set_like_state` | Set like state on a note (idempotent) |
| `xhs_favorite` | Toggle favorite state on a note | | `xhs_set_favorite_state` | Set favorite state on a note (idempotent) |
| `xhs_get_unprocessed_notifications` | Get unprocessed notification tasks from local SQLite state | | `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_task` | Manually mark notification task status (new/pending/ignored/replied/failed) |
| `xhs_mark_notification_tasks` | Batch mark notification task statuses | | `xhs_mark_notification_tasks` | Batch mark notification task statuses |
+2 -2
View File
@@ -101,8 +101,8 @@ pnpm test
| `xhs_publish_video` | 发布视频笔记 | | `xhs_publish_video` | 发布视频笔记 |
| `xhs_post_comment` | 发表评论 | | `xhs_post_comment` | 发表评论 |
| `xhs_reply_comment` | 回复评论 | | `xhs_reply_comment` | 回复评论 |
| `xhs_like` | 切换点赞状态 | | `xhs_set_like_state` | 设置点赞状态(幂等) |
| `xhs_favorite` | 切换收藏状态 | | `xhs_set_favorite_state` | 设置收藏状态(幂等) |
| `xhs_get_unprocessed_notifications` | 从本地 SQLite 获取“未处理”通知任务 | | `xhs_get_unprocessed_notifications` | 从本地 SQLite 获取“未处理”通知任务 |
| `xhs_mark_notification_task` | 手动标记通知任务状态(new/pending/ignored/replied/failed | | `xhs_mark_notification_task` | 手动标记通知任务状态(new/pending/ignored/replied/failed |
| `xhs_mark_notification_tasks` | 批量标记通知任务状态 | | `xhs_mark_notification_tasks` | 批量标记通知任务状态 |
+72 -29
View File
@@ -18,7 +18,7 @@ import { publishImageNote } from './publish.js';
import { publishVideoNote } from './publish-video.js'; import { publishVideoNote } from './publish-video.js';
import { listMyNotes } from './my-notes.js'; 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 { setLikeState, setFavoriteState } from './interaction.js';
import { replyNotification } from './notification.js'; import { replyNotification } from './notification.js';
import { import {
getNotificationStateStore, getNotificationStateStore,
@@ -41,8 +41,8 @@ import {
ListMyNotesSchema, ListMyNotesSchema,
PostCommentSchema, PostCommentSchema,
ReplyCommentSchema, ReplyCommentSchema,
LikeSchema, SetLikeStateSchema,
FavoriteSchema, SetFavoriteStateSchema,
ReplyNotificationSchema, ReplyNotificationSchema,
GetUnprocessedNotificationsSchema, GetUnprocessedNotificationsSchema,
MarkNotificationTaskSchema, MarkNotificationTaskSchema,
@@ -632,18 +632,29 @@ export const xiaohongshuPlugin: PlatformPlugin = {
PostCommentSchema, PostCommentSchema,
async (args) => { async (args) => {
return withErrorHandling('xhs_post_comment', async () => { return withErrorHandling('xhs_post_comment', async () => {
const timeoutMs = const { data, meta } = await runWithIdempotency(
config.operationTimeouts['comment'] ?? 'xhs_post_comment',
config.operationTimeouts['default'] ?? args.request_id,
20_000; {
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( return await browser.withPage(
PLATFORM, PLATFORM,
async (page) => async (page) =>
postComment(page, args.feed_id, args.xsec_token, args.content), postComment(page, args.feed_id, args.xsec_token, args.content),
timeoutMs, timeoutMs,
);
},
); );
return ok(result); return ok(data, meta);
}); });
}, },
); );
@@ -695,15 +706,15 @@ export const xiaohongshuPlugin: PlatformPlugin = {
); );
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// xhs_like // xhs_set_like_state
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
server.tool( server.tool(
'xhs_like', 'xhs_set_like_state',
'Toggle like on a Xiaohongshu note', 'Set like state on a Xiaohongshu note (idempotent)',
LikeSchema, SetLikeStateSchema,
async (args) => { async (args) => {
return withErrorHandling('xhs_like', async () => { return withErrorHandling('xhs_set_like_state', async () => {
const timeoutMs = const timeoutMs =
config.operationTimeouts['like'] ?? config.operationTimeouts['like'] ??
config.operationTimeouts['default'] ?? config.operationTimeouts['default'] ??
@@ -712,7 +723,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
const result = await browser.withPage( const result = await browser.withPage(
PLATFORM, PLATFORM,
async (page) => async (page) =>
toggleLike(page, args.feed_id, args.xsec_token), setLikeState(page, args.feed_id, args.xsec_token, args.liked),
timeoutMs, timeoutMs,
); );
return ok(result); return ok(result);
@@ -721,15 +732,15 @@ export const xiaohongshuPlugin: PlatformPlugin = {
); );
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// xhs_favorite // xhs_set_favorite_state
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
server.tool( server.tool(
'xhs_favorite', 'xhs_set_favorite_state',
'Toggle favorite on a Xiaohongshu note', 'Set favorite state on a Xiaohongshu note (idempotent)',
FavoriteSchema, SetFavoriteStateSchema,
async (args) => { async (args) => {
return withErrorHandling('xhs_favorite', async () => { return withErrorHandling('xhs_set_favorite_state', async () => {
const timeoutMs = const timeoutMs =
config.operationTimeouts['favorite'] ?? config.operationTimeouts['favorite'] ??
config.operationTimeouts['default'] ?? config.operationTimeouts['default'] ??
@@ -738,7 +749,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
const result = await browser.withPage( const result = await browser.withPage(
PLATFORM, PLATFORM,
async (page) => async (page) =>
toggleFavorite(page, args.feed_id, args.xsec_token), setFavoriteState(page, args.feed_id, args.xsec_token, args.favorited),
timeoutMs, timeoutMs,
); );
return ok(result); return ok(result);
@@ -770,8 +781,24 @@ export const xiaohongshuPlugin: PlatformPlugin = {
? args.statuses ? args.statuses
: ['new', 'failed']; : ['new', 'failed'];
const tasks = getNotificationStateStore().listByStatuses(statuses, args.max_count); const store = getNotificationStateStore();
return ok(tasks, syncResult ? { synced: syncResult } : undefined); 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, ListFailedNotificationTasksSchema,
async (args) => { async (args) => {
return withErrorHandling('xhs_list_failed_notification_tasks', async () => { return withErrorHandling('xhs_list_failed_notification_tasks', async () => {
const tasks = getNotificationStateStore().listByStatuses(['failed'], args.max_count); const store = getNotificationStateStore();
return ok(tasks); 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); .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 // toggleLike — pure toggle, clicks the like button once
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -59,8 +64,7 @@ export async function toggleLike(
): Promise<{ success: boolean; liked: boolean }> { ): Promise<{ success: boolean; liked: boolean }> {
log.info({ feedId }, 'Toggling like on note'); log.info({ feedId }, 'Toggling like on note');
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' }); await openFeedOverlay(page, feedId, xsecToken);
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
const clicked = await clickLastMatch(page, '.engage-bar-style .like-wrapper'); const clicked = await clickLastMatch(page, '.engage-bar-style .like-wrapper');
if (!clicked) { if (!clicked) {
@@ -86,8 +90,7 @@ export async function toggleFavorite(
): Promise<{ success: boolean; favorited: boolean }> { ): Promise<{ success: boolean; favorited: boolean }> {
log.info({ feedId }, 'Toggling favorite on note'); log.info({ feedId }, 'Toggling favorite on note');
await page.goto(buildFeedUrl(feedId, xsecToken), { waitUntil: 'domcontentloaded' }); await openFeedOverlay(page, feedId, xsecToken);
await page.waitForSelector(selDetail.noteContainer, { timeout: 10_000 });
const clicked = await clickLastMatch(page, '.engage-bar-style .collect-wrapper'); const clicked = await clickLastMatch(page, '.engage-bar-style .collect-wrapper');
if (!clicked) { if (!clicked) {
@@ -101,3 +104,71 @@ export async function toggleFavorite(
log.info({ feedId, favorited }, 'Favorite toggle complete'); log.info({ feedId, favorited }, 'Favorite toggle complete');
return { success: true, favorited }; 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( listByStatuses(
statuses: NotificationTaskStatus[], statuses: NotificationTaskStatus[],
maxCount: number, maxCount: number,
offset = 0,
): NotificationTask[] { ): NotificationTask[] {
if (statuses.length === 0) return []; if (statuses.length === 0) return [];
@@ -212,13 +213,28 @@ export class NotificationStateStore {
WHERE status IN (${placeholders}) WHERE status IN (${placeholders})
ORDER BY first_seen_at ASC ORDER BY first_seen_at ASC
LIMIT ? LIMIT ?
OFFSET ?
`; `;
const stmt = this.db.prepare(query); 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)); 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 { getByFingerprint(fingerprint: string): NotificationTask | null {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT SELECT
+34 -6
View File
@@ -159,6 +159,12 @@ export const PublishVideoSchema = {
/** xhs_post_comment */ /** xhs_post_comment */
export const PostCommentSchema = { 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'), feed_id: z.string().describe('Feed ID to comment on'),
xsec_token: z.string().describe('Security token for the feed'), xsec_token: z.string().describe('Security token for the feed'),
content: z.string().min(1).describe('Comment text'), content: z.string().min(1).describe('Comment text'),
@@ -179,10 +185,17 @@ export const ReplyCommentSchema = {
content: z.string().min(1).describe('Reply text'), content: z.string().min(1).describe('Reply text'),
}; };
/** xhs_like */ /** xhs_set_like_state */
export const LikeSchema = { export const SetLikeStateSchema = {
feed_id: z.string().describe('Feed ID to toggle like'), feed_id: z.string().describe('Feed ID to set like state'),
xsec_token: z.string().describe('Security token for the feed'), 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 */ /** xhs_list_my_notes */
@@ -242,6 +255,10 @@ export const GetUnprocessedNotificationsSchema = {
.optional() .optional()
.default(20) .default(20)
.describe('Maximum number of unprocessed notifications to return (1200, 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 statuses: z
.array(z.enum(['new', 'pending', 'failed'])) .array(z.enum(['new', 'pending', 'failed']))
.optional() .optional()
@@ -275,6 +292,10 @@ export const ListFailedNotificationTasksSchema = {
.optional() .optional()
.default(20) .default(20)
.describe('Maximum number of failed tasks to return (1200, 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 */ /** xhs_retry_notification_task */
@@ -329,8 +350,15 @@ export const RetryNotificationTasksSchema = {
.describe('Continue processing remaining tasks after one task fails'), .describe('Continue processing remaining tasks after one task fails'),
}; };
/** xhs_favorite */ /** xhs_set_favorite_state */
export const FavoriteSchema = { export const SetFavoriteStateSchema = {
feed_id: z.string().describe('Feed ID to toggle favorite'), feed_id: z.string().describe('Feed ID to set favorite state'),
xsec_token: z.string().describe('Security token for the feed'), 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,
}; };