| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735 |
- /// <reference lib="dom" />
- 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';
- import { aiService } from '../../ai/index.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?from=menu&target=video';
- 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;
- }
- }
- /**
- * 关闭页面上可能存在的弹窗对话框
- */
- private async closeModalDialogs(): Promise<boolean> {
- if (!this.page) return false;
-
- let closedAny = false;
-
- try {
- // 检查并关闭 Element UI / Vue 弹窗
- const modalSelectors = [
- // Element UI 弹窗关闭按钮
- '.el-dialog__close',
- '.el-dialog__headerbtn',
- '.el-message-box__close',
- '.el-overlay-dialog .el-icon-close',
- // 通用关闭按钮
- '[class*="modal"] [class*="close"]',
- '[class*="dialog"] [class*="close"]',
- '[role="dialog"] button[aria-label="close"]',
- '[role="dialog"] [class*="close"]',
- // 取消/关闭按钮
- '.el-dialog__footer button:has-text("取消")',
- '.el-dialog__footer button:has-text("关闭")',
- '[role="dialog"] button:has-text("取消")',
- '[role="dialog"] button:has-text("关闭")',
- // 遮罩层(点击遮罩关闭)
- '.el-overlay[style*="display: none"]',
- ];
-
- for (const selector of modalSelectors) {
- try {
- const closeBtn = this.page.locator(selector).first();
- if (await closeBtn.count() > 0 && await closeBtn.isVisible()) {
- logger.info(`[Xiaohongshu] Found modal close button: ${selector}`);
- await closeBtn.click({ timeout: 2000 });
- closedAny = true;
- await this.page.waitForTimeout(500);
- }
- } catch (e) {
- // 忽略错误,继续尝试下一个选择器
- }
- }
-
- // 尝试按 ESC 键关闭弹窗
- if (!closedAny) {
- const hasModal = await this.page.locator('.el-overlay-dialog, [role="dialog"]').count();
- if (hasModal > 0) {
- logger.info('[Xiaohongshu] Trying ESC key to close modal...');
- await this.page.keyboard.press('Escape');
- await this.page.waitForTimeout(500);
-
- // 检查是否关闭成功
- const stillHasModal = await this.page.locator('.el-overlay-dialog, [role="dialog"]').count();
- closedAny = stillHasModal < hasModal;
- }
- }
-
- if (closedAny) {
- logger.info('[Xiaohongshu] Successfully closed modal dialog');
- }
-
- } catch (error) {
- logger.warn('[Xiaohongshu] Error closing modal:', error);
- }
-
- return closedAny;
- }
- /**
- * 获取账号信息
- * 通过拦截 API 响应获取准确数据
- */
- 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;
- };
- homeData?: {
- fans?: number;
- notes?: number;
- };
- } = {};
- // 用于等待 API 响应的 Promise
- let resolvePersonalInfo: () => void;
- let resolveNotesCount: () => void;
- const personalInfoPromise = new Promise<void>((resolve) => { resolvePersonalInfo = resolve; });
- const notesCountPromise = new Promise<void>((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<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 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,
- });
- // 使用 AI 辅助发布接口
- const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/publish/ai-assisted`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- ...requestBody,
- return_screenshot: true,
- }),
- signal: AbortSignal.timeout(300000), // 5分钟超时
- });
- const result = await response.json();
- logger.info('[Xiaohongshu API] Response:', { ...result, screenshot_base64: result.screenshot_base64 ? '[截图已省略]' : undefined });
- // 使用通用的 AI 辅助处理方法
- return await this.aiProcessPythonPublishResult(result, undefined, onProgress);
- } 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 {
- logger.info('[Xiaohongshu Publish] Initializing browser...');
- await this.initBrowser({ headless: useHeadless });
-
- if (!this.page) {
- throw new Error('浏览器初始化失败,page 为 null');
- }
-
- logger.info('[Xiaohongshu Publish] Setting cookies...');
- await this.setCookies(cookies);
- if (!useHeadless) {
- logger.info('[Xiaohongshu Publish] Running in HEADFUL mode');
- onProgress?.(1, '已打开浏览器窗口,请注意查看...');
- }
- // 再次检查 page 状态
- if (!this.page) throw new Error('Page not initialized after setCookies');
- // 检查视频文件是否存在
- 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 {}
- // 上传视频 - 优先使用 AI 截图分析找到上传入口
- let uploadTriggered = false;
- // 方法1: AI 截图分析找到上传入口
- logger.info('[Xiaohongshu Publish] Using AI to find upload entry...');
- try {
- const screenshot = await this.screenshotBase64();
- const guide = await aiService.getPageOperationGuide(screenshot, 'xiaohongshu', '找到视频上传入口并点击上传按钮');
- logger.info(`[Xiaohongshu Publish] AI analysis result:`, guide);
- if (guide.hasAction && guide.targetSelector) {
- logger.info(`[Xiaohongshu Publish] AI suggested selector: ${guide.targetSelector}`);
- try {
- const [fileChooser] = await Promise.all([
- this.page.waitForEvent('filechooser', { timeout: 15000 }),
- this.page.click(guide.targetSelector),
- ]);
- await fileChooser.setFiles(params.videoPath);
- uploadTriggered = true;
- logger.info('[Xiaohongshu Publish] Upload triggered via AI selector');
- } catch (e) {
- logger.warn(`[Xiaohongshu Publish] AI selector click failed: ${e}`);
- }
- }
- } catch (e) {
- logger.warn(`[Xiaohongshu Publish] AI analysis failed: ${e}`);
- }
- // 方法2: 尝试点击常见的上传区域触发 file chooser
- if (!uploadTriggered) {
- logger.info('[Xiaohongshu Publish] Trying common upload selectors...');
- const uploadSelectors = [
- // 小红书常见上传区域选择器
- '[class*="upload-area"]',
- '[class*="upload-btn"]',
- '[class*="upload-trigger"]',
- '[class*="upload-container"]',
- '[class*="drag-area"]',
- 'div[class*="upload"] div',
- '.upload-wrapper',
- '.video-upload',
- 'button:has-text("上传")',
- 'div:has-text("上传视频"):not(:has(div))',
- 'span:has-text("上传视频")',
- '[class*="add-video"]',
- ];
-
- for (const selector of uploadSelectors) {
- if (uploadTriggered) break;
- try {
- const element = this.page.locator(selector).first();
- if (await element.count() > 0 && await element.isVisible()) {
- logger.info(`[Xiaohongshu Publish] Trying selector: ${selector}`);
- const [fileChooser] = await Promise.all([
- this.page.waitForEvent('filechooser', { timeout: 5000 }),
- element.click(),
- ]);
- await fileChooser.setFiles(params.videoPath);
- uploadTriggered = true;
- logger.info(`[Xiaohongshu Publish] Upload triggered via selector: ${selector}`);
- }
- } catch (e) {
- // 继续尝试下一个选择器
- }
- }
- }
- // 方法3: 直接设置 file input
- if (!uploadTriggered) {
- logger.info('[Xiaohongshu Publish] Trying direct file input...');
- const fileInputs = await this.page.$$('input[type="file"]');
- logger.info(`[Xiaohongshu Publish] Found ${fileInputs.length} file inputs`);
-
- for (const fileInput of fileInputs) {
- try {
- const accept = await fileInput.getAttribute('accept');
- if (!accept || accept.includes('video') || accept.includes('*')) {
- await fileInput.setInputFiles(params.videoPath);
- uploadTriggered = true;
- logger.info('[Xiaohongshu Publish] Upload triggered via file input');
-
- // 直接设置后需要等待一下,让页面响应
- 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;
- }
- }
- } catch (e) {
- logger.warn(`[Xiaohongshu Publish] File input method failed: ${e}`);
- }
- }
- }
- if (!uploadTriggered) {
- // 截图调试
- try {
- if (this.page) {
- 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}`);
- }
- } catch {}
- throw new Error('无法上传视频文件');
- }
- onProgress?.(15, '视频上传中...');
- // 等待视频上传完成
- const maxWaitTime = 300000; // 5分钟
- const startTime = Date.now();
- let lastAiCheckTime = 0;
- const aiCheckInterval = 10000; // 每10秒使用AI检测一次
- let uploadComplete = false;
- while (Date.now() - startTime < maxWaitTime && !uploadComplete) {
- 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}`);
- }
- // 检查上传进度(通过DOM)
- let progressDetected = false;
- 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}%`);
- progressDetected = true;
- if (progress >= 100) {
- logger.info('[Xiaohongshu Publish] Upload progress reached 100%');
- uploadComplete = true;
- break;
- }
- }
- }
- // 检查是否上传完成 - 扩展检测范围
- 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}`);
- uploadComplete = true;
- break;
- }
- }
-
- if (uploadComplete) break;
-
- // 如果标题输入框出现,说明可以开始填写了
- const titleInput = await this.page.locator('input[placeholder*="标题"]').count();
- if (titleInput > 0) {
- logger.info('[Xiaohongshu Publish] Title input found, upload must be complete');
- uploadComplete = true;
- break;
- }
- // 使用AI检测上传进度(每隔一段时间检测一次)
- if (!progressDetected && !uploadComplete && Date.now() - lastAiCheckTime > aiCheckInterval) {
- lastAiCheckTime = Date.now();
- try {
- const screenshot = await this.screenshotBase64();
- const uploadStatus = await aiService.analyzeUploadProgress(screenshot, 'xiaohongshu');
- logger.info(`[Xiaohongshu Publish] AI upload status:`, uploadStatus);
-
- if (uploadStatus.isComplete) {
- logger.info('[Xiaohongshu Publish] AI detected upload complete');
- uploadComplete = true;
- break;
- }
-
- if (uploadStatus.isFailed) {
- throw new Error(`视频上传失败: ${uploadStatus.statusDescription}`);
- }
-
- if (uploadStatus.progress !== null) {
- onProgress?.(15 + Math.floor(uploadStatus.progress * 0.4), `视频上传中: ${uploadStatus.progress}%`);
- if (uploadStatus.progress >= 100) {
- logger.info('[Xiaohongshu Publish] AI detected progress 100%');
- uploadComplete = true;
- break;
- }
- }
- } catch (aiError) {
- logger.warn('[Xiaohongshu Publish] AI progress check failed:', aiError);
- }
- }
- // 检查是否上传失败
- 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!');
- try {
- if (this.page) {
- const screenshotPath = `uploads/debug/xhs_not_in_edit_${Date.now()}.png`;
- await this.page.screenshot({ path: screenshotPath, fullPage: true });
- }
- } catch {}
- throw new Error('页面状态异常,请重试');
- }
- onProgress?.(85, '正在发布...');
- // 先关闭可能存在的弹窗
- await this.closeModalDialogs();
- // 滚动到页面底部,确保发布按钮可见
- 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...');
-
- // 先截图看当前页面状态
- try {
- if (this.page) {
- 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}`);
- }
- } catch {}
-
- 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 {
- if (this.page) {
- 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();
- let aiCheckCounter = 0;
- let lastProgressCheckTime = 0;
- const progressCheckInterval = 5000; // 每5秒检测一次发布进度
- 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(),
- };
- }
- // 检查发布进度条(DOM方式)
- const publishProgressText = await this.page.locator('[class*="progress"], [class*="loading"]').first().textContent().catch(() => '');
- if (publishProgressText) {
- const match = publishProgressText.match(/(\d+)%/);
- if (match) {
- const progress = parseInt(match[1]);
- onProgress?.(90 + Math.floor(progress * 0.1), `发布中: ${progress}%`);
- logger.info(`[Xiaohongshu Publish] Publish progress: ${progress}%`);
- }
- }
- // AI检测发布进度(定期检测)
- if (Date.now() - lastProgressCheckTime > progressCheckInterval) {
- lastProgressCheckTime = Date.now();
- try {
- const screenshot = await this.screenshotBase64();
- const publishStatus = await aiService.analyzePublishProgress(screenshot, 'xiaohongshu');
- logger.info(`[Xiaohongshu Publish] AI publish progress status:`, publishStatus);
-
- if (publishStatus.isComplete) {
- logger.info('[Xiaohongshu Publish] AI detected publish complete');
- onProgress?.(100, '发布成功!');
- await this.closeBrowser();
- return { success: true, videoUrl: this.page.url() };
- }
-
- if (publishStatus.isFailed) {
- throw new Error(`发布失败: ${publishStatus.statusDescription}`);
- }
-
- if (publishStatus.progress !== null) {
- onProgress?.(90 + Math.floor(publishStatus.progress * 0.1), `发布中: ${publishStatus.progress}%`);
- }
-
- if (publishStatus.isPublishing) {
- logger.info(`[Xiaohongshu Publish] Still publishing: ${publishStatus.statusDescription}`);
- }
- } catch (aiError) {
- logger.warn('[Xiaohongshu Publish] AI publish progress check failed:', aiError);
- }
- }
- // 检查错误提示
- const errorToast = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
- if (errorToast && (errorToast.includes('失败') || errorToast.includes('错误'))) {
- throw new Error(`发布失败: ${errorToast}`);
- }
- // AI 辅助检测发布状态(每隔几次循环)
- aiCheckCounter++;
- if (aiCheckCounter >= 3) {
- aiCheckCounter = 0;
- const aiStatus = await this.aiAnalyzePublishStatus();
- if (aiStatus) {
- logger.info(`[Xiaohongshu Publish] AI status: ${aiStatus.status}, confidence: ${aiStatus.confidence}%`);
- if (aiStatus.status === 'success' && aiStatus.confidence >= 70) {
- logger.info('[Xiaohongshu Publish] AI detected publish success');
- onProgress?.(100, '发布成功!');
- await this.closeBrowser();
- return {
- success: true,
- videoUrl: currentUrl,
- };
- }
- if (aiStatus.status === 'failed' && aiStatus.confidence >= 70) {
- logger.error(`[Xiaohongshu Publish] AI detected failure: ${aiStatus.errorMessage}`);
- throw new Error(aiStatus.errorMessage || 'AI 检测到发布失败');
- }
- // AI 检测到需要验证码
- if (aiStatus.status === 'need_captcha') {
- logger.warn('[Xiaohongshu Publish] AI detected captcha required');
- if (onCaptchaRequired) {
- const imageBase64 = await this.screenshotBase64();
- try {
- const captchaCode = await onCaptchaRequired({
- taskId: `xhs_captcha_${Date.now()}`,
- type: aiStatus.captchaType === 'sms' ? 'sms' : 'image',
- imageBase64,
- });
- if (captchaCode) {
- const guide = await this.aiGetPublishOperationGuide('需要输入验证码');
- if (guide?.hasAction && guide.targetSelector) {
- await this.page.fill(guide.targetSelector, captchaCode);
- await this.page.waitForTimeout(500);
- const confirmGuide = await this.aiGetPublishOperationGuide('已输入验证码,需要点击确认');
- if (confirmGuide?.hasAction && confirmGuide.targetSelector) {
- await this.page.click(confirmGuide.targetSelector);
- }
- }
- }
- } catch (captchaError) {
- logger.error('[Xiaohongshu Publish] Captcha handling failed:', captchaError);
- }
- }
- }
- // AI 建议需要操作
- if (aiStatus.status === 'need_action' && aiStatus.nextAction) {
- logger.info(`[Xiaohongshu Publish] AI suggests action: ${aiStatus.nextAction.targetDescription}`);
-
- // 先尝试关闭可能存在的弹窗
- await this.closeModalDialogs();
-
- const guide = await this.aiGetPublishOperationGuide(aiStatus.pageDescription);
- if (guide?.hasAction) {
- await this.aiExecuteOperation(guide);
- }
- }
- }
- }
- const elapsed = Math.floor((Date.now() - publishStartTime) / 1000);
- onProgress?.(90 + Math.min(9, Math.floor(elapsed / 15)), `等待发布完成 (${elapsed}s)...`);
- }
- // 超时,使用 AI 做最后一次状态检查
- const finalAiStatus = await this.aiAnalyzePublishStatus();
- if (finalAiStatus) {
- logger.info(`[Xiaohongshu Publish] Final AI status: ${finalAiStatus.status}`);
- if (finalAiStatus.status === 'success') {
- onProgress?.(100, '发布成功!');
- await this.closeBrowser();
- return {
- success: true,
- videoUrl: this.page.url(),
- };
- }
- if (finalAiStatus.status === 'failed') {
- throw new Error(finalAiStatus.errorMessage || 'AI 检测到发布失败');
- }
- }
- // 截图调试
- try {
- if (this.page) {
- 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<CommentData[]> {
- 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<CommentData[]> {
- // 优先尝试使用 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<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;
- }
- }
- /**
- * 删除已发布的作品
- * 使用小红书笔记管理页面: https://creator.xiaohongshu.com/new/note-manager
- */
- async deleteWork(
- cookies: string,
- noteId: string,
- onCaptchaRequired?: (captchaInfo: { taskId: string; imageUrl?: string }) => Promise<string>
- ): 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 {
- if (this.page) {
- 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 内的 <span>删除</span>
- 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 {
- if (this.page) {
- 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<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,
- };
- }
- }
- }
|