base.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. import type { Browser, BrowserContext, Page } from 'playwright';
  2. import type {
  3. PlatformType,
  4. QRCodeInfo,
  5. LoginStatusResult,
  6. ProxyConfig,
  7. } from '@media-manager/shared';
  8. import { BrowserManager } from '../browser.js';
  9. import { logger } from '../../utils/logger.js';
  10. import { aiService, type PublishStatusAnalysis, type PageOperationGuide } from '../../ai/index.js';
  11. export interface WorkItem {
  12. videoId?: string;
  13. title: string;
  14. coverUrl: string;
  15. videoUrl?: string;
  16. duration: string;
  17. publishTime: string;
  18. status: string;
  19. playCount: number;
  20. likeCount: number;
  21. commentCount: number;
  22. shareCount: number;
  23. }
  24. export interface AccountProfile {
  25. accountId: string;
  26. accountName: string;
  27. avatarUrl: string;
  28. fansCount: number;
  29. worksCount: number;
  30. worksList?: WorkItem[];
  31. }
  32. export interface PublishParams {
  33. videoPath: string;
  34. title: string;
  35. description?: string;
  36. coverPath?: string;
  37. tags?: string[];
  38. scheduledTime?: string | Date; // 定时发布时间
  39. location?: string; // 位置信息
  40. extra?: Record<string, unknown>;
  41. }
  42. export interface PublishResult {
  43. success: boolean;
  44. videoUrl?: string;
  45. platformVideoId?: string;
  46. errorMessage?: string;
  47. }
  48. export interface DateRange {
  49. startDate: string;
  50. endDate: string;
  51. }
  52. export interface AnalyticsData {
  53. fansCount: number;
  54. fansIncrease: number;
  55. viewsCount: number;
  56. likesCount: number;
  57. commentsCount: number;
  58. sharesCount: number;
  59. income?: number;
  60. }
  61. export interface CommentData {
  62. commentId: string;
  63. authorId: string;
  64. authorName: string;
  65. authorAvatar: string;
  66. content: string;
  67. likeCount: number;
  68. commentTime: string;
  69. parentCommentId?: string;
  70. }
  71. export interface InitBrowserOptions {
  72. proxyConfig?: ProxyConfig;
  73. headless?: boolean; // 是否使用无头模式
  74. }
  75. /**
  76. * 平台适配器基类
  77. */
  78. export abstract class BasePlatformAdapter {
  79. abstract readonly platform: PlatformType;
  80. abstract readonly loginUrl: string;
  81. protected browser: Browser | null = null;
  82. protected context: BrowserContext | null = null;
  83. protected page: Page | null = null;
  84. protected isHeadless: boolean = false; // 记录当前是否为无头模式
  85. /**
  86. * 初始化浏览器
  87. * @param options.proxyConfig 代理配置
  88. * @param options.headless 是否使用无头模式(后台运行),默认 false
  89. */
  90. async initBrowser(options?: InitBrowserOptions | ProxyConfig): Promise<void> {
  91. // 如果已有浏览器上下文,先关闭
  92. if (this.context) {
  93. await this.closeBrowser();
  94. }
  95. // 兼容旧的调用方式(直接传 proxyConfig)
  96. let proxyConfig: ProxyConfig | undefined;
  97. let headless = false;
  98. if (options && 'headless' in options) {
  99. proxyConfig = options.proxyConfig;
  100. headless = options.headless ?? false;
  101. } else {
  102. proxyConfig = options as ProxyConfig | undefined;
  103. }
  104. this.isHeadless = headless;
  105. this.browser = await BrowserManager.getBrowser({ headless });
  106. const contextOptions: Record<string, unknown> = {
  107. viewport: { width: 1920, height: 1080 },
  108. locale: 'zh-CN',
  109. timezoneId: 'Asia/Shanghai',
  110. };
  111. if (proxyConfig?.enabled) {
  112. contextOptions.proxy = {
  113. server: `${proxyConfig.type}://${proxyConfig.host}:${proxyConfig.port}`,
  114. username: proxyConfig.username,
  115. password: proxyConfig.password,
  116. };
  117. }
  118. this.context = await this.browser.newContext(contextOptions);
  119. this.page = await this.context.newPage();
  120. }
  121. /**
  122. * 关闭浏览器
  123. * 对于 headful 模式,会关闭整个浏览器窗口
  124. */
  125. async closeBrowser(): Promise<void> {
  126. if (this.page) {
  127. await this.page.close();
  128. this.page = null;
  129. }
  130. if (this.context) {
  131. await this.context.close();
  132. this.context = null;
  133. }
  134. // 关闭对应的浏览器实例(特别是 headful 模式的浏览器窗口)
  135. if (!this.isHeadless) {
  136. await BrowserManager.closeBrowser({ headless: false });
  137. }
  138. this.browser = null;
  139. }
  140. /**
  141. * 设置 Cookie
  142. * 支持两种格式:
  143. * 1. JSON 数组格式:[{name, value, domain, path}]
  144. * 2. 字符串格式:name=value; name2=value2;
  145. */
  146. async setCookies(cookies: string): Promise<void> {
  147. if (!this.context) {
  148. throw new Error('Browser context not initialized');
  149. }
  150. try {
  151. let cookieList: Array<{ name: string; value: string; domain?: string; path?: string }>;
  152. // 尝试解析为 JSON
  153. try {
  154. cookieList = JSON.parse(cookies);
  155. } catch {
  156. // JSON 解析失败,尝试解析为 name=value 格式的字符串
  157. cookieList = this.parseCookieString(cookies);
  158. }
  159. // 确保每个 cookie 都有必要的字段
  160. const formattedCookies = cookieList.map(c => ({
  161. name: c.name,
  162. value: c.value,
  163. domain: c.domain || this.getCookieDomain(),
  164. path: c.path || '/',
  165. }));
  166. await this.context.addCookies(formattedCookies);
  167. logger.info(`Set ${formattedCookies.length} cookies`);
  168. } catch (error) {
  169. logger.error('Failed to set cookies:', error);
  170. throw error;
  171. }
  172. }
  173. /**
  174. * 解析 name=value; 格式的 cookie 字符串
  175. */
  176. private parseCookieString(cookieStr: string): Array<{ name: string; value: string; domain?: string; path?: string }> {
  177. const cookies: Array<{ name: string; value: string; domain?: string; path?: string }> = [];
  178. const pairs = cookieStr.split(';').map(s => s.trim()).filter(s => s);
  179. for (const pair of pairs) {
  180. const eqIndex = pair.indexOf('=');
  181. if (eqIndex > 0) {
  182. const name = pair.substring(0, eqIndex).trim();
  183. const value = pair.substring(eqIndex + 1).trim();
  184. if (name && !['path', 'domain', 'expires', 'max-age', 'secure', 'httponly', 'samesite'].includes(name.toLowerCase())) {
  185. cookies.push({ name, value });
  186. }
  187. }
  188. }
  189. return cookies;
  190. }
  191. /**
  192. * 获取平台对应的 cookie domain
  193. */
  194. protected getCookieDomain(): string {
  195. return '.douyin.com'; // 子类可以覆盖
  196. }
  197. /**
  198. * 获取 Cookie
  199. */
  200. async getCookies(): Promise<string> {
  201. if (!this.context) {
  202. throw new Error('Browser context not initialized');
  203. }
  204. const cookies = await this.context.cookies();
  205. return JSON.stringify(cookies);
  206. }
  207. /**
  208. * 等待元素
  209. */
  210. protected async waitForSelector(selector: string, timeout: number = 10000): Promise<void> {
  211. if (!this.page) throw new Error('Page not initialized');
  212. await this.page.waitForSelector(selector, { timeout });
  213. }
  214. /**
  215. * 点击元素
  216. */
  217. protected async click(selector: string): Promise<void> {
  218. if (!this.page) throw new Error('Page not initialized');
  219. await this.page.click(selector);
  220. }
  221. /**
  222. * 输入文本
  223. */
  224. protected async type(selector: string, text: string): Promise<void> {
  225. if (!this.page) throw new Error('Page not initialized');
  226. await this.page.fill(selector, text);
  227. }
  228. /**
  229. * 截图
  230. */
  231. protected async screenshot(path: string): Promise<void> {
  232. if (!this.page) throw new Error('Page not initialized');
  233. await this.page.screenshot({ path });
  234. }
  235. /**
  236. * 截图并返回 Base64 格式
  237. */
  238. protected async screenshotBase64(): Promise<string> {
  239. if (!this.page) throw new Error('Page not initialized');
  240. const buffer = await this.page.screenshot({ type: 'jpeg', quality: 80 });
  241. return buffer.toString('base64');
  242. }
  243. /**
  244. * 获取页面 HTML
  245. */
  246. protected async getPageHtml(): Promise<string> {
  247. if (!this.page) throw new Error('Page not initialized');
  248. return await this.page.content();
  249. }
  250. // ==================== AI 辅助发布方法 ====================
  251. /**
  252. * AI 分析发布页面状态
  253. * @returns 发布状态分析结果
  254. */
  255. protected async aiAnalyzePublishStatus(): Promise<PublishStatusAnalysis | null> {
  256. if (!aiService.isAvailable()) {
  257. logger.debug('[AI Publish] AI service not available');
  258. return null;
  259. }
  260. try {
  261. const screenshot = await this.screenshotBase64();
  262. const result = await aiService.analyzePublishStatus(screenshot, this.platform);
  263. logger.info(`[AI Publish] Status analysis: ${result.status}, confidence: ${result.confidence}%`);
  264. return result;
  265. } catch (error) {
  266. logger.error('[AI Publish] Failed to analyze status:', error);
  267. return null;
  268. }
  269. }
  270. /**
  271. * AI 获取发布操作指导
  272. * @param currentStatus 当前状态描述
  273. * @returns 操作指导
  274. */
  275. protected async aiGetPublishOperationGuide(currentStatus: string): Promise<PageOperationGuide | null> {
  276. if (!aiService.isAvailable()) {
  277. logger.debug('[AI Publish] AI service not available');
  278. return null;
  279. }
  280. try {
  281. const html = await this.getPageHtml();
  282. const result = await aiService.analyzePublishPageHtml(html, this.platform, currentStatus);
  283. logger.info(`[AI Publish] Operation guide: hasAction=${result.hasAction}, action=${result.actionType}`);
  284. return result;
  285. } catch (error) {
  286. logger.error('[AI Publish] Failed to get operation guide:', error);
  287. return null;
  288. }
  289. }
  290. /**
  291. * 将AI返回的选择器转换为Playwright兼容的格式
  292. * @param selector 原始选择器
  293. * @returns 转换后的选择器
  294. */
  295. private convertToPlaywrightSelector(selector: string): string {
  296. let converted = selector;
  297. // 将 :contains('text') 或 :contains("text") 转换为 :has-text("text")
  298. converted = converted.replace(/:contains\(['"]([^'"]+)['"]\)/g, ':has-text("$1")');
  299. converted = converted.replace(/:contains\(([^)]+)\)/g, ':has-text("$1")');
  300. // 移除不支持的伪类选择器
  301. // 如果选择器包含不支持的语法,尝试简化
  302. if (converted.includes(':contains')) {
  303. // 如果还有 :contains,提取文本用于 text= 选择器
  304. const match = converted.match(/:contains\(['"]?([^'")\]]+)['"]?\)/);
  305. if (match) {
  306. return `text="${match[1]}"`;
  307. }
  308. }
  309. return converted;
  310. }
  311. /**
  312. * AI 辅助执行操作
  313. * @param guide 操作指导
  314. * @returns 是否执行成功
  315. */
  316. protected async aiExecuteOperation(guide: PageOperationGuide): Promise<boolean> {
  317. if (!this.page || !guide.hasAction) return false;
  318. try {
  319. switch (guide.actionType) {
  320. case 'click':
  321. if (guide.targetSelector) {
  322. // 转换选择器为Playwright兼容格式
  323. const selector = this.convertToPlaywrightSelector(guide.targetSelector);
  324. logger.info(`[AI Publish] Clicking: ${selector} (original: ${guide.targetSelector})`);
  325. try {
  326. await this.page.click(selector, { timeout: 10000 });
  327. return true;
  328. } catch (selectorError) {
  329. // 如果选择器失败,尝试使用位置点击
  330. if (guide.targetPosition) {
  331. logger.info(`[AI Publish] Selector failed, trying position click: ${guide.targetPosition.x}, ${guide.targetPosition.y}`);
  332. await this.page.mouse.click(guide.targetPosition.x, guide.targetPosition.y);
  333. return true;
  334. }
  335. // 尝试使用文本匹配
  336. if (guide.targetDescription) {
  337. const textSelector = `text="${guide.targetDescription}"`;
  338. logger.info(`[AI Publish] Trying text selector: ${textSelector}`);
  339. try {
  340. await this.page.click(textSelector, { timeout: 5000 });
  341. return true;
  342. } catch {
  343. // 继续抛出原始错误
  344. }
  345. }
  346. throw selectorError;
  347. }
  348. }
  349. break;
  350. case 'input':
  351. if (guide.targetSelector && guide.inputText) {
  352. const selector = this.convertToPlaywrightSelector(guide.targetSelector);
  353. logger.info(`[AI Publish] Inputting to: ${selector}`);
  354. await this.page.fill(selector, guide.inputText);
  355. return true;
  356. }
  357. break;
  358. case 'wait':
  359. logger.info('[AI Publish] Waiting...');
  360. await this.page.waitForTimeout(3000);
  361. return true;
  362. case 'scroll':
  363. logger.info('[AI Publish] Scrolling...');
  364. await this.page.evaluate(() => window.scrollBy(0, 300));
  365. return true;
  366. }
  367. } catch (error) {
  368. logger.error(`[AI Publish] Failed to execute operation:`, error);
  369. }
  370. return false;
  371. }
  372. /**
  373. * AI 辅助发布监控循环
  374. * 监控发布过程,检测验证码和发布结果
  375. * @param options 配置选项
  376. * @returns 发布结果
  377. */
  378. protected async aiAssistedPublishMonitor(options: {
  379. maxAttempts?: number;
  380. checkInterval?: number;
  381. onCaptchaRequired?: (captchaInfo: {
  382. taskId: string;
  383. type: 'sms' | 'image';
  384. captchaDescription?: string;
  385. imageBase64?: string;
  386. }) => Promise<string>;
  387. onProgress?: (progress: number, message: string) => void;
  388. }): Promise<{ success: boolean; message: string; needManualIntervention?: boolean }> {
  389. const maxAttempts = options.maxAttempts || 30;
  390. const checkInterval = options.checkInterval || 3000;
  391. for (let attempt = 0; attempt < maxAttempts; attempt++) {
  392. // 等待页面稳定
  393. await this.page?.waitForTimeout(checkInterval);
  394. // AI 分析当前页面状态
  395. const status = await this.aiAnalyzePublishStatus();
  396. if (!status) {
  397. logger.warn('[AI Publish] AI analysis unavailable, continuing...');
  398. continue;
  399. }
  400. logger.info(`[AI Publish] Attempt ${attempt + 1}/${maxAttempts}: status=${status.status}`);
  401. switch (status.status) {
  402. case 'success':
  403. options.onProgress?.(100, '发布成功');
  404. return { success: true, message: status.pageDescription };
  405. case 'failed':
  406. options.onProgress?.(0, status.errorMessage || '发布失败');
  407. return { success: false, message: status.errorMessage || '发布失败' };
  408. case 'need_captcha':
  409. if (options.onCaptchaRequired) {
  410. options.onProgress?.(50, `检测到验证码: ${status.captchaDescription || '请输入验证码'}`);
  411. // 如果是图片验证码,截图发送
  412. let imageBase64: string | undefined;
  413. if (status.captchaType === 'image' || status.captchaType === 'slider') {
  414. imageBase64 = await this.screenshotBase64();
  415. }
  416. try {
  417. const captchaCode = await options.onCaptchaRequired({
  418. taskId: `captcha_${Date.now()}`,
  419. type: status.captchaType === 'sms' ? 'sms' : 'image',
  420. captchaDescription: status.captchaDescription,
  421. imageBase64,
  422. });
  423. // 用户输入了验证码,尝试 AI 指导输入
  424. if (captchaCode && status.nextAction?.targetSelector) {
  425. await this.page?.fill(status.nextAction.targetSelector, captchaCode);
  426. await this.page?.waitForTimeout(500);
  427. // 尝试点击确认按钮
  428. const guide = await this.aiGetPublishOperationGuide('已输入验证码,需要点击确认');
  429. if (guide?.hasAction && guide.actionType === 'click' && guide.targetSelector) {
  430. await this.page?.click(guide.targetSelector);
  431. }
  432. }
  433. } catch (captchaError) {
  434. logger.error('[AI Publish] Captcha handling failed:', captchaError);
  435. return { success: false, message: '验证码处理失败', needManualIntervention: true };
  436. }
  437. } else {
  438. // 没有验证码处理回调,需要人工介入
  439. logger.warn('[AI Publish] Captcha required but no handler provided');
  440. return { success: false, message: '需要验证码', needManualIntervention: true };
  441. }
  442. break;
  443. case 'need_action':
  444. if (status.nextAction) {
  445. options.onProgress?.(Math.min(80, 50 + attempt * 2), status.pageDescription);
  446. // 尝试执行 AI 建议的操作
  447. const guide = await this.aiGetPublishOperationGuide(status.pageDescription);
  448. if (guide?.hasAction) {
  449. await this.aiExecuteOperation(guide);
  450. }
  451. }
  452. break;
  453. case 'uploading':
  454. case 'processing':
  455. options.onProgress?.(Math.min(90, 30 + attempt * 2), status.pageDescription);
  456. // 继续等待
  457. break;
  458. }
  459. }
  460. // 超过最大尝试次数
  461. logger.warn('[AI Publish] Max attempts reached');
  462. return { success: false, message: '发布超时,请检查发布状态' };
  463. }
  464. /**
  465. * AI 辅助处理 Python API 发布结果
  466. * 当 Python 返回截图时,使用 AI 分析发布状态
  467. * @param result Python API 返回的结果
  468. * @param onCaptchaRequired 验证码回调
  469. * @param onProgress 进度回调
  470. * @returns 处理后的发布结果
  471. */
  472. protected async aiProcessPythonPublishResult(
  473. result: {
  474. success?: boolean;
  475. screenshot_base64?: string;
  476. page_url?: string;
  477. video_id?: string;
  478. video_url?: string;
  479. need_captcha?: boolean;
  480. captcha_type?: string;
  481. status?: string;
  482. error?: string;
  483. },
  484. onCaptchaRequired?: (captchaInfo: {
  485. taskId: string;
  486. type: 'sms' | 'image';
  487. phone?: string;
  488. imageBase64?: string;
  489. }) => Promise<string>,
  490. onProgress?: (progress: number, message: string) => void
  491. ): Promise<PublishResult & { needCaptcha?: boolean; captchaType?: string }> {
  492. // 如果 Python 返回成功
  493. if (result.success) {
  494. onProgress?.(100, '发布成功');
  495. return {
  496. success: true,
  497. platformVideoId: result.video_id || `${this.platform}_${Date.now()}`,
  498. videoUrl: result.video_url || '',
  499. };
  500. }
  501. // 如果返回了截图,使用 AI 分析
  502. if (result.screenshot_base64 && aiService.isAvailable()) {
  503. logger.info(`[${this.platform} Python] Got screenshot, analyzing with AI...`);
  504. const aiStatus = await aiService.analyzePublishStatus(result.screenshot_base64, this.platform);
  505. logger.info(`[${this.platform} Python] AI analysis: status=${aiStatus.status}, confidence=${aiStatus.confidence}%`);
  506. // AI 判断发布成功
  507. if (aiStatus.status === 'success' && aiStatus.confidence >= 70) {
  508. onProgress?.(100, '发布成功');
  509. return {
  510. success: true,
  511. platformVideoId: result.video_id || `${this.platform}_${Date.now()}`,
  512. videoUrl: result.video_url || result.page_url || '',
  513. };
  514. }
  515. // AI 检测到需要验证码
  516. if (aiStatus.status === 'need_captcha') {
  517. logger.info(`[${this.platform} Python] AI detected captcha: ${aiStatus.captchaDescription}`);
  518. if (onCaptchaRequired) {
  519. onProgress?.(50, `AI 检测到验证码: ${aiStatus.captchaDescription || '请输入验证码'}`);
  520. try {
  521. const captchaCode = await onCaptchaRequired({
  522. taskId: `${this.platform}_captcha_${Date.now()}`,
  523. type: aiStatus.captchaType === 'sms' ? 'sms' : 'image',
  524. imageBase64: result.screenshot_base64,
  525. });
  526. // 验证码已获取,但 Python 发布已结束,需要通过 Playwright 继续
  527. logger.info(`[${this.platform} Python] Got captcha code, need to continue with Playwright`);
  528. } catch {
  529. logger.error(`[${this.platform} Python] Captcha handling failed`);
  530. }
  531. }
  532. return {
  533. success: false,
  534. needCaptcha: true,
  535. captchaType: aiStatus.captchaType || 'image',
  536. errorMessage: aiStatus.captchaDescription || '需要验证码',
  537. };
  538. }
  539. // AI 判断发布失败
  540. if (aiStatus.status === 'failed' && aiStatus.confidence >= 70) {
  541. return {
  542. success: false,
  543. errorMessage: aiStatus.errorMessage || 'AI 检测到发布失败',
  544. };
  545. }
  546. }
  547. // Python 返回需要验证码
  548. if (result.need_captcha || result.status === 'need_captcha') {
  549. logger.info(`[${this.platform} Python] Captcha required: type=${result.captcha_type}`);
  550. onProgress?.(0, `检测到需要验证码,切换到浏览器模式...`);
  551. return {
  552. success: false,
  553. needCaptcha: true,
  554. captchaType: result.captcha_type || 'image',
  555. errorMessage: result.error || '需要验证码',
  556. };
  557. }
  558. // 其他失败情况
  559. return {
  560. success: false,
  561. errorMessage: result.error || '发布失败',
  562. };
  563. }
  564. // ==================== 抽象方法 - 子类必须实现 ====================
  565. /**
  566. * 获取扫码登录二维码
  567. */
  568. abstract getQRCode(): Promise<QRCodeInfo>;
  569. /**
  570. * 检查扫码状态
  571. */
  572. abstract checkQRCodeStatus(qrcodeKey: string): Promise<LoginStatusResult>;
  573. /**
  574. * 检查登录状态
  575. */
  576. abstract checkLoginStatus(cookies: string): Promise<boolean>;
  577. /**
  578. * 获取账号信息
  579. */
  580. abstract getAccountInfo(cookies: string): Promise<AccountProfile>;
  581. /**
  582. * 发布视频
  583. */
  584. abstract publishVideo(cookies: string, params: PublishParams): Promise<PublishResult>;
  585. /**
  586. * 获取评论列表
  587. */
  588. abstract getComments(cookies: string, videoId: string): Promise<CommentData[]>;
  589. /**
  590. * 回复评论
  591. */
  592. abstract replyComment(cookies: string, commentId: string, content: string): Promise<boolean>;
  593. /**
  594. * 获取数据统计
  595. */
  596. abstract getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData>;
  597. }