xiaohongshu.ts 64 KB

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