新增通知任务运营MCP:手动标记、失败列表与失败重试
This commit is contained in:
@@ -6,7 +6,7 @@ Multi-platform social media automation service that exposes browser-based action
|
|||||||
|
|
||||||
## Features
|
## 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
|
- **REST API** with Bearer token authentication and per-route rate limiting
|
||||||
- **Browser automation** via `rebrowser-playwright` with per-platform serial queueing
|
- **Browser automation** via `rebrowser-playwright` with per-platform serial queueing
|
||||||
- **Cookie persistence** with file-based storage (`0600`, atomic writes)
|
- **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_like` | Toggle like state on a note |
|
||||||
| `xhs_favorite` | Toggle favorite 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_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 |
|
| `xhs_reply_notification` | Reply to a specific notification |
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
|
|||||||
+4
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- 小红书 **17 个 MCP 工具**(登录、浏览、发布、互动、通知、自动化)
|
- 小红书 **20 个 MCP 工具**(登录、浏览、发布、互动、通知、自动化)
|
||||||
- 带 Bearer Token 鉴权与按路由限流的 REST API
|
- 带 Bearer Token 鉴权与按路由限流的 REST API
|
||||||
- 基于 `rebrowser-playwright` 的浏览器自动化,按平台串行队列执行
|
- 基于 `rebrowser-playwright` 的浏览器自动化,按平台串行队列执行
|
||||||
- 文件型 Cookie 持久化(`0600` 权限、原子写入)
|
- 文件型 Cookie 持久化(`0600` 权限、原子写入)
|
||||||
@@ -104,6 +104,9 @@ pnpm test
|
|||||||
| `xhs_like` | 切换点赞状态 |
|
| `xhs_like` | 切换点赞状态 |
|
||||||
| `xhs_favorite` | 切换收藏状态 |
|
| `xhs_favorite` | 切换收藏状态 |
|
||||||
| `xhs_get_unprocessed_notifications` | 从本地 SQLite 获取“未处理”通知任务 |
|
| `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` | 对通知进行回复 |
|
| `xhs_reply_notification` | 对通知进行回复 |
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ import {
|
|||||||
FavoriteSchema,
|
FavoriteSchema,
|
||||||
ReplyNotificationSchema,
|
ReplyNotificationSchema,
|
||||||
GetUnprocessedNotificationsSchema,
|
GetUnprocessedNotificationsSchema,
|
||||||
|
MarkNotificationTaskSchema,
|
||||||
|
ListFailedNotificationTasksSchema,
|
||||||
|
RetryNotificationTaskSchema,
|
||||||
} from './schemas.js';
|
} from './schemas.js';
|
||||||
import type { SearchFilters } from './types.js';
|
import type { SearchFilters } from './types.js';
|
||||||
import type { PlatformPlugin } from '../../server/app.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
|
// xhs_reply_notification
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -219,6 +219,20 @@ export class NotificationStateStore {
|
|||||||
return rows.map((r) => this.rowToTask(r));
|
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 {
|
findOpenFingerprint(userId: string, content: string): string | null {
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
SELECT fingerprint
|
SELECT fingerprint
|
||||||
@@ -281,6 +295,38 @@ export class NotificationStateStore {
|
|||||||
stmt.run(reason ?? 'Ignored by operator', fingerprint);
|
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 {
|
private rowToTask(row: NotificationRow): NotificationTask {
|
||||||
return {
|
return {
|
||||||
fingerprint: row.fingerprint,
|
fingerprint: row.fingerprint,
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export const LikeSchema = {
|
|||||||
/** xhs_list_my_notes — no parameters. */
|
/** xhs_list_my_notes — no parameters. */
|
||||||
export const ListMyNotesSchema = {};
|
export const ListMyNotesSchema = {};
|
||||||
|
|
||||||
// -- Phase 5: Notifications (2 tools) --------------------------------------
|
// -- Phase 5: Notifications & automation -----------------------------------
|
||||||
|
|
||||||
/** xhs_get_comment_notifications */
|
/** xhs_get_comment_notifications */
|
||||||
export const GetCommentNotificationsSchema = {
|
export const GetCommentNotificationsSchema = {
|
||||||
@@ -191,6 +191,40 @@ export const GetUnprocessedNotificationsSchema = {
|
|||||||
.describe('Whether to sync latest notifications from Xiaohongshu before querying local state'),
|
.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 */
|
/** xhs_favorite */
|
||||||
export const FavoriteSchema = {
|
export const FavoriteSchema = {
|
||||||
feed_id: z.string().describe('Feed ID to toggle favorite'),
|
feed_id: z.string().describe('Feed ID to toggle favorite'),
|
||||||
|
|||||||
Reference in New Issue
Block a user