///
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,
};
}
}
}