weixin.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  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. // Python 多平台发布服务配置
  15. const PYTHON_PUBLISH_SERVICE_URL = process.env.PYTHON_PUBLISH_SERVICE_URL || process.env.XHS_SERVICE_URL || 'http://localhost:5005';
  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. async getAccountInfo(cookies: string): Promise<AccountProfile> {
  93. try {
  94. await this.initBrowser();
  95. await this.setCookies(cookies);
  96. if (!this.page) throw new Error('Page not initialized');
  97. await this.page.goto(this.loginUrl);
  98. await this.page.waitForLoadState('networkidle');
  99. // 获取账号信息
  100. const accountId = await this.page.$eval('span.finder-uniq-id', el => el.textContent?.trim() || '').catch(() => '');
  101. const accountName = await this.page.$eval('h2.finder-nickname', el => el.textContent?.trim() || '').catch(() => '');
  102. const avatarUrl = await this.page.$eval('img.avatar', el => el.getAttribute('src') || '').catch(() => '');
  103. await this.closeBrowser();
  104. return {
  105. accountId: accountId || `weixin_${Date.now()}`,
  106. accountName: accountName || '视频号账号',
  107. avatarUrl,
  108. fansCount: 0,
  109. worksCount: 0,
  110. };
  111. } catch (error) {
  112. logger.error('Weixin getAccountInfo error:', error);
  113. await this.closeBrowser();
  114. return {
  115. accountId: `weixin_${Date.now()}`,
  116. accountName: '视频号账号',
  117. avatarUrl: '',
  118. fansCount: 0,
  119. worksCount: 0,
  120. };
  121. }
  122. }
  123. /**
  124. * 检查 Python 发布服务是否可用
  125. */
  126. private async checkPythonServiceAvailable(): Promise<boolean> {
  127. try {
  128. const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/health`, {
  129. method: 'GET',
  130. signal: AbortSignal.timeout(3000),
  131. });
  132. if (response.ok) {
  133. const data = await response.json();
  134. return data.status === 'ok' && data.supported_platforms?.includes('weixin');
  135. }
  136. return false;
  137. } catch {
  138. return false;
  139. }
  140. }
  141. /**
  142. * 通过 Python 服务发布视频(带 AI 辅助)
  143. */
  144. private async publishVideoViaPython(
  145. cookies: string,
  146. params: PublishParams,
  147. onProgress?: (progress: number, message: string) => void
  148. ): Promise<PublishResult> {
  149. logger.info('[Weixin Python] Starting publish via Python service with AI assist...');
  150. onProgress?.(5, '正在通过 Python 服务发布...');
  151. try {
  152. // 将相对路径转换为绝对路径
  153. const absoluteVideoPath = path.isAbsolute(params.videoPath)
  154. ? params.videoPath
  155. : path.resolve(SERVER_ROOT, params.videoPath);
  156. const absoluteCoverPath = params.coverPath
  157. ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath))
  158. : undefined;
  159. // 使用 AI 辅助发布接口
  160. const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/publish/ai-assisted`, {
  161. method: 'POST',
  162. headers: {
  163. 'Content-Type': 'application/json',
  164. },
  165. body: JSON.stringify({
  166. platform: 'weixin',
  167. cookie: cookies,
  168. title: params.title,
  169. description: params.description || params.title,
  170. video_path: absoluteVideoPath,
  171. cover_path: absoluteCoverPath,
  172. tags: params.tags || [],
  173. post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null,
  174. location: params.location || '重庆市',
  175. return_screenshot: true,
  176. }),
  177. signal: AbortSignal.timeout(600000),
  178. });
  179. const result = await response.json();
  180. logger.info('[Weixin Python] Response:', { ...result, screenshot_base64: result.screenshot_base64 ? '[截图已省略]' : undefined });
  181. // 使用通用的 AI 辅助处理方法
  182. return await this.aiProcessPythonPublishResult(result, undefined, onProgress);
  183. } catch (error) {
  184. logger.error('[Weixin Python] Publish failed:', error);
  185. throw error;
  186. }
  187. }
  188. async publishVideo(
  189. cookies: string,
  190. params: PublishParams,
  191. onProgress?: (progress: number, message: string) => void,
  192. onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string }) => Promise<string>,
  193. options?: { headless?: boolean }
  194. ): Promise<PublishResult> {
  195. // 优先尝试使用 Python 服务
  196. const pythonAvailable = await this.checkPythonServiceAvailable();
  197. if (pythonAvailable) {
  198. logger.info('[Weixin] Python service available, using Python method');
  199. try {
  200. return await this.publishVideoViaPython(cookies, params, onProgress);
  201. } catch (pythonError) {
  202. logger.warn('[Weixin] Python publish failed, falling back to Playwright:', pythonError);
  203. onProgress?.(0, 'Python 服务发布失败,正在切换到浏览器模式...');
  204. }
  205. } else {
  206. logger.info('[Weixin] Python service not available, using Playwright method');
  207. }
  208. // 回退到 Playwright 方式
  209. const useHeadless = options?.headless ?? true;
  210. try {
  211. await this.initBrowser({ headless: useHeadless });
  212. await this.setCookies(cookies);
  213. if (!this.page) throw new Error('Page not initialized');
  214. onProgress?.(5, '正在打开上传页面...');
  215. await this.page.goto(this.publishUrl, {
  216. waitUntil: 'domcontentloaded',
  217. timeout: 60000,
  218. });
  219. await this.page.waitForTimeout(3000);
  220. // 检查是否需要登录
  221. const currentUrl = this.page.url();
  222. if (currentUrl.includes('login')) {
  223. await this.closeBrowser();
  224. return {
  225. success: false,
  226. errorMessage: '账号登录已过期,请重新登录',
  227. };
  228. }
  229. onProgress?.(10, '正在上传视频...');
  230. // 上传视频
  231. let uploadTriggered = false;
  232. const uploadDiv = this.page.locator('div.upload-content, [class*="upload-area"]').first();
  233. if (await uploadDiv.count() > 0) {
  234. try {
  235. const [fileChooser] = await Promise.all([
  236. this.page.waitForEvent('filechooser', { timeout: 10000 }),
  237. uploadDiv.click(),
  238. ]);
  239. await fileChooser.setFiles(params.videoPath);
  240. uploadTriggered = true;
  241. } catch {
  242. logger.warn('[Weixin Publish] File chooser method failed');
  243. }
  244. }
  245. // 备用方法:直接设置 file input
  246. if (!uploadTriggered) {
  247. const fileInput = await this.page.$('input[type="file"]');
  248. if (fileInput) {
  249. await fileInput.setInputFiles(params.videoPath);
  250. uploadTriggered = true;
  251. }
  252. }
  253. if (!uploadTriggered) {
  254. throw new Error('未找到上传入口');
  255. }
  256. onProgress?.(20, '视频上传中...');
  257. // 等待上传完成
  258. const maxWaitTime = 300000; // 5分钟
  259. const startTime = Date.now();
  260. while (Date.now() - startTime < maxWaitTime) {
  261. // 检查发布按钮是否可用
  262. try {
  263. const buttonClass = await this.page.getByRole('button', { name: '发表' }).getAttribute('class');
  264. if (buttonClass && !buttonClass.includes('disabled')) {
  265. logger.info('[Weixin Publish] Upload completed, publish button enabled');
  266. break;
  267. }
  268. } catch {
  269. // 继续等待
  270. }
  271. // 检查上传进度
  272. const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => '');
  273. if (progressText) {
  274. const match = progressText.match(/(\d+)%/);
  275. if (match) {
  276. const progress = parseInt(match[1]);
  277. onProgress?.(20 + Math.floor(progress * 0.4), `视频上传中: ${progress}%`);
  278. }
  279. }
  280. await this.page.waitForTimeout(3000);
  281. }
  282. onProgress?.(60, '正在填写视频信息...');
  283. // 填写标题和话题
  284. const editorDiv = this.page.locator('div.input-editor, [contenteditable="true"]').first();
  285. if (await editorDiv.count() > 0) {
  286. await editorDiv.click();
  287. await this.page.keyboard.type(params.title);
  288. if (params.tags && params.tags.length > 0) {
  289. await this.page.keyboard.press('Enter');
  290. for (const tag of params.tags) {
  291. await this.page.keyboard.type('#' + tag);
  292. await this.page.keyboard.press('Space');
  293. }
  294. }
  295. }
  296. onProgress?.(80, '正在发布...');
  297. // 点击发布
  298. const publishBtn = this.page.locator('div.form-btns button:has-text("发表"), button:has-text("发表")').first();
  299. if (await publishBtn.count() > 0) {
  300. await publishBtn.click();
  301. } else {
  302. throw new Error('未找到发布按钮');
  303. }
  304. // 等待发布结果
  305. onProgress?.(90, '等待发布完成...');
  306. const publishMaxWait = 120000; // 2分钟
  307. const publishStartTime = Date.now();
  308. let aiCheckCounter = 0;
  309. while (Date.now() - publishStartTime < publishMaxWait) {
  310. await this.page.waitForTimeout(3000);
  311. const newUrl = this.page.url();
  312. // 检查是否跳转到列表页
  313. if (newUrl.includes('/post/list')) {
  314. onProgress?.(100, '发布成功!');
  315. await this.closeBrowser();
  316. return {
  317. success: true,
  318. videoUrl: newUrl,
  319. };
  320. }
  321. // 检查错误提示
  322. const errorHint = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => '');
  323. if (errorHint && (errorHint.includes('失败') || errorHint.includes('错误'))) {
  324. throw new Error(`发布失败: ${errorHint}`);
  325. }
  326. // AI 辅助检测(每 3 次循环)
  327. aiCheckCounter++;
  328. if (aiCheckCounter >= 3) {
  329. aiCheckCounter = 0;
  330. const aiStatus = await this.aiAnalyzePublishStatus();
  331. if (aiStatus) {
  332. logger.info(`[Weixin Publish] AI status: ${aiStatus.status}, confidence: ${aiStatus.confidence}%`);
  333. if (aiStatus.status === 'success' && aiStatus.confidence >= 70) {
  334. onProgress?.(100, '发布成功!');
  335. await this.closeBrowser();
  336. return { success: true, videoUrl: this.page.url() };
  337. }
  338. if (aiStatus.status === 'failed' && aiStatus.confidence >= 70) {
  339. throw new Error(aiStatus.errorMessage || 'AI 检测到发布失败');
  340. }
  341. if (aiStatus.status === 'need_captcha' && onCaptchaRequired) {
  342. const imageBase64 = await this.screenshotBase64();
  343. try {
  344. const captchaCode = await onCaptchaRequired({
  345. taskId: `weixin_captcha_${Date.now()}`,
  346. type: 'image',
  347. imageBase64,
  348. });
  349. if (captchaCode) {
  350. const guide = await this.aiGetPublishOperationGuide('需要输入验证码');
  351. if (guide?.hasAction && guide.targetSelector) {
  352. await this.page.fill(guide.targetSelector, captchaCode);
  353. }
  354. }
  355. } catch {
  356. logger.error('[Weixin Publish] Captcha handling failed');
  357. }
  358. }
  359. if (aiStatus.status === 'need_action' && aiStatus.nextAction) {
  360. const guide = await this.aiGetPublishOperationGuide(aiStatus.pageDescription);
  361. if (guide?.hasAction) {
  362. await this.aiExecuteOperation(guide);
  363. }
  364. }
  365. }
  366. }
  367. }
  368. // 超时,AI 最终检查
  369. const finalAiStatus = await this.aiAnalyzePublishStatus();
  370. if (finalAiStatus?.status === 'success') {
  371. onProgress?.(100, '发布成功!');
  372. await this.closeBrowser();
  373. return { success: true, videoUrl: this.page.url() };
  374. }
  375. throw new Error('发布超时,请手动检查是否发布成功');
  376. } catch (error) {
  377. logger.error('Weixin publishVideo error:', error);
  378. await this.closeBrowser();
  379. return {
  380. success: false,
  381. errorMessage: error instanceof Error ? error.message : '发布失败',
  382. };
  383. }
  384. }
  385. /**
  386. * 通过 Python API 获取评论
  387. */
  388. private async getCommentsViaPython(cookies: string, videoId: string): Promise<CommentData[]> {
  389. logger.info('[Weixin] Getting comments via Python API...');
  390. const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/comments`, {
  391. method: 'POST',
  392. headers: {
  393. 'Content-Type': 'application/json',
  394. },
  395. body: JSON.stringify({
  396. platform: 'weixin',
  397. cookie: cookies,
  398. work_id: videoId,
  399. }),
  400. });
  401. if (!response.ok) {
  402. throw new Error(`Python API returned ${response.status}`);
  403. }
  404. const result = await response.json();
  405. if (!result.success) {
  406. throw new Error(result.error || 'Failed to get comments');
  407. }
  408. // 转换数据格式
  409. return (result.comments || []).map((comment: {
  410. comment_id: string;
  411. author_id: string;
  412. author_name: string;
  413. author_avatar: string;
  414. content: string;
  415. like_count: number;
  416. create_time: string;
  417. reply_count: number;
  418. }) => ({
  419. commentId: comment.comment_id,
  420. authorId: comment.author_id,
  421. authorName: comment.author_name,
  422. authorAvatar: comment.author_avatar,
  423. content: comment.content,
  424. likeCount: comment.like_count,
  425. commentTime: comment.create_time,
  426. replyCount: comment.reply_count,
  427. }));
  428. }
  429. async getComments(cookies: string, videoId: string): Promise<CommentData[]> {
  430. // 优先尝试使用 Python API
  431. const pythonAvailable = await this.checkPythonServiceAvailable();
  432. if (pythonAvailable) {
  433. logger.info('[Weixin] Python service available, using Python API for comments');
  434. try {
  435. return await this.getCommentsViaPython(cookies, videoId);
  436. } catch (pythonError) {
  437. logger.warn('[Weixin] Python API getComments failed:', pythonError);
  438. }
  439. }
  440. logger.warn('Weixin getComments - Python API not available');
  441. return [];
  442. }
  443. async replyComment(cookies: string, videoId: string, commentId: string, content: string): Promise<boolean> {
  444. logger.warn('Weixin replyComment not implemented');
  445. return false;
  446. }
  447. async getAnalytics(cookies: string, dateRange?: DateRange): Promise<AnalyticsData> {
  448. logger.warn('Weixin getAnalytics not implemented');
  449. return {
  450. totalViews: 0,
  451. totalLikes: 0,
  452. totalComments: 0,
  453. totalShares: 0,
  454. periodViews: [],
  455. };
  456. }
  457. }