/// import path from 'path'; 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'; // 服务器根目录(用于构造绝对路径) const SERVER_ROOT = path.resolve(process.cwd()); /** * 小红书平台适配器 */ 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 { 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 { 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 { 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; } } /** * 获取账号信息 * 通过拦截 API 响应获取准确数据 */ async getAccountInfo(cookies: string): Promise { 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; }; homeData?: { fans?: number; notes?: number; }; } = {}; // 用于等待 API 响应的 Promise let resolvePersonalInfo: () => void; let resolveNotesCount: () => void; const personalInfoPromise = new Promise((resolve) => { resolvePersonalInfo = resolve; }); const notesCountPromise = new Promise((resolve) => { resolveNotesCount = resolve; }); // 设置超时自动 resolve setTimeout(() => resolvePersonalInfo(), 10000); setTimeout(() => resolveNotesCount(), 10000); // 设置 API 响应监听器 this.page.on('response', async (response) => { const url = response.url(); try { // 监听用户信息 API - personal_info 接口 // URL: https://creator.xiaohongshu.com/api/galaxy/creator/home/personal_info // 返回结构: { data: { name, avatar, fans_count, red_num, follow_count, faved_count } } if (url.includes('/api/galaxy/creator/home/personal_info')) { const data = await response.json(); logger.info(`[Xiaohongshu API] Personal info:`, JSON.stringify(data).slice(0, 1000)); if (data?.data) { const info = data.data; capturedData.userInfo = { nickname: info.name, avatar: info.avatar, userId: info.red_num, // 小红书号 redId: info.red_num, fans: info.fans_count, }; logger.info(`[Xiaohongshu API] Captured personal info:`, capturedData.userInfo); } resolvePersonalInfo(); } // 监听笔记列表 API (获取作品数) - 新版 edith API // URL: https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted // 返回结构: { data: { tags: [{ name: "所有笔记", notes_count: 1 }] } } if (url.includes('edith.xiaohongshu.com') && url.includes('/creator/note/user/posted')) { const data = await response.json(); logger.info(`[Xiaohongshu API] Posted notes (edith):`, JSON.stringify(data).slice(0, 800)); if (data?.data?.tags && Array.isArray(data.data.tags)) { // 从 tags 数组中找到 "所有笔记" 的 notes_count const allNotesTag = data.data.tags.find((tag: { id?: string; name?: string; notes_count?: number }) => tag.id?.includes('note_time') || tag.name === '所有笔记' ); if (allNotesTag?.notes_count !== undefined) { capturedData.homeData = capturedData.homeData || {}; capturedData.homeData.notes = allNotesTag.notes_count; logger.info(`[Xiaohongshu API] Total notes from edith API: ${allNotesTag.notes_count}`); } } resolveNotesCount(); } } catch (e) { // 忽略非 JSON 响应 logger.debug(`[Xiaohongshu API] Failed to parse response: ${url}`); } }); // 1. 先访问创作者首页获取用户信息 // URL: https://creator.xiaohongshu.com/new/home // API: /api/galaxy/creator/home/personal_info logger.info('[Xiaohongshu] Navigating to creator home...'); await this.page.goto('https://creator.xiaohongshu.com/new/home', { waitUntil: 'networkidle', timeout: 30000, }); // 等待 personal_info API 响应 await Promise.race([personalInfoPromise, this.page.waitForTimeout(5000)]); logger.info(`[Xiaohongshu] After home page, capturedData.userInfo:`, capturedData.userInfo); // 2. 再访问笔记管理页面获取作品数 // URL: https://creator.xiaohongshu.com/new/note-manager // API: https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted logger.info('[Xiaohongshu] Navigating to note manager...'); await this.page.goto('https://creator.xiaohongshu.com/new/note-manager', { waitUntil: 'networkidle', timeout: 30000, }); // 等待 notes API 响应 await Promise.race([notesCountPromise, this.page.waitForTimeout(5000)]); logger.info(`[Xiaohongshu] After note manager, capturedData.homeData:`, capturedData.homeData); // 检查是否需要登录 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, }; } // 使用捕获的数据 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 !== undefined) fansCount = capturedData.userInfo.fans; } // homeData.notes 来自笔记列表 API,直接使用(优先级最高) if (capturedData.homeData) { if (capturedData.homeData.notes !== undefined) { worksCount = capturedData.homeData.notes; logger.info(`[Xiaohongshu] Using notes count from API: ${worksCount}`); } } // 如果 API 没捕获到,尝试从页面 DOM 获取 if (fansCount === 0 || worksCount === 0) { const statsData = await this.page.evaluate(() => { const result = { fans: 0, notes: 0, name: '', avatar: '' }; // 获取页面文本 const allText = document.body.innerText; // 尝试匹配粉丝数 const fansMatch = allText.match(/粉丝[::\s]*(\d+(?:\.\d+)?[万亿]?)|(\d+(?:\.\d+)?[万亿]?)\s*粉丝/); if (fansMatch) { const numStr = fansMatch[1] || fansMatch[2]; result.fans = Math.floor(parseFloat(numStr) * (numStr.includes('万') ? 10000 : numStr.includes('亿') ? 100000000 : 1)); } // 尝试匹配笔记数 const notesMatch = allText.match(/笔记[::\s]*(\d+)|(\d+)\s*篇?笔记|共\s*(\d+)\s*篇/); if (notesMatch) { result.notes = parseInt(notesMatch[1] || notesMatch[2] || notesMatch[3]); } // 获取用户名 const nameEl = document.querySelector('[class*="nickname"], [class*="user-name"], [class*="creator-name"]'); if (nameEl) result.name = nameEl.textContent?.trim() || ''; // 获取头像 const avatarEl = document.querySelector('[class*="avatar"] img, [class*="user-avatar"] img'); if (avatarEl) result.avatar = (avatarEl as HTMLImageElement).src || ''; return result; }); if (fansCount === 0 && statsData.fans > 0) fansCount = statsData.fans; if (worksCount === 0 && statsData.notes > 0) worksCount = statsData.notes; if ((!accountName || accountName === '小红书账号') && statsData.name) accountName = statsData.name; if (!avatarUrl && statsData.avatar) avatarUrl = statsData.avatar; } 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 { 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 { 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 absoluteVideoPath = path.isAbsolute(params.videoPath) ? params.videoPath : path.resolve(SERVER_ROOT, params.videoPath); const absoluteCoverPath = params.coverPath ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath)) : undefined; const requestBody = { platform: 'xiaohongshu', cookie: cookieStr, title: params.title, description: params.description || params.title, video_path: absoluteVideoPath, cover_path: absoluteCoverPath, tags: params.tags || [], post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null, }; logger.info('[Xiaohongshu API] Request body:', { platform: requestBody.platform, title: requestBody.title, video_path: requestBody.video_path, has_cookie: !!requestBody.cookie, cookie_length: requestBody.cookie?.length || 0, }); const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/publish`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), signal: AbortSignal.timeout(300000), // 5分钟超时 }); const result = await response.json(); logger.info('[Xiaohongshu API] Response:', result); 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, options?: { headless?: boolean } ): Promise { // 优先尝试使用 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 : '发布失败', }; } } /** * 通过 Python API 获取评论 */ private async getCommentsViaPython(cookies: string, videoId: string): Promise { logger.info('[Xiaohongshu] Getting comments via Python API...'); const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ platform: 'xiaohongshu', cookie: cookies, work_id: videoId, }), }); if (!response.ok) { throw new Error(`Python API returned ${response.status}`); } const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Failed to get comments'); } // 转换数据格式 return (result.comments || []).map((comment: { comment_id: string; author_id: string; author_name: string; author_avatar: string; content: string; like_count: number; create_time: string; reply_count: number; replies?: Array<{ comment_id: string; author_id: string; author_name: string; author_avatar: string; content: string; like_count: number; create_time: string; }>; }) => ({ commentId: comment.comment_id, authorId: comment.author_id, authorName: comment.author_name, authorAvatar: comment.author_avatar, content: comment.content, likeCount: comment.like_count, commentTime: comment.create_time, replyCount: comment.reply_count, replies: comment.replies?.map((reply: { comment_id: string; author_id: string; author_name: string; author_avatar: string; content: string; like_count: number; create_time: string; }) => ({ commentId: reply.comment_id, authorId: reply.author_id, authorName: reply.author_name, authorAvatar: reply.author_avatar, content: reply.content, likeCount: reply.like_count, commentTime: reply.create_time, })), })); } /** * 获取评论列表 */ async getComments(cookies: string, videoId: string): Promise { // 优先尝试使用 Python API const pythonAvailable = await this.checkPythonServiceAvailable(); if (pythonAvailable) { logger.info('[Xiaohongshu] Python service available, using Python API for comments'); try { return await this.getCommentsViaPython(cookies, videoId); } catch (pythonError) { logger.warn('[Xiaohongshu] Python API getComments failed, falling back to Playwright:', pythonError); } } // 回退到 Playwright 方式 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 { 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; } } /** * 删除已发布的作品 * 使用小红书笔记管理页面: https://creator.xiaohongshu.com/new/note-manager */ async deleteWork( cookies: string, noteId: string, onCaptchaRequired?: (captchaInfo: { taskId: string; imageUrl?: string }) => Promise ): Promise<{ success: boolean; errorMessage?: string }> { try { // 使用无头浏览器后台运行 await this.initBrowser({ headless: true }); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); logger.info(`[Xiaohongshu Delete] Starting delete for note: ${noteId}`); // 访问笔记管理页面(新版) const noteManagerUrl = 'https://creator.xiaohongshu.com/new/note-manager'; await this.page.goto(noteManagerUrl, { waitUntil: 'networkidle', 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 Delete] Current URL: ${currentUrl}`); // 截图用于调试 try { const screenshotPath = `uploads/debug/xhs_delete_page_${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`[Xiaohongshu Delete] Page screenshot: ${screenshotPath}`); } catch {} // 在笔记管理页面找到对应的笔记行 // 页面结构: // - 每条笔记是 div.note 元素 // - 笔记ID在 data-impression 属性的 JSON 中: noteId: "xxx" // - 删除按钮是 span.control.data-del 内的 删除 let deleteClicked = false; // 方式1: 通过 data-impression 属性找到对应笔记,然后点击其删除按钮 logger.info(`[Xiaohongshu Delete] Looking for note with ID: ${noteId}`); // 查找所有笔记卡片 const noteCards = this.page.locator('div.note'); const noteCount = await noteCards.count(); logger.info(`[Xiaohongshu Delete] Found ${noteCount} note cards`); for (let i = 0; i < noteCount; i++) { const card = noteCards.nth(i); const impression = await card.getAttribute('data-impression').catch(() => ''); // 检查 data-impression 中是否包含目标 noteId if (impression && impression.includes(noteId)) { logger.info(`[Xiaohongshu Delete] Found target note at index ${i}`); // 在该笔记卡片内查找删除按钮 (span.data-del) const deleteBtn = card.locator('span.data-del, span.control.data-del').first(); if (await deleteBtn.count() > 0) { await deleteBtn.click(); deleteClicked = true; logger.info(`[Xiaohongshu Delete] Clicked delete button for note ${noteId}`); break; } } } // 方式2: 如果方式1没找到,尝试直接用 evaluate 在 DOM 中查找 if (!deleteClicked) { logger.info('[Xiaohongshu Delete] Trying evaluate method to find note by data-impression...'); deleteClicked = await this.page.evaluate((nid: string) => { // 查找所有 div.note 元素 const notes = document.querySelectorAll('div.note'); console.log(`[XHS Delete] Found ${notes.length} note elements`); for (const note of notes) { const impression = note.getAttribute('data-impression') || ''; if (impression.includes(nid)) { console.log(`[XHS Delete] Found note with ID ${nid}`); // 查找删除按钮 const deleteBtn = note.querySelector('span.data-del') || note.querySelector('.control.data-del'); if (deleteBtn) { console.log(`[XHS Delete] Clicking delete button`); (deleteBtn as HTMLElement).click(); return true; } } } return false; }, noteId); if (deleteClicked) { logger.info('[Xiaohongshu Delete] Delete button clicked via evaluate'); } } // 方式3: 如果还没找到,尝试点击第一个可见的删除按钮 if (!deleteClicked) { logger.info('[Xiaohongshu Delete] Trying to click first visible delete button...'); const allDeleteBtns = this.page.locator('span.data-del'); const btnCount = await allDeleteBtns.count(); logger.info(`[Xiaohongshu Delete] Found ${btnCount} delete buttons on page`); for (let i = 0; i < btnCount; i++) { const btn = allDeleteBtns.nth(i); if (await btn.isVisible().catch(() => false)) { await btn.click(); deleteClicked = true; logger.info(`[Xiaohongshu Delete] Clicked delete button ${i}`); break; } } } if (!deleteClicked) { // 截图调试 try { const screenshotPath = `uploads/debug/xhs_delete_no_btn_${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`[Xiaohongshu Delete] No delete button found, screenshot: ${screenshotPath}`); } catch {} throw new Error('未找到删除按钮'); } await this.page.waitForTimeout(1000); // 检查是否需要验证码 const captchaVisible = await this.page.locator('[class*="captcha"], [class*="verify"]').count() > 0; if (captchaVisible && onCaptchaRequired) { logger.info('[Xiaohongshu Delete] Captcha required'); // 点击发送验证码 const sendCodeBtn = this.page.locator('button:has-text("发送验证码"), button:has-text("获取验证码")').first(); if (await sendCodeBtn.count() > 0) { await sendCodeBtn.click(); logger.info('[Xiaohongshu Delete] Verification code sent'); } // 通过回调获取验证码 const taskId = `delete_xhs_${noteId}_${Date.now()}`; const code = await onCaptchaRequired({ taskId }); if (code) { // 输入验证码 const codeInput = this.page.locator('input[placeholder*="验证码"], input[type="text"]').first(); if (await codeInput.count() > 0) { await codeInput.fill(code); logger.info('[Xiaohongshu Delete] Verification code entered'); } // 点击确认按钮 const confirmBtn = this.page.locator('button:has-text("确定"), button:has-text("确认")').first(); if (await confirmBtn.count() > 0) { await confirmBtn.click(); await this.page.waitForTimeout(2000); } } } // 确认删除(可能有二次确认弹窗) const confirmDeleteSelectors = [ 'button:has-text("确认删除")', 'button:has-text("确定")', 'button:has-text("确认")', '[class*="modal"] button[class*="primary"]', '[class*="dialog"] button[class*="confirm"]', '.d-button.red:has-text("确")', ]; for (const selector of confirmDeleteSelectors) { const confirmBtn = this.page.locator(selector).first(); if (await confirmBtn.count() > 0 && await confirmBtn.isVisible()) { await confirmBtn.click(); logger.info(`[Xiaohongshu Delete] Confirm button clicked via: ${selector}`); await this.page.waitForTimeout(1000); } } // 等待删除完成 await this.page.waitForTimeout(2000); // 检查是否删除成功(页面刷新或出现成功提示) const successToast = await this.page.locator('[class*="success"]:has-text("成功"), [class*="toast"]:has-text("删除成功")').count(); if (successToast > 0) { logger.info('[Xiaohongshu Delete] Delete success toast found'); } logger.info('[Xiaohongshu Delete] Delete completed'); await this.closeBrowser(); return { success: true }; } catch (error) { logger.error('[Xiaohongshu Delete] Error:', error); await this.closeBrowser(); return { success: false, errorMessage: error instanceof Error ? error.message : '删除失败', }; } } /** * 获取数据统计 */ async getAnalytics(cookies: string, dateRange: DateRange): Promise { 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, }; } } }