|
|
@@ -0,0 +1,1139 @@
|
|
|
+/// <reference lib="dom" />
|
|
|
+import { BasePlatformAdapter } from './base.js';
|
|
|
+import type {
|
|
|
+ AccountProfile,
|
|
|
+ PublishParams,
|
|
|
+ PublishResult,
|
|
|
+ DateRange,
|
|
|
+ AnalyticsData,
|
|
|
+ CommentData,
|
|
|
+ WorkItem,
|
|
|
+} from './base.js';
|
|
|
+import type { PlatformType, QRCodeInfo, LoginStatusResult } from '@media-manager/shared';
|
|
|
+import { logger } from '../../utils/logger.js';
|
|
|
+
|
|
|
+// 小红书 Python API 服务配置
|
|
|
+const XHS_PYTHON_SERVICE_URL = process.env.XHS_SERVICE_URL || 'http://localhost:5005';
|
|
|
+
|
|
|
+/**
|
|
|
+ * 小红书平台适配器
|
|
|
+ */
|
|
|
+export class XiaohongshuAdapter extends BasePlatformAdapter {
|
|
|
+ readonly platform: PlatformType = 'xiaohongshu';
|
|
|
+ readonly loginUrl = 'https://creator.xiaohongshu.com/';
|
|
|
+ readonly publishUrl = 'https://creator.xiaohongshu.com/publish/publish';
|
|
|
+ readonly creatorHomeUrl = 'https://creator.xiaohongshu.com/creator/home';
|
|
|
+ readonly contentManageUrl = 'https://creator.xiaohongshu.com/creator/content';
|
|
|
+
|
|
|
+ protected getCookieDomain(): string {
|
|
|
+ return '.xiaohongshu.com';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取扫码登录二维码
|
|
|
+ */
|
|
|
+ async getQRCode(): Promise<QRCodeInfo> {
|
|
|
+ try {
|
|
|
+ await this.initBrowser();
|
|
|
+
|
|
|
+ if (!this.page) throw new Error('Page not initialized');
|
|
|
+
|
|
|
+ // 访问创作者中心
|
|
|
+ await this.page.goto(this.loginUrl);
|
|
|
+
|
|
|
+ // 等待二维码出现
|
|
|
+ await this.waitForSelector('[class*="qrcode"] img, .qrcode-image img', 30000);
|
|
|
+
|
|
|
+ // 获取二维码图片
|
|
|
+ const qrcodeImg = await this.page.$('[class*="qrcode"] img, .qrcode-image img');
|
|
|
+ const qrcodeUrl = await qrcodeImg?.getAttribute('src');
|
|
|
+
|
|
|
+ if (!qrcodeUrl) {
|
|
|
+ throw new Error('Failed to get QR code');
|
|
|
+ }
|
|
|
+
|
|
|
+ const qrcodeKey = `xiaohongshu_${Date.now()}`;
|
|
|
+
|
|
|
+ return {
|
|
|
+ qrcodeUrl,
|
|
|
+ qrcodeKey,
|
|
|
+ expireTime: Date.now() + 300000, // 5分钟过期
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Xiaohongshu getQRCode error:', error);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查扫码状态
|
|
|
+ */
|
|
|
+ async checkQRCodeStatus(qrcodeKey: string): Promise<LoginStatusResult> {
|
|
|
+ try {
|
|
|
+ if (!this.page) {
|
|
|
+ return { status: 'expired', message: '二维码已过期' };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否登录成功(URL 变化)
|
|
|
+ const currentUrl = this.page.url();
|
|
|
+
|
|
|
+ if (currentUrl.includes('/creator/home') || currentUrl.includes('/publish')) {
|
|
|
+ // 登录成功,获取 cookie
|
|
|
+ const cookies = await this.getCookies();
|
|
|
+ await this.closeBrowser();
|
|
|
+
|
|
|
+ return {
|
|
|
+ status: 'success',
|
|
|
+ message: '登录成功',
|
|
|
+ cookies,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否扫码
|
|
|
+ const scanTip = await this.page.$('[class*="scan-success"], [class*="scanned"]');
|
|
|
+ if (scanTip) {
|
|
|
+ return { status: 'scanned', message: '已扫码,请确认登录' };
|
|
|
+ }
|
|
|
+
|
|
|
+ return { status: 'waiting', message: '等待扫码' };
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Xiaohongshu checkQRCodeStatus error:', error);
|
|
|
+ return { status: 'error', message: '检查状态失败' };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查登录状态
|
|
|
+ */
|
|
|
+ async checkLoginStatus(cookies: string): Promise<boolean> {
|
|
|
+ try {
|
|
|
+ await this.initBrowser({ headless: true });
|
|
|
+ await this.setCookies(cookies);
|
|
|
+
|
|
|
+ if (!this.page) throw new Error('Page not initialized');
|
|
|
+
|
|
|
+ // 访问创作者中心
|
|
|
+ await this.page.goto(this.creatorHomeUrl, {
|
|
|
+ waitUntil: 'domcontentloaded',
|
|
|
+ timeout: 30000,
|
|
|
+ });
|
|
|
+
|
|
|
+ await this.page.waitForTimeout(3000);
|
|
|
+
|
|
|
+ const url = this.page.url();
|
|
|
+ logger.info(`Xiaohongshu checkLoginStatus URL: ${url}`);
|
|
|
+
|
|
|
+ // 如果被重定向到登录页面,说明未登录
|
|
|
+ const isLoginPage = url.includes('login') || url.includes('passport');
|
|
|
+
|
|
|
+ await this.closeBrowser();
|
|
|
+
|
|
|
+ return !isLoginPage;
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Xiaohongshu checkLoginStatus error:', error);
|
|
|
+ await this.closeBrowser();
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取账号信息
|
|
|
+ */
|
|
|
+ async getAccountInfo(cookies: string): Promise<AccountProfile> {
|
|
|
+ try {
|
|
|
+ await this.initBrowser({ headless: true });
|
|
|
+ await this.setCookies(cookies);
|
|
|
+
|
|
|
+ if (!this.page) throw new Error('Page not initialized');
|
|
|
+
|
|
|
+ let accountId = `xiaohongshu_${Date.now()}`;
|
|
|
+ let accountName = '小红书账号';
|
|
|
+ let avatarUrl = '';
|
|
|
+ let fansCount = 0;
|
|
|
+ let worksCount = 0;
|
|
|
+ let worksList: WorkItem[] = [];
|
|
|
+
|
|
|
+ // 用于捕获 API 响应
|
|
|
+ const capturedData: {
|
|
|
+ userInfo?: {
|
|
|
+ nickname?: string;
|
|
|
+ avatar?: string;
|
|
|
+ userId?: string;
|
|
|
+ redId?: string;
|
|
|
+ fans?: number;
|
|
|
+ notes?: number;
|
|
|
+ };
|
|
|
+ } = {};
|
|
|
+
|
|
|
+ // 设置 API 响应监听器
|
|
|
+ this.page.on('response', async (response) => {
|
|
|
+ const url = response.url();
|
|
|
+ try {
|
|
|
+ // 监听用户信息 API
|
|
|
+ if (url.includes('/api/galaxy/creator/home/personal_info') ||
|
|
|
+ url.includes('/api/sns/web/v1/user/selfinfo') ||
|
|
|
+ url.includes('/user/selfinfo')) {
|
|
|
+ const data = await response.json();
|
|
|
+ logger.info(`[Xiaohongshu API] User info response:`, JSON.stringify(data).slice(0, 500));
|
|
|
+
|
|
|
+ const userInfo = data?.data?.user_info || data?.data || data;
|
|
|
+ if (userInfo) {
|
|
|
+ capturedData.userInfo = {
|
|
|
+ nickname: userInfo.nickname || userInfo.name || userInfo.userName,
|
|
|
+ avatar: userInfo.image || userInfo.avatar || userInfo.images,
|
|
|
+ userId: userInfo.user_id || userInfo.userId,
|
|
|
+ redId: userInfo.red_id || userInfo.redId,
|
|
|
+ fans: userInfo.fans || userInfo.fansCount,
|
|
|
+ notes: userInfo.notes || userInfo.noteCount,
|
|
|
+ };
|
|
|
+ logger.info(`[Xiaohongshu API] Captured user info:`, capturedData.userInfo);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 监听创作者主页数据
|
|
|
+ if (url.includes('/api/galaxy/creator/home/home_page') ||
|
|
|
+ url.includes('/api/galaxy/creator/data')) {
|
|
|
+ const data = await response.json();
|
|
|
+ logger.info(`[Xiaohongshu API] Creator home response:`, JSON.stringify(data).slice(0, 500));
|
|
|
+
|
|
|
+ if (data?.data) {
|
|
|
+ const homeData = data.data;
|
|
|
+ if (homeData.fans_count !== undefined) {
|
|
|
+ capturedData.userInfo = capturedData.userInfo || {};
|
|
|
+ capturedData.userInfo.fans = homeData.fans_count;
|
|
|
+ }
|
|
|
+ if (homeData.note_count !== undefined) {
|
|
|
+ capturedData.userInfo = capturedData.userInfo || {};
|
|
|
+ capturedData.userInfo.notes = homeData.note_count;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 忽略非 JSON 响应
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 访问创作者中心
|
|
|
+ logger.info('[Xiaohongshu] Navigating to creator center...');
|
|
|
+ await this.page.goto(this.creatorHomeUrl, {
|
|
|
+ waitUntil: 'domcontentloaded',
|
|
|
+ timeout: 30000,
|
|
|
+ });
|
|
|
+
|
|
|
+ await this.page.waitForTimeout(3000);
|
|
|
+
|
|
|
+ // 检查是否需要登录
|
|
|
+ const currentUrl = this.page.url();
|
|
|
+ if (currentUrl.includes('login') || currentUrl.includes('passport')) {
|
|
|
+ logger.warn('[Xiaohongshu] Cookie expired, needs login');
|
|
|
+ await this.closeBrowser();
|
|
|
+ return {
|
|
|
+ accountId,
|
|
|
+ accountName,
|
|
|
+ avatarUrl,
|
|
|
+ fansCount,
|
|
|
+ worksCount,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 等待 API 响应
|
|
|
+ await this.page.waitForTimeout(3000);
|
|
|
+
|
|
|
+ // 使用捕获的数据
|
|
|
+ if (capturedData.userInfo) {
|
|
|
+ if (capturedData.userInfo.nickname) {
|
|
|
+ accountName = capturedData.userInfo.nickname;
|
|
|
+ }
|
|
|
+ if (capturedData.userInfo.avatar) {
|
|
|
+ avatarUrl = capturedData.userInfo.avatar;
|
|
|
+ }
|
|
|
+ if (capturedData.userInfo.userId) {
|
|
|
+ accountId = `xiaohongshu_${capturedData.userInfo.userId}`;
|
|
|
+ } else if (capturedData.userInfo.redId) {
|
|
|
+ accountId = `xiaohongshu_${capturedData.userInfo.redId}`;
|
|
|
+ }
|
|
|
+ if (capturedData.userInfo.fans) {
|
|
|
+ fansCount = capturedData.userInfo.fans;
|
|
|
+ }
|
|
|
+ if (capturedData.userInfo.notes) {
|
|
|
+ worksCount = capturedData.userInfo.notes;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 尝试获取作品列表
|
|
|
+ try {
|
|
|
+ await this.page.goto(this.contentManageUrl, {
|
|
|
+ waitUntil: 'domcontentloaded',
|
|
|
+ timeout: 30000,
|
|
|
+ });
|
|
|
+ await this.page.waitForTimeout(3000);
|
|
|
+
|
|
|
+ worksList = await this.page.evaluate(() => {
|
|
|
+ const items: WorkItem[] = [];
|
|
|
+ const cards = document.querySelectorAll('[class*="note-item"], [class*="content-item"]');
|
|
|
+
|
|
|
+ cards.forEach((card) => {
|
|
|
+ try {
|
|
|
+ const coverImg = card.querySelector('img');
|
|
|
+ const coverUrl = coverImg?.src || '';
|
|
|
+
|
|
|
+ const titleEl = card.querySelector('[class*="title"], [class*="desc"]');
|
|
|
+ const title = titleEl?.textContent?.trim() || '无标题';
|
|
|
+
|
|
|
+ const timeEl = card.querySelector('[class*="time"], [class*="date"]');
|
|
|
+ const publishTime = timeEl?.textContent?.trim() || '';
|
|
|
+
|
|
|
+ const statusEl = card.querySelector('[class*="status"]');
|
|
|
+ const status = statusEl?.textContent?.trim() || '';
|
|
|
+
|
|
|
+ // 获取数据指标
|
|
|
+ const statsEl = card.querySelector('[class*="stats"], [class*="data"]');
|
|
|
+ const statsText = statsEl?.textContent || '';
|
|
|
+
|
|
|
+ const likeMatch = statsText.match(/(\d+)\s*赞/);
|
|
|
+ const commentMatch = statsText.match(/(\d+)\s*评/);
|
|
|
+ const collectMatch = statsText.match(/(\d+)\s*藏/);
|
|
|
+
|
|
|
+ items.push({
|
|
|
+ title,
|
|
|
+ coverUrl,
|
|
|
+ duration: '',
|
|
|
+ publishTime,
|
|
|
+ status,
|
|
|
+ playCount: 0,
|
|
|
+ likeCount: likeMatch ? parseInt(likeMatch[1]) : 0,
|
|
|
+ commentCount: commentMatch ? parseInt(commentMatch[1]) : 0,
|
|
|
+ shareCount: collectMatch ? parseInt(collectMatch[1]) : 0,
|
|
|
+ });
|
|
|
+ } catch {}
|
|
|
+ });
|
|
|
+
|
|
|
+ return items;
|
|
|
+ });
|
|
|
+
|
|
|
+ logger.info(`[Xiaohongshu] Fetched ${worksList.length} works`);
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[Xiaohongshu] Failed to fetch works list:', e);
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.closeBrowser();
|
|
|
+
|
|
|
+ logger.info(`[Xiaohongshu] Account info: ${accountName}, ID: ${accountId}, Fans: ${fansCount}, Works: ${worksCount}`);
|
|
|
+
|
|
|
+ return {
|
|
|
+ accountId,
|
|
|
+ accountName,
|
|
|
+ avatarUrl,
|
|
|
+ fansCount,
|
|
|
+ worksCount,
|
|
|
+ worksList,
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Xiaohongshu getAccountInfo error:', error);
|
|
|
+ await this.closeBrowser();
|
|
|
+ return {
|
|
|
+ accountId: `xiaohongshu_${Date.now()}`,
|
|
|
+ accountName: '小红书账号',
|
|
|
+ avatarUrl: '',
|
|
|
+ fansCount: 0,
|
|
|
+ worksCount: 0,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查 Python API 服务是否可用
|
|
|
+ */
|
|
|
+ private async checkPythonServiceAvailable(): Promise<boolean> {
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/health`, {
|
|
|
+ method: 'GET',
|
|
|
+ signal: AbortSignal.timeout(3000),
|
|
|
+ });
|
|
|
+ if (response.ok) {
|
|
|
+ const data = await response.json();
|
|
|
+ return data.status === 'ok' && data.xhs_sdk === true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ } catch {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通过 Python API 服务发布视频(推荐方式,更稳定)
|
|
|
+ * 参考: matrix 项目的小红书发布逻辑
|
|
|
+ */
|
|
|
+ private async publishVideoViaApi(
|
|
|
+ cookies: string,
|
|
|
+ params: PublishParams,
|
|
|
+ onProgress?: (progress: number, message: string) => void
|
|
|
+ ): Promise<PublishResult> {
|
|
|
+ logger.info('[Xiaohongshu API] Starting publish via Python API service...');
|
|
|
+ onProgress?.(5, '正在通过 API 发布...');
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 准备 cookie 字符串
|
|
|
+ let cookieStr = cookies;
|
|
|
+
|
|
|
+ // 如果 cookies 是 JSON 数组格式,转换为字符串格式
|
|
|
+ try {
|
|
|
+ const cookieArray = JSON.parse(cookies);
|
|
|
+ if (Array.isArray(cookieArray)) {
|
|
|
+ cookieStr = cookieArray.map((c: { name: string; value: string }) => `${c.name}=${c.value}`).join('; ');
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 已经是字符串格式
|
|
|
+ }
|
|
|
+
|
|
|
+ onProgress?.(10, '正在上传视频...');
|
|
|
+
|
|
|
+ const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/publish`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ },
|
|
|
+ body: JSON.stringify({
|
|
|
+ cookie: cookieStr,
|
|
|
+ title: params.title,
|
|
|
+ description: params.description || params.title,
|
|
|
+ video_path: params.videoPath,
|
|
|
+ cover_path: params.coverPath,
|
|
|
+ topics: params.tags || [],
|
|
|
+ post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null,
|
|
|
+ }),
|
|
|
+ signal: AbortSignal.timeout(300000), // 5分钟超时
|
|
|
+ });
|
|
|
+
|
|
|
+ const result = await response.json();
|
|
|
+
|
|
|
+ if (result.success) {
|
|
|
+ onProgress?.(100, '发布成功');
|
|
|
+ logger.info('[Xiaohongshu API] Publish successful:', result.data);
|
|
|
+ return {
|
|
|
+ success: true,
|
|
|
+ videoId: result.data?.note_id || `xhs_${Date.now()}`,
|
|
|
+ videoUrl: result.data?.url || '',
|
|
|
+ message: '发布成功',
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ throw new Error(result.error || '发布失败');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('[Xiaohongshu API] Publish failed:', error);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 发布视频/笔记
|
|
|
+ * 优先使用 Python API 服务(更稳定),如果不可用则回退到 Playwright 方式
|
|
|
+ */
|
|
|
+ async publishVideo(
|
|
|
+ cookies: string,
|
|
|
+ params: PublishParams,
|
|
|
+ onProgress?: (progress: number, message: string) => void,
|
|
|
+ onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise<string>,
|
|
|
+ options?: { headless?: boolean }
|
|
|
+ ): Promise<PublishResult> {
|
|
|
+ // 优先尝试使用 Python API 服务
|
|
|
+ const apiAvailable = await this.checkPythonServiceAvailable();
|
|
|
+ if (apiAvailable) {
|
|
|
+ logger.info('[Xiaohongshu] Python API service available, using API method');
|
|
|
+ try {
|
|
|
+ return await this.publishVideoViaApi(cookies, params, onProgress);
|
|
|
+ } catch (apiError) {
|
|
|
+ logger.warn('[Xiaohongshu] API publish failed, falling back to Playwright:', apiError);
|
|
|
+ onProgress?.(0, 'API发布失败,正在切换到浏览器模式...');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ logger.info('[Xiaohongshu] Python API service not available, using Playwright method');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 回退到 Playwright 方式
|
|
|
+ const useHeadless = options?.headless ?? true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ await this.initBrowser({ headless: useHeadless });
|
|
|
+ await this.setCookies(cookies);
|
|
|
+
|
|
|
+ if (!useHeadless) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Running in HEADFUL mode');
|
|
|
+ onProgress?.(1, '已打开浏览器窗口,请注意查看...');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.page) throw new Error('Page not initialized');
|
|
|
+
|
|
|
+ // 检查视频文件是否存在
|
|
|
+ const fs = await import('fs');
|
|
|
+ if (!fs.existsSync(params.videoPath)) {
|
|
|
+ throw new Error(`视频文件不存在: ${params.videoPath}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ onProgress?.(5, '正在打开发布页面...');
|
|
|
+ logger.info(`[Xiaohongshu Publish] Starting upload for: ${params.videoPath}`);
|
|
|
+
|
|
|
+ // 访问发布页面
|
|
|
+ await this.page.goto(this.publishUrl, {
|
|
|
+ waitUntil: 'domcontentloaded',
|
|
|
+ timeout: 60000,
|
|
|
+ });
|
|
|
+
|
|
|
+ await this.page.waitForTimeout(3000);
|
|
|
+
|
|
|
+ // 检查是否需要登录
|
|
|
+ const currentUrl = this.page.url();
|
|
|
+ if (currentUrl.includes('login') || currentUrl.includes('passport')) {
|
|
|
+ throw new Error('登录已过期,请重新登录');
|
|
|
+ }
|
|
|
+
|
|
|
+ logger.info(`[Xiaohongshu Publish] Page loaded: ${currentUrl}`);
|
|
|
+
|
|
|
+ onProgress?.(10, '正在选择视频文件...');
|
|
|
+
|
|
|
+ // 确保在"上传视频"标签页
|
|
|
+ try {
|
|
|
+ const videoTab = this.page.locator('div.tab:has-text("上传视频"), span:has-text("上传视频")').first();
|
|
|
+ if (await videoTab.count() > 0) {
|
|
|
+ await videoTab.click();
|
|
|
+ await this.page.waitForTimeout(1000);
|
|
|
+ logger.info('[Xiaohongshu Publish] Clicked video tab');
|
|
|
+ }
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ // 上传视频文件 - 小红书需要点击"上传视频"按钮触发文件选择
|
|
|
+ let uploadTriggered = false;
|
|
|
+
|
|
|
+ // 方法1: 点击"上传视频"按钮触发 file chooser
|
|
|
+ try {
|
|
|
+ logger.info('[Xiaohongshu Publish] Looking for upload button...');
|
|
|
+
|
|
|
+ // 小红书的上传按钮通常显示"上传视频"文字
|
|
|
+ const uploadBtnSelectors = [
|
|
|
+ 'button:has-text("上传视频")',
|
|
|
+ 'div:has-text("上传视频"):not(:has(*))', // 纯文字的 div
|
|
|
+ '[class*="upload-btn"]',
|
|
|
+ '[class*="upload"] button',
|
|
|
+ 'span:has-text("上传视频")',
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const selector of uploadBtnSelectors) {
|
|
|
+ try {
|
|
|
+ const uploadBtn = this.page.locator(selector).first();
|
|
|
+ if (await uploadBtn.count() > 0 && await uploadBtn.isVisible()) {
|
|
|
+ logger.info(`[Xiaohongshu Publish] Found upload button via: ${selector}`);
|
|
|
+ const [fileChooser] = await Promise.all([
|
|
|
+ this.page.waitForEvent('filechooser', { timeout: 15000 }),
|
|
|
+ uploadBtn.click(),
|
|
|
+ ]);
|
|
|
+ await fileChooser.setFiles(params.videoPath);
|
|
|
+ uploadTriggered = true;
|
|
|
+ logger.info('[Xiaohongshu Publish] File selected via file chooser (button click)');
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn(`[Xiaohongshu Publish] Button click failed for ${selector}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[Xiaohongshu Publish] Upload button method failed:', e);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 方法2: 点击上传区域(拖拽区域)
|
|
|
+ if (!uploadTriggered) {
|
|
|
+ try {
|
|
|
+ logger.info('[Xiaohongshu Publish] Trying click upload area...');
|
|
|
+ const uploadAreaSelectors = [
|
|
|
+ '[class*="upload-wrapper"]',
|
|
|
+ '[class*="upload-area"]',
|
|
|
+ '[class*="drag-area"]',
|
|
|
+ '[class*="drop"]',
|
|
|
+ 'div:has-text("拖拽视频到此")',
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const selector of uploadAreaSelectors) {
|
|
|
+ const uploadArea = this.page.locator(selector).first();
|
|
|
+ if (await uploadArea.count() > 0 && await uploadArea.isVisible()) {
|
|
|
+ logger.info(`[Xiaohongshu Publish] Found upload area via: ${selector}`);
|
|
|
+ const [fileChooser] = await Promise.all([
|
|
|
+ this.page.waitForEvent('filechooser', { timeout: 15000 }),
|
|
|
+ uploadArea.click(),
|
|
|
+ ]);
|
|
|
+ await fileChooser.setFiles(params.videoPath);
|
|
|
+ uploadTriggered = true;
|
|
|
+ logger.info('[Xiaohongshu Publish] File selected via file chooser (area click)');
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[Xiaohongshu Publish] Upload area method failed:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 方法3: 直接设置隐藏的 file input(最后尝试)
|
|
|
+ if (!uploadTriggered) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Trying direct file input...');
|
|
|
+ const uploadSelectors = [
|
|
|
+ 'input[type="file"][accept*="video"]',
|
|
|
+ 'input[type="file"]',
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const selector of uploadSelectors) {
|
|
|
+ try {
|
|
|
+ const fileInput = await this.page.$(selector);
|
|
|
+ if (fileInput) {
|
|
|
+ await fileInput.setInputFiles(params.videoPath);
|
|
|
+ uploadTriggered = true;
|
|
|
+ logger.info(`[Xiaohongshu Publish] File set via direct input: ${selector}`);
|
|
|
+
|
|
|
+ // 直接设置后需要等待一下,让页面响应
|
|
|
+ await this.page.waitForTimeout(2000);
|
|
|
+
|
|
|
+ // 检查页面是否有变化
|
|
|
+ const hasChange = await this.page.locator('[class*="video-preview"], video, [class*="progress"], [class*="upload-success"]').count() > 0;
|
|
|
+ if (hasChange) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Page responded to file input');
|
|
|
+ break;
|
|
|
+ } else {
|
|
|
+ // 如果页面没有响应,尝试触发 change 事件
|
|
|
+ await this.page.evaluate((sel) => {
|
|
|
+ const input = document.querySelector(sel) as HTMLInputElement;
|
|
|
+ if (input) {
|
|
|
+ input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
+ }
|
|
|
+ }, selector);
|
|
|
+ await this.page.waitForTimeout(2000);
|
|
|
+ logger.info('[Xiaohongshu Publish] Dispatched change event');
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn(`[Xiaohongshu Publish] Failed with selector ${selector}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!uploadTriggered) {
|
|
|
+ // 截图调试
|
|
|
+ const screenshotPath = `uploads/debug/xhs_no_upload_${Date.now()}.png`;
|
|
|
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
|
+ logger.info(`[Xiaohongshu Publish] Screenshot saved: ${screenshotPath}`);
|
|
|
+ throw new Error('无法上传视频文件');
|
|
|
+ }
|
|
|
+
|
|
|
+ onProgress?.(15, '视频上传中...');
|
|
|
+
|
|
|
+ // 等待视频上传完成
|
|
|
+ const maxWaitTime = 300000; // 5分钟
|
|
|
+ const startTime = Date.now();
|
|
|
+
|
|
|
+ while (Date.now() - startTime < maxWaitTime) {
|
|
|
+ await this.page.waitForTimeout(3000);
|
|
|
+
|
|
|
+ // 检查当前URL是否变化(上传成功后可能跳转)
|
|
|
+ const newUrl = this.page.url();
|
|
|
+ if (newUrl !== currentUrl && !newUrl.includes('upload')) {
|
|
|
+ logger.info(`[Xiaohongshu Publish] URL changed to: ${newUrl}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查上传进度
|
|
|
+ const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => '');
|
|
|
+ if (progressText) {
|
|
|
+ const match = progressText.match(/(\d+)%/);
|
|
|
+ if (match) {
|
|
|
+ const progress = parseInt(match[1]);
|
|
|
+ onProgress?.(15 + Math.floor(progress * 0.4), `视频上传中: ${progress}%`);
|
|
|
+ logger.info(`[Xiaohongshu Publish] Upload progress: ${progress}%`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否上传完成 - 扩展检测范围
|
|
|
+ const uploadCompleteSelectors = [
|
|
|
+ '[class*="upload-success"]',
|
|
|
+ '[class*="video-preview"]',
|
|
|
+ 'video',
|
|
|
+ '[class*="cover"]', // 封面设置区域
|
|
|
+ 'input[placeholder*="标题"]', // 标题输入框出现
|
|
|
+ '[class*="title"] input',
|
|
|
+ '[class*="editor"]', // 编辑器区域
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const selector of uploadCompleteSelectors) {
|
|
|
+ const count = await this.page.locator(selector).count();
|
|
|
+ if (count > 0) {
|
|
|
+ logger.info(`[Xiaohongshu Publish] Video upload completed, found: ${selector}`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果标题输入框出现,说明可以开始填写了
|
|
|
+ const titleInput = await this.page.locator('input[placeholder*="标题"]').count();
|
|
|
+ if (titleInput > 0) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Title input found, upload must be complete');
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否上传失败
|
|
|
+ const failText = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
|
|
|
+ if (failText && failText.includes('失败')) {
|
|
|
+ throw new Error(`视频上传失败: ${failText}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否还在初始上传页面
|
|
|
+ const stillOnUploadPage = await this.page.locator('div:has-text("拖拽视频到此")').count();
|
|
|
+ if (stillOnUploadPage > 0 && Date.now() - startTime > 10000) {
|
|
|
+ logger.warn('[Xiaohongshu Publish] Still on upload page after 10s, retrying upload...');
|
|
|
+ // 可能需要重新触发上传
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ onProgress?.(55, '正在填写笔记信息...');
|
|
|
+
|
|
|
+ // 填写标题
|
|
|
+ logger.info('[Xiaohongshu Publish] Filling title...');
|
|
|
+ const titleSelectors = [
|
|
|
+ 'input[placeholder*="标题"]',
|
|
|
+ '[class*="title"] input',
|
|
|
+ 'textarea[placeholder*="标题"]',
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const selector of titleSelectors) {
|
|
|
+ const titleInput = this.page.locator(selector).first();
|
|
|
+ if (await titleInput.count() > 0) {
|
|
|
+ await titleInput.fill(params.title.slice(0, 20)); // 小红书标题限制20字
|
|
|
+ logger.info(`[Xiaohongshu Publish] Title filled via: ${selector}`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 填写描述/正文
|
|
|
+ if (params.description) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Filling description...');
|
|
|
+ const descSelectors = [
|
|
|
+ '[class*="content-input"] [contenteditable="true"]',
|
|
|
+ 'textarea[placeholder*="正文"]',
|
|
|
+ '[class*="editor"] [contenteditable="true"]',
|
|
|
+ '#post-textarea',
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const selector of descSelectors) {
|
|
|
+ const descInput = this.page.locator(selector).first();
|
|
|
+ if (await descInput.count() > 0) {
|
|
|
+ await descInput.click();
|
|
|
+ await this.page.keyboard.type(params.description, { delay: 30 });
|
|
|
+ logger.info(`[Xiaohongshu Publish] Description filled via: ${selector}`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ onProgress?.(65, '正在添加话题标签...');
|
|
|
+
|
|
|
+ // 添加话题标签 - 注意不要触发话题选择器弹窗
|
|
|
+ // 小红书会自动识别 # 开头的话题,不需要从弹窗选择
|
|
|
+ if (params.tags && params.tags.length > 0) {
|
|
|
+ // 找到正文输入框
|
|
|
+ const descSelectors = [
|
|
|
+ '[class*="content-input"] [contenteditable="true"]',
|
|
|
+ '[class*="editor"] [contenteditable="true"]',
|
|
|
+ '#post-textarea',
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const selector of descSelectors) {
|
|
|
+ const descInput = this.page.locator(selector).first();
|
|
|
+ if (await descInput.count() > 0) {
|
|
|
+ await descInput.click();
|
|
|
+ // 添加空行后再添加标签
|
|
|
+ await this.page.keyboard.press('Enter');
|
|
|
+ for (const tag of params.tags) {
|
|
|
+ await this.page.keyboard.type(`#${tag} `, { delay: 30 });
|
|
|
+ }
|
|
|
+ logger.info(`[Xiaohongshu Publish] Tags added: ${params.tags.join(', ')}`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.page.waitForTimeout(500);
|
|
|
+ }
|
|
|
+
|
|
|
+ onProgress?.(75, '等待处理完成...');
|
|
|
+
|
|
|
+ // 等待视频处理完成,检查是否有"上传成功"标识
|
|
|
+ await this.page.waitForTimeout(2000);
|
|
|
+
|
|
|
+ // 检查当前页面是否还在编辑状态
|
|
|
+ const stillInEditMode = await this.page.locator('input[placeholder*="标题"], [class*="video-preview"]').count() > 0;
|
|
|
+ if (!stillInEditMode) {
|
|
|
+ logger.error('[Xiaohongshu Publish] Page is no longer in edit mode!');
|
|
|
+ const screenshotPath = `uploads/debug/xhs_not_in_edit_${Date.now()}.png`;
|
|
|
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
|
+ throw new Error('页面状态异常,请重试');
|
|
|
+ }
|
|
|
+
|
|
|
+ onProgress?.(85, '正在发布...');
|
|
|
+
|
|
|
+ // 滚动到页面底部,确保发布按钮可见
|
|
|
+ logger.info('[Xiaohongshu Publish] Scrolling to bottom...');
|
|
|
+ await this.page.evaluate(() => {
|
|
|
+ window.scrollTo(0, document.body.scrollHeight);
|
|
|
+ });
|
|
|
+ await this.page.waitForTimeout(1000);
|
|
|
+
|
|
|
+ // 点击发布按钮
|
|
|
+ logger.info('[Xiaohongshu Publish] Looking for publish button...');
|
|
|
+
|
|
|
+ // 先截图看当前页面状态
|
|
|
+ const beforeClickPath = `uploads/debug/xhs_before_publish_${Date.now()}.png`;
|
|
|
+ await this.page.screenshot({ path: beforeClickPath, fullPage: true });
|
|
|
+ logger.info(`[Xiaohongshu Publish] Screenshot before publish: ${beforeClickPath}`);
|
|
|
+
|
|
|
+ let publishClicked = false;
|
|
|
+
|
|
|
+ // 方法1: 使用 Playwright locator 点击(模拟真实鼠标点击)
|
|
|
+ const publishBtnSelectors = [
|
|
|
+ 'button.publishBtn',
|
|
|
+ '.publishBtn',
|
|
|
+ 'button.d-button.red',
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const selector of publishBtnSelectors) {
|
|
|
+ try {
|
|
|
+ const btn = this.page.locator(selector).first();
|
|
|
+ const count = await btn.count();
|
|
|
+ logger.info(`[Xiaohongshu Publish] Checking selector ${selector}: count=${count}`);
|
|
|
+ if (count > 0 && await btn.isVisible()) {
|
|
|
+ // 确保按钮在视口内
|
|
|
+ await btn.scrollIntoViewIfNeeded();
|
|
|
+ await this.page.waitForTimeout(500);
|
|
|
+
|
|
|
+ // 获取按钮位置并使用鼠标点击
|
|
|
+ const box = await btn.boundingBox();
|
|
|
+ if (box) {
|
|
|
+ // 使用 page.mouse.click 模拟真实鼠标点击
|
|
|
+ const x = box.x + box.width / 2;
|
|
|
+ const y = box.y + box.height / 2;
|
|
|
+ logger.info(`[Xiaohongshu Publish] Clicking at position: (${x}, ${y})`);
|
|
|
+ await this.page.mouse.click(x, y);
|
|
|
+ publishClicked = true;
|
|
|
+ logger.info(`[Xiaohongshu Publish] Publish button clicked via mouse.click: ${selector}`);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn(`[Xiaohongshu Publish] Failed with selector ${selector}:`, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 方法2: 使用 Playwright locator.click() 配合 force 选项
|
|
|
+ if (!publishClicked) {
|
|
|
+ try {
|
|
|
+ const btn = this.page.locator('button.publishBtn').first();
|
|
|
+ if (await btn.count() > 0) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Trying locator.click with force...');
|
|
|
+ await btn.click({ force: true, timeout: 5000 });
|
|
|
+ publishClicked = true;
|
|
|
+ logger.info('[Xiaohongshu Publish] Publish button clicked via locator.click(force)');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[Xiaohongshu Publish] locator.click(force) failed:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 方法3: 使用 getByRole
|
|
|
+ if (!publishClicked) {
|
|
|
+ try {
|
|
|
+ const publishBtn = this.page.getByRole('button', { name: '发布', exact: true });
|
|
|
+ if (await publishBtn.count() > 0) {
|
|
|
+ const buttons = await publishBtn.all();
|
|
|
+ for (const btn of buttons) {
|
|
|
+ if (await btn.isVisible() && await btn.isEnabled()) {
|
|
|
+ // 使用鼠标点击
|
|
|
+ const box = await btn.boundingBox();
|
|
|
+ if (box) {
|
|
|
+ const x = box.x + box.width / 2;
|
|
|
+ const y = box.y + box.height / 2;
|
|
|
+ await this.page.mouse.click(x, y);
|
|
|
+ publishClicked = true;
|
|
|
+ logger.info('[Xiaohongshu Publish] Publish button clicked via getByRole');
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[Xiaohongshu Publish] getByRole failed:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果还是没找到,尝试用 evaluate 直接查找和点击
|
|
|
+ if (!publishClicked) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Trying evaluate method...');
|
|
|
+ try {
|
|
|
+ publishClicked = await this.page.evaluate(() => {
|
|
|
+ // 查找所有包含"发布"文字的按钮
|
|
|
+ const buttons = Array.from(document.querySelectorAll('button, div[role="button"]'));
|
|
|
+ for (const btn of buttons) {
|
|
|
+ const text = btn.textContent?.trim();
|
|
|
+ // 找到只包含"发布"两个字的按钮(排除"发布笔记"等)
|
|
|
+ if (text === '发布' && (btn as HTMLElement).offsetParent !== null) {
|
|
|
+ (btn as HTMLElement).click();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ if (publishClicked) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Publish button clicked via evaluate');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ logger.warn('[Xiaohongshu Publish] evaluate failed:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!publishClicked) {
|
|
|
+ // 截图调试
|
|
|
+ try {
|
|
|
+ const screenshotPath = `uploads/debug/xhs_no_publish_btn_${Date.now()}.png`;
|
|
|
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
|
+ logger.info(`[Xiaohongshu Publish] Screenshot saved: ${screenshotPath}`);
|
|
|
+ } catch {}
|
|
|
+ throw new Error('未找到发布按钮');
|
|
|
+ }
|
|
|
+
|
|
|
+ onProgress?.(90, '等待发布完成...');
|
|
|
+
|
|
|
+ // 等待发布结果
|
|
|
+ const publishMaxWait = 120000; // 2分钟
|
|
|
+ const publishStartTime = Date.now();
|
|
|
+
|
|
|
+ while (Date.now() - publishStartTime < publishMaxWait) {
|
|
|
+ await this.page.waitForTimeout(3000);
|
|
|
+ const currentUrl = this.page.url();
|
|
|
+
|
|
|
+ // 检查是否跳转到内容管理页面
|
|
|
+ if (currentUrl.includes('/content') || currentUrl.includes('/creator/home')) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Publish success! Redirected to content page');
|
|
|
+ onProgress?.(100, '发布成功!');
|
|
|
+ await this.closeBrowser();
|
|
|
+ return {
|
|
|
+ success: true,
|
|
|
+ videoUrl: currentUrl,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查成功提示
|
|
|
+ const successToast = await this.page.locator('[class*="success"]:has-text("成功"), [class*="toast"]:has-text("发布成功")').count();
|
|
|
+ if (successToast > 0) {
|
|
|
+ logger.info('[Xiaohongshu Publish] Found success toast');
|
|
|
+ await this.page.waitForTimeout(2000);
|
|
|
+ onProgress?.(100, '发布成功!');
|
|
|
+ await this.closeBrowser();
|
|
|
+ return {
|
|
|
+ success: true,
|
|
|
+ videoUrl: this.page.url(),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查错误提示
|
|
|
+ const errorToast = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
|
|
|
+ if (errorToast && (errorToast.includes('失败') || errorToast.includes('错误'))) {
|
|
|
+ throw new Error(`发布失败: ${errorToast}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const elapsed = Math.floor((Date.now() - publishStartTime) / 1000);
|
|
|
+ onProgress?.(90 + Math.min(9, Math.floor(elapsed / 15)), `等待发布完成 (${elapsed}s)...`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 超时,截图调试
|
|
|
+ try {
|
|
|
+ const screenshotPath = `uploads/debug/xhs_publish_timeout_${Date.now()}.png`;
|
|
|
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
|
+ logger.info(`[Xiaohongshu Publish] Timeout screenshot saved: ${screenshotPath}`);
|
|
|
+ } catch {}
|
|
|
+
|
|
|
+ throw new Error('发布超时,请手动检查是否发布成功');
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('[Xiaohongshu Publish] Error:', error);
|
|
|
+ await this.closeBrowser();
|
|
|
+ return {
|
|
|
+ success: false,
|
|
|
+ errorMessage: error instanceof Error ? error.message : '发布失败',
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取评论列表
|
|
|
+ */
|
|
|
+ async getComments(cookies: string, videoId: string): Promise<CommentData[]> {
|
|
|
+ try {
|
|
|
+ await this.initBrowser({ headless: true });
|
|
|
+ await this.setCookies(cookies);
|
|
|
+
|
|
|
+ if (!this.page) throw new Error('Page not initialized');
|
|
|
+
|
|
|
+ const comments: CommentData[] = [];
|
|
|
+
|
|
|
+ // 设置 API 响应监听器
|
|
|
+ this.page.on('response', async (response) => {
|
|
|
+ const url = response.url();
|
|
|
+ try {
|
|
|
+ // 监听评论列表 API
|
|
|
+ if (url.includes('/api/sns/web/v2/comment/page') ||
|
|
|
+ url.includes('/api/galaxy/creator/comment')) {
|
|
|
+ const data = await response.json();
|
|
|
+ logger.info(`[Xiaohongshu API] Comments response:`, JSON.stringify(data).slice(0, 500));
|
|
|
+
|
|
|
+ const commentList = data?.data?.comments || data?.comments || [];
|
|
|
+ for (const comment of commentList) {
|
|
|
+ comments.push({
|
|
|
+ commentId: comment.id || comment.comment_id || '',
|
|
|
+ authorId: comment.user_info?.user_id || comment.user_id || '',
|
|
|
+ authorName: comment.user_info?.nickname || comment.nickname || '',
|
|
|
+ authorAvatar: comment.user_info?.image || comment.avatar || '',
|
|
|
+ content: comment.content || '',
|
|
|
+ likeCount: comment.like_count || 0,
|
|
|
+ commentTime: comment.create_time || comment.time || '',
|
|
|
+ parentCommentId: comment.target_comment_id || undefined,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {}
|
|
|
+ });
|
|
|
+
|
|
|
+ // 访问评论管理页面
|
|
|
+ await this.page.goto(`${this.contentManageUrl}?tab=comment`, {
|
|
|
+ waitUntil: 'domcontentloaded',
|
|
|
+ timeout: 30000,
|
|
|
+ });
|
|
|
+
|
|
|
+ await this.page.waitForTimeout(5000);
|
|
|
+
|
|
|
+ await this.closeBrowser();
|
|
|
+ return comments;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Xiaohongshu getComments error:', error);
|
|
|
+ await this.closeBrowser();
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 回复评论
|
|
|
+ */
|
|
|
+ async replyComment(cookies: string, commentId: string, content: string): Promise<boolean> {
|
|
|
+ try {
|
|
|
+ await this.initBrowser({ headless: true });
|
|
|
+ await this.setCookies(cookies);
|
|
|
+
|
|
|
+ if (!this.page) throw new Error('Page not initialized');
|
|
|
+
|
|
|
+ // 访问评论管理页面
|
|
|
+ await this.page.goto(`${this.contentManageUrl}?tab=comment`, {
|
|
|
+ waitUntil: 'networkidle',
|
|
|
+ timeout: 30000,
|
|
|
+ });
|
|
|
+
|
|
|
+ await this.page.waitForTimeout(2000);
|
|
|
+
|
|
|
+ // 找到对应评论并点击回复
|
|
|
+ const commentItem = this.page.locator(`[data-comment-id="${commentId}"], [data-id="${commentId}"]`).first();
|
|
|
+ if (await commentItem.count() > 0) {
|
|
|
+ const replyBtn = commentItem.locator('[class*="reply"], button:has-text("回复")').first();
|
|
|
+ if (await replyBtn.count() > 0) {
|
|
|
+ await replyBtn.click();
|
|
|
+ await this.page.waitForTimeout(500);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 输入回复内容
|
|
|
+ const replyInput = this.page.locator('[class*="reply-input"] textarea, [class*="comment-input"] textarea').first();
|
|
|
+ if (await replyInput.count() > 0) {
|
|
|
+ await replyInput.fill(content);
|
|
|
+ await this.page.waitForTimeout(500);
|
|
|
+
|
|
|
+ // 点击发送
|
|
|
+ const sendBtn = this.page.locator('button:has-text("发送"), button:has-text("回复")').first();
|
|
|
+ if (await sendBtn.count() > 0) {
|
|
|
+ await sendBtn.click();
|
|
|
+ await this.page.waitForTimeout(2000);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.closeBrowser();
|
|
|
+ return true;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Xiaohongshu replyComment error:', error);
|
|
|
+ await this.closeBrowser();
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取数据统计
|
|
|
+ */
|
|
|
+ async getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData> {
|
|
|
+ try {
|
|
|
+ await this.initBrowser({ headless: true });
|
|
|
+ await this.setCookies(cookies);
|
|
|
+
|
|
|
+ if (!this.page) throw new Error('Page not initialized');
|
|
|
+
|
|
|
+ const analytics: AnalyticsData = {
|
|
|
+ fansCount: 0,
|
|
|
+ fansIncrease: 0,
|
|
|
+ viewsCount: 0,
|
|
|
+ likesCount: 0,
|
|
|
+ commentsCount: 0,
|
|
|
+ sharesCount: 0,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 设置 API 响应监听器
|
|
|
+ this.page.on('response', async (response) => {
|
|
|
+ const url = response.url();
|
|
|
+ try {
|
|
|
+ if (url.includes('/api/galaxy/creator/data') ||
|
|
|
+ url.includes('/api/galaxy/creator/home')) {
|
|
|
+ const data = await response.json();
|
|
|
+ if (data?.data) {
|
|
|
+ const d = data.data;
|
|
|
+ analytics.fansCount = d.fans_count || analytics.fansCount;
|
|
|
+ analytics.fansIncrease = d.fans_increase || analytics.fansIncrease;
|
|
|
+ analytics.viewsCount = d.view_count || d.read_count || analytics.viewsCount;
|
|
|
+ analytics.likesCount = d.like_count || analytics.likesCount;
|
|
|
+ analytics.commentsCount = d.comment_count || analytics.commentsCount;
|
|
|
+ analytics.sharesCount = d.collect_count || analytics.sharesCount;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {}
|
|
|
+ });
|
|
|
+
|
|
|
+ // 访问数据中心
|
|
|
+ await this.page.goto('https://creator.xiaohongshu.com/creator/data', {
|
|
|
+ waitUntil: 'domcontentloaded',
|
|
|
+ timeout: 30000,
|
|
|
+ });
|
|
|
+
|
|
|
+ await this.page.waitForTimeout(5000);
|
|
|
+ await this.closeBrowser();
|
|
|
+
|
|
|
+ return analytics;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('Xiaohongshu getAnalytics error:', error);
|
|
|
+ await this.closeBrowser();
|
|
|
+ return {
|
|
|
+ fansCount: 0,
|
|
|
+ fansIncrease: 0,
|
|
|
+ viewsCount: 0,
|
|
|
+ likesCount: 0,
|
|
|
+ commentsCount: 0,
|
|
|
+ sharesCount: 0,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|