221 lines
7.6 KiB
TypeScript
221 lines
7.6 KiB
TypeScript
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<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 };
|
|
}
|