优化运营MCP:新增状态型点赞收藏、评论幂等与通知游标分页
This commit is contained in:
@@ -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
@@ -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` | 批量标记通知任务状态 |
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (1–200, 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 (1–200, 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user