diff --git a/src/platforms/xiaohongshu/index.ts b/src/platforms/xiaohongshu/index.ts index f4f6ee4..cab82ce 100644 --- a/src/platforms/xiaohongshu/index.ts +++ b/src/platforms/xiaohongshu/index.ts @@ -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) }], + }; + }); + }, + ); }, }; diff --git a/src/platforms/xiaohongshu/notification.ts b/src/platforms/xiaohongshu/notification.ts new file mode 100644 index 0000000..8ce911b --- /dev/null +++ b/src/platforms/xiaohongshu/notification.ts @@ -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 { + 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 { + // 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> = 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 }; +} diff --git a/src/platforms/xiaohongshu/routes.ts b/src/platforms/xiaohongshu/routes.ts index 41d504a..c03f1ab 100644 --- a/src/platforms/xiaohongshu/routes.ts +++ b/src/platforms/xiaohongshu/routes.ts @@ -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); + } 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); + } catch (err) { + handleError(res, err); + } + })(); + }); + return router; } diff --git a/src/platforms/xiaohongshu/schemas.ts b/src/platforms/xiaohongshu/schemas.ts index 656e705..f040bef 100644 --- a/src/platforms/xiaohongshu/schemas.ts +++ b/src/platforms/xiaohongshu/schemas.ts @@ -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 (1–50, 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'), diff --git a/src/platforms/xiaohongshu/selectors.ts b/src/platforms/xiaohongshu/selectors.ts index c000495..66582c1 100644 --- a/src/platforms/xiaohongshu/selectors.ts +++ b/src/platforms/xiaohongshu/selectors.ts @@ -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', diff --git a/src/platforms/xiaohongshu/types.ts b/src/platforms/xiaohongshu/types.ts index 8ea154b..e9ceb48 100644 --- a/src/platforms/xiaohongshu/types.ts +++ b/src/platforms/xiaohongshu/types.ts @@ -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 { diff --git a/web/src/api/endpoints.ts b/web/src/api/endpoints.ts index c93024b..bcdbdf7 100644 --- a/web/src/api/endpoints.ts +++ b/web/src/api/endpoints.ts @@ -11,6 +11,7 @@ import type { ApiResponse, PublishResult, CommentResult, + CommentNotification, } from './types'; // Health (no auth required) @@ -112,6 +113,20 @@ export const replyComment = (data: { body: JSON.stringify(data), }); +// Notifications +export const getCommentNotifications = (maxCount = 20) => + apiFetch>(`/api/xhs/notifications/comments?max_count=${maxCount}`); + +export const replyNotification = (data: { + user_id: string; + comment_content: string; + reply_content: string; +}) => + apiFetch>('/api/xhs/notifications/reply', { + method: 'POST', + body: JSON.stringify(data), + }); + export const toggleLike = (feedId: string, xsecToken: string) => apiFetch>('/api/xhs/like', { method: 'POST', diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 08225f0..4e4e507 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -77,6 +77,18 @@ export interface UserProfile { feeds: Feed[]; } +export interface CommentNotification { + userId: string; + nickname: string; + avatar: string; + content: string; + type: string; + time: string; + feedId: string; + xsecToken: string; + noteImage: string; +} + export interface SearchFilters { sort?: 'general' | 'time_descending' | 'popularity_descending'; type?: 'all' | 'note' | 'video'; diff --git a/web/src/components/feed/NotificationPanel.tsx b/web/src/components/feed/NotificationPanel.tsx new file mode 100644 index 0000000..7101e66 --- /dev/null +++ b/web/src/components/feed/NotificationPanel.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect } from 'react'; +import { getCommentNotifications, replyNotification } from '@/api/endpoints'; +import type { CommentNotification } from '@/api/types'; +import { Badge } from '@/components/ui/Badge'; +import { Spinner } from '@/components/ui/Spinner'; +import { Button } from '@/components/ui/Button'; +import { Textarea } from '@/components/ui/Textarea'; +import { useToast } from '@/context/ToastContext'; +import { formatTime } from '@/lib/formatters'; + +interface Props { + onClose: () => void; +} + +export function NotificationPanel({ onClose }: Props) { + const { toast } = useToast(); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [replyingId, setReplyingId] = useState(null); + const [replyText, setReplyText] = useState(''); + const [sendingReply, setSendingReply] = useState(false); + + const load = async () => { + setLoading(true); + setError(null); + try { + const res = await getCommentNotifications(20); + if (res.success && res.data) { + setNotifications(res.data); + } else { + setError(res.error?.message || '加载通知失败'); + } + } catch (err) { + setError(err instanceof Error ? err.message : '加载通知失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handleReply = async (n: CommentNotification) => { + if (!replyText.trim()) { + toast('warning', '回复内容不能为空'); + return; + } + setSendingReply(true); + try { + const res = await replyNotification({ + user_id: n.userId, + comment_content: n.content, + reply_content: replyText, + }); + if (res.success) { + toast('success', '回复成功'); + setReplyingId(null); + setReplyText(''); + } else { + toast('error', res.error?.message || '回复失败'); + } + } catch (err) { + toast('error', err instanceof Error ? err.message : '回复失败'); + } finally { + setSendingReply(false); + } + }; + + const uniqueKey = (n: CommentNotification, i: number) => + `${n.userId}-${n.feedId}-${i}`; + + return ( +
+
+
+ {/* Sticky header */} +
+

评论通知

+
+ + +
+
+ + {/* Loading */} + {loading && ( +
+ )} + + {/* Error */} + {error && ( +
{error}
+ )} + + {/* Empty */} + {!loading && !error && notifications.length === 0 && ( +
暂无评论通知
+ )} + + {/* Notification list */} + {!loading && notifications.length > 0 && ( +
+ {notifications.map((n, i) => { + const key = uniqueKey(n, i); + const isReplying = replyingId === key; + return ( +
+ {/* Header row */} +
+ {n.avatar ? ( + + ) : ( +
+ {n.nickname?.[0]?.toUpperCase() ?? '?'} +
+ )} +
+
+ {n.nickname} + {n.type} +
+

{n.content}

+

{formatTime(n.time)}

+
+ {/* Note thumbnail */} + {n.noteImage && ( + + )} +
+ + {/* Reply toggle */} + {!isReplying ? ( + + ) : ( +
+