weixin.ts 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096
  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. * 参考: matrix/tencent_uploader/main.py
  21. */
  22. export class WeixinAdapter extends BasePlatformAdapter {
  23. readonly platform: PlatformType = 'weixin_video';
  24. readonly loginUrl = 'https://channels.weixin.qq.com/platform';
  25. readonly publishUrl = 'https://channels.weixin.qq.com/platform/post/create';
  26. protected getCookieDomain(): string {
  27. return '.weixin.qq.com';
  28. }
  29. async getQRCode(): Promise<QRCodeInfo> {
  30. try {
  31. await this.initBrowser();
  32. if (!this.page) throw new Error('Page not initialized');
  33. // 访问登录页面
  34. await this.page.goto('https://channels.weixin.qq.com/platform/login-for-iframe?dark_mode=true&host_type=1');
  35. // 点击二维码切换
  36. await this.page.locator('.qrcode').click();
  37. // 获取二维码
  38. const qrcodeImg = await this.page.locator('img.qrcode').getAttribute('src');
  39. if (!qrcodeImg) {
  40. throw new Error('Failed to get QR code');
  41. }
  42. return {
  43. qrcodeUrl: qrcodeImg,
  44. qrcodeKey: `weixin_${Date.now()}`,
  45. expireTime: Date.now() + 300000,
  46. };
  47. } catch (error) {
  48. logger.error('Weixin getQRCode error:', error);
  49. throw error;
  50. }
  51. }
  52. async checkQRCodeStatus(qrcodeKey: string): Promise<LoginStatusResult> {
  53. try {
  54. if (!this.page) {
  55. return { status: 'expired', message: '二维码已过期' };
  56. }
  57. // 检查是否扫码成功
  58. const maskDiv = this.page.locator('.mask').first();
  59. const className = await maskDiv.getAttribute('class');
  60. if (className && className.includes('show')) {
  61. // 等待登录完成
  62. await this.page.waitForTimeout(3000);
  63. const cookies = await this.getCookies();
  64. if (cookies && cookies.length > 10) {
  65. await this.closeBrowser();
  66. return { status: 'success', message: '登录成功', cookies };
  67. }
  68. }
  69. return { status: 'waiting', message: '等待扫码' };
  70. } catch (error) {
  71. logger.error('Weixin checkQRCodeStatus error:', error);
  72. return { status: 'error', message: '检查状态失败' };
  73. }
  74. }
  75. async checkLoginStatus(cookies: string): Promise<boolean> {
  76. try {
  77. await this.initBrowser();
  78. await this.setCookies(cookies);
  79. if (!this.page) throw new Error('Page not initialized');
  80. await this.page.goto(this.publishUrl);
  81. await this.page.waitForLoadState('networkidle');
  82. // 检查是否需要登录
  83. const needLogin = await this.page.$('div.title-name:has-text("视频号小店")');
  84. await this.closeBrowser();
  85. return !needLogin;
  86. } catch (error) {
  87. logger.error('Weixin checkLoginStatus error:', error);
  88. await this.closeBrowser();
  89. return false;
  90. }
  91. }
  92. /**
  93. * 关闭页面上可能存在的弹窗对话框
  94. */
  95. private async closeModalDialogs(): Promise<boolean> {
  96. if (!this.page) return false;
  97. let closedAny = false;
  98. try {
  99. const modalSelectors = [
  100. // 微信视频号常见弹窗关闭按钮
  101. '.weui-desktop-dialog__close',
  102. '.weui-desktop-btn__default:has-text("取消")',
  103. '.weui-desktop-btn__default:has-text("关闭")',
  104. '.weui-desktop-dialog-close',
  105. '[class*="dialog"] [class*="close"]',
  106. '[class*="modal"] [class*="close"]',
  107. '[role="dialog"] button[aria-label="close"]',
  108. 'button:has-text("关闭")',
  109. 'button:has-text("取消")',
  110. 'button:has-text("我知道了")',
  111. '.close-btn',
  112. '.icon-close',
  113. ];
  114. for (const selector of modalSelectors) {
  115. try {
  116. const closeBtn = this.page.locator(selector).first();
  117. if (await closeBtn.count() > 0 && await closeBtn.isVisible()) {
  118. logger.info(`[Weixin] Found modal close button: ${selector}`);
  119. await closeBtn.click({ timeout: 2000 });
  120. closedAny = true;
  121. await this.page.waitForTimeout(500);
  122. }
  123. } catch (e) {
  124. // 忽略错误,继续尝试下一个选择器
  125. }
  126. }
  127. // 尝试按 ESC 键关闭弹窗
  128. if (!closedAny) {
  129. const hasModal = await this.page.locator('[class*="dialog"], [class*="modal"], [role="dialog"]').count();
  130. if (hasModal > 0) {
  131. logger.info('[Weixin] Trying ESC key to close modal...');
  132. await this.page.keyboard.press('Escape');
  133. await this.page.waitForTimeout(500);
  134. closedAny = true;
  135. }
  136. }
  137. if (closedAny) {
  138. logger.info('[Weixin] Successfully closed modal dialog');
  139. }
  140. } catch (error) {
  141. logger.warn('[Weixin] Error closing modal:', error);
  142. }
  143. return closedAny;
  144. }
  145. async getAccountInfo(cookies: string): Promise<AccountProfile> {
  146. try {
  147. await this.initBrowser();
  148. await this.setCookies(cookies);
  149. if (!this.page) throw new Error('Page not initialized');
  150. // 访问视频号创作者平台首页
  151. await this.page.goto('https://channels.weixin.qq.com/platform/home');
  152. await this.page.waitForLoadState('networkidle');
  153. await this.page.waitForTimeout(2000);
  154. // 从页面提取账号信息
  155. const accountData = await this.page.evaluate(() => {
  156. const result: { accountId?: string; accountName?: string; avatarUrl?: string; fansCount?: number; worksCount?: number } = {};
  157. try {
  158. // ===== 1. 优先使用精确选择器获取视频号 ID =====
  159. // 方法1: 通过 #finder-uid-copy 的 data-clipboard-text 属性获取
  160. const finderIdCopyEl = document.querySelector('#finder-uid-copy');
  161. if (finderIdCopyEl) {
  162. const clipboardText = finderIdCopyEl.getAttribute('data-clipboard-text');
  163. if (clipboardText && clipboardText.length >= 10) {
  164. result.accountId = clipboardText;
  165. console.log('[WeixinVideo] Found finder ID from data-clipboard-text:', result.accountId);
  166. } else {
  167. const text = finderIdCopyEl.textContent?.trim();
  168. if (text && /^[a-zA-Z0-9_]+$/.test(text) && text.length >= 10) {
  169. result.accountId = text;
  170. console.log('[WeixinVideo] Found finder ID from #finder-uid-copy text:', result.accountId);
  171. }
  172. }
  173. }
  174. // 方法2: 通过 .finder-uniq-id 选择器获取
  175. if (!result.accountId) {
  176. const finderUniqIdEl = document.querySelector('.finder-uniq-id');
  177. if (finderUniqIdEl) {
  178. const clipboardText = finderUniqIdEl.getAttribute('data-clipboard-text');
  179. if (clipboardText && clipboardText.length >= 10) {
  180. result.accountId = clipboardText;
  181. console.log('[WeixinVideo] Found finder ID from .finder-uniq-id data-clipboard-text:', result.accountId);
  182. } else {
  183. const text = finderUniqIdEl.textContent?.trim();
  184. if (text && /^[a-zA-Z0-9_]+$/.test(text) && text.length >= 10) {
  185. result.accountId = text;
  186. console.log('[WeixinVideo] Found finder ID from .finder-uniq-id text:', result.accountId);
  187. }
  188. }
  189. }
  190. }
  191. // 方法3: 从页面文本中正则匹配
  192. if (!result.accountId) {
  193. const bodyText = document.body.innerText || '';
  194. const finderIdPatterns = [
  195. /视频号ID[::\s]*([a-zA-Z0-9_]+)/,
  196. /视频号[::\s]*ID[::\s]*([a-zA-Z0-9_]+)/,
  197. ];
  198. for (const pattern of finderIdPatterns) {
  199. const match = bodyText.match(pattern);
  200. if (match && match[1] && match[1].length >= 10) {
  201. result.accountId = match[1];
  202. console.log('[WeixinVideo] Found finder ID from regex:', result.accountId);
  203. break;
  204. }
  205. }
  206. }
  207. // ===== 2. 获取账号名称 =====
  208. const nicknameEl = document.querySelector('h2.finder-nickname') ||
  209. document.querySelector('.finder-nickname');
  210. if (nicknameEl) {
  211. const text = nicknameEl.textContent?.trim();
  212. if (text && text.length >= 2 && text.length <= 30) {
  213. result.accountName = text;
  214. console.log('[WeixinVideo] Found name:', result.accountName);
  215. }
  216. }
  217. // ===== 3. 获取头像 =====
  218. const avatarEl = document.querySelector('img.avatar') as HTMLImageElement;
  219. if (avatarEl?.src && avatarEl.src.startsWith('http')) {
  220. result.avatarUrl = avatarEl.src;
  221. } else {
  222. const altAvatarEl = document.querySelector('img[alt="视频号头像"]') as HTMLImageElement;
  223. if (altAvatarEl?.src && altAvatarEl.src.startsWith('http')) {
  224. result.avatarUrl = altAvatarEl.src;
  225. }
  226. }
  227. // ===== 4. 获取视频数和关注者数 =====
  228. const contentInfo = document.querySelector('.finder-content-info');
  229. if (contentInfo) {
  230. const infoDivs = contentInfo.querySelectorAll('div');
  231. infoDivs.forEach(div => {
  232. const text = div.textContent || '';
  233. const numEl = div.querySelector('.finder-info-num');
  234. if (numEl) {
  235. const num = parseInt(numEl.textContent?.trim() || '0', 10);
  236. if (text.includes('视频') || text.includes('作品')) {
  237. result.worksCount = num;
  238. } else if (text.includes('关注者') || text.includes('粉丝')) {
  239. result.fansCount = num;
  240. }
  241. }
  242. });
  243. }
  244. // 备选:从页面整体文本中匹配
  245. if (result.fansCount === undefined || result.worksCount === undefined) {
  246. const bodyText = document.body.innerText || '';
  247. if (result.fansCount === undefined) {
  248. const fansMatch = bodyText.match(/关注者\s*(\d+(?:\.\d+)?[万wW]?)/);
  249. if (fansMatch) {
  250. let count = parseFloat(fansMatch[1]);
  251. if (fansMatch[1].includes('万') || fansMatch[1].toLowerCase().includes('w')) {
  252. count = count * 10000;
  253. }
  254. result.fansCount = Math.floor(count);
  255. }
  256. }
  257. if (result.worksCount === undefined) {
  258. const worksMatch = bodyText.match(/视频\s*(\d+)/);
  259. if (worksMatch) {
  260. result.worksCount = parseInt(worksMatch[1], 10);
  261. }
  262. }
  263. }
  264. } catch (e) {
  265. console.error('[WeixinVideo] Extract error:', e);
  266. }
  267. return result;
  268. });
  269. logger.info('[Weixin] Extracted account data:', accountData);
  270. // 如果首页没有获取到视频号 ID,尝试访问账号设置页面
  271. let finalAccountId = accountData.accountId;
  272. if (!finalAccountId || finalAccountId.length < 10) {
  273. logger.info('[Weixin] Finder ID not found on home page, trying account settings page...');
  274. try {
  275. await this.page.goto('https://channels.weixin.qq.com/platform/account');
  276. await this.page.waitForLoadState('networkidle');
  277. await this.page.waitForTimeout(2000);
  278. const settingsId = await this.page.evaluate(() => {
  279. const bodyText = document.body.innerText || '';
  280. const patterns = [
  281. /视频号ID[::\s]*([a-zA-Z0-9_]+)/,
  282. /视频号[::\s]*ID[::\s]*([a-zA-Z0-9_]+)/,
  283. /唯一标识[::\s]*([a-zA-Z0-9_]+)/,
  284. ];
  285. for (const pattern of patterns) {
  286. const match = bodyText.match(pattern);
  287. if (match && match[1]) {
  288. return match[1];
  289. }
  290. }
  291. return null;
  292. });
  293. if (settingsId) {
  294. finalAccountId = settingsId;
  295. logger.info('[Weixin] Found finder ID from settings page:', finalAccountId);
  296. }
  297. } catch (e) {
  298. logger.warn('[Weixin] Failed to fetch from settings page:', e);
  299. }
  300. }
  301. await this.closeBrowser();
  302. return {
  303. accountId: finalAccountId || `weixin_video_${Date.now()}`,
  304. accountName: accountData.accountName || '视频号账号',
  305. avatarUrl: accountData.avatarUrl || '',
  306. fansCount: accountData.fansCount || 0,
  307. worksCount: accountData.worksCount || 0,
  308. };
  309. } catch (error) {
  310. logger.error('Weixin getAccountInfo error:', error);
  311. await this.closeBrowser();
  312. return {
  313. accountId: `weixin_video_${Date.now()}`,
  314. accountName: '视频号账号',
  315. avatarUrl: '',
  316. fansCount: 0,
  317. worksCount: 0,
  318. };
  319. }
  320. }
  321. /**
  322. * 检查 Python 发布服务是否可用
  323. */
  324. private async checkPythonServiceAvailable(): Promise<boolean> {
  325. try {
  326. const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
  327. const response = await fetch(`${pythonUrl}/health`, {
  328. method: 'GET',
  329. signal: AbortSignal.timeout(3000),
  330. });
  331. if (response.ok) {
  332. const data = await response.json();
  333. return data.status === 'ok' && data.supported_platforms?.includes('weixin');
  334. }
  335. return false;
  336. } catch {
  337. return false;
  338. }
  339. }
  340. /**
  341. * 通过 Python 服务发布视频(带 AI 辅助)
  342. */
  343. private async publishVideoViaPython(
  344. cookies: string,
  345. params: PublishParams,
  346. onProgress?: (progress: number, message: string) => void
  347. ): Promise<PublishResult> {
  348. logger.info('[Weixin Python] Starting publish via Python service with AI assist...');
  349. onProgress?.(5, '正在通过 Python 服务发布...');
  350. try {
  351. // 将相对路径转换为绝对路径
  352. const absoluteVideoPath = path.isAbsolute(params.videoPath)
  353. ? params.videoPath
  354. : path.resolve(SERVER_ROOT, params.videoPath);
  355. const absoluteCoverPath = params.coverPath
  356. ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath))
  357. : undefined;
  358. // 使用 AI 辅助发布接口
  359. const extra = (params.extra || {}) as Record<string, unknown>;
  360. const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
  361. const response = await fetch(`${pythonUrl}/publish/ai-assisted`, {
  362. method: 'POST',
  363. headers: {
  364. 'Content-Type': 'application/json',
  365. },
  366. body: JSON.stringify({
  367. platform: 'weixin',
  368. cookie: cookies,
  369. user_id: (extra as any).userId,
  370. publish_task_id: (extra as any).publishTaskId,
  371. publish_account_id: (extra as any).publishAccountId,
  372. proxy: (extra as any).publishProxy || null,
  373. title: params.title,
  374. description: params.description || params.title,
  375. video_path: absoluteVideoPath,
  376. cover_path: absoluteCoverPath,
  377. tags: params.tags || [],
  378. post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null,
  379. location: params.location || '重庆市',
  380. return_screenshot: true,
  381. }),
  382. signal: AbortSignal.timeout(600000),
  383. });
  384. const result = await response.json();
  385. logger.info('[Weixin Python] Response:', { ...result, screenshot_base64: result.screenshot_base64 ? '[截图已省略]' : undefined });
  386. // 使用通用的 AI 辅助处理方法
  387. return await this.aiProcessPythonPublishResult(result, undefined, onProgress);
  388. } catch (error) {
  389. logger.error('[Weixin Python] Publish failed:', error);
  390. throw error;
  391. }
  392. }
  393. async publishVideo(
  394. cookies: string,
  395. params: PublishParams,
  396. onProgress?: (progress: number, message: string) => void,
  397. onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string }) => Promise<string>,
  398. options?: { headless?: boolean }
  399. ): Promise<PublishResult> {
  400. // 只使用 Python 服务发布
  401. const pythonAvailable = await this.checkPythonServiceAvailable();
  402. if (!pythonAvailable) {
  403. logger.error('[Weixin] Python service not available');
  404. return {
  405. success: false,
  406. errorMessage: 'Python 发布服务不可用,请确保 Python 服务已启动',
  407. };
  408. }
  409. logger.info('[Weixin] Using Python service for publishing');
  410. try {
  411. const result = await this.publishVideoViaPython(cookies, params, onProgress);
  412. // 检查是否需要验证码
  413. if (!result.success && result.errorMessage?.includes('验证码')) {
  414. logger.info('[Weixin] Python detected captcha, need headful browser');
  415. return {
  416. success: false,
  417. errorMessage: `CAPTCHA_REQUIRED:${result.errorMessage}`,
  418. };
  419. }
  420. return result;
  421. } catch (pythonError) {
  422. logger.error('[Weixin] Python publish failed:', pythonError);
  423. return {
  424. success: false,
  425. errorMessage: pythonError instanceof Error ? pythonError.message : '发布失败',
  426. };
  427. }
  428. /* ========== Playwright 方式已注释,只使用 Python API ==========
  429. const useHeadless = options?.headless ?? true;
  430. try {
  431. logger.info('[Weixin Publish] Initializing browser...');
  432. await this.initBrowser({ headless: useHeadless });
  433. if (!this.page) {
  434. throw new Error('浏览器初始化失败,page 为 null');
  435. }
  436. logger.info('[Weixin Publish] Setting cookies...');
  437. await this.setCookies(cookies);
  438. // 再次检查 page 状态
  439. if (!this.page) throw new Error('Page not initialized after setCookies');
  440. onProgress?.(5, '正在打开上传页面...');
  441. await this.page.goto(this.publishUrl, {
  442. waitUntil: 'domcontentloaded',
  443. timeout: 60000,
  444. });
  445. await this.page.waitForTimeout(3000);
  446. // 先关闭可能存在的弹窗
  447. await this.closeModalDialogs();
  448. // 检查是否需要登录
  449. const currentUrl = this.page.url();
  450. if (currentUrl.includes('login')) {
  451. await this.closeBrowser();
  452. return {
  453. success: false,
  454. errorMessage: '账号登录已过期,请重新登录',
  455. };
  456. }
  457. // 再次关闭可能的弹窗
  458. await this.closeModalDialogs();
  459. // 检查是否在发布页面,如果不在则尝试点击"发表视频"按钮
  460. const pageUrl = this.page.url();
  461. if (!pageUrl.includes('post/create')) {
  462. logger.info('[Weixin Publish] Not on publish page, looking for "发表视频" button...');
  463. onProgress?.(8, '正在进入发布页面...');
  464. // 使用AI寻找发表视频按钮
  465. try {
  466. const screenshot = await this.screenshotBase64();
  467. const guide = await aiService.getPageOperationGuide(screenshot, 'weixin_video', '找到"发表视频"按钮并点击进入视频发布页面');
  468. logger.info(`[Weixin Publish] AI guide for publish button:`, guide);
  469. if (guide.hasAction && guide.targetSelector) {
  470. await this.page.click(guide.targetSelector, { timeout: 5000 });
  471. await this.page.waitForTimeout(3000);
  472. }
  473. } catch (e) {
  474. logger.warn(`[Weixin Publish] AI could not find publish button: ${e}`);
  475. }
  476. // 尝试常见的发表视频按钮选择器
  477. const publishBtnSelectors = [
  478. 'button:has-text("发表视频")',
  479. 'a:has-text("发表视频")',
  480. '[class*="publish"]:has-text("发表")',
  481. '[class*="create"]:has-text("发表")',
  482. '.post-video-btn',
  483. ];
  484. for (const selector of publishBtnSelectors) {
  485. try {
  486. const btn = this.page.locator(selector).first();
  487. if (await btn.count() > 0 && await btn.isVisible()) {
  488. logger.info(`[Weixin Publish] Found publish button: ${selector}`);
  489. await btn.click({ timeout: 5000 });
  490. await this.page.waitForTimeout(3000);
  491. break;
  492. }
  493. } catch (e) {
  494. // 继续尝试下一个选择器
  495. }
  496. }
  497. // 关闭可能弹出的弹窗
  498. await this.closeModalDialogs();
  499. }
  500. onProgress?.(10, '正在上传视频...');
  501. // 上传视频 - 优先使用 AI 截图分析找到上传入口
  502. let uploadTriggered = false;
  503. // 方法1: AI 截图分析找到上传入口
  504. logger.info('[Weixin Publish] Using AI to find upload entry...');
  505. try {
  506. const screenshot = await this.screenshotBase64();
  507. const guide = await aiService.getPageOperationGuide(screenshot, 'weixin_video', '找到视频上传入口并点击上传按钮');
  508. logger.info(`[Weixin Publish] AI analysis result:`, guide);
  509. if (guide.hasAction && guide.targetSelector) {
  510. logger.info(`[Weixin Publish] AI suggested selector: ${guide.targetSelector}`);
  511. try {
  512. const [fileChooser] = await Promise.all([
  513. this.page.waitForEvent('filechooser', { timeout: 10000 }),
  514. this.page.click(guide.targetSelector),
  515. ]);
  516. await fileChooser.setFiles(params.videoPath);
  517. uploadTriggered = true;
  518. logger.info('[Weixin Publish] Upload triggered via AI selector');
  519. } catch (e) {
  520. logger.warn(`[Weixin Publish] AI selector click failed: ${e}`);
  521. }
  522. }
  523. } catch (e) {
  524. logger.warn(`[Weixin Publish] AI analysis failed: ${e}`);
  525. }
  526. // 方法2: 尝试点击常见的上传区域触发 file chooser
  527. if (!uploadTriggered) {
  528. logger.info('[Weixin Publish] Trying common upload selectors...');
  529. const uploadSelectors = [
  530. // 微信视频号发布页面 - 带"+"号的上传区域
  531. '[class*="add-media"]',
  532. '[class*="add-video"]',
  533. '[class*="media-add"]',
  534. '[class*="video-add"]',
  535. '[class*="plus"]',
  536. '[class*="add-btn"]',
  537. '[class*="add-icon"]',
  538. // 视频封面/媒体区域
  539. '[class*="video-cover"]',
  540. '[class*="media-cover"]',
  541. '[class*="cover-upload"]',
  542. '[class*="media-upload"]',
  543. '[class*="post-media"]',
  544. // 通用上传区域
  545. '[class*="upload-area"]',
  546. '[class*="upload-btn"]',
  547. '[class*="upload-video"]',
  548. '[class*="video-upload"]',
  549. '[class*="upload-content"]',
  550. '[class*="upload-zone"]',
  551. '[class*="upload-wrap"]',
  552. '[class*="uploader"]',
  553. // 拖拽区域
  554. '[class*="drag"]',
  555. '[class*="drop"]',
  556. // 匹配上传提示文字
  557. 'div:has-text("上传时长")',
  558. 'div:has-text("点击上传")',
  559. 'div:has-text("拖拽上传")',
  560. 'div:has-text("MP4")',
  561. // 带虚线边框的容器
  562. '[style*="dashed"]',
  563. '[class*="dashed"]',
  564. // 微信视频号特有选择器
  565. '[class*="post-cover"]',
  566. '.weui-desktop-upload__area',
  567. '[class*="finder-upload"]',
  568. '[class*="finder-post"]',
  569. // 通用触发器
  570. '.upload-trigger',
  571. '.video-uploader',
  572. '.add-video-btn',
  573. ];
  574. for (const selector of uploadSelectors) {
  575. if (uploadTriggered) break;
  576. try {
  577. const element = this.page.locator(selector).first();
  578. if (await element.count() > 0 && await element.isVisible()) {
  579. logger.info(`[Weixin Publish] Trying selector: ${selector}`);
  580. const [fileChooser] = await Promise.all([
  581. this.page.waitForEvent('filechooser', { timeout: 5000 }),
  582. element.click(),
  583. ]);
  584. await fileChooser.setFiles(params.videoPath);
  585. uploadTriggered = true;
  586. logger.info(`[Weixin Publish] Upload triggered via selector: ${selector}`);
  587. }
  588. } catch (e) {
  589. // 继续尝试下一个选择器
  590. }
  591. }
  592. }
  593. // 方法3: 直接设置 file input
  594. if (!uploadTriggered) {
  595. logger.info('[Weixin Publish] Trying file input method...');
  596. const fileInputs = await this.page.$$('input[type="file"]');
  597. logger.info(`[Weixin Publish] Found ${fileInputs.length} file inputs`);
  598. for (const fileInput of fileInputs) {
  599. try {
  600. const accept = await fileInput.getAttribute('accept');
  601. if (!accept || accept.includes('video') || accept.includes('*')) {
  602. await fileInput.setInputFiles(params.videoPath);
  603. uploadTriggered = true;
  604. logger.info('[Weixin Publish] Upload triggered via file input');
  605. break;
  606. }
  607. } catch (e) {
  608. logger.warn(`[Weixin Publish] File input method failed: ${e}`);
  609. }
  610. }
  611. }
  612. // 方法4: 如果AI给出了坐标,尝试基于坐标点击
  613. if (!uploadTriggered) {
  614. logger.info('[Weixin Publish] Trying AI position-based click...');
  615. try {
  616. const screenshot = await this.screenshotBase64();
  617. const guide = await aiService.getPageOperationGuide(screenshot, 'weixin_video', '请找到页面上的视频上传区域或"发表视频"按钮,返回该元素的中心坐标');
  618. logger.info(`[Weixin Publish] AI position analysis:`, guide);
  619. if (guide.hasAction && guide.targetPosition) {
  620. const { x, y } = guide.targetPosition;
  621. logger.info(`[Weixin Publish] Clicking at position: ${x}, ${y}`);
  622. // 先尝试普通点击(可能是"发表视频"按钮)
  623. await this.page.mouse.click(x, y);
  624. await this.page.waitForTimeout(2000);
  625. // 检查是否触发了文件选择器或跳转到了发布页
  626. const newUrl = this.page.url();
  627. if (newUrl.includes('post/create')) {
  628. logger.info('[Weixin Publish] Navigated to publish page, retrying upload...');
  629. // 重新尝试上传
  630. const uploadArea = this.page.locator('[class*="upload"], [class*="drag"]').first();
  631. if (await uploadArea.count() > 0) {
  632. const [fileChooser] = await Promise.all([
  633. this.page.waitForEvent('filechooser', { timeout: 10000 }),
  634. uploadArea.click(),
  635. ]);
  636. await fileChooser.setFiles(params.videoPath);
  637. uploadTriggered = true;
  638. logger.info('[Weixin Publish] Upload triggered after navigation');
  639. }
  640. }
  641. }
  642. } catch (e) {
  643. logger.warn(`[Weixin Publish] Position-based click failed: ${e}`);
  644. }
  645. }
  646. // 方法5: 点击页面左侧区域(微信视频号发布页面的上传区域在左侧)
  647. if (!uploadTriggered) {
  648. logger.info('[Weixin Publish] Trying left area click (upload area is usually on the left)...');
  649. try {
  650. const viewport = this.page.viewportSize();
  651. if (viewport) {
  652. // 微信视频号发布页面的上传区域在页面左侧中央
  653. // 根据截图布局,大约在 x=400-550, y=250-400 的区域
  654. const clickPositions = [
  655. { x: viewport.width * 0.35, y: viewport.height * 0.35 }, // 左侧偏上
  656. { x: viewport.width * 0.35, y: viewport.height * 0.4 }, // 左侧中央
  657. { x: 450, y: 300 }, // 固定位置尝试
  658. { x: 540, y: 350 }, // 固定位置尝试
  659. ];
  660. for (const pos of clickPositions) {
  661. if (uploadTriggered) break;
  662. try {
  663. logger.info(`[Weixin Publish] Trying click at: ${pos.x}, ${pos.y}`);
  664. const [fileChooser] = await Promise.all([
  665. this.page.waitForEvent('filechooser', { timeout: 5000 }),
  666. this.page.mouse.click(pos.x, pos.y),
  667. ]);
  668. await fileChooser.setFiles(params.videoPath);
  669. uploadTriggered = true;
  670. logger.info(`[Weixin Publish] Upload triggered via position click: ${pos.x}, ${pos.y}`);
  671. } catch (e) {
  672. // 继续尝试下一个位置
  673. }
  674. }
  675. }
  676. } catch (e) {
  677. logger.warn(`[Weixin Publish] Left area click failed: ${e}`);
  678. }
  679. }
  680. if (!uploadTriggered) {
  681. // 截图调试
  682. try {
  683. if (this.page) {
  684. const screenshotPath = `uploads/debug/weixin_no_upload_${Date.now()}.png`;
  685. await this.page.screenshot({ path: screenshotPath, fullPage: true });
  686. logger.info(`[Weixin Publish] Screenshot saved: ${screenshotPath}`);
  687. }
  688. } catch {}
  689. throw new Error('未找到上传入口');
  690. }
  691. onProgress?.(20, '视频上传中...');
  692. // 等待上传完成
  693. const maxWaitTime = 300000; // 5分钟
  694. const startTime = Date.now();
  695. let lastAiCheckTime = 0;
  696. const aiCheckInterval = 10000; // 每10秒使用AI检测一次
  697. while (Date.now() - startTime < maxWaitTime) {
  698. // 检查发布按钮是否可用
  699. try {
  700. const buttonClass = await this.page.getByRole('button', { name: '发表' }).getAttribute('class');
  701. if (buttonClass && !buttonClass.includes('disabled')) {
  702. logger.info('[Weixin Publish] Upload completed, publish button enabled');
  703. break;
  704. }
  705. } catch {
  706. // 继续等待
  707. }
  708. // 检查上传进度(通过DOM)
  709. let progressDetected = false;
  710. const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => '');
  711. if (progressText) {
  712. const match = progressText.match(/(\d+)%/);
  713. if (match) {
  714. const progress = parseInt(match[1]);
  715. onProgress?.(20 + Math.floor(progress * 0.4), `视频上传中: ${progress}%`);
  716. progressDetected = true;
  717. if (progress >= 100) {
  718. logger.info('[Weixin Publish] Upload progress reached 100%');
  719. break;
  720. }
  721. }
  722. }
  723. // 使用AI检测上传进度(每隔一段时间检测一次)
  724. if (!progressDetected && Date.now() - lastAiCheckTime > aiCheckInterval) {
  725. lastAiCheckTime = Date.now();
  726. try {
  727. const screenshot = await this.screenshotBase64();
  728. const uploadStatus = await aiService.analyzeUploadProgress(screenshot, 'weixin_video');
  729. logger.info(`[Weixin Publish] AI upload status:`, uploadStatus);
  730. if (uploadStatus.isComplete) {
  731. logger.info('[Weixin Publish] AI detected upload complete');
  732. break;
  733. }
  734. if (uploadStatus.isFailed) {
  735. throw new Error(`视频上传失败: ${uploadStatus.statusDescription}`);
  736. }
  737. if (uploadStatus.progress !== null) {
  738. onProgress?.(20 + Math.floor(uploadStatus.progress * 0.4), `视频上传中: ${uploadStatus.progress}%`);
  739. if (uploadStatus.progress >= 100) {
  740. logger.info('[Weixin Publish] AI detected progress 100%');
  741. break;
  742. }
  743. }
  744. } catch (aiError) {
  745. logger.warn('[Weixin Publish] AI progress check failed:', aiError);
  746. }
  747. }
  748. await this.page.waitForTimeout(3000);
  749. }
  750. onProgress?.(60, '正在填写视频信息...');
  751. // 填写标题和话题
  752. const editorDiv = this.page.locator('div.input-editor, [contenteditable="true"]').first();
  753. if (await editorDiv.count() > 0) {
  754. await editorDiv.click();
  755. await this.page.keyboard.type(params.title);
  756. if (params.tags && params.tags.length > 0) {
  757. await this.page.keyboard.press('Enter');
  758. for (const tag of params.tags) {
  759. await this.page.keyboard.type('#' + tag);
  760. await this.page.keyboard.press('Space');
  761. }
  762. }
  763. }
  764. onProgress?.(80, '正在发布...');
  765. // 点击发布
  766. const publishBtn = this.page.locator('div.form-btns button:has-text("发表"), button:has-text("发表")').first();
  767. if (await publishBtn.count() > 0) {
  768. await publishBtn.click();
  769. } else {
  770. throw new Error('未找到发布按钮');
  771. }
  772. // 等待发布结果
  773. onProgress?.(90, '等待发布完成...');
  774. const publishMaxWait = 120000; // 2分钟
  775. const publishStartTime = Date.now();
  776. let aiCheckCounter = 0;
  777. let lastProgressCheckTime = 0;
  778. const progressCheckInterval = 5000; // 每5秒检测一次发布进度
  779. while (Date.now() - publishStartTime < publishMaxWait) {
  780. await this.page.waitForTimeout(3000);
  781. const newUrl = this.page.url();
  782. // 检查是否跳转到列表页
  783. if (newUrl.includes('/post/list')) {
  784. onProgress?.(100, '发布成功!');
  785. await this.closeBrowser();
  786. return {
  787. success: true,
  788. videoUrl: newUrl,
  789. };
  790. }
  791. // 检查发布进度条(DOM方式)
  792. const publishProgressText = await this.page.locator('[class*="progress"], [class*="loading"]').first().textContent().catch(() => '');
  793. if (publishProgressText) {
  794. const match = publishProgressText.match(/(\d+)%/);
  795. if (match) {
  796. const progress = parseInt(match[1]);
  797. onProgress?.(90 + Math.floor(progress * 0.1), `发布中: ${progress}%`);
  798. logger.info(`[Weixin Publish] Publish progress: ${progress}%`);
  799. }
  800. }
  801. // AI检测发布进度(定期检测)
  802. if (Date.now() - lastProgressCheckTime > progressCheckInterval) {
  803. lastProgressCheckTime = Date.now();
  804. try {
  805. const screenshot = await this.screenshotBase64();
  806. const publishStatus = await aiService.analyzePublishProgress(screenshot, 'weixin_video');
  807. logger.info(`[Weixin Publish] AI publish status:`, publishStatus);
  808. if (publishStatus.isComplete) {
  809. logger.info('[Weixin Publish] AI detected publish complete');
  810. onProgress?.(100, '发布成功!');
  811. await this.closeBrowser();
  812. return { success: true, videoUrl: this.page.url() };
  813. }
  814. if (publishStatus.isFailed) {
  815. throw new Error(`发布失败: ${publishStatus.statusDescription}`);
  816. }
  817. if (publishStatus.progress !== null) {
  818. onProgress?.(90 + Math.floor(publishStatus.progress * 0.1), `发布中: ${publishStatus.progress}%`);
  819. }
  820. if (publishStatus.isPublishing) {
  821. logger.info(`[Weixin Publish] Still publishing: ${publishStatus.statusDescription}`);
  822. }
  823. } catch (aiError) {
  824. logger.warn('[Weixin Publish] AI publish progress check failed:', aiError);
  825. }
  826. }
  827. // 检查错误提示
  828. const errorHint = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
  829. if (errorHint && (errorHint.includes('失败') || errorHint.includes('错误'))) {
  830. throw new Error(`发布失败: ${errorHint}`);
  831. }
  832. // AI 辅助检测状态(每 3 次循环)
  833. aiCheckCounter++;
  834. if (aiCheckCounter >= 3) {
  835. aiCheckCounter = 0;
  836. const aiStatus = await this.aiAnalyzePublishStatus();
  837. if (aiStatus) {
  838. logger.info(`[Weixin Publish] AI status: ${aiStatus.status}, confidence: ${aiStatus.confidence}%`);
  839. if (aiStatus.status === 'success' && aiStatus.confidence >= 70) {
  840. onProgress?.(100, '发布成功!');
  841. await this.closeBrowser();
  842. return { success: true, videoUrl: this.page.url() };
  843. }
  844. if (aiStatus.status === 'failed' && aiStatus.confidence >= 70) {
  845. throw new Error(aiStatus.errorMessage || 'AI 检测到发布失败');
  846. }
  847. if (aiStatus.status === 'need_captcha' && onCaptchaRequired) {
  848. const imageBase64 = await this.screenshotBase64();
  849. try {
  850. const captchaCode = await onCaptchaRequired({
  851. taskId: `weixin_captcha_${Date.now()}`,
  852. type: 'image',
  853. imageBase64,
  854. });
  855. if (captchaCode) {
  856. const guide = await this.aiGetPublishOperationGuide('需要输入验证码');
  857. if (guide?.hasAction && guide.targetSelector) {
  858. await this.page.fill(guide.targetSelector, captchaCode);
  859. }
  860. }
  861. } catch {
  862. logger.error('[Weixin Publish] Captcha handling failed');
  863. }
  864. }
  865. if (aiStatus.status === 'need_action' && aiStatus.nextAction) {
  866. const guide = await this.aiGetPublishOperationGuide(aiStatus.pageDescription);
  867. if (guide?.hasAction) {
  868. await this.aiExecuteOperation(guide);
  869. }
  870. }
  871. }
  872. }
  873. }
  874. // 超时,AI 最终检查
  875. const finalAiStatus = await this.aiAnalyzePublishStatus();
  876. if (finalAiStatus?.status === 'success') {
  877. onProgress?.(100, '发布成功!');
  878. await this.closeBrowser();
  879. return { success: true, videoUrl: this.page.url() };
  880. }
  881. throw new Error('发布超时,请手动检查是否发布成功');
  882. } catch (error) {
  883. logger.error('Weixin publishVideo error:', error);
  884. await this.closeBrowser();
  885. return {
  886. success: false,
  887. errorMessage: error instanceof Error ? error.message : '发布失败',
  888. };
  889. }
  890. ========== Playwright 方式已注释结束 ========== */
  891. }
  892. /**
  893. * 通过 Python API 获取评论
  894. */
  895. private async getCommentsViaPython(cookies: string, videoId: string): Promise<CommentData[]> {
  896. logger.info('[Weixin] Getting comments via Python API...');
  897. const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
  898. const response = await fetch(`${pythonUrl}/comments`, {
  899. method: 'POST',
  900. headers: {
  901. 'Content-Type': 'application/json',
  902. },
  903. body: JSON.stringify({
  904. platform: 'weixin',
  905. cookie: cookies,
  906. work_id: videoId,
  907. }),
  908. });
  909. if (!response.ok) {
  910. throw new Error(`Python API returned ${response.status}`);
  911. }
  912. const result = await response.json();
  913. if (!result.success) {
  914. throw new Error(result.error || 'Failed to get comments');
  915. }
  916. // 转换数据格式
  917. return (result.comments || []).map((comment: {
  918. comment_id: string;
  919. author_id: string;
  920. author_name: string;
  921. author_avatar: string;
  922. content: string;
  923. like_count: number;
  924. create_time: string;
  925. reply_count: number;
  926. }) => ({
  927. commentId: comment.comment_id,
  928. authorId: comment.author_id,
  929. authorName: comment.author_name,
  930. authorAvatar: comment.author_avatar,
  931. content: comment.content,
  932. likeCount: comment.like_count,
  933. commentTime: comment.create_time,
  934. replyCount: comment.reply_count,
  935. }));
  936. }
  937. async getComments(cookies: string, videoId: string): Promise<CommentData[]> {
  938. // 优先尝试使用 Python API
  939. const pythonAvailable = await this.checkPythonServiceAvailable();
  940. if (pythonAvailable) {
  941. logger.info('[Weixin] Python service available, using Python API for comments');
  942. try {
  943. return await this.getCommentsViaPython(cookies, videoId);
  944. } catch (pythonError) {
  945. logger.warn('[Weixin] Python API getComments failed:', pythonError);
  946. }
  947. }
  948. logger.warn('Weixin getComments - Python API not available');
  949. return [];
  950. }
  951. async replyComment(cookies: string, videoId: string, commentId: string, content: string): Promise<boolean> {
  952. logger.warn('Weixin replyComment not implemented');
  953. return false;
  954. }
  955. async getAnalytics(cookies: string, dateRange?: DateRange): Promise<AnalyticsData> {
  956. logger.warn('Weixin getAnalytics not implemented');
  957. return {
  958. totalViews: 0,
  959. totalLikes: 0,
  960. totalComments: 0,
  961. totalShares: 0,
  962. periodViews: [],
  963. };
  964. }
  965. }