From ceaad6f15b4394a51677d340d0ef54ed295c6388 Mon Sep 17 00:00:00 2001 From: kurihada Date: Tue, 3 Mar 2026 12:27:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BF=90=E8=90=A5MCP?= =?UTF-8?q?=EF=BC=9A=E6=96=B0=E5=A2=9E=E7=8A=B6=E6=80=81=E5=9E=8B=E7=82=B9?= =?UTF-8?q?=E8=B5=9E=E6=94=B6=E8=97=8F=E3=80=81=E8=AF=84=E8=AE=BA=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E4=B8=8E=E9=80=9A=E7=9F=A5=E6=B8=B8=E6=A0=87=E5=88=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- README.zh-CN.md | 4 +- src/platforms/xiaohongshu/index.ts | 101 +++++++++++++----- src/platforms/xiaohongshu/interaction.ts | 79 +++++++++++++- .../xiaohongshu/notification-state.ts | 18 +++- src/platforms/xiaohongshu/schemas.ts | 40 +++++-- 6 files changed, 202 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 736bbee..c7e179e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6345735..a50dc36 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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` | 批量标记通知任务状态 | diff --git a/src/platforms/xiaohongshu/index.ts b/src/platforms/xiaohongshu/index.ts index f427e9d..32b1c2d 100644 --- a/src/platforms/xiaohongshu/index.ts +++ b/src/platforms/xiaohongshu/index.ts @@ -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 } : {}), + }, + }); }); }, ); diff --git a/src/platforms/xiaohongshu/interaction.ts b/src/platforms/xiaohongshu/interaction.ts index e046d79..7a2683f 100644 --- a/src/platforms/xiaohongshu/interaction.ts +++ b/src/platforms/xiaohongshu/interaction.ts @@ -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 { + 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, + }; +} diff --git a/src/platforms/xiaohongshu/notification-state.ts b/src/platforms/xiaohongshu/notification-state.ts index 7177e0d..497ffcf 100644 --- a/src/platforms/xiaohongshu/notification-state.ts +++ b/src/platforms/xiaohongshu/notification-state.ts @@ -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 diff --git a/src/platforms/xiaohongshu/schemas.ts b/src/platforms/xiaohongshu/schemas.ts index 69dc475..1dc4f9a 100644 --- a/src/platforms/xiaohongshu/schemas.ts +++ b/src/platforms/xiaohongshu/schemas.ts @@ -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, };