xiaohongshu.ts 63 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735
  1. /// <reference lib="dom" />
  2. import path from 'path';
  3. import { BasePlatformAdapter } from './base.js';
  4. import type {
  5. AccountProfile,
  6. PublishParams,
  7. PublishResult,
  8. DateRange,
  9. AnalyticsData,
  10. CommentData,
  11. WorkItem,
  12. } from './base.js';
  13. import type { PlatformType, QRCodeInfo, LoginStatusResult } from '@media-manager/shared';
  14. import { logger } from '../../utils/logger.js';
  15. import { aiService } from '../../ai/index.js';
  16. // 小红书 Python API 服务配置
  17. const XHS_PYTHON_SERVICE_URL = process.env.XHS_SERVICE_URL || 'http://localhost:5005';
  18. // 服务器根目录(用于构造绝对路径)
  19. const SERVER_ROOT = path.resolve(process.cwd());
  20. /**
  21. * 小红书平台适配器
  22. */
  23. export class XiaohongshuAdapter extends BasePlatformAdapter {
  24. readonly platform: PlatformType = 'xiaohongshu';
  25. readonly loginUrl = 'https://creator.xiaohongshu.com/';
  26. readonly publishUrl = 'https://creator.xiaohongshu.com/publish/publish?from=menu&target=video';
  27. readonly creatorHomeUrl = 'https://creator.xiaohongshu.com/creator/home';
  28. readonly contentManageUrl = 'https://creator.xiaohongshu.com/creator/content';
  29. protected getCookieDomain(): string {
  30. return '.xiaohongshu.com';
  31. }
  32. /**
  33. * 获取扫码登录二维码
  34. */
  35. async getQRCode(): Promise<QRCodeInfo> {
  36. try {
  37. await this.initBrowser();
  38. if (!this.page) throw new Error('Page not initialized');
  39. // 访问创作者中心
  40. await this.page.goto(this.loginUrl);
  41. // 等待二维码出现
  42. await this.waitForSelector('[class*="qrcode"] img, .qrcode-image img', 30000);
  43. // 获取二维码图片
  44. const qrcodeImg = await this.page.$('[class*="qrcode"] img, .qrcode-image img');
  45. const qrcodeUrl = await qrcodeImg?.getAttribute('src');
  46. if (!qrcodeUrl) {
  47. throw new Error('Failed to get QR code');
  48. }
  49. const qrcodeKey = `xiaohongshu_${Date.now()}`;
  50. return {
  51. qrcodeUrl,
  52. qrcodeKey,
  53. expireTime: Date.now() + 300000, // 5分钟过期
  54. };
  55. } catch (error) {
  56. logger.error('Xiaohongshu getQRCode error:', error);
  57. throw error;
  58. }
  59. }
  60. /**
  61. * 检查扫码状态
  62. */
  63. async checkQRCodeStatus(qrcodeKey: string): Promise<LoginStatusResult> {
  64. try {
  65. if (!this.page) {
  66. return { status: 'expired', message: '二维码已过期' };
  67. }
  68. // 检查是否登录成功(URL 变化)
  69. const currentUrl = this.page.url();
  70. if (currentUrl.includes('/creator/home') || currentUrl.includes('/publish')) {
  71. // 登录成功,获取 cookie
  72. const cookies = await this.getCookies();
  73. await this.closeBrowser();
  74. return {
  75. status: 'success',
  76. message: '登录成功',
  77. cookies,
  78. };
  79. }
  80. // 检查是否扫码
  81. const scanTip = await this.page.$('[class*="scan-success"], [class*="scanned"]');
  82. if (scanTip) {
  83. return { status: 'scanned', message: '已扫码,请确认登录' };
  84. }
  85. return { status: 'waiting', message: '等待扫码' };
  86. } catch (error) {
  87. logger.error('Xiaohongshu checkQRCodeStatus error:', error);
  88. return { status: 'error', message: '检查状态失败' };
  89. }
  90. }
  91. /**
  92. * 检查登录状态
  93. */
  94. async checkLoginStatus(cookies: string): Promise<boolean> {
  95. try {
  96. await this.initBrowser({ headless: true });
  97. await this.setCookies(cookies);
  98. if (!this.page) throw new Error('Page not initialized');
  99. // 访问创作者中心
  100. await this.page.goto(this.creatorHomeUrl, {
  101. waitUntil: 'domcontentloaded',
  102. timeout: 30000,
  103. });
  104. await this.page.waitForTimeout(3000);
  105. const url = this.page.url();
  106. logger.info(`Xiaohongshu checkLoginStatus URL: ${url}`);
  107. // 如果被重定向到登录页面,说明未登录
  108. const isLoginPage = url.includes('login') || url.includes('passport');
  109. await this.closeBrowser();
  110. return !isLoginPage;
  111. } catch (error) {
  112. logger.error('Xiaohongshu checkLoginStatus error:', error);
  113. await this.closeBrowser();
  114. return false;
  115. }
  116. }
  117. /**
  118. * 关闭页面上可能存在的弹窗对话框
  119. */
  120. private async closeModalDialogs(): Promise<boolean> {
  121. if (!this.page) return false;
  122. let closedAny = false;
  123. try {
  124. // 检查并关闭 Element UI / Vue 弹窗
  125. const modalSelectors = [
  126. // Element UI 弹窗关闭按钮
  127. '.el-dialog__close',
  128. '.el-dialog__headerbtn',
  129. '.el-message-box__close',
  130. '.el-overlay-dialog .el-icon-close',
  131. // 通用关闭按钮
  132. '[class*="modal"] [class*="close"]',
  133. '[class*="dialog"] [class*="close"]',
  134. '[role="dialog"] button[aria-label="close"]',
  135. '[role="dialog"] [class*="close"]',
  136. // 取消/关闭按钮
  137. '.el-dialog__footer button:has-text("取消")',
  138. '.el-dialog__footer button:has-text("关闭")',
  139. '[role="dialog"] button:has-text("取消")',
  140. '[role="dialog"] button:has-text("关闭")',
  141. // 遮罩层(点击遮罩关闭)
  142. '.el-overlay[style*="display: none"]',
  143. ];
  144. for (const selector of modalSelectors) {
  145. try {
  146. const closeBtn = this.page.locator(selector).first();
  147. if (await closeBtn.count() > 0 && await closeBtn.isVisible()) {
  148. logger.info(`[Xiaohongshu] Found modal close button: ${selector}`);
  149. await closeBtn.click({ timeout: 2000 });
  150. closedAny = true;
  151. await this.page.waitForTimeout(500);
  152. }
  153. } catch (e) {
  154. // 忽略错误,继续尝试下一个选择器
  155. }
  156. }
  157. // 尝试按 ESC 键关闭弹窗
  158. if (!closedAny) {
  159. const hasModal = await this.page.locator('.el-overlay-dialog, [role="dialog"]').count();
  160. if (hasModal > 0) {
  161. logger.info('[Xiaohongshu] Trying ESC key to close modal...');
  162. await this.page.keyboard.press('Escape');
  163. await this.page.waitForTimeout(500);
  164. // 检查是否关闭成功
  165. const stillHasModal = await this.page.locator('.el-overlay-dialog, [role="dialog"]').count();
  166. closedAny = stillHasModal < hasModal;
  167. }
  168. }
  169. if (closedAny) {
  170. logger.info('[Xiaohongshu] Successfully closed modal dialog');
  171. }
  172. } catch (error) {
  173. logger.warn('[Xiaohongshu] Error closing modal:', error);
  174. }
  175. return closedAny;
  176. }
  177. /**
  178. * 获取账号信息
  179. * 通过拦截 API 响应获取准确数据
  180. */
  181. async getAccountInfo(cookies: string): Promise<AccountProfile> {
  182. try {
  183. await this.initBrowser({ headless: true });
  184. await this.setCookies(cookies);
  185. if (!this.page) throw new Error('Page not initialized');
  186. let accountId = `xiaohongshu_${Date.now()}`;
  187. let accountName = '小红书账号';
  188. let avatarUrl = '';
  189. let fansCount = 0;
  190. let worksCount = 0;
  191. let worksList: WorkItem[] = [];
  192. // 用于捕获 API 响应
  193. const capturedData: {
  194. userInfo?: {
  195. nickname?: string;
  196. avatar?: string;
  197. userId?: string;
  198. redId?: string;
  199. fans?: number;
  200. notes?: number;
  201. };
  202. homeData?: {
  203. fans?: number;
  204. notes?: number;
  205. };
  206. } = {};
  207. // 用于等待 API 响应的 Promise
  208. let resolvePersonalInfo: () => void;
  209. let resolveNotesCount: () => void;
  210. const personalInfoPromise = new Promise<void>((resolve) => { resolvePersonalInfo = resolve; });
  211. const notesCountPromise = new Promise<void>((resolve) => { resolveNotesCount = resolve; });
  212. // 设置超时自动 resolve
  213. setTimeout(() => resolvePersonalInfo(), 10000);
  214. setTimeout(() => resolveNotesCount(), 10000);
  215. // 设置 API 响应监听器
  216. this.page.on('response', async (response) => {
  217. const url = response.url();
  218. try {
  219. // 监听用户信息 API - personal_info 接口
  220. // URL: https://creator.xiaohongshu.com/api/galaxy/creator/home/personal_info
  221. // 返回结构: { data: { name, avatar, fans_count, red_num, follow_count, faved_count } }
  222. if (url.includes('/api/galaxy/creator/home/personal_info')) {
  223. const data = await response.json();
  224. logger.info(`[Xiaohongshu API] Personal info:`, JSON.stringify(data).slice(0, 1000));
  225. if (data?.data) {
  226. const info = data.data;
  227. capturedData.userInfo = {
  228. nickname: info.name,
  229. avatar: info.avatar,
  230. userId: info.red_num, // 小红书号
  231. redId: info.red_num,
  232. fans: info.fans_count,
  233. };
  234. logger.info(`[Xiaohongshu API] Captured personal info:`, capturedData.userInfo);
  235. }
  236. resolvePersonalInfo();
  237. }
  238. // 监听笔记列表 API (获取作品数) - 新版 edith API
  239. // URL: https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted
  240. // 返回结构: { data: { tags: [{ name: "所有笔记", notes_count: 1 }] } }
  241. if (url.includes('edith.xiaohongshu.com') && url.includes('/creator/note/user/posted')) {
  242. const data = await response.json();
  243. logger.info(`[Xiaohongshu API] Posted notes (edith):`, JSON.stringify(data).slice(0, 800));
  244. if (data?.data?.tags && Array.isArray(data.data.tags)) {
  245. // 从 tags 数组中找到 "所有笔记" 的 notes_count
  246. const allNotesTag = data.data.tags.find((tag: { id?: string; name?: string; notes_count?: number }) =>
  247. tag.id?.includes('note_time') || tag.name === '所有笔记'
  248. );
  249. if (allNotesTag?.notes_count !== undefined) {
  250. capturedData.homeData = capturedData.homeData || {};
  251. capturedData.homeData.notes = allNotesTag.notes_count;
  252. logger.info(`[Xiaohongshu API] Total notes from edith API: ${allNotesTag.notes_count}`);
  253. }
  254. }
  255. resolveNotesCount();
  256. }
  257. } catch (e) {
  258. // 忽略非 JSON 响应
  259. logger.debug(`[Xiaohongshu API] Failed to parse response: ${url}`);
  260. }
  261. });
  262. // 1. 先访问创作者首页获取用户信息
  263. // URL: https://creator.xiaohongshu.com/new/home
  264. // API: /api/galaxy/creator/home/personal_info
  265. logger.info('[Xiaohongshu] Navigating to creator home...');
  266. await this.page.goto('https://creator.xiaohongshu.com/new/home', {
  267. waitUntil: 'networkidle',
  268. timeout: 30000,
  269. });
  270. // 等待 personal_info API 响应
  271. await Promise.race([personalInfoPromise, this.page.waitForTimeout(5000)]);
  272. logger.info(`[Xiaohongshu] After home page, capturedData.userInfo:`, capturedData.userInfo);
  273. // 2. 再访问笔记管理页面获取作品数
  274. // URL: https://creator.xiaohongshu.com/new/note-manager
  275. // API: https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted
  276. logger.info('[Xiaohongshu] Navigating to note manager...');
  277. await this.page.goto('https://creator.xiaohongshu.com/new/note-manager', {
  278. waitUntil: 'networkidle',
  279. timeout: 30000,
  280. });
  281. // 等待 notes API 响应
  282. await Promise.race([notesCountPromise, this.page.waitForTimeout(5000)]);
  283. logger.info(`[Xiaohongshu] After note manager, capturedData.homeData:`, capturedData.homeData);
  284. // 检查是否需要登录
  285. const currentUrl = this.page.url();
  286. if (currentUrl.includes('login') || currentUrl.includes('passport')) {
  287. logger.warn('[Xiaohongshu] Cookie expired, needs login');
  288. await this.closeBrowser();
  289. return {
  290. accountId,
  291. accountName,
  292. avatarUrl,
  293. fansCount,
  294. worksCount,
  295. };
  296. }
  297. // 使用捕获的数据
  298. if (capturedData.userInfo) {
  299. if (capturedData.userInfo.nickname) accountName = capturedData.userInfo.nickname;
  300. if (capturedData.userInfo.avatar) avatarUrl = capturedData.userInfo.avatar;
  301. if (capturedData.userInfo.userId) accountId = `xiaohongshu_${capturedData.userInfo.userId}`;
  302. else if (capturedData.userInfo.redId) accountId = `xiaohongshu_${capturedData.userInfo.redId}`;
  303. if (capturedData.userInfo.fans !== undefined) fansCount = capturedData.userInfo.fans;
  304. }
  305. // homeData.notes 来自笔记列表 API,直接使用(优先级最高)
  306. if (capturedData.homeData) {
  307. if (capturedData.homeData.notes !== undefined) {
  308. worksCount = capturedData.homeData.notes;
  309. logger.info(`[Xiaohongshu] Using notes count from API: ${worksCount}`);
  310. }
  311. }
  312. // 如果 API 没捕获到,尝试从页面 DOM 获取
  313. if (fansCount === 0 || worksCount === 0) {
  314. const statsData = await this.page.evaluate(() => {
  315. const result = { fans: 0, notes: 0, name: '', avatar: '' };
  316. // 获取页面文本
  317. const allText = document.body.innerText;
  318. // 尝试匹配粉丝数
  319. const fansMatch = allText.match(/粉丝[::\s]*(\d+(?:\.\d+)?[万亿]?)|(\d+(?:\.\d+)?[万亿]?)\s*粉丝/);
  320. if (fansMatch) {
  321. const numStr = fansMatch[1] || fansMatch[2];
  322. result.fans = Math.floor(parseFloat(numStr) * (numStr.includes('万') ? 10000 : numStr.includes('亿') ? 100000000 : 1));
  323. }
  324. // 尝试匹配笔记数
  325. const notesMatch = allText.match(/笔记[::\s]*(\d+)|(\d+)\s*篇?笔记|共\s*(\d+)\s*篇/);
  326. if (notesMatch) {
  327. result.notes = parseInt(notesMatch[1] || notesMatch[2] || notesMatch[3]);
  328. }
  329. // 获取用户名
  330. const nameEl = document.querySelector('[class*="nickname"], [class*="user-name"], [class*="creator-name"]');
  331. if (nameEl) result.name = nameEl.textContent?.trim() || '';
  332. // 获取头像
  333. const avatarEl = document.querySelector('[class*="avatar"] img, [class*="user-avatar"] img');
  334. if (avatarEl) result.avatar = (avatarEl as HTMLImageElement).src || '';
  335. return result;
  336. });
  337. if (fansCount === 0 && statsData.fans > 0) fansCount = statsData.fans;
  338. if (worksCount === 0 && statsData.notes > 0) worksCount = statsData.notes;
  339. if ((!accountName || accountName === '小红书账号') && statsData.name) accountName = statsData.name;
  340. if (!avatarUrl && statsData.avatar) avatarUrl = statsData.avatar;
  341. }
  342. await this.closeBrowser();
  343. logger.info(`[Xiaohongshu] Account info: ${accountName}, ID: ${accountId}, Fans: ${fansCount}, Works: ${worksCount}`);
  344. return {
  345. accountId,
  346. accountName,
  347. avatarUrl,
  348. fansCount,
  349. worksCount,
  350. worksList,
  351. };
  352. } catch (error) {
  353. logger.error('Xiaohongshu getAccountInfo error:', error);
  354. await this.closeBrowser();
  355. return {
  356. accountId: `xiaohongshu_${Date.now()}`,
  357. accountName: '小红书账号',
  358. avatarUrl: '',
  359. fansCount: 0,
  360. worksCount: 0,
  361. };
  362. }
  363. }
  364. /**
  365. * 检查 Python API 服务是否可用
  366. */
  367. private async checkPythonServiceAvailable(): Promise<boolean> {
  368. try {
  369. const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/health`, {
  370. method: 'GET',
  371. signal: AbortSignal.timeout(3000),
  372. });
  373. if (response.ok) {
  374. const data = await response.json();
  375. return data.status === 'ok' && data.xhs_sdk === true;
  376. }
  377. return false;
  378. } catch {
  379. return false;
  380. }
  381. }
  382. /**
  383. * 通过 Python API 服务发布视频(推荐方式,更稳定)
  384. * 参考: matrix 项目的小红书发布逻辑
  385. */
  386. private async publishVideoViaApi(
  387. cookies: string,
  388. params: PublishParams,
  389. onProgress?: (progress: number, message: string) => void
  390. ): Promise<PublishResult> {
  391. logger.info('[Xiaohongshu API] Starting publish via Python API service...');
  392. onProgress?.(5, '正在通过 API 发布...');
  393. try {
  394. // 准备 cookie 字符串
  395. let cookieStr = cookies;
  396. // 如果 cookies 是 JSON 数组格式,转换为字符串格式
  397. try {
  398. const cookieArray = JSON.parse(cookies);
  399. if (Array.isArray(cookieArray)) {
  400. cookieStr = cookieArray.map((c: { name: string; value: string }) => `${c.name}=${c.value}`).join('; ');
  401. }
  402. } catch {
  403. // 已经是字符串格式
  404. }
  405. onProgress?.(10, '正在上传视频...');
  406. // 将相对路径转换为绝对路径
  407. const absoluteVideoPath = path.isAbsolute(params.videoPath)
  408. ? params.videoPath
  409. : path.resolve(SERVER_ROOT, params.videoPath);
  410. const absoluteCoverPath = params.coverPath
  411. ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath))
  412. : undefined;
  413. const requestBody = {
  414. platform: 'xiaohongshu',
  415. cookie: cookieStr,
  416. title: params.title,
  417. description: params.description || params.title,
  418. video_path: absoluteVideoPath,
  419. cover_path: absoluteCoverPath,
  420. tags: params.tags || [],
  421. post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null,
  422. };
  423. logger.info('[Xiaohongshu API] Request body:', {
  424. platform: requestBody.platform,
  425. title: requestBody.title,
  426. video_path: requestBody.video_path,
  427. has_cookie: !!requestBody.cookie,
  428. cookie_length: requestBody.cookie?.length || 0,
  429. });
  430. // 使用 AI 辅助发布接口
  431. const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/publish/ai-assisted`, {
  432. method: 'POST',
  433. headers: {
  434. 'Content-Type': 'application/json',
  435. },
  436. body: JSON.stringify({
  437. ...requestBody,
  438. return_screenshot: true,
  439. }),
  440. signal: AbortSignal.timeout(300000), // 5分钟超时
  441. });
  442. const result = await response.json();
  443. logger.info('[Xiaohongshu API] Response:', { ...result, screenshot_base64: result.screenshot_base64 ? '[截图已省略]' : undefined });
  444. // 使用通用的 AI 辅助处理方法
  445. return await this.aiProcessPythonPublishResult(result, undefined, onProgress);
  446. } catch (error) {
  447. logger.error('[Xiaohongshu API] Publish failed:', error);
  448. throw error;
  449. }
  450. }
  451. /**
  452. * 发布视频/笔记
  453. * 优先使用 Python API 服务(更稳定),如果不可用则回退到 Playwright 方式
  454. */
  455. async publishVideo(
  456. cookies: string,
  457. params: PublishParams,
  458. onProgress?: (progress: number, message: string) => void,
  459. onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise<string>,
  460. options?: { headless?: boolean }
  461. ): Promise<PublishResult> {
  462. // 优先尝试使用 Python API 服务
  463. const apiAvailable = await this.checkPythonServiceAvailable();
  464. if (apiAvailable) {
  465. logger.info('[Xiaohongshu] Python API service available, using API method');
  466. try {
  467. return await this.publishVideoViaApi(cookies, params, onProgress);
  468. } catch (apiError) {
  469. logger.warn('[Xiaohongshu] API publish failed, falling back to Playwright:', apiError);
  470. onProgress?.(0, 'API发布失败,正在切换到浏览器模式...');
  471. }
  472. } else {
  473. logger.info('[Xiaohongshu] Python API service not available, using Playwright method');
  474. }
  475. // 回退到 Playwright 方式
  476. const useHeadless = options?.headless ?? true;
  477. try {
  478. logger.info('[Xiaohongshu Publish] Initializing browser...');
  479. await this.initBrowser({ headless: useHeadless });
  480. if (!this.page) {
  481. throw new Error('浏览器初始化失败,page 为 null');
  482. }
  483. logger.info('[Xiaohongshu Publish] Setting cookies...');
  484. await this.setCookies(cookies);
  485. if (!useHeadless) {
  486. logger.info('[Xiaohongshu Publish] Running in HEADFUL mode');
  487. onProgress?.(1, '已打开浏览器窗口,请注意查看...');
  488. }
  489. // 再次检查 page 状态
  490. if (!this.page) throw new Error('Page not initialized after setCookies');
  491. // 检查视频文件是否存在
  492. const fs = await import('fs');
  493. if (!fs.existsSync(params.videoPath)) {
  494. throw new Error(`视频文件不存在: ${params.videoPath}`);
  495. }
  496. onProgress?.(5, '正在打开发布页面...');
  497. logger.info(`[Xiaohongshu Publish] Starting upload for: ${params.videoPath}`);
  498. // 访问发布页面
  499. await this.page.goto(this.publishUrl, {
  500. waitUntil: 'domcontentloaded',
  501. timeout: 60000,
  502. });
  503. await this.page.waitForTimeout(3000);
  504. // 检查是否需要登录
  505. const currentUrl = this.page.url();
  506. if (currentUrl.includes('login') || currentUrl.includes('passport')) {
  507. throw new Error('登录已过期,请重新登录');
  508. }
  509. logger.info(`[Xiaohongshu Publish] Page loaded: ${currentUrl}`);
  510. onProgress?.(10, '正在选择视频文件...');
  511. // 确保在"上传视频"标签页
  512. try {
  513. const videoTab = this.page.locator('div.tab:has-text("上传视频"), span:has-text("上传视频")').first();
  514. if (await videoTab.count() > 0) {
  515. await videoTab.click();
  516. await this.page.waitForTimeout(1000);
  517. logger.info('[Xiaohongshu Publish] Clicked video tab');
  518. }
  519. } catch {}
  520. // 上传视频 - 优先使用 AI 截图分析找到上传入口
  521. let uploadTriggered = false;
  522. // 方法1: AI 截图分析找到上传入口
  523. logger.info('[Xiaohongshu Publish] Using AI to find upload entry...');
  524. try {
  525. const screenshot = await this.screenshotBase64();
  526. const guide = await aiService.getPageOperationGuide(screenshot, 'xiaohongshu', '找到视频上传入口并点击上传按钮');
  527. logger.info(`[Xiaohongshu Publish] AI analysis result:`, guide);
  528. if (guide.hasAction && guide.targetSelector) {
  529. logger.info(`[Xiaohongshu Publish] AI suggested selector: ${guide.targetSelector}`);
  530. try {
  531. const [fileChooser] = await Promise.all([
  532. this.page.waitForEvent('filechooser', { timeout: 15000 }),
  533. this.page.click(guide.targetSelector),
  534. ]);
  535. await fileChooser.setFiles(params.videoPath);
  536. uploadTriggered = true;
  537. logger.info('[Xiaohongshu Publish] Upload triggered via AI selector');
  538. } catch (e) {
  539. logger.warn(`[Xiaohongshu Publish] AI selector click failed: ${e}`);
  540. }
  541. }
  542. } catch (e) {
  543. logger.warn(`[Xiaohongshu Publish] AI analysis failed: ${e}`);
  544. }
  545. // 方法2: 尝试点击常见的上传区域触发 file chooser
  546. if (!uploadTriggered) {
  547. logger.info('[Xiaohongshu Publish] Trying common upload selectors...');
  548. const uploadSelectors = [
  549. // 小红书常见上传区域选择器
  550. '[class*="upload-area"]',
  551. '[class*="upload-btn"]',
  552. '[class*="upload-trigger"]',
  553. '[class*="upload-container"]',
  554. '[class*="drag-area"]',
  555. 'div[class*="upload"] div',
  556. '.upload-wrapper',
  557. '.video-upload',
  558. 'button:has-text("上传")',
  559. 'div:has-text("上传视频"):not(:has(div))',
  560. 'span:has-text("上传视频")',
  561. '[class*="add-video"]',
  562. ];
  563. for (const selector of uploadSelectors) {
  564. if (uploadTriggered) break;
  565. try {
  566. const element = this.page.locator(selector).first();
  567. if (await element.count() > 0 && await element.isVisible()) {
  568. logger.info(`[Xiaohongshu Publish] Trying selector: ${selector}`);
  569. const [fileChooser] = await Promise.all([
  570. this.page.waitForEvent('filechooser', { timeout: 5000 }),
  571. element.click(),
  572. ]);
  573. await fileChooser.setFiles(params.videoPath);
  574. uploadTriggered = true;
  575. logger.info(`[Xiaohongshu Publish] Upload triggered via selector: ${selector}`);
  576. }
  577. } catch (e) {
  578. // 继续尝试下一个选择器
  579. }
  580. }
  581. }
  582. // 方法3: 直接设置 file input
  583. if (!uploadTriggered) {
  584. logger.info('[Xiaohongshu Publish] Trying direct file input...');
  585. const fileInputs = await this.page.$$('input[type="file"]');
  586. logger.info(`[Xiaohongshu Publish] Found ${fileInputs.length} file inputs`);
  587. for (const fileInput of fileInputs) {
  588. try {
  589. const accept = await fileInput.getAttribute('accept');
  590. if (!accept || accept.includes('video') || accept.includes('*')) {
  591. await fileInput.setInputFiles(params.videoPath);
  592. uploadTriggered = true;
  593. logger.info('[Xiaohongshu Publish] Upload triggered via file input');
  594. // 直接设置后需要等待一下,让页面响应
  595. await this.page.waitForTimeout(2000);
  596. // 检查页面是否有变化
  597. const hasChange = await this.page.locator('[class*="video-preview"], video, [class*="progress"], [class*="upload-success"]').count() > 0;
  598. if (hasChange) {
  599. logger.info('[Xiaohongshu Publish] Page responded to file input');
  600. break;
  601. }
  602. }
  603. } catch (e) {
  604. logger.warn(`[Xiaohongshu Publish] File input method failed: ${e}`);
  605. }
  606. }
  607. }
  608. if (!uploadTriggered) {
  609. // 截图调试
  610. try {
  611. if (this.page) {
  612. const screenshotPath = `uploads/debug/xhs_no_upload_${Date.now()}.png`;
  613. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  614. logger.info(`[Xiaohongshu Publish] Screenshot saved: ${screenshotPath}`);
  615. }
  616. } catch {}
  617. throw new Error('无法上传视频文件');
  618. }
  619. onProgress?.(15, '视频上传中...');
  620. // 等待视频上传完成
  621. const maxWaitTime = 300000; // 5分钟
  622. const startTime = Date.now();
  623. let lastAiCheckTime = 0;
  624. const aiCheckInterval = 10000; // 每10秒使用AI检测一次
  625. let uploadComplete = false;
  626. while (Date.now() - startTime < maxWaitTime && !uploadComplete) {
  627. await this.page.waitForTimeout(3000);
  628. // 检查当前URL是否变化(上传成功后可能跳转)
  629. const newUrl = this.page.url();
  630. if (newUrl !== currentUrl && !newUrl.includes('upload')) {
  631. logger.info(`[Xiaohongshu Publish] URL changed to: ${newUrl}`);
  632. }
  633. // 检查上传进度(通过DOM)
  634. let progressDetected = false;
  635. const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => '');
  636. if (progressText) {
  637. const match = progressText.match(/(\d+)%/);
  638. if (match) {
  639. const progress = parseInt(match[1]);
  640. onProgress?.(15 + Math.floor(progress * 0.4), `视频上传中: ${progress}%`);
  641. logger.info(`[Xiaohongshu Publish] Upload progress: ${progress}%`);
  642. progressDetected = true;
  643. if (progress >= 100) {
  644. logger.info('[Xiaohongshu Publish] Upload progress reached 100%');
  645. uploadComplete = true;
  646. break;
  647. }
  648. }
  649. }
  650. // 检查是否上传完成 - 扩展检测范围
  651. const uploadCompleteSelectors = [
  652. '[class*="upload-success"]',
  653. '[class*="video-preview"]',
  654. 'video',
  655. '[class*="cover"]', // 封面设置区域
  656. 'input[placeholder*="标题"]', // 标题输入框出现
  657. '[class*="title"] input',
  658. '[class*="editor"]', // 编辑器区域
  659. ];
  660. for (const selector of uploadCompleteSelectors) {
  661. const count = await this.page.locator(selector).count();
  662. if (count > 0) {
  663. logger.info(`[Xiaohongshu Publish] Video upload completed, found: ${selector}`);
  664. uploadComplete = true;
  665. break;
  666. }
  667. }
  668. if (uploadComplete) break;
  669. // 如果标题输入框出现,说明可以开始填写了
  670. const titleInput = await this.page.locator('input[placeholder*="标题"]').count();
  671. if (titleInput > 0) {
  672. logger.info('[Xiaohongshu Publish] Title input found, upload must be complete');
  673. uploadComplete = true;
  674. break;
  675. }
  676. // 使用AI检测上传进度(每隔一段时间检测一次)
  677. if (!progressDetected && !uploadComplete && Date.now() - lastAiCheckTime > aiCheckInterval) {
  678. lastAiCheckTime = Date.now();
  679. try {
  680. const screenshot = await this.screenshotBase64();
  681. const uploadStatus = await aiService.analyzeUploadProgress(screenshot, 'xiaohongshu');
  682. logger.info(`[Xiaohongshu Publish] AI upload status:`, uploadStatus);
  683. if (uploadStatus.isComplete) {
  684. logger.info('[Xiaohongshu Publish] AI detected upload complete');
  685. uploadComplete = true;
  686. break;
  687. }
  688. if (uploadStatus.isFailed) {
  689. throw new Error(`视频上传失败: ${uploadStatus.statusDescription}`);
  690. }
  691. if (uploadStatus.progress !== null) {
  692. onProgress?.(15 + Math.floor(uploadStatus.progress * 0.4), `视频上传中: ${uploadStatus.progress}%`);
  693. if (uploadStatus.progress >= 100) {
  694. logger.info('[Xiaohongshu Publish] AI detected progress 100%');
  695. uploadComplete = true;
  696. break;
  697. }
  698. }
  699. } catch (aiError) {
  700. logger.warn('[Xiaohongshu Publish] AI progress check failed:', aiError);
  701. }
  702. }
  703. // 检查是否上传失败
  704. const failText = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
  705. if (failText && failText.includes('失败')) {
  706. throw new Error(`视频上传失败: ${failText}`);
  707. }
  708. // 检查是否还在初始上传页面
  709. const stillOnUploadPage = await this.page.locator('div:has-text("拖拽视频到此")').count();
  710. if (stillOnUploadPage > 0 && Date.now() - startTime > 10000) {
  711. logger.warn('[Xiaohongshu Publish] Still on upload page after 10s, retrying upload...');
  712. // 可能需要重新触发上传
  713. break;
  714. }
  715. }
  716. onProgress?.(55, '正在填写笔记信息...');
  717. // 填写标题
  718. logger.info('[Xiaohongshu Publish] Filling title...');
  719. const titleSelectors = [
  720. 'input[placeholder*="标题"]',
  721. '[class*="title"] input',
  722. 'textarea[placeholder*="标题"]',
  723. ];
  724. for (const selector of titleSelectors) {
  725. const titleInput = this.page.locator(selector).first();
  726. if (await titleInput.count() > 0) {
  727. await titleInput.fill(params.title.slice(0, 20)); // 小红书标题限制20字
  728. logger.info(`[Xiaohongshu Publish] Title filled via: ${selector}`);
  729. break;
  730. }
  731. }
  732. // 填写描述/正文
  733. if (params.description) {
  734. logger.info('[Xiaohongshu Publish] Filling description...');
  735. const descSelectors = [
  736. '[class*="content-input"] [contenteditable="true"]',
  737. 'textarea[placeholder*="正文"]',
  738. '[class*="editor"] [contenteditable="true"]',
  739. '#post-textarea',
  740. ];
  741. for (const selector of descSelectors) {
  742. const descInput = this.page.locator(selector).first();
  743. if (await descInput.count() > 0) {
  744. await descInput.click();
  745. await this.page.keyboard.type(params.description, { delay: 30 });
  746. logger.info(`[Xiaohongshu Publish] Description filled via: ${selector}`);
  747. break;
  748. }
  749. }
  750. }
  751. onProgress?.(65, '正在添加话题标签...');
  752. // 添加话题标签 - 注意不要触发话题选择器弹窗
  753. // 小红书会自动识别 # 开头的话题,不需要从弹窗选择
  754. if (params.tags && params.tags.length > 0) {
  755. // 找到正文输入框
  756. const descSelectors = [
  757. '[class*="content-input"] [contenteditable="true"]',
  758. '[class*="editor"] [contenteditable="true"]',
  759. '#post-textarea',
  760. ];
  761. for (const selector of descSelectors) {
  762. const descInput = this.page.locator(selector).first();
  763. if (await descInput.count() > 0) {
  764. await descInput.click();
  765. // 添加空行后再添加标签
  766. await this.page.keyboard.press('Enter');
  767. for (const tag of params.tags) {
  768. await this.page.keyboard.type(`#${tag} `, { delay: 30 });
  769. }
  770. logger.info(`[Xiaohongshu Publish] Tags added: ${params.tags.join(', ')}`);
  771. break;
  772. }
  773. }
  774. await this.page.waitForTimeout(500);
  775. }
  776. onProgress?.(75, '等待处理完成...');
  777. // 等待视频处理完成,检查是否有"上传成功"标识
  778. await this.page.waitForTimeout(2000);
  779. // 检查当前页面是否还在编辑状态
  780. const stillInEditMode = await this.page.locator('input[placeholder*="标题"], [class*="video-preview"]').count() > 0;
  781. if (!stillInEditMode) {
  782. logger.error('[Xiaohongshu Publish] Page is no longer in edit mode!');
  783. try {
  784. if (this.page) {
  785. const screenshotPath = `uploads/debug/xhs_not_in_edit_${Date.now()}.png`;
  786. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  787. }
  788. } catch {}
  789. throw new Error('页面状态异常,请重试');
  790. }
  791. onProgress?.(85, '正在发布...');
  792. // 先关闭可能存在的弹窗
  793. await this.closeModalDialogs();
  794. // 滚动到页面底部,确保发布按钮可见
  795. logger.info('[Xiaohongshu Publish] Scrolling to bottom...');
  796. await this.page.evaluate(() => {
  797. window.scrollTo(0, document.body.scrollHeight);
  798. });
  799. await this.page.waitForTimeout(1000);
  800. // 点击发布按钮
  801. logger.info('[Xiaohongshu Publish] Looking for publish button...');
  802. // 先截图看当前页面状态
  803. try {
  804. if (this.page) {
  805. const beforeClickPath = `uploads/debug/xhs_before_publish_${Date.now()}.png`;
  806. await this.page.screenshot({ path: beforeClickPath, fullPage: true });
  807. logger.info(`[Xiaohongshu Publish] Screenshot before publish: ${beforeClickPath}`);
  808. }
  809. } catch {}
  810. let publishClicked = false;
  811. // 方法1: 使用 Playwright locator 点击(模拟真实鼠标点击)
  812. const publishBtnSelectors = [
  813. 'button.publishBtn',
  814. '.publishBtn',
  815. 'button.d-button.red',
  816. ];
  817. for (const selector of publishBtnSelectors) {
  818. try {
  819. const btn = this.page.locator(selector).first();
  820. const count = await btn.count();
  821. logger.info(`[Xiaohongshu Publish] Checking selector ${selector}: count=${count}`);
  822. if (count > 0 && await btn.isVisible()) {
  823. // 确保按钮在视口内
  824. await btn.scrollIntoViewIfNeeded();
  825. await this.page.waitForTimeout(500);
  826. // 获取按钮位置并使用鼠标点击
  827. const box = await btn.boundingBox();
  828. if (box) {
  829. // 使用 page.mouse.click 模拟真实鼠标点击
  830. const x = box.x + box.width / 2;
  831. const y = box.y + box.height / 2;
  832. logger.info(`[Xiaohongshu Publish] Clicking at position: (${x}, ${y})`);
  833. await this.page.mouse.click(x, y);
  834. publishClicked = true;
  835. logger.info(`[Xiaohongshu Publish] Publish button clicked via mouse.click: ${selector}`);
  836. break;
  837. }
  838. }
  839. } catch (e) {
  840. logger.warn(`[Xiaohongshu Publish] Failed with selector ${selector}:`, e);
  841. }
  842. }
  843. // 方法2: 使用 Playwright locator.click() 配合 force 选项
  844. if (!publishClicked) {
  845. try {
  846. const btn = this.page.locator('button.publishBtn').first();
  847. if (await btn.count() > 0) {
  848. logger.info('[Xiaohongshu Publish] Trying locator.click with force...');
  849. await btn.click({ force: true, timeout: 5000 });
  850. publishClicked = true;
  851. logger.info('[Xiaohongshu Publish] Publish button clicked via locator.click(force)');
  852. }
  853. } catch (e) {
  854. logger.warn('[Xiaohongshu Publish] locator.click(force) failed:', e);
  855. }
  856. }
  857. // 方法3: 使用 getByRole
  858. if (!publishClicked) {
  859. try {
  860. const publishBtn = this.page.getByRole('button', { name: '发布', exact: true });
  861. if (await publishBtn.count() > 0) {
  862. const buttons = await publishBtn.all();
  863. for (const btn of buttons) {
  864. if (await btn.isVisible() && await btn.isEnabled()) {
  865. // 使用鼠标点击
  866. const box = await btn.boundingBox();
  867. if (box) {
  868. const x = box.x + box.width / 2;
  869. const y = box.y + box.height / 2;
  870. await this.page.mouse.click(x, y);
  871. publishClicked = true;
  872. logger.info('[Xiaohongshu Publish] Publish button clicked via getByRole');
  873. break;
  874. }
  875. }
  876. }
  877. }
  878. } catch (e) {
  879. logger.warn('[Xiaohongshu Publish] getByRole failed:', e);
  880. }
  881. }
  882. // 如果还是没找到,尝试用 evaluate 直接查找和点击
  883. if (!publishClicked) {
  884. logger.info('[Xiaohongshu Publish] Trying evaluate method...');
  885. try {
  886. publishClicked = await this.page.evaluate(() => {
  887. // 查找所有包含"发布"文字的按钮
  888. const buttons = Array.from(document.querySelectorAll('button, div[role="button"]'));
  889. for (const btn of buttons) {
  890. const text = btn.textContent?.trim();
  891. // 找到只包含"发布"两个字的按钮(排除"发布笔记"等)
  892. if (text === '发布' && (btn as HTMLElement).offsetParent !== null) {
  893. (btn as HTMLElement).click();
  894. return true;
  895. }
  896. }
  897. return false;
  898. });
  899. if (publishClicked) {
  900. logger.info('[Xiaohongshu Publish] Publish button clicked via evaluate');
  901. }
  902. } catch (e) {
  903. logger.warn('[Xiaohongshu Publish] evaluate failed:', e);
  904. }
  905. }
  906. if (!publishClicked) {
  907. // 截图调试
  908. try {
  909. if (this.page) {
  910. const screenshotPath = `uploads/debug/xhs_no_publish_btn_${Date.now()}.png`;
  911. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  912. logger.info(`[Xiaohongshu Publish] Screenshot saved: ${screenshotPath}`);
  913. }
  914. } catch {}
  915. throw new Error('未找到发布按钮');
  916. }
  917. onProgress?.(90, '等待发布完成...');
  918. // 等待发布结果
  919. const publishMaxWait = 120000; // 2分钟
  920. const publishStartTime = Date.now();
  921. let aiCheckCounter = 0;
  922. let lastProgressCheckTime = 0;
  923. const progressCheckInterval = 5000; // 每5秒检测一次发布进度
  924. while (Date.now() - publishStartTime < publishMaxWait) {
  925. await this.page.waitForTimeout(3000);
  926. const currentUrl = this.page.url();
  927. // 检查是否跳转到内容管理页面
  928. if (currentUrl.includes('/content') || currentUrl.includes('/creator/home')) {
  929. logger.info('[Xiaohongshu Publish] Publish success! Redirected to content page');
  930. onProgress?.(100, '发布成功!');
  931. await this.closeBrowser();
  932. return {
  933. success: true,
  934. videoUrl: currentUrl,
  935. };
  936. }
  937. // 检查成功提示
  938. const successToast = await this.page.locator('[class*="success"]:has-text("成功"), [class*="toast"]:has-text("发布成功")').count();
  939. if (successToast > 0) {
  940. logger.info('[Xiaohongshu Publish] Found success toast');
  941. await this.page.waitForTimeout(2000);
  942. onProgress?.(100, '发布成功!');
  943. await this.closeBrowser();
  944. return {
  945. success: true,
  946. videoUrl: this.page.url(),
  947. };
  948. }
  949. // 检查发布进度条(DOM方式)
  950. const publishProgressText = await this.page.locator('[class*="progress"], [class*="loading"]').first().textContent().catch(() => '');
  951. if (publishProgressText) {
  952. const match = publishProgressText.match(/(\d+)%/);
  953. if (match) {
  954. const progress = parseInt(match[1]);
  955. onProgress?.(90 + Math.floor(progress * 0.1), `发布中: ${progress}%`);
  956. logger.info(`[Xiaohongshu Publish] Publish progress: ${progress}%`);
  957. }
  958. }
  959. // AI检测发布进度(定期检测)
  960. if (Date.now() - lastProgressCheckTime > progressCheckInterval) {
  961. lastProgressCheckTime = Date.now();
  962. try {
  963. const screenshot = await this.screenshotBase64();
  964. const publishStatus = await aiService.analyzePublishProgress(screenshot, 'xiaohongshu');
  965. logger.info(`[Xiaohongshu Publish] AI publish progress status:`, publishStatus);
  966. if (publishStatus.isComplete) {
  967. logger.info('[Xiaohongshu Publish] AI detected publish complete');
  968. onProgress?.(100, '发布成功!');
  969. await this.closeBrowser();
  970. return { success: true, videoUrl: this.page.url() };
  971. }
  972. if (publishStatus.isFailed) {
  973. throw new Error(`发布失败: ${publishStatus.statusDescription}`);
  974. }
  975. if (publishStatus.progress !== null) {
  976. onProgress?.(90 + Math.floor(publishStatus.progress * 0.1), `发布中: ${publishStatus.progress}%`);
  977. }
  978. if (publishStatus.isPublishing) {
  979. logger.info(`[Xiaohongshu Publish] Still publishing: ${publishStatus.statusDescription}`);
  980. }
  981. } catch (aiError) {
  982. logger.warn('[Xiaohongshu Publish] AI publish progress check failed:', aiError);
  983. }
  984. }
  985. // 检查错误提示
  986. const errorToast = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
  987. if (errorToast && (errorToast.includes('失败') || errorToast.includes('错误'))) {
  988. throw new Error(`发布失败: ${errorToast}`);
  989. }
  990. // AI 辅助检测发布状态(每隔几次循环)
  991. aiCheckCounter++;
  992. if (aiCheckCounter >= 3) {
  993. aiCheckCounter = 0;
  994. const aiStatus = await this.aiAnalyzePublishStatus();
  995. if (aiStatus) {
  996. logger.info(`[Xiaohongshu Publish] AI status: ${aiStatus.status}, confidence: ${aiStatus.confidence}%`);
  997. if (aiStatus.status === 'success' && aiStatus.confidence >= 70) {
  998. logger.info('[Xiaohongshu Publish] AI detected publish success');
  999. onProgress?.(100, '发布成功!');
  1000. await this.closeBrowser();
  1001. return {
  1002. success: true,
  1003. videoUrl: currentUrl,
  1004. };
  1005. }
  1006. if (aiStatus.status === 'failed' && aiStatus.confidence >= 70) {
  1007. logger.error(`[Xiaohongshu Publish] AI detected failure: ${aiStatus.errorMessage}`);
  1008. throw new Error(aiStatus.errorMessage || 'AI 检测到发布失败');
  1009. }
  1010. // AI 检测到需要验证码
  1011. if (aiStatus.status === 'need_captcha') {
  1012. logger.warn('[Xiaohongshu Publish] AI detected captcha required');
  1013. if (onCaptchaRequired) {
  1014. const imageBase64 = await this.screenshotBase64();
  1015. try {
  1016. const captchaCode = await onCaptchaRequired({
  1017. taskId: `xhs_captcha_${Date.now()}`,
  1018. type: aiStatus.captchaType === 'sms' ? 'sms' : 'image',
  1019. imageBase64,
  1020. });
  1021. if (captchaCode) {
  1022. const guide = await this.aiGetPublishOperationGuide('需要输入验证码');
  1023. if (guide?.hasAction && guide.targetSelector) {
  1024. await this.page.fill(guide.targetSelector, captchaCode);
  1025. await this.page.waitForTimeout(500);
  1026. const confirmGuide = await this.aiGetPublishOperationGuide('已输入验证码,需要点击确认');
  1027. if (confirmGuide?.hasAction && confirmGuide.targetSelector) {
  1028. await this.page.click(confirmGuide.targetSelector);
  1029. }
  1030. }
  1031. }
  1032. } catch (captchaError) {
  1033. logger.error('[Xiaohongshu Publish] Captcha handling failed:', captchaError);
  1034. }
  1035. }
  1036. }
  1037. // AI 建议需要操作
  1038. if (aiStatus.status === 'need_action' && aiStatus.nextAction) {
  1039. logger.info(`[Xiaohongshu Publish] AI suggests action: ${aiStatus.nextAction.targetDescription}`);
  1040. // 先尝试关闭可能存在的弹窗
  1041. await this.closeModalDialogs();
  1042. const guide = await this.aiGetPublishOperationGuide(aiStatus.pageDescription);
  1043. if (guide?.hasAction) {
  1044. await this.aiExecuteOperation(guide);
  1045. }
  1046. }
  1047. }
  1048. }
  1049. const elapsed = Math.floor((Date.now() - publishStartTime) / 1000);
  1050. onProgress?.(90 + Math.min(9, Math.floor(elapsed / 15)), `等待发布完成 (${elapsed}s)...`);
  1051. }
  1052. // 超时,使用 AI 做最后一次状态检查
  1053. const finalAiStatus = await this.aiAnalyzePublishStatus();
  1054. if (finalAiStatus) {
  1055. logger.info(`[Xiaohongshu Publish] Final AI status: ${finalAiStatus.status}`);
  1056. if (finalAiStatus.status === 'success') {
  1057. onProgress?.(100, '发布成功!');
  1058. await this.closeBrowser();
  1059. return {
  1060. success: true,
  1061. videoUrl: this.page.url(),
  1062. };
  1063. }
  1064. if (finalAiStatus.status === 'failed') {
  1065. throw new Error(finalAiStatus.errorMessage || 'AI 检测到发布失败');
  1066. }
  1067. }
  1068. // 截图调试
  1069. try {
  1070. if (this.page) {
  1071. const screenshotPath = `uploads/debug/xhs_publish_timeout_${Date.now()}.png`;
  1072. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  1073. logger.info(`[Xiaohongshu Publish] Timeout screenshot saved: ${screenshotPath}`);
  1074. }
  1075. } catch {}
  1076. throw new Error('发布超时,请手动检查是否发布成功');
  1077. } catch (error) {
  1078. logger.error('[Xiaohongshu Publish] Error:', error);
  1079. await this.closeBrowser();
  1080. return {
  1081. success: false,
  1082. errorMessage: error instanceof Error ? error.message : '发布失败',
  1083. };
  1084. }
  1085. }
  1086. /**
  1087. * 通过 Python API 获取评论
  1088. */
  1089. private async getCommentsViaPython(cookies: string, videoId: string): Promise<CommentData[]> {
  1090. logger.info('[Xiaohongshu] Getting comments via Python API...');
  1091. const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/comments`, {
  1092. method: 'POST',
  1093. headers: {
  1094. 'Content-Type': 'application/json',
  1095. },
  1096. body: JSON.stringify({
  1097. platform: 'xiaohongshu',
  1098. cookie: cookies,
  1099. work_id: videoId,
  1100. }),
  1101. });
  1102. if (!response.ok) {
  1103. throw new Error(`Python API returned ${response.status}`);
  1104. }
  1105. const result = await response.json();
  1106. if (!result.success) {
  1107. throw new Error(result.error || 'Failed to get comments');
  1108. }
  1109. // 转换数据格式
  1110. return (result.comments || []).map((comment: {
  1111. comment_id: string;
  1112. author_id: string;
  1113. author_name: string;
  1114. author_avatar: string;
  1115. content: string;
  1116. like_count: number;
  1117. create_time: string;
  1118. reply_count: number;
  1119. replies?: Array<{
  1120. comment_id: string;
  1121. author_id: string;
  1122. author_name: string;
  1123. author_avatar: string;
  1124. content: string;
  1125. like_count: number;
  1126. create_time: string;
  1127. }>;
  1128. }) => ({
  1129. commentId: comment.comment_id,
  1130. authorId: comment.author_id,
  1131. authorName: comment.author_name,
  1132. authorAvatar: comment.author_avatar,
  1133. content: comment.content,
  1134. likeCount: comment.like_count,
  1135. commentTime: comment.create_time,
  1136. replyCount: comment.reply_count,
  1137. replies: comment.replies?.map((reply: {
  1138. comment_id: string;
  1139. author_id: string;
  1140. author_name: string;
  1141. author_avatar: string;
  1142. content: string;
  1143. like_count: number;
  1144. create_time: string;
  1145. }) => ({
  1146. commentId: reply.comment_id,
  1147. authorId: reply.author_id,
  1148. authorName: reply.author_name,
  1149. authorAvatar: reply.author_avatar,
  1150. content: reply.content,
  1151. likeCount: reply.like_count,
  1152. commentTime: reply.create_time,
  1153. })),
  1154. }));
  1155. }
  1156. /**
  1157. * 获取评论列表
  1158. */
  1159. async getComments(cookies: string, videoId: string): Promise<CommentData[]> {
  1160. // 优先尝试使用 Python API
  1161. const pythonAvailable = await this.checkPythonServiceAvailable();
  1162. if (pythonAvailable) {
  1163. logger.info('[Xiaohongshu] Python service available, using Python API for comments');
  1164. try {
  1165. return await this.getCommentsViaPython(cookies, videoId);
  1166. } catch (pythonError) {
  1167. logger.warn('[Xiaohongshu] Python API getComments failed, falling back to Playwright:', pythonError);
  1168. }
  1169. }
  1170. // 回退到 Playwright 方式
  1171. try {
  1172. await this.initBrowser({ headless: true });
  1173. await this.setCookies(cookies);
  1174. if (!this.page) throw new Error('Page not initialized');
  1175. const comments: CommentData[] = [];
  1176. // 设置 API 响应监听器
  1177. this.page.on('response', async (response) => {
  1178. const url = response.url();
  1179. try {
  1180. // 监听评论列表 API
  1181. if (url.includes('/api/sns/web/v2/comment/page') ||
  1182. url.includes('/api/galaxy/creator/comment')) {
  1183. const data = await response.json();
  1184. logger.info(`[Xiaohongshu API] Comments response:`, JSON.stringify(data).slice(0, 500));
  1185. const commentList = data?.data?.comments || data?.comments || [];
  1186. for (const comment of commentList) {
  1187. comments.push({
  1188. commentId: comment.id || comment.comment_id || '',
  1189. authorId: comment.user_info?.user_id || comment.user_id || '',
  1190. authorName: comment.user_info?.nickname || comment.nickname || '',
  1191. authorAvatar: comment.user_info?.image || comment.avatar || '',
  1192. content: comment.content || '',
  1193. likeCount: comment.like_count || 0,
  1194. commentTime: comment.create_time || comment.time || '',
  1195. parentCommentId: comment.target_comment_id || undefined,
  1196. });
  1197. }
  1198. }
  1199. } catch {}
  1200. });
  1201. // 访问评论管理页面
  1202. await this.page.goto(`${this.contentManageUrl}?tab=comment`, {
  1203. waitUntil: 'domcontentloaded',
  1204. timeout: 30000,
  1205. });
  1206. await this.page.waitForTimeout(5000);
  1207. await this.closeBrowser();
  1208. return comments;
  1209. } catch (error) {
  1210. logger.error('Xiaohongshu getComments error:', error);
  1211. await this.closeBrowser();
  1212. return [];
  1213. }
  1214. }
  1215. /**
  1216. * 回复评论
  1217. */
  1218. async replyComment(cookies: string, commentId: string, content: string): Promise<boolean> {
  1219. try {
  1220. await this.initBrowser({ headless: true });
  1221. await this.setCookies(cookies);
  1222. if (!this.page) throw new Error('Page not initialized');
  1223. // 访问评论管理页面
  1224. await this.page.goto(`${this.contentManageUrl}?tab=comment`, {
  1225. waitUntil: 'networkidle',
  1226. timeout: 30000,
  1227. });
  1228. await this.page.waitForTimeout(2000);
  1229. // 找到对应评论并点击回复
  1230. const commentItem = this.page.locator(`[data-comment-id="${commentId}"], [data-id="${commentId}"]`).first();
  1231. if (await commentItem.count() > 0) {
  1232. const replyBtn = commentItem.locator('[class*="reply"], button:has-text("回复")').first();
  1233. if (await replyBtn.count() > 0) {
  1234. await replyBtn.click();
  1235. await this.page.waitForTimeout(500);
  1236. }
  1237. }
  1238. // 输入回复内容
  1239. const replyInput = this.page.locator('[class*="reply-input"] textarea, [class*="comment-input"] textarea').first();
  1240. if (await replyInput.count() > 0) {
  1241. await replyInput.fill(content);
  1242. await this.page.waitForTimeout(500);
  1243. // 点击发送
  1244. const sendBtn = this.page.locator('button:has-text("发送"), button:has-text("回复")').first();
  1245. if (await sendBtn.count() > 0) {
  1246. await sendBtn.click();
  1247. await this.page.waitForTimeout(2000);
  1248. }
  1249. }
  1250. await this.closeBrowser();
  1251. return true;
  1252. } catch (error) {
  1253. logger.error('Xiaohongshu replyComment error:', error);
  1254. await this.closeBrowser();
  1255. return false;
  1256. }
  1257. }
  1258. /**
  1259. * 删除已发布的作品
  1260. * 使用小红书笔记管理页面: https://creator.xiaohongshu.com/new/note-manager
  1261. */
  1262. async deleteWork(
  1263. cookies: string,
  1264. noteId: string,
  1265. onCaptchaRequired?: (captchaInfo: { taskId: string; imageUrl?: string }) => Promise<string>
  1266. ): Promise<{ success: boolean; errorMessage?: string }> {
  1267. try {
  1268. // 使用无头浏览器后台运行
  1269. await this.initBrowser({ headless: true });
  1270. await this.setCookies(cookies);
  1271. if (!this.page) throw new Error('Page not initialized');
  1272. logger.info(`[Xiaohongshu Delete] Starting delete for note: ${noteId}`);
  1273. // 访问笔记管理页面(新版)
  1274. const noteManagerUrl = 'https://creator.xiaohongshu.com/new/note-manager';
  1275. await this.page.goto(noteManagerUrl, {
  1276. waitUntil: 'networkidle',
  1277. timeout: 60000,
  1278. });
  1279. await this.page.waitForTimeout(3000);
  1280. // 检查是否需要登录
  1281. const currentUrl = this.page.url();
  1282. if (currentUrl.includes('login') || currentUrl.includes('passport')) {
  1283. throw new Error('登录已过期,请重新登录');
  1284. }
  1285. logger.info(`[Xiaohongshu Delete] Current URL: ${currentUrl}`);
  1286. // 截图用于调试
  1287. try {
  1288. if (this.page) {
  1289. const screenshotPath = `uploads/debug/xhs_delete_page_${Date.now()}.png`;
  1290. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  1291. logger.info(`[Xiaohongshu Delete] Page screenshot: ${screenshotPath}`);
  1292. }
  1293. } catch {}
  1294. // 在笔记管理页面找到对应的笔记行
  1295. // 页面结构:
  1296. // - 每条笔记是 div.note 元素
  1297. // - 笔记ID在 data-impression 属性的 JSON 中: noteId: "xxx"
  1298. // - 删除按钮是 span.control.data-del 内的 <span>删除</span>
  1299. let deleteClicked = false;
  1300. // 方式1: 通过 data-impression 属性找到对应笔记,然后点击其删除按钮
  1301. logger.info(`[Xiaohongshu Delete] Looking for note with ID: ${noteId}`);
  1302. // 查找所有笔记卡片
  1303. const noteCards = this.page.locator('div.note');
  1304. const noteCount = await noteCards.count();
  1305. logger.info(`[Xiaohongshu Delete] Found ${noteCount} note cards`);
  1306. for (let i = 0; i < noteCount; i++) {
  1307. const card = noteCards.nth(i);
  1308. const impression = await card.getAttribute('data-impression').catch(() => '');
  1309. // 检查 data-impression 中是否包含目标 noteId
  1310. if (impression && impression.includes(noteId)) {
  1311. logger.info(`[Xiaohongshu Delete] Found target note at index ${i}`);
  1312. // 在该笔记卡片内查找删除按钮 (span.data-del)
  1313. const deleteBtn = card.locator('span.data-del, span.control.data-del').first();
  1314. if (await deleteBtn.count() > 0) {
  1315. await deleteBtn.click();
  1316. deleteClicked = true;
  1317. logger.info(`[Xiaohongshu Delete] Clicked delete button for note ${noteId}`);
  1318. break;
  1319. }
  1320. }
  1321. }
  1322. // 方式2: 如果方式1没找到,尝试直接用 evaluate 在 DOM 中查找
  1323. if (!deleteClicked) {
  1324. logger.info('[Xiaohongshu Delete] Trying evaluate method to find note by data-impression...');
  1325. deleteClicked = await this.page.evaluate((nid: string) => {
  1326. // 查找所有 div.note 元素
  1327. const notes = document.querySelectorAll('div.note');
  1328. console.log(`[XHS Delete] Found ${notes.length} note elements`);
  1329. for (const note of notes) {
  1330. const impression = note.getAttribute('data-impression') || '';
  1331. if (impression.includes(nid)) {
  1332. console.log(`[XHS Delete] Found note with ID ${nid}`);
  1333. // 查找删除按钮
  1334. const deleteBtn = note.querySelector('span.data-del') ||
  1335. note.querySelector('.control.data-del');
  1336. if (deleteBtn) {
  1337. console.log(`[XHS Delete] Clicking delete button`);
  1338. (deleteBtn as HTMLElement).click();
  1339. return true;
  1340. }
  1341. }
  1342. }
  1343. return false;
  1344. }, noteId);
  1345. if (deleteClicked) {
  1346. logger.info('[Xiaohongshu Delete] Delete button clicked via evaluate');
  1347. }
  1348. }
  1349. // 方式3: 如果还没找到,尝试点击第一个可见的删除按钮
  1350. if (!deleteClicked) {
  1351. logger.info('[Xiaohongshu Delete] Trying to click first visible delete button...');
  1352. const allDeleteBtns = this.page.locator('span.data-del');
  1353. const btnCount = await allDeleteBtns.count();
  1354. logger.info(`[Xiaohongshu Delete] Found ${btnCount} delete buttons on page`);
  1355. for (let i = 0; i < btnCount; i++) {
  1356. const btn = allDeleteBtns.nth(i);
  1357. if (await btn.isVisible().catch(() => false)) {
  1358. await btn.click();
  1359. deleteClicked = true;
  1360. logger.info(`[Xiaohongshu Delete] Clicked delete button ${i}`);
  1361. break;
  1362. }
  1363. }
  1364. }
  1365. if (!deleteClicked) {
  1366. // 截图调试
  1367. try {
  1368. if (this.page) {
  1369. const screenshotPath = `uploads/debug/xhs_delete_no_btn_${Date.now()}.png`;
  1370. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  1371. logger.info(`[Xiaohongshu Delete] No delete button found, screenshot: ${screenshotPath}`);
  1372. }
  1373. } catch {}
  1374. throw new Error('未找到删除按钮');
  1375. }
  1376. await this.page.waitForTimeout(1000);
  1377. // 检查是否需要验证码
  1378. const captchaVisible = await this.page.locator('[class*="captcha"], [class*="verify"]').count() > 0;
  1379. if (captchaVisible && onCaptchaRequired) {
  1380. logger.info('[Xiaohongshu Delete] Captcha required');
  1381. // 点击发送验证码
  1382. const sendCodeBtn = this.page.locator('button:has-text("发送验证码"), button:has-text("获取验证码")').first();
  1383. if (await sendCodeBtn.count() > 0) {
  1384. await sendCodeBtn.click();
  1385. logger.info('[Xiaohongshu Delete] Verification code sent');
  1386. }
  1387. // 通过回调获取验证码
  1388. const taskId = `delete_xhs_${noteId}_${Date.now()}`;
  1389. const code = await onCaptchaRequired({ taskId });
  1390. if (code) {
  1391. // 输入验证码
  1392. const codeInput = this.page.locator('input[placeholder*="验证码"], input[type="text"]').first();
  1393. if (await codeInput.count() > 0) {
  1394. await codeInput.fill(code);
  1395. logger.info('[Xiaohongshu Delete] Verification code entered');
  1396. }
  1397. // 点击确认按钮
  1398. const confirmBtn = this.page.locator('button:has-text("确定"), button:has-text("确认")').first();
  1399. if (await confirmBtn.count() > 0) {
  1400. await confirmBtn.click();
  1401. await this.page.waitForTimeout(2000);
  1402. }
  1403. }
  1404. }
  1405. // 确认删除(可能有二次确认弹窗)
  1406. const confirmDeleteSelectors = [
  1407. 'button:has-text("确认删除")',
  1408. 'button:has-text("确定")',
  1409. 'button:has-text("确认")',
  1410. '[class*="modal"] button[class*="primary"]',
  1411. '[class*="dialog"] button[class*="confirm"]',
  1412. '.d-button.red:has-text("确")',
  1413. ];
  1414. for (const selector of confirmDeleteSelectors) {
  1415. const confirmBtn = this.page.locator(selector).first();
  1416. if (await confirmBtn.count() > 0 && await confirmBtn.isVisible()) {
  1417. await confirmBtn.click();
  1418. logger.info(`[Xiaohongshu Delete] Confirm button clicked via: ${selector}`);
  1419. await this.page.waitForTimeout(1000);
  1420. }
  1421. }
  1422. // 等待删除完成
  1423. await this.page.waitForTimeout(2000);
  1424. // 检查是否删除成功(页面刷新或出现成功提示)
  1425. const successToast = await this.page.locator('[class*="success"]:has-text("成功"), [class*="toast"]:has-text("删除成功")').count();
  1426. if (successToast > 0) {
  1427. logger.info('[Xiaohongshu Delete] Delete success toast found');
  1428. }
  1429. logger.info('[Xiaohongshu Delete] Delete completed');
  1430. await this.closeBrowser();
  1431. return { success: true };
  1432. } catch (error) {
  1433. logger.error('[Xiaohongshu Delete] Error:', error);
  1434. await this.closeBrowser();
  1435. return {
  1436. success: false,
  1437. errorMessage: error instanceof Error ? error.message : '删除失败',
  1438. };
  1439. }
  1440. }
  1441. /**
  1442. * 获取数据统计
  1443. */
  1444. async getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData> {
  1445. try {
  1446. await this.initBrowser({ headless: true });
  1447. await this.setCookies(cookies);
  1448. if (!this.page) throw new Error('Page not initialized');
  1449. const analytics: AnalyticsData = {
  1450. fansCount: 0,
  1451. fansIncrease: 0,
  1452. viewsCount: 0,
  1453. likesCount: 0,
  1454. commentsCount: 0,
  1455. sharesCount: 0,
  1456. };
  1457. // 设置 API 响应监听器
  1458. this.page.on('response', async (response) => {
  1459. const url = response.url();
  1460. try {
  1461. if (url.includes('/api/galaxy/creator/data') ||
  1462. url.includes('/api/galaxy/creator/home')) {
  1463. const data = await response.json();
  1464. if (data?.data) {
  1465. const d = data.data;
  1466. analytics.fansCount = d.fans_count || analytics.fansCount;
  1467. analytics.fansIncrease = d.fans_increase || analytics.fansIncrease;
  1468. analytics.viewsCount = d.view_count || d.read_count || analytics.viewsCount;
  1469. analytics.likesCount = d.like_count || analytics.likesCount;
  1470. analytics.commentsCount = d.comment_count || analytics.commentsCount;
  1471. analytics.sharesCount = d.collect_count || analytics.sharesCount;
  1472. }
  1473. }
  1474. } catch {}
  1475. });
  1476. // 访问数据中心
  1477. await this.page.goto('https://creator.xiaohongshu.com/creator/data', {
  1478. waitUntil: 'domcontentloaded',
  1479. timeout: 30000,
  1480. });
  1481. await this.page.waitForTimeout(5000);
  1482. await this.closeBrowser();
  1483. return analytics;
  1484. } catch (error) {
  1485. logger.error('Xiaohongshu getAnalytics error:', error);
  1486. await this.closeBrowser();
  1487. return {
  1488. fansCount: 0,
  1489. fansIncrease: 0,
  1490. viewsCount: 0,
  1491. likesCount: 0,
  1492. commentsCount: 0,
  1493. sharesCount: 0,
  1494. };
  1495. }
  1496. }
  1497. }