新增通知任务运营MCP:手动标记、失败列表与失败重试
This commit is contained in:
@@ -6,7 +6,7 @@ Multi-platform social media automation service that exposes browser-based action
|
||||
|
||||
## Features
|
||||
|
||||
- **17 MCP tools** for Xiaohongshu (login, browsing, publishing, interactions, notifications, automation)
|
||||
- **20 MCP tools** for Xiaohongshu (login, browsing, publishing, interactions, notifications, automation)
|
||||
- **REST API** with Bearer token authentication and per-route rate limiting
|
||||
- **Browser automation** via `rebrowser-playwright` with per-platform serial queueing
|
||||
- **Cookie persistence** with file-based storage (`0600`, atomic writes)
|
||||
@@ -104,6 +104,9 @@ Add this in `claude_desktop_config.json`:
|
||||
| `xhs_like` | Toggle like state on a note |
|
||||
| `xhs_favorite` | Toggle favorite state on a note |
|
||||
| `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_list_failed_notification_tasks` | List failed notification tasks for triage/retry |
|
||||
| `xhs_retry_notification_task` | Retry a failed notification task by fingerprint |
|
||||
| `xhs_reply_notification` | Reply to a specific notification |
|
||||
|
||||
## REST API
|
||||
|
||||
+4
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 小红书 **17 个 MCP 工具**(登录、浏览、发布、互动、通知、自动化)
|
||||
- 小红书 **20 个 MCP 工具**(登录、浏览、发布、互动、通知、自动化)
|
||||
- 带 Bearer Token 鉴权与按路由限流的 REST API
|
||||
- 基于 `rebrowser-playwright` 的浏览器自动化,按平台串行队列执行
|
||||
- 文件型 Cookie 持久化(`0600` 权限、原子写入)
|
||||
@@ -104,6 +104,9 @@ pnpm test
|
||||
| `xhs_like` | 切换点赞状态 |
|
||||
| `xhs_favorite` | 切换收藏状态 |
|
||||
| `xhs_get_unprocessed_notifications` | 从本地 SQLite 获取“未处理”通知任务 |
|
||||
| `xhs_mark_notification_task` | 手动标记通知任务状态(new/pending/ignored/replied/failed) |
|
||||
| `xhs_list_failed_notification_tasks` | 获取失败通知任务列表(用于排障/重试) |
|
||||
| `xhs_retry_notification_task` | 按 fingerprint 重试失败通知任务 |
|
||||
| `xhs_reply_notification` | 对通知进行回复 |
|
||||
|
||||
## REST API
|
||||
|
||||
@@ -37,6 +37,9 @@ import {
|
||||
FavoriteSchema,
|
||||
ReplyNotificationSchema,
|
||||
GetUnprocessedNotificationsSchema,
|
||||
MarkNotificationTaskSchema,
|
||||
ListFailedNotificationTasksSchema,
|
||||
RetryNotificationTaskSchema,
|
||||
} from './schemas.js';
|
||||
import type { SearchFilters } from './types.js';
|
||||
import type { PlatformPlugin } from '../../server/app.js';
|
||||
@@ -601,7 +604,7 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
||||
);
|
||||
|
||||
// =====================================================================
|
||||
// Notifications (2 tools)
|
||||
// Notifications (5 tools)
|
||||
// =====================================================================
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -639,6 +642,129 @@ export const xiaohongshuPlugin: PlatformPlugin = {
|
||||
},
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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 {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
task: updated,
|
||||
}),
|
||||
}],
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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 tasks = getNotificationStateStore().listByStatuses(['failed'], args.max_count);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ tasks }),
|
||||
}],
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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 store = getNotificationStateStore();
|
||||
const task = store.getByFingerprint(args.fingerprint);
|
||||
if (!task) {
|
||||
throw new Error(`Notification task not found: ${args.fingerprint}`);
|
||||
}
|
||||
if (task.status !== 'failed') {
|
||||
throw new Error(`Task status must be failed to retry, got: ${task.status}`);
|
||||
}
|
||||
|
||||
const replyContent = args.reply_content ?? task.replyContent;
|
||||
if (!replyContent) {
|
||||
throw new Error(
|
||||
'Retry requires reply_content when task has no stored replyContent',
|
||||
);
|
||||
}
|
||||
|
||||
store.markPending(args.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(args.fingerprint, replyContent);
|
||||
} else {
|
||||
store.markFailed(args.fingerprint, 'Retry reply returned success=false');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
...result,
|
||||
fingerprint: args.fingerprint,
|
||||
}),
|
||||
}],
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
store.markFailed(args.fingerprint, message);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// xhs_reply_notification
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -219,6 +219,20 @@ export class NotificationStateStore {
|
||||
return rows.map((r) => this.rowToTask(r));
|
||||
}
|
||||
|
||||
getByFingerprint(fingerprint: string): NotificationTask | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
fingerprint, user_id, nickname, avatar, content, type, time,
|
||||
feed_id, xsec_token, note_image, status, first_seen_at, last_seen_at,
|
||||
retry_count, last_attempt_at, replied_at, reply_content, error_message
|
||||
FROM notification_tasks
|
||||
WHERE fingerprint = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
const row = stmt.get(fingerprint) as NotificationRow | undefined;
|
||||
return row ? this.rowToTask(row) : null;
|
||||
}
|
||||
|
||||
findOpenFingerprint(userId: string, content: string): string | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT fingerprint
|
||||
@@ -281,6 +295,38 @@ export class NotificationStateStore {
|
||||
stmt.run(reason ?? 'Ignored by operator', fingerprint);
|
||||
}
|
||||
|
||||
setStatus(
|
||||
fingerprint: string,
|
||||
status: NotificationTaskStatus,
|
||||
note?: string,
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE notification_tasks
|
||||
SET
|
||||
status = ?,
|
||||
last_attempt_at = CASE WHEN ? IN ('pending', 'failed', 'replied') THEN ? ELSE last_attempt_at END,
|
||||
replied_at = CASE WHEN ? = 'replied' THEN ? ELSE replied_at END,
|
||||
error_message = CASE WHEN ? = 'failed' THEN COALESCE(?, 'Marked as failed') WHEN ? = 'ignored' THEN COALESCE(?, 'Ignored by operator') ELSE error_message END,
|
||||
reply_content = CASE WHEN ? = 'replied' THEN COALESCE(?, reply_content) ELSE reply_content END
|
||||
WHERE fingerprint = ?
|
||||
`);
|
||||
stmt.run(
|
||||
status,
|
||||
status,
|
||||
now,
|
||||
status,
|
||||
now,
|
||||
status,
|
||||
note ?? null,
|
||||
status,
|
||||
note ?? null,
|
||||
status,
|
||||
note ?? null,
|
||||
fingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
private rowToTask(row: NotificationRow): NotificationTask {
|
||||
return {
|
||||
fingerprint: row.fingerprint,
|
||||
|
||||
@@ -145,7 +145,7 @@ export const LikeSchema = {
|
||||
/** xhs_list_my_notes — no parameters. */
|
||||
export const ListMyNotesSchema = {};
|
||||
|
||||
// -- Phase 5: Notifications (2 tools) --------------------------------------
|
||||
// -- Phase 5: Notifications & automation -----------------------------------
|
||||
|
||||
/** xhs_get_comment_notifications */
|
||||
export const GetCommentNotificationsSchema = {
|
||||
@@ -191,6 +191,40 @@ export const GetUnprocessedNotificationsSchema = {
|
||||
.describe('Whether to sync latest notifications from Xiaohongshu before querying local state'),
|
||||
};
|
||||
|
||||
/** xhs_mark_notification_task */
|
||||
export const MarkNotificationTaskSchema = {
|
||||
fingerprint: z.string().describe('Notification task fingerprint'),
|
||||
status: z
|
||||
.enum(['new', 'pending', 'ignored', 'replied', 'failed'])
|
||||
.describe('Target status for this notification task'),
|
||||
note: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional note/reason (used as reply_content for replied, or error_message for failed/ignored)'),
|
||||
};
|
||||
|
||||
/** xhs_list_failed_notification_tasks */
|
||||
export const ListFailedNotificationTasksSchema = {
|
||||
max_count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.optional()
|
||||
.default(20)
|
||||
.describe('Maximum number of failed tasks to return (1–200, default 20)'),
|
||||
};
|
||||
|
||||
/** xhs_retry_notification_task */
|
||||
export const RetryNotificationTaskSchema = {
|
||||
fingerprint: z.string().describe('Notification task fingerprint to retry'),
|
||||
reply_content: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Optional override reply text. If omitted, uses stored reply_content from previous attempt.'),
|
||||
};
|
||||
|
||||
/** xhs_favorite */
|
||||
export const FavoriteSchema = {
|
||||
feed_id: z.string().describe('Feed ID to toggle favorite'),
|
||||
|
||||
Reference in New Issue
Block a user