import type { Page } from 'rebrowser-playwright'; import { logger } from '@social/core/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 }; }