baijiahao.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
  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. } from './base.js';
  12. import type { PlatformType, QRCodeInfo, LoginStatusResult } from '@media-manager/shared';
  13. import { logger } from '../../utils/logger.js';
  14. import { aiService } from '../../ai/index.js';
  15. import { getPythonServiceBaseUrl } from '../../services/PythonServiceConfigService.js';
  16. // 服务器根目录(用于构造绝对路径)
  17. const SERVER_ROOT = path.resolve(process.cwd());
  18. /**
  19. * 百家号平台适配器
  20. */
  21. export class BaijiahaoAdapter extends BasePlatformAdapter {
  22. readonly platform: PlatformType = 'baijiahao';
  23. readonly loginUrl = 'https://baijiahao.baidu.com/';
  24. readonly publishUrl = 'https://baijiahao.baidu.com/builder/rc/edit?type=videoV2&is_from_cms=1';
  25. protected getCookieDomain(): string {
  26. return '.baidu.com';
  27. }
  28. /**
  29. * 检查 Python 发布服务是否可用
  30. */
  31. private async checkPythonServiceAvailable(): Promise<boolean> {
  32. try {
  33. const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
  34. const response = await fetch(`${pythonUrl}/health`, {
  35. method: 'GET',
  36. signal: AbortSignal.timeout(3000),
  37. });
  38. if (response.ok) {
  39. const data = await response.json();
  40. return data.status === 'ok' && data.supported_platforms?.includes('baijiahao');
  41. }
  42. return false;
  43. } catch {
  44. return false;
  45. }
  46. }
  47. async getQRCode(): Promise<QRCodeInfo> {
  48. try {
  49. await this.initBrowser();
  50. if (!this.page) throw new Error('Page not initialized');
  51. // 访问百家号登录页面
  52. await this.page.goto('https://baijiahao.baidu.com/', {
  53. waitUntil: 'domcontentloaded',
  54. timeout: 30000,
  55. });
  56. // 等待二维码出现
  57. await this.page.waitForSelector('img[src*="qrcode"]', { timeout: 15000 });
  58. // 获取二维码图片
  59. const qrcodeImg = await this.page.$('img[src*="qrcode"]');
  60. const qrcodeUrl = await qrcodeImg?.getAttribute('src');
  61. if (!qrcodeUrl) {
  62. throw new Error('Failed to get QR code');
  63. }
  64. return {
  65. qrcodeUrl,
  66. qrcodeKey: `baijiahao_${Date.now()}`,
  67. expireTime: Date.now() + 300000,
  68. };
  69. } catch (error) {
  70. logger.error('Baijiahao getQRCode error:', error);
  71. throw error;
  72. }
  73. }
  74. async checkQRCodeStatus(qrcodeKey: string): Promise<LoginStatusResult> {
  75. try {
  76. if (!this.page) {
  77. return { status: 'expired', message: '二维码已过期' };
  78. }
  79. // 检查是否登录成功(URL 变化或出现用户信息)
  80. const currentUrl = this.page.url();
  81. if (currentUrl.includes('/builder/rc/home') || currentUrl.includes('/builder/rc/content')) {
  82. const cookies = await this.getCookies();
  83. await this.closeBrowser();
  84. return {
  85. status: 'success',
  86. message: '登录成功',
  87. cookies,
  88. };
  89. }
  90. // 检查是否需要手机验证
  91. const phoneInput = await this.page.$('input[type="tel"]');
  92. if (phoneInput) {
  93. return { status: 'scanned', message: '需要手机验证' };
  94. }
  95. return { status: 'pending', message: '等待扫码' };
  96. } catch (error) {
  97. logger.error('Baijiahao checkQRCodeStatus error:', error);
  98. return { status: 'expired', message: '检查状态失败' };
  99. }
  100. }
  101. async checkLoginStatus(cookies: string): Promise<boolean> {
  102. try {
  103. await this.initBrowser({ headless: true });
  104. await this.setCookies(cookies);
  105. if (!this.page) throw new Error('Page not initialized');
  106. // 访问百家号后台首页
  107. await this.page.goto('https://baijiahao.baidu.com/builder/rc/home', {
  108. waitUntil: 'domcontentloaded',
  109. timeout: 30000,
  110. });
  111. await this.page.waitForTimeout(2000);
  112. const currentUrl = this.page.url();
  113. const isLoggedIn = currentUrl.includes('/builder/rc/') && !currentUrl.includes('login');
  114. await this.closeBrowser();
  115. return isLoggedIn;
  116. } catch (error) {
  117. logger.error('Baijiahao checkLoginStatus error:', error);
  118. await this.closeBrowser();
  119. return false;
  120. }
  121. }
  122. /**
  123. * 关闭页面上可能存在的弹窗对话框
  124. */
  125. private async closeModalDialogs(): Promise<boolean> {
  126. if (!this.page) return false;
  127. let closedAny = false;
  128. try {
  129. const modalSelectors = [
  130. // 百家号常见弹窗关闭按钮
  131. '.Dialog-close',
  132. '.modal-close',
  133. '[class*="dialog"] [class*="close"]',
  134. '[class*="modal"] [class*="close"]',
  135. '[role="dialog"] button[aria-label="close"]',
  136. '.ant-modal-close',
  137. 'button:has-text("关闭")',
  138. 'button:has-text("取消")',
  139. 'button:has-text("我知道了")',
  140. 'button:has-text("暂不")',
  141. '.close-btn',
  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(`[Baijiahao] 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('[class*="dialog"], [class*="modal"], [role="dialog"]').count();
  159. if (hasModal > 0) {
  160. logger.info('[Baijiahao] Trying ESC key to close modal...');
  161. await this.page.keyboard.press('Escape');
  162. await this.page.waitForTimeout(500);
  163. closedAny = true;
  164. }
  165. }
  166. if (closedAny) {
  167. logger.info('[Baijiahao] Successfully closed modal dialog');
  168. }
  169. } catch (error) {
  170. logger.warn('[Baijiahao] Error closing modal:', error);
  171. }
  172. return closedAny;
  173. }
  174. async getAccountInfo(cookies: string): Promise<AccountProfile> {
  175. try {
  176. await this.initBrowser({ headless: true });
  177. await this.setCookies(cookies);
  178. if (!this.page) throw new Error('Page not initialized');
  179. // 访问设置页面获取账号信息
  180. await this.page.goto('https://baijiahao.baidu.com/builder/rc/home', {
  181. waitUntil: 'domcontentloaded',
  182. timeout: 30000,
  183. });
  184. await this.page.waitForTimeout(3000);
  185. // 尝试从页面获取账号信息
  186. const accountInfo = await this.page.evaluate(() => {
  187. // 尝试获取用户名
  188. const nameEl = document.querySelector('.user-name, .user-info .name, [class*="author-name"]');
  189. const name = nameEl?.textContent?.trim() || '';
  190. // 尝试获取头像
  191. const avatarEl = document.querySelector('.user-avatar img, .author-avatar img, [class*="avatar"] img');
  192. const avatar = avatarEl?.getAttribute('src') || '';
  193. return { name, avatar };
  194. });
  195. await this.closeBrowser();
  196. return {
  197. accountId: `baijiahao_${Date.now()}`,
  198. accountName: accountInfo.name || '百家号账号',
  199. avatarUrl: accountInfo.avatar || '',
  200. fansCount: 0,
  201. worksCount: 0,
  202. };
  203. } catch (error) {
  204. logger.error('Baijiahao getAccountInfo error:', error);
  205. await this.closeBrowser();
  206. return {
  207. accountId: '',
  208. accountName: '百家号账号',
  209. avatarUrl: '',
  210. fansCount: 0,
  211. worksCount: 0,
  212. };
  213. }
  214. }
  215. /**
  216. * 通过 Python 服务发布视频(带 AI 辅助)
  217. */
  218. private async publishVideoViaPython(
  219. cookies: string,
  220. params: PublishParams,
  221. onProgress?: (progress: number, message: string) => void
  222. ): Promise<PublishResult> {
  223. logger.info('[Baijiahao Python] Starting publish via Python service with AI assist...');
  224. onProgress?.(5, '正在通过 Python 服务发布...');
  225. try {
  226. // 准备 cookie 字符串
  227. let cookieStr = cookies;
  228. try {
  229. const cookieArray = JSON.parse(cookies);
  230. if (Array.isArray(cookieArray)) {
  231. cookieStr = cookieArray.map((c: { name: string; value: string }) => `${c.name}=${c.value}`).join('; ');
  232. }
  233. } catch {
  234. // 已经是字符串格式
  235. }
  236. // 将相对路径转换为绝对路径
  237. const absoluteVideoPath = path.isAbsolute(params.videoPath)
  238. ? params.videoPath
  239. : path.resolve(SERVER_ROOT, params.videoPath);
  240. const absoluteCoverPath = params.coverPath
  241. ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath))
  242. : undefined;
  243. // 使用 AI 辅助发布接口
  244. const extra = (params.extra || {}) as Record<string, unknown>;
  245. const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
  246. const response = await fetch(`${pythonUrl}/publish/ai-assisted`, {
  247. method: 'POST',
  248. headers: {
  249. 'Content-Type': 'application/json',
  250. },
  251. body: JSON.stringify({
  252. platform: 'baijiahao',
  253. cookie: cookieStr,
  254. user_id: (extra as any).userId,
  255. publish_task_id: (extra as any).publishTaskId,
  256. publish_account_id: (extra as any).publishAccountId,
  257. proxy: (extra as any).publishProxy || null,
  258. title: params.title,
  259. description: params.description || params.title,
  260. video_path: absoluteVideoPath,
  261. cover_path: absoluteCoverPath,
  262. tags: params.tags || [],
  263. post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null,
  264. return_screenshot: true,
  265. }),
  266. signal: AbortSignal.timeout(600000), // 10分钟超时
  267. });
  268. const result = await response.json();
  269. logger.info('[Baijiahao Python] Response:', { ...result, screenshot_base64: result.screenshot_base64 ? '[截图已省略]' : undefined });
  270. // 使用通用的 AI 辅助处理方法
  271. return await this.aiProcessPythonPublishResult(result, undefined, onProgress);
  272. } catch (error) {
  273. logger.error('[Baijiahao Python] Publish failed:', error);
  274. throw error;
  275. }
  276. }
  277. async publishVideo(
  278. cookies: string,
  279. params: PublishParams,
  280. onProgress?: (progress: number, message: string) => void,
  281. onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string }) => Promise<string>,
  282. options?: { headless?: boolean }
  283. ): Promise<PublishResult> {
  284. // 只使用 Python 服务发布
  285. const pythonAvailable = await this.checkPythonServiceAvailable();
  286. if (!pythonAvailable) {
  287. logger.error('[Baijiahao] Python service not available');
  288. return {
  289. success: false,
  290. errorMessage: 'Python 发布服务不可用,请确保 Python 服务已启动',
  291. };
  292. }
  293. logger.info('[Baijiahao] Using Python service for publishing');
  294. try {
  295. const result = await this.publishVideoViaPython(cookies, params, onProgress);
  296. // 检查是否需要验证码
  297. if (!result.success && result.errorMessage?.includes('验证码')) {
  298. logger.info('[Baijiahao] Python detected captcha, need headful browser');
  299. return {
  300. success: false,
  301. errorMessage: `CAPTCHA_REQUIRED:${result.errorMessage}`,
  302. };
  303. }
  304. return result;
  305. } catch (pythonError) {
  306. logger.error('[Baijiahao] Python publish failed:', pythonError);
  307. return {
  308. success: false,
  309. errorMessage: pythonError instanceof Error ? pythonError.message : '发布失败',
  310. };
  311. }
  312. /* ========== Playwright 方式已注释,只使用 Python API ==========
  313. const useHeadless = options?.headless ?? true;
  314. try {
  315. await this.initBrowser({ headless: useHeadless });
  316. await this.setCookies(cookies);
  317. if (!this.page) throw new Error('Page not initialized');
  318. onProgress?.(5, '正在打开发布页面...');
  319. // 访问发布页面
  320. await this.page.goto(this.publishUrl, {
  321. waitUntil: 'domcontentloaded',
  322. timeout: 60000,
  323. });
  324. await this.page.waitForTimeout(3000);
  325. // 先关闭可能存在的弹窗
  326. await this.closeModalDialogs();
  327. // 检查是否需要登录
  328. const currentUrl = this.page.url();
  329. if (currentUrl.includes('login') || currentUrl.includes('passport')) {
  330. await this.closeBrowser();
  331. return {
  332. success: false,
  333. errorMessage: '账号登录已过期,请重新登录',
  334. };
  335. }
  336. // 再次关闭可能的弹窗(登录后可能出现活动弹窗)
  337. await this.closeModalDialogs();
  338. onProgress?.(10, '正在上传视频...');
  339. // 上传视频 - 优先使用 AI 截图分析找到上传入口
  340. let uploadTriggered = false;
  341. // 方法1: AI 截图分析找到上传入口
  342. logger.info('[Baijiahao Publish] Using AI to find upload entry...');
  343. try {
  344. const screenshot = await this.screenshotBase64();
  345. const guide = await aiService.getPageOperationGuide(screenshot, 'baijiahao', '找到视频上传入口并点击上传按钮');
  346. logger.info(`[Baijiahao Publish] AI analysis result:`, guide);
  347. if (guide.hasAction && guide.targetSelector) {
  348. logger.info(`[Baijiahao Publish] AI suggested selector: ${guide.targetSelector}`);
  349. try {
  350. const [fileChooser] = await Promise.all([
  351. this.page.waitForEvent('filechooser', { timeout: 10000 }),
  352. this.page.click(guide.targetSelector),
  353. ]);
  354. await fileChooser.setFiles(params.videoPath);
  355. uploadTriggered = true;
  356. logger.info('[Baijiahao Publish] Upload triggered via AI selector');
  357. } catch (e) {
  358. logger.warn(`[Baijiahao Publish] AI selector click failed: ${e}`);
  359. }
  360. }
  361. } catch (e) {
  362. logger.warn(`[Baijiahao Publish] AI analysis failed: ${e}`);
  363. }
  364. // 方法2: 尝试点击常见的上传区域触发 file chooser
  365. if (!uploadTriggered) {
  366. logger.info('[Baijiahao Publish] Trying common upload selectors...');
  367. const uploadSelectors = [
  368. // 百家号常见上传区域选择器 - 虚线框拖拽上传区域
  369. '[class*="drag"]',
  370. '[class*="drop"]',
  371. '[class*="upload-area"]',
  372. '[class*="upload-zone"]',
  373. '[class*="upload-wrapper"]',
  374. '[class*="upload-box"]',
  375. '[class*="upload-btn"]',
  376. '[class*="upload-video"]',
  377. '[class*="video-upload"]',
  378. '[class*="drag-upload"]',
  379. '.upload-container',
  380. '.video-uploader',
  381. 'div[class*="uploader"]',
  382. // 匹配包含"点击上传"文字的区域
  383. 'div:has-text("点击上传")',
  384. 'div:has-text("拖动入此区域")',
  385. 'span:has-text("点击上传")',
  386. // 带虚线边框的容器(通常是拖拽上传区域)
  387. '[style*="dashed"]',
  388. '[class*="dashed"]',
  389. '[class*="border-dashed"]',
  390. // 其他常见选择器
  391. 'button:has-text("上传")',
  392. '[class*="add-btn"]',
  393. '.bjh-upload',
  394. '[class*="file-select"]',
  395. // 通用的上传触发器
  396. '[class*="trigger"]',
  397. '[class*="picker"]',
  398. ];
  399. for (const selector of uploadSelectors) {
  400. if (uploadTriggered) break;
  401. try {
  402. const element = this.page.locator(selector).first();
  403. if (await element.count() > 0 && await element.isVisible()) {
  404. logger.info(`[Baijiahao Publish] Trying selector: ${selector}`);
  405. const [fileChooser] = await Promise.all([
  406. this.page.waitForEvent('filechooser', { timeout: 5000 }),
  407. element.click(),
  408. ]);
  409. await fileChooser.setFiles(params.videoPath);
  410. uploadTriggered = true;
  411. logger.info(`[Baijiahao Publish] Upload triggered via selector: ${selector}`);
  412. }
  413. } catch (e) {
  414. // 继续尝试下一个选择器
  415. }
  416. }
  417. }
  418. // 方法3: 直接设置 file input
  419. if (!uploadTriggered) {
  420. logger.info('[Baijiahao Publish] Trying file input method...');
  421. const fileInputs = await this.page.$$('input[type="file"]');
  422. logger.info(`[Baijiahao Publish] Found ${fileInputs.length} file inputs`);
  423. for (const fileInput of fileInputs) {
  424. try {
  425. const accept = await fileInput.getAttribute('accept');
  426. if (!accept || accept.includes('video') || accept.includes('*')) {
  427. await fileInput.setInputFiles(params.videoPath);
  428. uploadTriggered = true;
  429. logger.info('[Baijiahao Publish] Upload triggered via file input');
  430. break;
  431. }
  432. } catch (e) {
  433. logger.warn(`[Baijiahao Publish] File input method failed: ${e}`);
  434. }
  435. }
  436. }
  437. // 方法4: 如果AI给出了坐标,尝试基于坐标点击
  438. if (!uploadTriggered) {
  439. logger.info('[Baijiahao Publish] Trying AI position-based click...');
  440. try {
  441. const screenshot = await this.screenshotBase64();
  442. const guide = await aiService.getPageOperationGuide(screenshot, 'baijiahao', '请找到页面中央的虚线框上传区域(有"点击上传或将文件拖动入此区域"文字的区域),返回该区域的中心坐标');
  443. logger.info(`[Baijiahao Publish] AI position analysis:`, guide);
  444. if (guide.hasAction && guide.targetPosition) {
  445. const { x, y } = guide.targetPosition;
  446. logger.info(`[Baijiahao Publish] Clicking at position: ${x}, ${y}`);
  447. const [fileChooser] = await Promise.all([
  448. this.page.waitForEvent('filechooser', { timeout: 10000 }),
  449. this.page.mouse.click(x, y),
  450. ]);
  451. await fileChooser.setFiles(params.videoPath);
  452. uploadTriggered = true;
  453. logger.info('[Baijiahao Publish] Upload triggered via position click');
  454. }
  455. } catch (e) {
  456. logger.warn(`[Baijiahao Publish] Position-based click failed: ${e}`);
  457. }
  458. }
  459. // 方法5: 点击页面中央区域(百家号上传区域通常在中央)
  460. if (!uploadTriggered) {
  461. logger.info('[Baijiahao Publish] Trying center area click...');
  462. try {
  463. const viewport = this.page.viewportSize();
  464. if (viewport) {
  465. // 百家号的上传区域大约在页面中央偏上的位置
  466. const centerX = viewport.width / 2;
  467. const centerY = viewport.height * 0.35; // 上传区域通常在页面上半部分
  468. logger.info(`[Baijiahao Publish] Clicking center area: ${centerX}, ${centerY}`);
  469. const [fileChooser] = await Promise.all([
  470. this.page.waitForEvent('filechooser', { timeout: 10000 }),
  471. this.page.mouse.click(centerX, centerY),
  472. ]);
  473. await fileChooser.setFiles(params.videoPath);
  474. uploadTriggered = true;
  475. logger.info('[Baijiahao Publish] Upload triggered via center click');
  476. }
  477. } catch (e) {
  478. logger.warn(`[Baijiahao Publish] Center click failed: ${e}`);
  479. }
  480. }
  481. if (!uploadTriggered) {
  482. // 截图调试
  483. try {
  484. if (this.page) {
  485. const screenshotPath = `uploads/debug/baijiahao_no_upload_${Date.now()}.png`;
  486. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  487. logger.info(`[Baijiahao Publish] Screenshot saved: ${screenshotPath}`);
  488. }
  489. } catch {}
  490. throw new Error('未找到上传入口');
  491. }
  492. // 等待上传完成
  493. onProgress?.(30, '视频上传中...');
  494. await this.page.waitForTimeout(5000);
  495. // 等待视频处理
  496. const maxWaitTime = 300000; // 5分钟
  497. const startTime = Date.now();
  498. let lastAiCheckTime = 0;
  499. const aiCheckInterval = 10000; // 每10秒使用AI检测一次
  500. while (Date.now() - startTime < maxWaitTime) {
  501. // 检查上传进度(通过DOM)
  502. let progressDetected = false;
  503. const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => '');
  504. if (progressText) {
  505. const match = progressText.match(/(\d+)%/);
  506. if (match) {
  507. const progress = parseInt(match[1]);
  508. onProgress?.(30 + Math.floor(progress * 0.3), `视频上传中: ${progress}%`);
  509. progressDetected = true;
  510. if (progress >= 100) {
  511. logger.info('[Baijiahao Publish] Upload progress reached 100%');
  512. break;
  513. }
  514. }
  515. }
  516. // 检查是否上传完成
  517. const uploadSuccess = await this.page.locator('[class*="success"], [class*="complete"]').count();
  518. if (uploadSuccess > 0) {
  519. logger.info('[Baijiahao Publish] Upload success indicator found');
  520. break;
  521. }
  522. // 使用AI检测上传进度(每隔一段时间检测一次)
  523. if (!progressDetected && Date.now() - lastAiCheckTime > aiCheckInterval) {
  524. lastAiCheckTime = Date.now();
  525. try {
  526. const screenshot = await this.screenshotBase64();
  527. const uploadStatus = await aiService.analyzeUploadProgress(screenshot, 'baijiahao');
  528. logger.info(`[Baijiahao Publish] AI upload status:`, uploadStatus);
  529. if (uploadStatus.isComplete) {
  530. logger.info('[Baijiahao Publish] AI detected upload complete');
  531. break;
  532. }
  533. if (uploadStatus.isFailed) {
  534. throw new Error(`视频上传失败: ${uploadStatus.statusDescription}`);
  535. }
  536. if (uploadStatus.progress !== null) {
  537. onProgress?.(30 + Math.floor(uploadStatus.progress * 0.3), `视频上传中: ${uploadStatus.progress}%`);
  538. if (uploadStatus.progress >= 100) {
  539. logger.info('[Baijiahao Publish] AI detected progress 100%');
  540. break;
  541. }
  542. }
  543. } catch (aiError) {
  544. logger.warn('[Baijiahao Publish] AI progress check failed:', aiError);
  545. }
  546. }
  547. await this.page.waitForTimeout(2000);
  548. }
  549. onProgress?.(60, '正在填写视频信息...');
  550. // 填写标题
  551. const titleInput = this.page.locator('input[placeholder*="标题"], textarea[placeholder*="标题"]').first();
  552. if (await titleInput.count() > 0) {
  553. await titleInput.fill(params.title);
  554. }
  555. // 填写描述
  556. if (params.description) {
  557. const descInput = this.page.locator('textarea[placeholder*="简介"], textarea[placeholder*="描述"]').first();
  558. if (await descInput.count() > 0) {
  559. await descInput.fill(params.description);
  560. }
  561. }
  562. onProgress?.(80, '正在发布...');
  563. // 点击发布按钮
  564. const publishBtn = this.page.locator('button:has-text("发布"), [class*="publish-btn"]').first();
  565. if (await publishBtn.count() > 0) {
  566. await publishBtn.click();
  567. } else {
  568. throw new Error('未找到发布按钮');
  569. }
  570. // 等待发布结果
  571. onProgress?.(90, '等待发布完成...');
  572. const publishMaxWait = 120000; // 2分钟
  573. const publishStartTime = Date.now();
  574. let lastProgressCheckTime = 0;
  575. const progressCheckInterval = 5000; // 每5秒检测一次发布进度
  576. while (Date.now() - publishStartTime < publishMaxWait) {
  577. await this.page.waitForTimeout(3000);
  578. // 检查是否跳转到内容管理页面
  579. const currentUrl = this.page.url();
  580. if (currentUrl.includes('/content') || currentUrl.includes('/rc/home')) {
  581. onProgress?.(100, '发布成功!');
  582. await this.closeBrowser();
  583. return {
  584. success: true,
  585. videoUrl: currentUrl,
  586. };
  587. }
  588. // 检查发布进度条(DOM方式)
  589. const publishProgressText = await this.page.locator('[class*="progress"], [class*="loading"]').first().textContent().catch(() => '');
  590. if (publishProgressText) {
  591. const match = publishProgressText.match(/(\d+)%/);
  592. if (match) {
  593. const progress = parseInt(match[1]);
  594. onProgress?.(90 + Math.floor(progress * 0.1), `发布中: ${progress}%`);
  595. logger.info(`[Baijiahao Publish] Publish progress: ${progress}%`);
  596. }
  597. }
  598. // AI检测发布进度(定期检测)
  599. if (Date.now() - lastProgressCheckTime > progressCheckInterval) {
  600. lastProgressCheckTime = Date.now();
  601. try {
  602. const screenshot = await this.screenshotBase64();
  603. const publishStatus = await aiService.analyzePublishProgress(screenshot, 'baijiahao');
  604. logger.info(`[Baijiahao Publish] AI publish status:`, publishStatus);
  605. if (publishStatus.isComplete) {
  606. logger.info('[Baijiahao Publish] AI detected publish complete');
  607. onProgress?.(100, '发布成功!');
  608. await this.closeBrowser();
  609. return { success: true, videoUrl: this.page.url() };
  610. }
  611. if (publishStatus.isFailed) {
  612. throw new Error(`发布失败: ${publishStatus.statusDescription}`);
  613. }
  614. if (publishStatus.progress !== null) {
  615. onProgress?.(90 + Math.floor(publishStatus.progress * 0.1), `发布中: ${publishStatus.progress}%`);
  616. }
  617. // 处理需要用户操作的情况(如验证码)
  618. if (publishStatus.needAction && onCaptchaRequired) {
  619. logger.info(`[Baijiahao Publish] Need action: ${publishStatus.actionDescription}`);
  620. const imageBase64 = await this.screenshotBase64();
  621. try {
  622. await onCaptchaRequired({
  623. taskId: `baijiahao_captcha_${Date.now()}`,
  624. type: 'image',
  625. imageBase64,
  626. });
  627. } catch {
  628. logger.error('[Baijiahao] Captcha handling failed');
  629. }
  630. }
  631. if (publishStatus.isPublishing) {
  632. logger.info(`[Baijiahao Publish] Still publishing: ${publishStatus.statusDescription}`);
  633. }
  634. } catch (aiError) {
  635. logger.warn('[Baijiahao Publish] AI publish progress check failed:', aiError);
  636. }
  637. }
  638. // 检查错误提示
  639. const errorHint = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
  640. if (errorHint && (errorHint.includes('失败') || errorHint.includes('错误'))) {
  641. throw new Error(`发布失败: ${errorHint}`);
  642. }
  643. }
  644. // 最后再检查一次AI状态
  645. const aiStatus = await this.aiAnalyzePublishStatus();
  646. if (aiStatus) {
  647. if (aiStatus.status === 'success') {
  648. onProgress?.(100, '发布成功!');
  649. await this.closeBrowser();
  650. return {
  651. success: true,
  652. videoUrl: this.page.url(),
  653. };
  654. }
  655. if (aiStatus.status === 'failed') {
  656. throw new Error(aiStatus.errorMessage || '发布失败');
  657. }
  658. }
  659. // 超时
  660. await this.closeBrowser();
  661. return {
  662. success: false,
  663. errorMessage: '发布超时,请手动检查是否发布成功',
  664. };
  665. } catch (error) {
  666. logger.error('[Baijiahao Publish] Error:', error);
  667. await this.closeBrowser();
  668. return {
  669. success: false,
  670. errorMessage: error instanceof Error ? error.message : '发布失败',
  671. };
  672. }
  673. ========== Playwright 方式已注释结束 ========== */
  674. }
  675. async getComments(cookies: string, videoId: string): Promise<CommentData[]> {
  676. logger.warn('[Baijiahao] getComments not implemented');
  677. return [];
  678. }
  679. async replyComment(cookies: string, commentId: string, content: string): Promise<boolean> {
  680. logger.warn('[Baijiahao] replyComment not implemented');
  681. return false;
  682. }
  683. async getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData> {
  684. logger.warn('[Baijiahao] getAnalytics not implemented');
  685. return {
  686. fansCount: 0,
  687. fansIncrease: 0,
  688. viewsCount: 0,
  689. likesCount: 0,
  690. commentsCount: 0,
  691. sharesCount: 0,
  692. };
  693. }
  694. }