xiaohongshu.ts 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478
  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. // 小红书 Python API 服务配置
  16. const XHS_PYTHON_SERVICE_URL = process.env.XHS_SERVICE_URL || 'http://localhost:5005';
  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';
  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. * 通过拦截 API 响应获取准确数据
  119. */
  120. async getAccountInfo(cookies: string): Promise<AccountProfile> {
  121. try {
  122. await this.initBrowser({ headless: true });
  123. await this.setCookies(cookies);
  124. if (!this.page) throw new Error('Page not initialized');
  125. let accountId = `xiaohongshu_${Date.now()}`;
  126. let accountName = '小红书账号';
  127. let avatarUrl = '';
  128. let fansCount = 0;
  129. let worksCount = 0;
  130. let worksList: WorkItem[] = [];
  131. // 用于捕获 API 响应
  132. const capturedData: {
  133. userInfo?: {
  134. nickname?: string;
  135. avatar?: string;
  136. userId?: string;
  137. redId?: string;
  138. fans?: number;
  139. notes?: number;
  140. };
  141. homeData?: {
  142. fans?: number;
  143. notes?: number;
  144. };
  145. } = {};
  146. // 用于等待 API 响应的 Promise
  147. let resolvePersonalInfo: () => void;
  148. let resolveNotesCount: () => void;
  149. const personalInfoPromise = new Promise<void>((resolve) => { resolvePersonalInfo = resolve; });
  150. const notesCountPromise = new Promise<void>((resolve) => { resolveNotesCount = resolve; });
  151. // 设置超时自动 resolve
  152. setTimeout(() => resolvePersonalInfo(), 10000);
  153. setTimeout(() => resolveNotesCount(), 10000);
  154. // 设置 API 响应监听器
  155. this.page.on('response', async (response) => {
  156. const url = response.url();
  157. try {
  158. // 监听用户信息 API - personal_info 接口
  159. // URL: https://creator.xiaohongshu.com/api/galaxy/creator/home/personal_info
  160. // 返回结构: { data: { name, avatar, fans_count, red_num, follow_count, faved_count } }
  161. if (url.includes('/api/galaxy/creator/home/personal_info')) {
  162. const data = await response.json();
  163. logger.info(`[Xiaohongshu API] Personal info:`, JSON.stringify(data).slice(0, 1000));
  164. if (data?.data) {
  165. const info = data.data;
  166. capturedData.userInfo = {
  167. nickname: info.name,
  168. avatar: info.avatar,
  169. userId: info.red_num, // 小红书号
  170. redId: info.red_num,
  171. fans: info.fans_count,
  172. };
  173. logger.info(`[Xiaohongshu API] Captured personal info:`, capturedData.userInfo);
  174. }
  175. resolvePersonalInfo();
  176. }
  177. // 监听笔记列表 API (获取作品数) - 新版 edith API
  178. // URL: https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted
  179. // 返回结构: { data: { tags: [{ name: "所有笔记", notes_count: 1 }] } }
  180. if (url.includes('edith.xiaohongshu.com') && url.includes('/creator/note/user/posted')) {
  181. const data = await response.json();
  182. logger.info(`[Xiaohongshu API] Posted notes (edith):`, JSON.stringify(data).slice(0, 800));
  183. if (data?.data?.tags && Array.isArray(data.data.tags)) {
  184. // 从 tags 数组中找到 "所有笔记" 的 notes_count
  185. const allNotesTag = data.data.tags.find((tag: { id?: string; name?: string; notes_count?: number }) =>
  186. tag.id?.includes('note_time') || tag.name === '所有笔记'
  187. );
  188. if (allNotesTag?.notes_count !== undefined) {
  189. capturedData.homeData = capturedData.homeData || {};
  190. capturedData.homeData.notes = allNotesTag.notes_count;
  191. logger.info(`[Xiaohongshu API] Total notes from edith API: ${allNotesTag.notes_count}`);
  192. }
  193. }
  194. resolveNotesCount();
  195. }
  196. } catch (e) {
  197. // 忽略非 JSON 响应
  198. logger.debug(`[Xiaohongshu API] Failed to parse response: ${url}`);
  199. }
  200. });
  201. // 1. 先访问创作者首页获取用户信息
  202. // URL: https://creator.xiaohongshu.com/new/home
  203. // API: /api/galaxy/creator/home/personal_info
  204. logger.info('[Xiaohongshu] Navigating to creator home...');
  205. await this.page.goto('https://creator.xiaohongshu.com/new/home', {
  206. waitUntil: 'networkidle',
  207. timeout: 30000,
  208. });
  209. // 等待 personal_info API 响应
  210. await Promise.race([personalInfoPromise, this.page.waitForTimeout(5000)]);
  211. logger.info(`[Xiaohongshu] After home page, capturedData.userInfo:`, capturedData.userInfo);
  212. // 2. 再访问笔记管理页面获取作品数
  213. // URL: https://creator.xiaohongshu.com/new/note-manager
  214. // API: https://edith.xiaohongshu.com/web_api/sns/v5/creator/note/user/posted
  215. logger.info('[Xiaohongshu] Navigating to note manager...');
  216. await this.page.goto('https://creator.xiaohongshu.com/new/note-manager', {
  217. waitUntil: 'networkidle',
  218. timeout: 30000,
  219. });
  220. // 等待 notes API 响应
  221. await Promise.race([notesCountPromise, this.page.waitForTimeout(5000)]);
  222. logger.info(`[Xiaohongshu] After note manager, capturedData.homeData:`, capturedData.homeData);
  223. // 检查是否需要登录
  224. const currentUrl = this.page.url();
  225. if (currentUrl.includes('login') || currentUrl.includes('passport')) {
  226. logger.warn('[Xiaohongshu] Cookie expired, needs login');
  227. await this.closeBrowser();
  228. return {
  229. accountId,
  230. accountName,
  231. avatarUrl,
  232. fansCount,
  233. worksCount,
  234. };
  235. }
  236. // 使用捕获的数据
  237. if (capturedData.userInfo) {
  238. if (capturedData.userInfo.nickname) accountName = capturedData.userInfo.nickname;
  239. if (capturedData.userInfo.avatar) avatarUrl = capturedData.userInfo.avatar;
  240. if (capturedData.userInfo.userId) accountId = `xiaohongshu_${capturedData.userInfo.userId}`;
  241. else if (capturedData.userInfo.redId) accountId = `xiaohongshu_${capturedData.userInfo.redId}`;
  242. if (capturedData.userInfo.fans !== undefined) fansCount = capturedData.userInfo.fans;
  243. }
  244. // homeData.notes 来自笔记列表 API,直接使用(优先级最高)
  245. if (capturedData.homeData) {
  246. if (capturedData.homeData.notes !== undefined) {
  247. worksCount = capturedData.homeData.notes;
  248. logger.info(`[Xiaohongshu] Using notes count from API: ${worksCount}`);
  249. }
  250. }
  251. // 如果 API 没捕获到,尝试从页面 DOM 获取
  252. if (fansCount === 0 || worksCount === 0) {
  253. const statsData = await this.page.evaluate(() => {
  254. const result = { fans: 0, notes: 0, name: '', avatar: '' };
  255. // 获取页面文本
  256. const allText = document.body.innerText;
  257. // 尝试匹配粉丝数
  258. const fansMatch = allText.match(/粉丝[::\s]*(\d+(?:\.\d+)?[万亿]?)|(\d+(?:\.\d+)?[万亿]?)\s*粉丝/);
  259. if (fansMatch) {
  260. const numStr = fansMatch[1] || fansMatch[2];
  261. result.fans = Math.floor(parseFloat(numStr) * (numStr.includes('万') ? 10000 : numStr.includes('亿') ? 100000000 : 1));
  262. }
  263. // 尝试匹配笔记数
  264. const notesMatch = allText.match(/笔记[::\s]*(\d+)|(\d+)\s*篇?笔记|共\s*(\d+)\s*篇/);
  265. if (notesMatch) {
  266. result.notes = parseInt(notesMatch[1] || notesMatch[2] || notesMatch[3]);
  267. }
  268. // 获取用户名
  269. const nameEl = document.querySelector('[class*="nickname"], [class*="user-name"], [class*="creator-name"]');
  270. if (nameEl) result.name = nameEl.textContent?.trim() || '';
  271. // 获取头像
  272. const avatarEl = document.querySelector('[class*="avatar"] img, [class*="user-avatar"] img');
  273. if (avatarEl) result.avatar = (avatarEl as HTMLImageElement).src || '';
  274. return result;
  275. });
  276. if (fansCount === 0 && statsData.fans > 0) fansCount = statsData.fans;
  277. if (worksCount === 0 && statsData.notes > 0) worksCount = statsData.notes;
  278. if ((!accountName || accountName === '小红书账号') && statsData.name) accountName = statsData.name;
  279. if (!avatarUrl && statsData.avatar) avatarUrl = statsData.avatar;
  280. }
  281. await this.closeBrowser();
  282. logger.info(`[Xiaohongshu] Account info: ${accountName}, ID: ${accountId}, Fans: ${fansCount}, Works: ${worksCount}`);
  283. return {
  284. accountId,
  285. accountName,
  286. avatarUrl,
  287. fansCount,
  288. worksCount,
  289. worksList,
  290. };
  291. } catch (error) {
  292. logger.error('Xiaohongshu getAccountInfo error:', error);
  293. await this.closeBrowser();
  294. return {
  295. accountId: `xiaohongshu_${Date.now()}`,
  296. accountName: '小红书账号',
  297. avatarUrl: '',
  298. fansCount: 0,
  299. worksCount: 0,
  300. };
  301. }
  302. }
  303. /**
  304. * 检查 Python API 服务是否可用
  305. */
  306. private async checkPythonServiceAvailable(): Promise<boolean> {
  307. try {
  308. const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/health`, {
  309. method: 'GET',
  310. signal: AbortSignal.timeout(3000),
  311. });
  312. if (response.ok) {
  313. const data = await response.json();
  314. return data.status === 'ok' && data.xhs_sdk === true;
  315. }
  316. return false;
  317. } catch {
  318. return false;
  319. }
  320. }
  321. /**
  322. * 通过 Python API 服务发布视频(推荐方式,更稳定)
  323. * 参考: matrix 项目的小红书发布逻辑
  324. */
  325. private async publishVideoViaApi(
  326. cookies: string,
  327. params: PublishParams,
  328. onProgress?: (progress: number, message: string) => void
  329. ): Promise<PublishResult> {
  330. logger.info('[Xiaohongshu API] Starting publish via Python API service...');
  331. onProgress?.(5, '正在通过 API 发布...');
  332. try {
  333. // 准备 cookie 字符串
  334. let cookieStr = cookies;
  335. // 如果 cookies 是 JSON 数组格式,转换为字符串格式
  336. try {
  337. const cookieArray = JSON.parse(cookies);
  338. if (Array.isArray(cookieArray)) {
  339. cookieStr = cookieArray.map((c: { name: string; value: string }) => `${c.name}=${c.value}`).join('; ');
  340. }
  341. } catch {
  342. // 已经是字符串格式
  343. }
  344. onProgress?.(10, '正在上传视频...');
  345. // 将相对路径转换为绝对路径
  346. const absoluteVideoPath = path.isAbsolute(params.videoPath)
  347. ? params.videoPath
  348. : path.resolve(SERVER_ROOT, params.videoPath);
  349. const absoluteCoverPath = params.coverPath
  350. ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath))
  351. : undefined;
  352. const requestBody = {
  353. platform: 'xiaohongshu',
  354. cookie: cookieStr,
  355. title: params.title,
  356. description: params.description || params.title,
  357. video_path: absoluteVideoPath,
  358. cover_path: absoluteCoverPath,
  359. tags: params.tags || [],
  360. post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null,
  361. };
  362. logger.info('[Xiaohongshu API] Request body:', {
  363. platform: requestBody.platform,
  364. title: requestBody.title,
  365. video_path: requestBody.video_path,
  366. has_cookie: !!requestBody.cookie,
  367. cookie_length: requestBody.cookie?.length || 0,
  368. });
  369. const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/publish`, {
  370. method: 'POST',
  371. headers: {
  372. 'Content-Type': 'application/json',
  373. },
  374. body: JSON.stringify(requestBody),
  375. signal: AbortSignal.timeout(300000), // 5分钟超时
  376. });
  377. const result = await response.json();
  378. logger.info('[Xiaohongshu API] Response:', result);
  379. if (result.success) {
  380. onProgress?.(100, '发布成功');
  381. logger.info('[Xiaohongshu API] Publish successful:', result.data);
  382. return {
  383. success: true,
  384. videoId: result.data?.note_id || `xhs_${Date.now()}`,
  385. videoUrl: result.data?.url || '',
  386. message: '发布成功',
  387. };
  388. } else {
  389. throw new Error(result.error || '发布失败');
  390. }
  391. } catch (error) {
  392. logger.error('[Xiaohongshu API] Publish failed:', error);
  393. throw error;
  394. }
  395. }
  396. /**
  397. * 发布视频/笔记
  398. * 优先使用 Python API 服务(更稳定),如果不可用则回退到 Playwright 方式
  399. */
  400. async publishVideo(
  401. cookies: string,
  402. params: PublishParams,
  403. onProgress?: (progress: number, message: string) => void,
  404. onCaptchaRequired?: (captchaInfo: { taskId: string; phone?: string }) => Promise<string>,
  405. options?: { headless?: boolean }
  406. ): Promise<PublishResult> {
  407. // 优先尝试使用 Python API 服务
  408. const apiAvailable = await this.checkPythonServiceAvailable();
  409. if (apiAvailable) {
  410. logger.info('[Xiaohongshu] Python API service available, using API method');
  411. try {
  412. return await this.publishVideoViaApi(cookies, params, onProgress);
  413. } catch (apiError) {
  414. logger.warn('[Xiaohongshu] API publish failed, falling back to Playwright:', apiError);
  415. onProgress?.(0, 'API发布失败,正在切换到浏览器模式...');
  416. }
  417. } else {
  418. logger.info('[Xiaohongshu] Python API service not available, using Playwright method');
  419. }
  420. // 回退到 Playwright 方式
  421. const useHeadless = options?.headless ?? true;
  422. try {
  423. await this.initBrowser({ headless: useHeadless });
  424. await this.setCookies(cookies);
  425. if (!useHeadless) {
  426. logger.info('[Xiaohongshu Publish] Running in HEADFUL mode');
  427. onProgress?.(1, '已打开浏览器窗口,请注意查看...');
  428. }
  429. if (!this.page) throw new Error('Page not initialized');
  430. // 检查视频文件是否存在
  431. const fs = await import('fs');
  432. if (!fs.existsSync(params.videoPath)) {
  433. throw new Error(`视频文件不存在: ${params.videoPath}`);
  434. }
  435. onProgress?.(5, '正在打开发布页面...');
  436. logger.info(`[Xiaohongshu Publish] Starting upload for: ${params.videoPath}`);
  437. // 访问发布页面
  438. await this.page.goto(this.publishUrl, {
  439. waitUntil: 'domcontentloaded',
  440. timeout: 60000,
  441. });
  442. await this.page.waitForTimeout(3000);
  443. // 检查是否需要登录
  444. const currentUrl = this.page.url();
  445. if (currentUrl.includes('login') || currentUrl.includes('passport')) {
  446. throw new Error('登录已过期,请重新登录');
  447. }
  448. logger.info(`[Xiaohongshu Publish] Page loaded: ${currentUrl}`);
  449. onProgress?.(10, '正在选择视频文件...');
  450. // 确保在"上传视频"标签页
  451. try {
  452. const videoTab = this.page.locator('div.tab:has-text("上传视频"), span:has-text("上传视频")').first();
  453. if (await videoTab.count() > 0) {
  454. await videoTab.click();
  455. await this.page.waitForTimeout(1000);
  456. logger.info('[Xiaohongshu Publish] Clicked video tab');
  457. }
  458. } catch {}
  459. // 上传视频文件 - 小红书需要点击"上传视频"按钮触发文件选择
  460. let uploadTriggered = false;
  461. // 方法1: 点击"上传视频"按钮触发 file chooser
  462. try {
  463. logger.info('[Xiaohongshu Publish] Looking for upload button...');
  464. // 小红书的上传按钮通常显示"上传视频"文字
  465. const uploadBtnSelectors = [
  466. 'button:has-text("上传视频")',
  467. 'div:has-text("上传视频"):not(:has(*))', // 纯文字的 div
  468. '[class*="upload-btn"]',
  469. '[class*="upload"] button',
  470. 'span:has-text("上传视频")',
  471. ];
  472. for (const selector of uploadBtnSelectors) {
  473. try {
  474. const uploadBtn = this.page.locator(selector).first();
  475. if (await uploadBtn.count() > 0 && await uploadBtn.isVisible()) {
  476. logger.info(`[Xiaohongshu Publish] Found upload button via: ${selector}`);
  477. const [fileChooser] = await Promise.all([
  478. this.page.waitForEvent('filechooser', { timeout: 15000 }),
  479. uploadBtn.click(),
  480. ]);
  481. await fileChooser.setFiles(params.videoPath);
  482. uploadTriggered = true;
  483. logger.info('[Xiaohongshu Publish] File selected via file chooser (button click)');
  484. break;
  485. }
  486. } catch (e) {
  487. logger.warn(`[Xiaohongshu Publish] Button click failed for ${selector}`);
  488. }
  489. }
  490. } catch (e) {
  491. logger.warn('[Xiaohongshu Publish] Upload button method failed:', e);
  492. }
  493. // 方法2: 点击上传区域(拖拽区域)
  494. if (!uploadTriggered) {
  495. try {
  496. logger.info('[Xiaohongshu Publish] Trying click upload area...');
  497. const uploadAreaSelectors = [
  498. '[class*="upload-wrapper"]',
  499. '[class*="upload-area"]',
  500. '[class*="drag-area"]',
  501. '[class*="drop"]',
  502. 'div:has-text("拖拽视频到此")',
  503. ];
  504. for (const selector of uploadAreaSelectors) {
  505. const uploadArea = this.page.locator(selector).first();
  506. if (await uploadArea.count() > 0 && await uploadArea.isVisible()) {
  507. logger.info(`[Xiaohongshu Publish] Found upload area via: ${selector}`);
  508. const [fileChooser] = await Promise.all([
  509. this.page.waitForEvent('filechooser', { timeout: 15000 }),
  510. uploadArea.click(),
  511. ]);
  512. await fileChooser.setFiles(params.videoPath);
  513. uploadTriggered = true;
  514. logger.info('[Xiaohongshu Publish] File selected via file chooser (area click)');
  515. break;
  516. }
  517. }
  518. } catch (e) {
  519. logger.warn('[Xiaohongshu Publish] Upload area method failed:', e);
  520. }
  521. }
  522. // 方法3: 直接设置隐藏的 file input(最后尝试)
  523. if (!uploadTriggered) {
  524. logger.info('[Xiaohongshu Publish] Trying direct file input...');
  525. const uploadSelectors = [
  526. 'input[type="file"][accept*="video"]',
  527. 'input[type="file"]',
  528. ];
  529. for (const selector of uploadSelectors) {
  530. try {
  531. const fileInput = await this.page.$(selector);
  532. if (fileInput) {
  533. await fileInput.setInputFiles(params.videoPath);
  534. uploadTriggered = true;
  535. logger.info(`[Xiaohongshu Publish] File set via direct input: ${selector}`);
  536. // 直接设置后需要等待一下,让页面响应
  537. await this.page.waitForTimeout(2000);
  538. // 检查页面是否有变化
  539. const hasChange = await this.page.locator('[class*="video-preview"], video, [class*="progress"], [class*="upload-success"]').count() > 0;
  540. if (hasChange) {
  541. logger.info('[Xiaohongshu Publish] Page responded to file input');
  542. break;
  543. } else {
  544. // 如果页面没有响应,尝试触发 change 事件
  545. await this.page.evaluate((sel) => {
  546. const input = document.querySelector(sel) as HTMLInputElement;
  547. if (input) {
  548. input.dispatchEvent(new Event('change', { bubbles: true }));
  549. }
  550. }, selector);
  551. await this.page.waitForTimeout(2000);
  552. logger.info('[Xiaohongshu Publish] Dispatched change event');
  553. }
  554. break;
  555. }
  556. } catch (e) {
  557. logger.warn(`[Xiaohongshu Publish] Failed with selector ${selector}`);
  558. }
  559. }
  560. }
  561. if (!uploadTriggered) {
  562. // 截图调试
  563. const screenshotPath = `uploads/debug/xhs_no_upload_${Date.now()}.png`;
  564. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  565. logger.info(`[Xiaohongshu Publish] Screenshot saved: ${screenshotPath}`);
  566. throw new Error('无法上传视频文件');
  567. }
  568. onProgress?.(15, '视频上传中...');
  569. // 等待视频上传完成
  570. const maxWaitTime = 300000; // 5分钟
  571. const startTime = Date.now();
  572. while (Date.now() - startTime < maxWaitTime) {
  573. await this.page.waitForTimeout(3000);
  574. // 检查当前URL是否变化(上传成功后可能跳转)
  575. const newUrl = this.page.url();
  576. if (newUrl !== currentUrl && !newUrl.includes('upload')) {
  577. logger.info(`[Xiaohongshu Publish] URL changed to: ${newUrl}`);
  578. }
  579. // 检查上传进度
  580. const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => '');
  581. if (progressText) {
  582. const match = progressText.match(/(\d+)%/);
  583. if (match) {
  584. const progress = parseInt(match[1]);
  585. onProgress?.(15 + Math.floor(progress * 0.4), `视频上传中: ${progress}%`);
  586. logger.info(`[Xiaohongshu Publish] Upload progress: ${progress}%`);
  587. }
  588. }
  589. // 检查是否上传完成 - 扩展检测范围
  590. const uploadCompleteSelectors = [
  591. '[class*="upload-success"]',
  592. '[class*="video-preview"]',
  593. 'video',
  594. '[class*="cover"]', // 封面设置区域
  595. 'input[placeholder*="标题"]', // 标题输入框出现
  596. '[class*="title"] input',
  597. '[class*="editor"]', // 编辑器区域
  598. ];
  599. for (const selector of uploadCompleteSelectors) {
  600. const count = await this.page.locator(selector).count();
  601. if (count > 0) {
  602. logger.info(`[Xiaohongshu Publish] Video upload completed, found: ${selector}`);
  603. break;
  604. }
  605. }
  606. // 如果标题输入框出现,说明可以开始填写了
  607. const titleInput = await this.page.locator('input[placeholder*="标题"]').count();
  608. if (titleInput > 0) {
  609. logger.info('[Xiaohongshu Publish] Title input found, upload must be complete');
  610. break;
  611. }
  612. // 检查是否上传失败
  613. const failText = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
  614. if (failText && failText.includes('失败')) {
  615. throw new Error(`视频上传失败: ${failText}`);
  616. }
  617. // 检查是否还在初始上传页面
  618. const stillOnUploadPage = await this.page.locator('div:has-text("拖拽视频到此")').count();
  619. if (stillOnUploadPage > 0 && Date.now() - startTime > 10000) {
  620. logger.warn('[Xiaohongshu Publish] Still on upload page after 10s, retrying upload...');
  621. // 可能需要重新触发上传
  622. break;
  623. }
  624. }
  625. onProgress?.(55, '正在填写笔记信息...');
  626. // 填写标题
  627. logger.info('[Xiaohongshu Publish] Filling title...');
  628. const titleSelectors = [
  629. 'input[placeholder*="标题"]',
  630. '[class*="title"] input',
  631. 'textarea[placeholder*="标题"]',
  632. ];
  633. for (const selector of titleSelectors) {
  634. const titleInput = this.page.locator(selector).first();
  635. if (await titleInput.count() > 0) {
  636. await titleInput.fill(params.title.slice(0, 20)); // 小红书标题限制20字
  637. logger.info(`[Xiaohongshu Publish] Title filled via: ${selector}`);
  638. break;
  639. }
  640. }
  641. // 填写描述/正文
  642. if (params.description) {
  643. logger.info('[Xiaohongshu Publish] Filling description...');
  644. const descSelectors = [
  645. '[class*="content-input"] [contenteditable="true"]',
  646. 'textarea[placeholder*="正文"]',
  647. '[class*="editor"] [contenteditable="true"]',
  648. '#post-textarea',
  649. ];
  650. for (const selector of descSelectors) {
  651. const descInput = this.page.locator(selector).first();
  652. if (await descInput.count() > 0) {
  653. await descInput.click();
  654. await this.page.keyboard.type(params.description, { delay: 30 });
  655. logger.info(`[Xiaohongshu Publish] Description filled via: ${selector}`);
  656. break;
  657. }
  658. }
  659. }
  660. onProgress?.(65, '正在添加话题标签...');
  661. // 添加话题标签 - 注意不要触发话题选择器弹窗
  662. // 小红书会自动识别 # 开头的话题,不需要从弹窗选择
  663. if (params.tags && params.tags.length > 0) {
  664. // 找到正文输入框
  665. const descSelectors = [
  666. '[class*="content-input"] [contenteditable="true"]',
  667. '[class*="editor"] [contenteditable="true"]',
  668. '#post-textarea',
  669. ];
  670. for (const selector of descSelectors) {
  671. const descInput = this.page.locator(selector).first();
  672. if (await descInput.count() > 0) {
  673. await descInput.click();
  674. // 添加空行后再添加标签
  675. await this.page.keyboard.press('Enter');
  676. for (const tag of params.tags) {
  677. await this.page.keyboard.type(`#${tag} `, { delay: 30 });
  678. }
  679. logger.info(`[Xiaohongshu Publish] Tags added: ${params.tags.join(', ')}`);
  680. break;
  681. }
  682. }
  683. await this.page.waitForTimeout(500);
  684. }
  685. onProgress?.(75, '等待处理完成...');
  686. // 等待视频处理完成,检查是否有"上传成功"标识
  687. await this.page.waitForTimeout(2000);
  688. // 检查当前页面是否还在编辑状态
  689. const stillInEditMode = await this.page.locator('input[placeholder*="标题"], [class*="video-preview"]').count() > 0;
  690. if (!stillInEditMode) {
  691. logger.error('[Xiaohongshu Publish] Page is no longer in edit mode!');
  692. const screenshotPath = `uploads/debug/xhs_not_in_edit_${Date.now()}.png`;
  693. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  694. throw new Error('页面状态异常,请重试');
  695. }
  696. onProgress?.(85, '正在发布...');
  697. // 滚动到页面底部,确保发布按钮可见
  698. logger.info('[Xiaohongshu Publish] Scrolling to bottom...');
  699. await this.page.evaluate(() => {
  700. window.scrollTo(0, document.body.scrollHeight);
  701. });
  702. await this.page.waitForTimeout(1000);
  703. // 点击发布按钮
  704. logger.info('[Xiaohongshu Publish] Looking for publish button...');
  705. // 先截图看当前页面状态
  706. const beforeClickPath = `uploads/debug/xhs_before_publish_${Date.now()}.png`;
  707. await this.page.screenshot({ path: beforeClickPath, fullPage: true });
  708. logger.info(`[Xiaohongshu Publish] Screenshot before publish: ${beforeClickPath}`);
  709. let publishClicked = false;
  710. // 方法1: 使用 Playwright locator 点击(模拟真实鼠标点击)
  711. const publishBtnSelectors = [
  712. 'button.publishBtn',
  713. '.publishBtn',
  714. 'button.d-button.red',
  715. ];
  716. for (const selector of publishBtnSelectors) {
  717. try {
  718. const btn = this.page.locator(selector).first();
  719. const count = await btn.count();
  720. logger.info(`[Xiaohongshu Publish] Checking selector ${selector}: count=${count}`);
  721. if (count > 0 && await btn.isVisible()) {
  722. // 确保按钮在视口内
  723. await btn.scrollIntoViewIfNeeded();
  724. await this.page.waitForTimeout(500);
  725. // 获取按钮位置并使用鼠标点击
  726. const box = await btn.boundingBox();
  727. if (box) {
  728. // 使用 page.mouse.click 模拟真实鼠标点击
  729. const x = box.x + box.width / 2;
  730. const y = box.y + box.height / 2;
  731. logger.info(`[Xiaohongshu Publish] Clicking at position: (${x}, ${y})`);
  732. await this.page.mouse.click(x, y);
  733. publishClicked = true;
  734. logger.info(`[Xiaohongshu Publish] Publish button clicked via mouse.click: ${selector}`);
  735. break;
  736. }
  737. }
  738. } catch (e) {
  739. logger.warn(`[Xiaohongshu Publish] Failed with selector ${selector}:`, e);
  740. }
  741. }
  742. // 方法2: 使用 Playwright locator.click() 配合 force 选项
  743. if (!publishClicked) {
  744. try {
  745. const btn = this.page.locator('button.publishBtn').first();
  746. if (await btn.count() > 0) {
  747. logger.info('[Xiaohongshu Publish] Trying locator.click with force...');
  748. await btn.click({ force: true, timeout: 5000 });
  749. publishClicked = true;
  750. logger.info('[Xiaohongshu Publish] Publish button clicked via locator.click(force)');
  751. }
  752. } catch (e) {
  753. logger.warn('[Xiaohongshu Publish] locator.click(force) failed:', e);
  754. }
  755. }
  756. // 方法3: 使用 getByRole
  757. if (!publishClicked) {
  758. try {
  759. const publishBtn = this.page.getByRole('button', { name: '发布', exact: true });
  760. if (await publishBtn.count() > 0) {
  761. const buttons = await publishBtn.all();
  762. for (const btn of buttons) {
  763. if (await btn.isVisible() && await btn.isEnabled()) {
  764. // 使用鼠标点击
  765. const box = await btn.boundingBox();
  766. if (box) {
  767. const x = box.x + box.width / 2;
  768. const y = box.y + box.height / 2;
  769. await this.page.mouse.click(x, y);
  770. publishClicked = true;
  771. logger.info('[Xiaohongshu Publish] Publish button clicked via getByRole');
  772. break;
  773. }
  774. }
  775. }
  776. }
  777. } catch (e) {
  778. logger.warn('[Xiaohongshu Publish] getByRole failed:', e);
  779. }
  780. }
  781. // 如果还是没找到,尝试用 evaluate 直接查找和点击
  782. if (!publishClicked) {
  783. logger.info('[Xiaohongshu Publish] Trying evaluate method...');
  784. try {
  785. publishClicked = await this.page.evaluate(() => {
  786. // 查找所有包含"发布"文字的按钮
  787. const buttons = Array.from(document.querySelectorAll('button, div[role="button"]'));
  788. for (const btn of buttons) {
  789. const text = btn.textContent?.trim();
  790. // 找到只包含"发布"两个字的按钮(排除"发布笔记"等)
  791. if (text === '发布' && (btn as HTMLElement).offsetParent !== null) {
  792. (btn as HTMLElement).click();
  793. return true;
  794. }
  795. }
  796. return false;
  797. });
  798. if (publishClicked) {
  799. logger.info('[Xiaohongshu Publish] Publish button clicked via evaluate');
  800. }
  801. } catch (e) {
  802. logger.warn('[Xiaohongshu Publish] evaluate failed:', e);
  803. }
  804. }
  805. if (!publishClicked) {
  806. // 截图调试
  807. try {
  808. const screenshotPath = `uploads/debug/xhs_no_publish_btn_${Date.now()}.png`;
  809. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  810. logger.info(`[Xiaohongshu Publish] Screenshot saved: ${screenshotPath}`);
  811. } catch {}
  812. throw new Error('未找到发布按钮');
  813. }
  814. onProgress?.(90, '等待发布完成...');
  815. // 等待发布结果
  816. const publishMaxWait = 120000; // 2分钟
  817. const publishStartTime = Date.now();
  818. while (Date.now() - publishStartTime < publishMaxWait) {
  819. await this.page.waitForTimeout(3000);
  820. const currentUrl = this.page.url();
  821. // 检查是否跳转到内容管理页面
  822. if (currentUrl.includes('/content') || currentUrl.includes('/creator/home')) {
  823. logger.info('[Xiaohongshu Publish] Publish success! Redirected to content page');
  824. onProgress?.(100, '发布成功!');
  825. await this.closeBrowser();
  826. return {
  827. success: true,
  828. videoUrl: currentUrl,
  829. };
  830. }
  831. // 检查成功提示
  832. const successToast = await this.page.locator('[class*="success"]:has-text("成功"), [class*="toast"]:has-text("发布成功")').count();
  833. if (successToast > 0) {
  834. logger.info('[Xiaohongshu Publish] Found success toast');
  835. await this.page.waitForTimeout(2000);
  836. onProgress?.(100, '发布成功!');
  837. await this.closeBrowser();
  838. return {
  839. success: true,
  840. videoUrl: this.page.url(),
  841. };
  842. }
  843. // 检查错误提示
  844. const errorToast = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
  845. if (errorToast && (errorToast.includes('失败') || errorToast.includes('错误'))) {
  846. throw new Error(`发布失败: ${errorToast}`);
  847. }
  848. const elapsed = Math.floor((Date.now() - publishStartTime) / 1000);
  849. onProgress?.(90 + Math.min(9, Math.floor(elapsed / 15)), `等待发布完成 (${elapsed}s)...`);
  850. }
  851. // 超时,截图调试
  852. try {
  853. const screenshotPath = `uploads/debug/xhs_publish_timeout_${Date.now()}.png`;
  854. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  855. logger.info(`[Xiaohongshu Publish] Timeout screenshot saved: ${screenshotPath}`);
  856. } catch {}
  857. throw new Error('发布超时,请手动检查是否发布成功');
  858. } catch (error) {
  859. logger.error('[Xiaohongshu Publish] Error:', error);
  860. await this.closeBrowser();
  861. return {
  862. success: false,
  863. errorMessage: error instanceof Error ? error.message : '发布失败',
  864. };
  865. }
  866. }
  867. /**
  868. * 通过 Python API 获取评论
  869. */
  870. private async getCommentsViaPython(cookies: string, videoId: string): Promise<CommentData[]> {
  871. logger.info('[Xiaohongshu] Getting comments via Python API...');
  872. const response = await fetch(`${XHS_PYTHON_SERVICE_URL}/comments`, {
  873. method: 'POST',
  874. headers: {
  875. 'Content-Type': 'application/json',
  876. },
  877. body: JSON.stringify({
  878. platform: 'xiaohongshu',
  879. cookie: cookies,
  880. work_id: videoId,
  881. }),
  882. });
  883. if (!response.ok) {
  884. throw new Error(`Python API returned ${response.status}`);
  885. }
  886. const result = await response.json();
  887. if (!result.success) {
  888. throw new Error(result.error || 'Failed to get comments');
  889. }
  890. // 转换数据格式
  891. return (result.comments || []).map((comment: {
  892. comment_id: string;
  893. author_id: string;
  894. author_name: string;
  895. author_avatar: string;
  896. content: string;
  897. like_count: number;
  898. create_time: string;
  899. reply_count: number;
  900. replies?: Array<{
  901. comment_id: string;
  902. author_id: string;
  903. author_name: string;
  904. author_avatar: string;
  905. content: string;
  906. like_count: number;
  907. create_time: string;
  908. }>;
  909. }) => ({
  910. commentId: comment.comment_id,
  911. authorId: comment.author_id,
  912. authorName: comment.author_name,
  913. authorAvatar: comment.author_avatar,
  914. content: comment.content,
  915. likeCount: comment.like_count,
  916. commentTime: comment.create_time,
  917. replyCount: comment.reply_count,
  918. replies: comment.replies?.map((reply: {
  919. comment_id: string;
  920. author_id: string;
  921. author_name: string;
  922. author_avatar: string;
  923. content: string;
  924. like_count: number;
  925. create_time: string;
  926. }) => ({
  927. commentId: reply.comment_id,
  928. authorId: reply.author_id,
  929. authorName: reply.author_name,
  930. authorAvatar: reply.author_avatar,
  931. content: reply.content,
  932. likeCount: reply.like_count,
  933. commentTime: reply.create_time,
  934. })),
  935. }));
  936. }
  937. /**
  938. * 获取评论列表
  939. */
  940. async getComments(cookies: string, videoId: string): Promise<CommentData[]> {
  941. // 优先尝试使用 Python API
  942. const pythonAvailable = await this.checkPythonServiceAvailable();
  943. if (pythonAvailable) {
  944. logger.info('[Xiaohongshu] Python service available, using Python API for comments');
  945. try {
  946. return await this.getCommentsViaPython(cookies, videoId);
  947. } catch (pythonError) {
  948. logger.warn('[Xiaohongshu] Python API getComments failed, falling back to Playwright:', pythonError);
  949. }
  950. }
  951. // 回退到 Playwright 方式
  952. try {
  953. await this.initBrowser({ headless: true });
  954. await this.setCookies(cookies);
  955. if (!this.page) throw new Error('Page not initialized');
  956. const comments: CommentData[] = [];
  957. // 设置 API 响应监听器
  958. this.page.on('response', async (response) => {
  959. const url = response.url();
  960. try {
  961. // 监听评论列表 API
  962. if (url.includes('/api/sns/web/v2/comment/page') ||
  963. url.includes('/api/galaxy/creator/comment')) {
  964. const data = await response.json();
  965. logger.info(`[Xiaohongshu API] Comments response:`, JSON.stringify(data).slice(0, 500));
  966. const commentList = data?.data?.comments || data?.comments || [];
  967. for (const comment of commentList) {
  968. comments.push({
  969. commentId: comment.id || comment.comment_id || '',
  970. authorId: comment.user_info?.user_id || comment.user_id || '',
  971. authorName: comment.user_info?.nickname || comment.nickname || '',
  972. authorAvatar: comment.user_info?.image || comment.avatar || '',
  973. content: comment.content || '',
  974. likeCount: comment.like_count || 0,
  975. commentTime: comment.create_time || comment.time || '',
  976. parentCommentId: comment.target_comment_id || undefined,
  977. });
  978. }
  979. }
  980. } catch {}
  981. });
  982. // 访问评论管理页面
  983. await this.page.goto(`${this.contentManageUrl}?tab=comment`, {
  984. waitUntil: 'domcontentloaded',
  985. timeout: 30000,
  986. });
  987. await this.page.waitForTimeout(5000);
  988. await this.closeBrowser();
  989. return comments;
  990. } catch (error) {
  991. logger.error('Xiaohongshu getComments error:', error);
  992. await this.closeBrowser();
  993. return [];
  994. }
  995. }
  996. /**
  997. * 回复评论
  998. */
  999. async replyComment(cookies: string, commentId: string, content: string): Promise<boolean> {
  1000. try {
  1001. await this.initBrowser({ headless: true });
  1002. await this.setCookies(cookies);
  1003. if (!this.page) throw new Error('Page not initialized');
  1004. // 访问评论管理页面
  1005. await this.page.goto(`${this.contentManageUrl}?tab=comment`, {
  1006. waitUntil: 'networkidle',
  1007. timeout: 30000,
  1008. });
  1009. await this.page.waitForTimeout(2000);
  1010. // 找到对应评论并点击回复
  1011. const commentItem = this.page.locator(`[data-comment-id="${commentId}"], [data-id="${commentId}"]`).first();
  1012. if (await commentItem.count() > 0) {
  1013. const replyBtn = commentItem.locator('[class*="reply"], button:has-text("回复")').first();
  1014. if (await replyBtn.count() > 0) {
  1015. await replyBtn.click();
  1016. await this.page.waitForTimeout(500);
  1017. }
  1018. }
  1019. // 输入回复内容
  1020. const replyInput = this.page.locator('[class*="reply-input"] textarea, [class*="comment-input"] textarea').first();
  1021. if (await replyInput.count() > 0) {
  1022. await replyInput.fill(content);
  1023. await this.page.waitForTimeout(500);
  1024. // 点击发送
  1025. const sendBtn = this.page.locator('button:has-text("发送"), button:has-text("回复")').first();
  1026. if (await sendBtn.count() > 0) {
  1027. await sendBtn.click();
  1028. await this.page.waitForTimeout(2000);
  1029. }
  1030. }
  1031. await this.closeBrowser();
  1032. return true;
  1033. } catch (error) {
  1034. logger.error('Xiaohongshu replyComment error:', error);
  1035. await this.closeBrowser();
  1036. return false;
  1037. }
  1038. }
  1039. /**
  1040. * 删除已发布的作品
  1041. * 使用小红书笔记管理页面: https://creator.xiaohongshu.com/new/note-manager
  1042. */
  1043. async deleteWork(
  1044. cookies: string,
  1045. noteId: string,
  1046. onCaptchaRequired?: (captchaInfo: { taskId: string; imageUrl?: string }) => Promise<string>
  1047. ): Promise<{ success: boolean; errorMessage?: string }> {
  1048. try {
  1049. // 使用无头浏览器后台运行
  1050. await this.initBrowser({ headless: true });
  1051. await this.setCookies(cookies);
  1052. if (!this.page) throw new Error('Page not initialized');
  1053. logger.info(`[Xiaohongshu Delete] Starting delete for note: ${noteId}`);
  1054. // 访问笔记管理页面(新版)
  1055. const noteManagerUrl = 'https://creator.xiaohongshu.com/new/note-manager';
  1056. await this.page.goto(noteManagerUrl, {
  1057. waitUntil: 'networkidle',
  1058. timeout: 60000,
  1059. });
  1060. await this.page.waitForTimeout(3000);
  1061. // 检查是否需要登录
  1062. const currentUrl = this.page.url();
  1063. if (currentUrl.includes('login') || currentUrl.includes('passport')) {
  1064. throw new Error('登录已过期,请重新登录');
  1065. }
  1066. logger.info(`[Xiaohongshu Delete] Current URL: ${currentUrl}`);
  1067. // 截图用于调试
  1068. try {
  1069. const screenshotPath = `uploads/debug/xhs_delete_page_${Date.now()}.png`;
  1070. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  1071. logger.info(`[Xiaohongshu Delete] Page screenshot: ${screenshotPath}`);
  1072. } catch {}
  1073. // 在笔记管理页面找到对应的笔记行
  1074. // 页面结构:
  1075. // - 每条笔记是 div.note 元素
  1076. // - 笔记ID在 data-impression 属性的 JSON 中: noteId: "xxx"
  1077. // - 删除按钮是 span.control.data-del 内的 <span>删除</span>
  1078. let deleteClicked = false;
  1079. // 方式1: 通过 data-impression 属性找到对应笔记,然后点击其删除按钮
  1080. logger.info(`[Xiaohongshu Delete] Looking for note with ID: ${noteId}`);
  1081. // 查找所有笔记卡片
  1082. const noteCards = this.page.locator('div.note');
  1083. const noteCount = await noteCards.count();
  1084. logger.info(`[Xiaohongshu Delete] Found ${noteCount} note cards`);
  1085. for (let i = 0; i < noteCount; i++) {
  1086. const card = noteCards.nth(i);
  1087. const impression = await card.getAttribute('data-impression').catch(() => '');
  1088. // 检查 data-impression 中是否包含目标 noteId
  1089. if (impression && impression.includes(noteId)) {
  1090. logger.info(`[Xiaohongshu Delete] Found target note at index ${i}`);
  1091. // 在该笔记卡片内查找删除按钮 (span.data-del)
  1092. const deleteBtn = card.locator('span.data-del, span.control.data-del').first();
  1093. if (await deleteBtn.count() > 0) {
  1094. await deleteBtn.click();
  1095. deleteClicked = true;
  1096. logger.info(`[Xiaohongshu Delete] Clicked delete button for note ${noteId}`);
  1097. break;
  1098. }
  1099. }
  1100. }
  1101. // 方式2: 如果方式1没找到,尝试直接用 evaluate 在 DOM 中查找
  1102. if (!deleteClicked) {
  1103. logger.info('[Xiaohongshu Delete] Trying evaluate method to find note by data-impression...');
  1104. deleteClicked = await this.page.evaluate((nid: string) => {
  1105. // 查找所有 div.note 元素
  1106. const notes = document.querySelectorAll('div.note');
  1107. console.log(`[XHS Delete] Found ${notes.length} note elements`);
  1108. for (const note of notes) {
  1109. const impression = note.getAttribute('data-impression') || '';
  1110. if (impression.includes(nid)) {
  1111. console.log(`[XHS Delete] Found note with ID ${nid}`);
  1112. // 查找删除按钮
  1113. const deleteBtn = note.querySelector('span.data-del') ||
  1114. note.querySelector('.control.data-del');
  1115. if (deleteBtn) {
  1116. console.log(`[XHS Delete] Clicking delete button`);
  1117. (deleteBtn as HTMLElement).click();
  1118. return true;
  1119. }
  1120. }
  1121. }
  1122. return false;
  1123. }, noteId);
  1124. if (deleteClicked) {
  1125. logger.info('[Xiaohongshu Delete] Delete button clicked via evaluate');
  1126. }
  1127. }
  1128. // 方式3: 如果还没找到,尝试点击第一个可见的删除按钮
  1129. if (!deleteClicked) {
  1130. logger.info('[Xiaohongshu Delete] Trying to click first visible delete button...');
  1131. const allDeleteBtns = this.page.locator('span.data-del');
  1132. const btnCount = await allDeleteBtns.count();
  1133. logger.info(`[Xiaohongshu Delete] Found ${btnCount} delete buttons on page`);
  1134. for (let i = 0; i < btnCount; i++) {
  1135. const btn = allDeleteBtns.nth(i);
  1136. if (await btn.isVisible().catch(() => false)) {
  1137. await btn.click();
  1138. deleteClicked = true;
  1139. logger.info(`[Xiaohongshu Delete] Clicked delete button ${i}`);
  1140. break;
  1141. }
  1142. }
  1143. }
  1144. if (!deleteClicked) {
  1145. // 截图调试
  1146. try {
  1147. const screenshotPath = `uploads/debug/xhs_delete_no_btn_${Date.now()}.png`;
  1148. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  1149. logger.info(`[Xiaohongshu Delete] No delete button found, screenshot: ${screenshotPath}`);
  1150. } catch {}
  1151. throw new Error('未找到删除按钮');
  1152. }
  1153. await this.page.waitForTimeout(1000);
  1154. // 检查是否需要验证码
  1155. const captchaVisible = await this.page.locator('[class*="captcha"], [class*="verify"]').count() > 0;
  1156. if (captchaVisible && onCaptchaRequired) {
  1157. logger.info('[Xiaohongshu Delete] Captcha required');
  1158. // 点击发送验证码
  1159. const sendCodeBtn = this.page.locator('button:has-text("发送验证码"), button:has-text("获取验证码")').first();
  1160. if (await sendCodeBtn.count() > 0) {
  1161. await sendCodeBtn.click();
  1162. logger.info('[Xiaohongshu Delete] Verification code sent');
  1163. }
  1164. // 通过回调获取验证码
  1165. const taskId = `delete_xhs_${noteId}_${Date.now()}`;
  1166. const code = await onCaptchaRequired({ taskId });
  1167. if (code) {
  1168. // 输入验证码
  1169. const codeInput = this.page.locator('input[placeholder*="验证码"], input[type="text"]').first();
  1170. if (await codeInput.count() > 0) {
  1171. await codeInput.fill(code);
  1172. logger.info('[Xiaohongshu Delete] Verification code entered');
  1173. }
  1174. // 点击确认按钮
  1175. const confirmBtn = this.page.locator('button:has-text("确定"), button:has-text("确认")').first();
  1176. if (await confirmBtn.count() > 0) {
  1177. await confirmBtn.click();
  1178. await this.page.waitForTimeout(2000);
  1179. }
  1180. }
  1181. }
  1182. // 确认删除(可能有二次确认弹窗)
  1183. const confirmDeleteSelectors = [
  1184. 'button:has-text("确认删除")',
  1185. 'button:has-text("确定")',
  1186. 'button:has-text("确认")',
  1187. '[class*="modal"] button[class*="primary"]',
  1188. '[class*="dialog"] button[class*="confirm"]',
  1189. '.d-button.red:has-text("确")',
  1190. ];
  1191. for (const selector of confirmDeleteSelectors) {
  1192. const confirmBtn = this.page.locator(selector).first();
  1193. if (await confirmBtn.count() > 0 && await confirmBtn.isVisible()) {
  1194. await confirmBtn.click();
  1195. logger.info(`[Xiaohongshu Delete] Confirm button clicked via: ${selector}`);
  1196. await this.page.waitForTimeout(1000);
  1197. }
  1198. }
  1199. // 等待删除完成
  1200. await this.page.waitForTimeout(2000);
  1201. // 检查是否删除成功(页面刷新或出现成功提示)
  1202. const successToast = await this.page.locator('[class*="success"]:has-text("成功"), [class*="toast"]:has-text("删除成功")').count();
  1203. if (successToast > 0) {
  1204. logger.info('[Xiaohongshu Delete] Delete success toast found');
  1205. }
  1206. logger.info('[Xiaohongshu Delete] Delete completed');
  1207. await this.closeBrowser();
  1208. return { success: true };
  1209. } catch (error) {
  1210. logger.error('[Xiaohongshu Delete] Error:', error);
  1211. await this.closeBrowser();
  1212. return {
  1213. success: false,
  1214. errorMessage: error instanceof Error ? error.message : '删除失败',
  1215. };
  1216. }
  1217. }
  1218. /**
  1219. * 获取数据统计
  1220. */
  1221. async getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData> {
  1222. try {
  1223. await this.initBrowser({ headless: true });
  1224. await this.setCookies(cookies);
  1225. if (!this.page) throw new Error('Page not initialized');
  1226. const analytics: AnalyticsData = {
  1227. fansCount: 0,
  1228. fansIncrease: 0,
  1229. viewsCount: 0,
  1230. likesCount: 0,
  1231. commentsCount: 0,
  1232. sharesCount: 0,
  1233. };
  1234. // 设置 API 响应监听器
  1235. this.page.on('response', async (response) => {
  1236. const url = response.url();
  1237. try {
  1238. if (url.includes('/api/galaxy/creator/data') ||
  1239. url.includes('/api/galaxy/creator/home')) {
  1240. const data = await response.json();
  1241. if (data?.data) {
  1242. const d = data.data;
  1243. analytics.fansCount = d.fans_count || analytics.fansCount;
  1244. analytics.fansIncrease = d.fans_increase || analytics.fansIncrease;
  1245. analytics.viewsCount = d.view_count || d.read_count || analytics.viewsCount;
  1246. analytics.likesCount = d.like_count || analytics.likesCount;
  1247. analytics.commentsCount = d.comment_count || analytics.commentsCount;
  1248. analytics.sharesCount = d.collect_count || analytics.sharesCount;
  1249. }
  1250. }
  1251. } catch {}
  1252. });
  1253. // 访问数据中心
  1254. await this.page.goto('https://creator.xiaohongshu.com/creator/data', {
  1255. waitUntil: 'domcontentloaded',
  1256. timeout: 30000,
  1257. });
  1258. await this.page.waitForTimeout(5000);
  1259. await this.closeBrowser();
  1260. return analytics;
  1261. } catch (error) {
  1262. logger.error('Xiaohongshu getAnalytics error:', error);
  1263. await this.closeBrowser();
  1264. return {
  1265. fansCount: 0,
  1266. fansIncrease: 0,
  1267. viewsCount: 0,
  1268. likesCount: 0,
  1269. commentsCount: 0,
  1270. sharesCount: 0,
  1271. };
  1272. }
  1273. }
  1274. }