新增评论通知功能:MCP工具 + REST端点 + 前端通知面板

- 新增 xhs_get_comment_notifications / xhs_reply_notification MCP工具
- 通知获取前先读取首页未读小红点数字,无未读则直接返回空,避免重复处理
- 新增 REST 端点 GET /notifications/comments 和 POST /notifications/reply
- 前端小红书页面新增「通知」按钮和 NotificationPanel slide-over 组件
- 通知面板支持查看评论通知列表和行内回复
This commit is contained in:
2026-03-02 18:46:52 +08:00
parent 54a3d9708a
commit 64dbc45265
11 changed files with 637 additions and 0 deletions
+65
View File
@@ -15,6 +15,7 @@ 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 { getCommentNotifications, replyNotification } from './notification.js';
import { createXhsRoutes } from './routes.js';
import {
CheckLoginSchema,
@@ -32,6 +33,8 @@ import {
ReplyCommentSchema,
LikeSchema,
FavoriteSchema,
GetCommentNotificationsSchema,
ReplyNotificationSchema,
} from './schemas.js';
import type { SearchFilters } from './types.js';
import type { PlatformPlugin } from '../../server/app.js';
@@ -588,5 +591,67 @@ export const xiaohongshuPlugin: PlatformPlugin = {
});
},
);
// =====================================================================
// Notifications (2 tools)
// =====================================================================
// -----------------------------------------------------------------------
// xhs_get_comment_notifications
// -----------------------------------------------------------------------
server.tool(
'xhs_get_comment_notifications',
'Get comment and @ notifications from Xiaohongshu notification page',
GetCommentNotificationsSchema,
async (args) => {
return withErrorHandling('xhs_get_comment_notifications', async () => {
const timeoutMs =
config.operationTimeouts['feed_detail'] ??
config.operationTimeouts['default'] ??
60_000;
const notifications = await browser.withPage(
PLATFORM,
async (page) =>
getCommentNotifications(page, args.max_count),
timeoutMs,
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(notifications) }],
};
});
},
);
// -----------------------------------------------------------------------
// xhs_reply_notification
// -----------------------------------------------------------------------
server.tool(
'xhs_reply_notification',
'Reply to a comment notification inline on the Xiaohongshu notification page',
ReplyNotificationSchema,
async (args) => {
return withErrorHandling('xhs_reply_notification', async () => {
const timeoutMs =
config.operationTimeouts['reply'] ??
config.operationTimeouts['default'] ??
20_000;
const result = await browser.withPage(
PLATFORM,
async (page) =>
replyNotification(page, args.user_id, args.comment_content, args.reply_content),
timeoutMs,
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
};
});
},
);
},
};
+220
View File
@@ -0,0 +1,220 @@
import type { Page } from 'rebrowser-playwright';
import { logger } from '../../utils/logger.js';
import { XHS_SELECTORS } from './selectors.js';
import type { CommentNotification } from './types.js';
const log = logger.child({ module: 'xhs-notification' });
const sel = XHS_SELECTORS.notification;
const EXPLORE_URL = 'https://www.xiaohongshu.com/explore';
const NOTIFICATION_URL = 'https://www.xiaohongshu.com/notification';
// ---------------------------------------------------------------------------
// Helper: extract userId from avatar href like /user/profile/xxx?xsecToken=yyy
// ---------------------------------------------------------------------------
function parseUserHref(href: string): { userId: string; xsecToken: string } {
const url = new URL(href, 'https://www.xiaohongshu.com');
const parts = url.pathname.split('/');
const userId = parts[parts.length - 1] || '';
const xsecToken = url.searchParams.get('xsecToken') || '';
return { userId, xsecToken };
}
function parseFeedHref(href: string): { feedId: string; xsecToken: string } {
const url = new URL(href, 'https://www.xiaohongshu.com');
const parts = url.pathname.split('/');
const feedId = parts[parts.length - 1] || '';
const xsecToken = url.searchParams.get('xsecToken') || '';
return { feedId, xsecToken };
}
// ---------------------------------------------------------------------------
// getUnreadCount — read the badge number from the explore page bottom menu
// ---------------------------------------------------------------------------
export async function getUnreadCount(page: Page): Promise<number> {
const currentUrl = page.url();
// Navigate to explore page if not already there
if (!currentUrl.includes('/explore')) {
await page.goto(EXPLORE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
}
const badge = await page.$(sel.unreadBadge);
if (!badge) {
log.info('No unread badge found');
return 0;
}
const text = (await badge.textContent())?.trim() || '';
const count = Number.parseInt(text, 10);
if (Number.isNaN(count) || count <= 0) {
log.info({ badgeText: text }, 'Unread badge text is not a positive number');
return 0;
}
log.info({ unreadCount: count }, 'Unread notification count');
return count;
}
// ---------------------------------------------------------------------------
// getCommentNotifications
// ---------------------------------------------------------------------------
export async function getCommentNotifications(
page: Page,
maxCount = 20,
): Promise<CommentNotification[]> {
// 1. Check unread count on explore page first
const unreadCount = await getUnreadCount(page);
if (unreadCount === 0) {
log.info('No unread notifications, returning empty');
return [];
}
// Use the smaller of unreadCount and maxCount
const limit = Math.min(unreadCount, maxCount);
log.info({ unreadCount, maxCount, limit }, 'Fetching comment notifications');
// 2. Navigate to notification page
await page.goto(NOTIFICATION_URL, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(sel.container, { timeout: 15_000 });
// Small delay for DOM to settle
await page.waitForTimeout(1000);
const containers = await page.$$(sel.container);
log.info({ count: containers.length }, 'Found notification containers');
const results: CommentNotification[] = [];
for (const container of containers) {
if (results.length >= limit) break;
// Check interaction hint — only include comment/@ notifications
const hintEl = await container.$(sel.interactionHint);
const hintText = hintEl ? (await hintEl.textContent())?.trim() || '' : '';
if (!hintText.includes('评论') && !hintText.includes('@')) continue;
// Extract user info from avatar link
const avatarLink = await container.$(sel.userAvatar);
const avatarHref = avatarLink ? await avatarLink.getAttribute('href') : '';
const avatarImg = avatarLink ? await avatarLink.$('img') : null;
const avatarSrc = avatarImg ? (await avatarImg.getAttribute('src')) || '' : '';
const { userId } = avatarHref ? parseUserHref(avatarHref) : { userId: '' };
// Nickname
const nameEl = await container.$(sel.userName);
const nickname = nameEl ? (await nameEl.textContent())?.trim() || '' : '';
// Comment content
const contentEl = await container.$(sel.interactionContent);
const content = contentEl ? (await contentEl.textContent())?.trim() || '' : '';
// Time
const timeEl = await container.$(sel.interactionTime);
const time = timeEl ? (await timeEl.textContent())?.trim() || '' : '';
// Feed info from thumbnail link
const extraImg = await container.$(sel.extraImage);
const noteImage = extraImg ? (await extraImg.getAttribute('src')) || '' : '';
const extraHref = extraImg
? await extraImg.evaluate((el) => {
const anchor = el.closest('a');
return anchor ? anchor.href : '';
})
: '';
const { feedId, xsecToken } = extraHref
? parseFeedHref(extraHref)
: { feedId: '', xsecToken: '' };
results.push({
userId,
nickname,
avatar: avatarSrc,
content,
type: hintText,
time,
feedId,
xsecToken,
noteImage,
});
}
log.info({ resultCount: results.length }, 'Comment notifications extracted');
return results;
}
// ---------------------------------------------------------------------------
// replyNotification
// ---------------------------------------------------------------------------
export async function replyNotification(
page: Page,
userId: string,
commentContent: string,
replyContent: string,
): Promise<{ success: boolean }> {
log.info({ userId, commentContent: commentContent.slice(0, 30) }, 'Replying to notification');
await page.goto(NOTIFICATION_URL, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(sel.container, { timeout: 15_000 });
await page.waitForTimeout(1000);
const containers = await page.$$(sel.container);
let targetContainer: Awaited<ReturnType<Page['$']>> = null;
for (const container of containers) {
// Match by userId
const avatarLink = await container.$(sel.userAvatar);
const avatarHref = avatarLink ? await avatarLink.getAttribute('href') : '';
const { userId: uid } = avatarHref ? parseUserHref(avatarHref) : { userId: '' };
if (uid !== userId) continue;
// Match by comment content
const contentEl = await container.$(sel.interactionContent);
const content = contentEl ? (await contentEl.textContent())?.trim() || '' : '';
if (content !== commentContent) continue;
targetContainer = container;
break;
}
if (!targetContainer) {
log.warn({ userId, commentContent }, 'Target notification not found');
return { success: false };
}
// Click the reply button
const replyBtn = await targetContainer.$(sel.replyButton);
if (!replyBtn) {
log.warn('Reply button not found on target notification');
return { success: false };
}
await replyBtn.click();
// Wait for the reply textarea to appear
const textarea = await targetContainer.waitForSelector(sel.replyInput, { timeout: 5_000 });
if (!textarea) {
log.warn('Reply textarea did not appear');
return { success: false };
}
// Type the reply
await textarea.fill(replyContent);
await page.waitForTimeout(300);
// Click submit
const submitBtn = await targetContainer.$(sel.replySubmit);
if (!submitBtn) {
log.warn('Reply submit button not found');
return { success: false };
}
await submitBtn.click();
await page.waitForTimeout(1000);
log.info('Reply submitted successfully');
return { success: true };
}
+72
View File
@@ -19,6 +19,7 @@ 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 { getCommentNotifications, replyNotification } from './notification.js';
import {
SearchSchema,
@@ -31,6 +32,8 @@ import {
ReplyCommentSchema,
LikeSchema,
FavoriteSchema,
GetCommentNotificationsSchema,
ReplyNotificationSchema,
} from './schemas.js';
import type { SearchFilters } from './types.js';
@@ -111,6 +114,16 @@ const ReplyCommentBodySchema = z.object({
user_id: ReplyCommentSchema.user_id,
});
const GetCommentNotificationsQuerySchema = z.object({
max_count: GetCommentNotificationsSchema.max_count,
});
const ReplyNotificationBodySchema = z.object({
user_id: ReplyNotificationSchema.user_id,
comment_content: ReplyNotificationSchema.comment_content,
reply_content: ReplyNotificationSchema.reply_content,
});
const LikeBodySchema = z.object({
feed_id: LikeSchema.feed_id,
xsec_token: LikeSchema.xsec_token,
@@ -616,6 +629,65 @@ export function createXhsRoutes(browser: BrowserManager): Router {
})();
});
// =========================================================================
// Notifications
// =========================================================================
// -----------------------------------------------------------------------
// GET /notifications/comments
// -----------------------------------------------------------------------
router.get('/notifications/comments', readRateLimiter, (req, res) => {
void (async () => {
try {
const query = GetCommentNotificationsQuerySchema.parse({
max_count: req.query.max_count ? Number(req.query.max_count) : undefined,
});
const timeoutMs =
config.operationTimeouts['feed_detail'] ??
config.operationTimeouts['default'] ??
60_000;
const notifications = await browser.withPage(
PLATFORM,
async (page) => getCommentNotifications(page, query.max_count),
timeoutMs,
);
res.json(successResponse(notifications) as ApiResponse<typeof notifications>);
} catch (err) {
handleError(res, err);
}
})();
});
// -----------------------------------------------------------------------
// POST /notifications/reply
// -----------------------------------------------------------------------
router.post('/notifications/reply', writeRateLimiter, (req, res) => {
void (async () => {
try {
const body = ReplyNotificationBodySchema.parse(req.body);
const timeoutMs =
config.operationTimeouts['reply'] ??
config.operationTimeouts['default'] ??
20_000;
const result = await browser.withPage(
PLATFORM,
async (page) =>
replyNotification(page, body.user_id, body.comment_content, body.reply_content),
timeoutMs,
);
res.json(successResponse(result) as ApiResponse<typeof result>);
} catch (err) {
handleError(res, err);
}
})();
});
return router;
}
+21
View File
@@ -145,6 +145,27 @@ export const LikeSchema = {
/** xhs_list_my_notes — no parameters. */
export const ListMyNotesSchema = {};
// -- Phase 5: Notifications (2 tools) --------------------------------------
/** xhs_get_comment_notifications */
export const GetCommentNotificationsSchema = {
max_count: z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(20)
.describe('Maximum number of notifications to return (150, default 20)'),
};
/** xhs_reply_notification */
export const ReplyNotificationSchema = {
user_id: z.string().describe('User ID of the comment author (from notification)'),
comment_content: z.string().describe('Original comment content to match the notification'),
reply_content: z.string().min(1).describe('Reply text to send'),
};
/** xhs_favorite */
export const FavoriteSchema = {
feed_id: z.string().describe('Feed ID to toggle favorite'),
+27
View File
@@ -187,6 +187,33 @@ export const XHS_SELECTORS = {
// -- Phase 4: Interaction (Like / Favorite) --------------------------------
// -- Phase 5: Notification ------------------------------------------------
notification: {
/** Each notification item container. */
container: '.container',
/** User avatar link (href contains userId + xsecToken). */
userAvatar: 'a.user-avatar',
/** User name link. */
userName: '.user-info a',
/** Interaction type hint (e.g. "评论了你的笔记"). */
interactionHint: '.interaction-hint span:first-child',
/** Notification time. */
interactionTime: '.interaction-time',
/** Comment content text. */
interactionContent: '.interaction-content',
/** Note thumbnail image (parent link href contains feedId + xsecToken). */
extraImage: '.extra img',
/** Reply button to expand inline reply. */
replyButton: '.action-reply',
/** Reply textarea that appears after clicking reply. */
replyInput: 'textarea.comment-input',
/** Reply submit button. */
replySubmit: 'button.submit',
/** Unread badge on the explore page bottom menu. */
unreadBadge: '#global > div.main-container > div.bottom-menu > div > li.link-wrapper.bottom-channel > a > div > div',
},
interaction: {
/** Like button on the feed detail page. */
likeButton: '.engage-bar-style .like-wrapper',
+14
View File
@@ -92,6 +92,20 @@ export interface UserProfile {
feeds: Feed[];
}
// -- Comment Notification -------------------------------------------------
export interface CommentNotification {
userId: string;
nickname: string;
avatar: string;
content: string;
type: string;
time: string;
feedId: string;
xsecToken: string;
noteImage: string;
}
// -- Search Filters -------------------------------------------------------
export interface SearchFilters {