/// import path from 'path'; import { BasePlatformAdapter } from './base.js'; import type { AccountProfile, PublishParams, PublishResult, DateRange, AnalyticsData, CommentData, } from './base.js'; import type { PlatformType, QRCodeInfo, LoginStatusResult } from '@media-manager/shared'; import { logger } from '../../utils/logger.js'; // Python 多平台发布服务配置 const PYTHON_PUBLISH_SERVICE_URL = process.env.PYTHON_PUBLISH_SERVICE_URL || process.env.XHS_SERVICE_URL || 'http://localhost:5005'; // 服务器根目录(用于构造绝对路径) const SERVER_ROOT = path.resolve(process.cwd()); /** * 微信视频号平台适配器 * 参考: matrix/tencent_uploader/main.py */ export class WeixinAdapter extends BasePlatformAdapter { readonly platform: PlatformType = 'weixin_video'; readonly loginUrl = 'https://channels.weixin.qq.com/platform'; readonly publishUrl = 'https://channels.weixin.qq.com/platform/post/create'; protected getCookieDomain(): string { return '.weixin.qq.com'; } async getQRCode(): Promise { try { await this.initBrowser(); if (!this.page) throw new Error('Page not initialized'); // 访问登录页面 await this.page.goto('https://channels.weixin.qq.com/platform/login-for-iframe?dark_mode=true&host_type=1'); // 点击二维码切换 await this.page.locator('.qrcode').click(); // 获取二维码 const qrcodeImg = await this.page.locator('img.qrcode').getAttribute('src'); if (!qrcodeImg) { throw new Error('Failed to get QR code'); } return { qrcodeUrl: qrcodeImg, qrcodeKey: `weixin_${Date.now()}`, expireTime: Date.now() + 300000, }; } catch (error) { logger.error('Weixin getQRCode error:', error); throw error; } } async checkQRCodeStatus(qrcodeKey: string): Promise { try { if (!this.page) { return { status: 'expired', message: '二维码已过期' }; } // 检查是否扫码成功 const maskDiv = this.page.locator('.mask').first(); const className = await maskDiv.getAttribute('class'); if (className && className.includes('show')) { // 等待登录完成 await this.page.waitForTimeout(3000); const cookies = await this.getCookies(); if (cookies && cookies.length > 10) { await this.closeBrowser(); return { status: 'success', message: '登录成功', cookies }; } } return { status: 'waiting', message: '等待扫码' }; } catch (error) { logger.error('Weixin checkQRCodeStatus error:', error); return { status: 'error', message: '检查状态失败' }; } } async checkLoginStatus(cookies: string): Promise { try { await this.initBrowser(); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); await this.page.goto(this.publishUrl); await this.page.waitForLoadState('networkidle'); // 检查是否需要登录 const needLogin = await this.page.$('div.title-name:has-text("视频号小店")'); await this.closeBrowser(); return !needLogin; } catch (error) { logger.error('Weixin checkLoginStatus error:', error); await this.closeBrowser(); return false; } } async getAccountInfo(cookies: string): Promise { try { await this.initBrowser(); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); await this.page.goto(this.loginUrl); await this.page.waitForLoadState('networkidle'); // 获取账号信息 const accountId = await this.page.$eval('span.finder-uniq-id', el => el.textContent?.trim() || '').catch(() => ''); const accountName = await this.page.$eval('h2.finder-nickname', el => el.textContent?.trim() || '').catch(() => ''); const avatarUrl = await this.page.$eval('img.avatar', el => el.getAttribute('src') || '').catch(() => ''); await this.closeBrowser(); return { accountId: accountId || `weixin_${Date.now()}`, accountName: accountName || '视频号账号', avatarUrl, fansCount: 0, worksCount: 0, }; } catch (error) { logger.error('Weixin getAccountInfo error:', error); await this.closeBrowser(); return { accountId: `weixin_${Date.now()}`, accountName: '视频号账号', avatarUrl: '', fansCount: 0, worksCount: 0, }; } } /** * 检查 Python 发布服务是否可用 */ private async checkPythonServiceAvailable(): Promise { try { const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/health`, { method: 'GET', signal: AbortSignal.timeout(3000), }); if (response.ok) { const data = await response.json(); return data.status === 'ok' && data.supported_platforms?.includes('weixin'); } return false; } catch { return false; } } /** * 通过 Python 服务发布视频(带 AI 辅助) */ private async publishVideoViaPython( cookies: string, params: PublishParams, onProgress?: (progress: number, message: string) => void ): Promise { logger.info('[Weixin Python] Starting publish via Python service with AI assist...'); onProgress?.(5, '正在通过 Python 服务发布...'); try { // 将相对路径转换为绝对路径 const absoluteVideoPath = path.isAbsolute(params.videoPath) ? params.videoPath : path.resolve(SERVER_ROOT, params.videoPath); const absoluteCoverPath = params.coverPath ? (path.isAbsolute(params.coverPath) ? params.coverPath : path.resolve(SERVER_ROOT, params.coverPath)) : undefined; // 使用 AI 辅助发布接口 const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/publish/ai-assisted`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ platform: 'weixin', cookie: cookies, title: params.title, description: params.description || params.title, video_path: absoluteVideoPath, cover_path: absoluteCoverPath, tags: params.tags || [], post_time: params.scheduledTime ? new Date(params.scheduledTime).toISOString().replace('T', ' ').slice(0, 19) : null, location: params.location || '重庆市', return_screenshot: true, }), signal: AbortSignal.timeout(600000), }); const result = await response.json(); logger.info('[Weixin Python] Response:', { ...result, screenshot_base64: result.screenshot_base64 ? '[截图已省略]' : undefined }); // 使用通用的 AI 辅助处理方法 return await this.aiProcessPythonPublishResult(result, undefined, onProgress); } catch (error) { logger.error('[Weixin Python] Publish failed:', error); throw error; } } async publishVideo( cookies: string, params: PublishParams, onProgress?: (progress: number, message: string) => void, onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string }) => Promise, options?: { headless?: boolean } ): Promise { // 优先尝试使用 Python 服务 const pythonAvailable = await this.checkPythonServiceAvailable(); if (pythonAvailable) { logger.info('[Weixin] Python service available, using Python method'); try { return await this.publishVideoViaPython(cookies, params, onProgress); } catch (pythonError) { logger.warn('[Weixin] Python publish failed, falling back to Playwright:', pythonError); onProgress?.(0, 'Python 服务发布失败,正在切换到浏览器模式...'); } } else { logger.info('[Weixin] Python service not available, using Playwright method'); } // 回退到 Playwright 方式 const useHeadless = options?.headless ?? true; try { await this.initBrowser({ headless: useHeadless }); await this.setCookies(cookies); if (!this.page) throw new Error('Page not initialized'); onProgress?.(5, '正在打开上传页面...'); await this.page.goto(this.publishUrl, { waitUntil: 'domcontentloaded', timeout: 60000, }); await this.page.waitForTimeout(3000); // 检查是否需要登录 const currentUrl = this.page.url(); if (currentUrl.includes('login')) { await this.closeBrowser(); return { success: false, errorMessage: '账号登录已过期,请重新登录', }; } onProgress?.(10, '正在上传视频...'); // 上传视频 let uploadTriggered = false; const uploadDiv = this.page.locator('div.upload-content, [class*="upload-area"]').first(); if (await uploadDiv.count() > 0) { try { const [fileChooser] = await Promise.all([ this.page.waitForEvent('filechooser', { timeout: 10000 }), uploadDiv.click(), ]); await fileChooser.setFiles(params.videoPath); uploadTriggered = true; } catch { logger.warn('[Weixin Publish] File chooser method failed'); } } // 备用方法:直接设置 file input if (!uploadTriggered) { const fileInput = await this.page.$('input[type="file"]'); if (fileInput) { await fileInput.setInputFiles(params.videoPath); uploadTriggered = true; } } if (!uploadTriggered) { throw new Error('未找到上传入口'); } onProgress?.(20, '视频上传中...'); // 等待上传完成 const maxWaitTime = 300000; // 5分钟 const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { // 检查发布按钮是否可用 try { const buttonClass = await this.page.getByRole('button', { name: '发表' }).getAttribute('class'); if (buttonClass && !buttonClass.includes('disabled')) { logger.info('[Weixin Publish] Upload completed, publish button enabled'); break; } } catch { // 继续等待 } // 检查上传进度 const progressText = await this.page.locator('[class*="progress"]').first().textContent().catch(() => ''); if (progressText) { const match = progressText.match(/(\d+)%/); if (match) { const progress = parseInt(match[1]); onProgress?.(20 + Math.floor(progress * 0.4), `视频上传中: ${progress}%`); } } await this.page.waitForTimeout(3000); } onProgress?.(60, '正在填写视频信息...'); // 填写标题和话题 const editorDiv = this.page.locator('div.input-editor, [contenteditable="true"]').first(); if (await editorDiv.count() > 0) { await editorDiv.click(); await this.page.keyboard.type(params.title); if (params.tags && params.tags.length > 0) { await this.page.keyboard.press('Enter'); for (const tag of params.tags) { await this.page.keyboard.type('#' + tag); await this.page.keyboard.press('Space'); } } } onProgress?.(80, '正在发布...'); // 点击发布 const publishBtn = this.page.locator('div.form-btns button:has-text("发表"), button:has-text("发表")').first(); if (await publishBtn.count() > 0) { await publishBtn.click(); } else { throw new Error('未找到发布按钮'); } // 等待发布结果 onProgress?.(90, '等待发布完成...'); const publishMaxWait = 120000; // 2分钟 const publishStartTime = Date.now(); let aiCheckCounter = 0; while (Date.now() - publishStartTime < publishMaxWait) { await this.page.waitForTimeout(3000); const newUrl = this.page.url(); // 检查是否跳转到列表页 if (newUrl.includes('/post/list')) { onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: newUrl, }; } // 检查错误提示 const errorHint = await this.page.locator('[class*="error"], [class*="fail"]').first().textContent().catch(() => ''); if (errorHint && (errorHint.includes('失败') || errorHint.includes('错误'))) { throw new Error(`发布失败: ${errorHint}`); } // AI 辅助检测(每 3 次循环) aiCheckCounter++; if (aiCheckCounter >= 3) { aiCheckCounter = 0; const aiStatus = await this.aiAnalyzePublishStatus(); if (aiStatus) { logger.info(`[Weixin Publish] AI status: ${aiStatus.status}, confidence: ${aiStatus.confidence}%`); if (aiStatus.status === 'success' && aiStatus.confidence >= 70) { onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: this.page.url() }; } if (aiStatus.status === 'failed' && aiStatus.confidence >= 70) { throw new Error(aiStatus.errorMessage || 'AI 检测到发布失败'); } if (aiStatus.status === 'need_captcha' && onCaptchaRequired) { const imageBase64 = await this.screenshotBase64(); try { const captchaCode = await onCaptchaRequired({ taskId: `weixin_captcha_${Date.now()}`, type: 'image', imageBase64, }); if (captchaCode) { const guide = await this.aiGetPublishOperationGuide('需要输入验证码'); if (guide?.hasAction && guide.targetSelector) { await this.page.fill(guide.targetSelector, captchaCode); } } } catch { logger.error('[Weixin Publish] Captcha handling failed'); } } if (aiStatus.status === 'need_action' && aiStatus.nextAction) { const guide = await this.aiGetPublishOperationGuide(aiStatus.pageDescription); if (guide?.hasAction) { await this.aiExecuteOperation(guide); } } } } } // 超时,AI 最终检查 const finalAiStatus = await this.aiAnalyzePublishStatus(); if (finalAiStatus?.status === 'success') { onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: this.page.url() }; } throw new Error('发布超时,请手动检查是否发布成功'); } catch (error) { logger.error('Weixin publishVideo error:', error); await this.closeBrowser(); return { success: false, errorMessage: error instanceof Error ? error.message : '发布失败', }; } } /** * 通过 Python API 获取评论 */ private async getCommentsViaPython(cookies: string, videoId: string): Promise { logger.info('[Weixin] Getting comments via Python API...'); const response = await fetch(`${PYTHON_PUBLISH_SERVICE_URL}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ platform: 'weixin', cookie: cookies, work_id: videoId, }), }); if (!response.ok) { throw new Error(`Python API returned ${response.status}`); } const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Failed to get comments'); } // 转换数据格式 return (result.comments || []).map((comment: { comment_id: string; author_id: string; author_name: string; author_avatar: string; content: string; like_count: number; create_time: string; reply_count: number; }) => ({ commentId: comment.comment_id, authorId: comment.author_id, authorName: comment.author_name, authorAvatar: comment.author_avatar, content: comment.content, likeCount: comment.like_count, commentTime: comment.create_time, replyCount: comment.reply_count, })); } async getComments(cookies: string, videoId: string): Promise { // 优先尝试使用 Python API const pythonAvailable = await this.checkPythonServiceAvailable(); if (pythonAvailable) { logger.info('[Weixin] Python service available, using Python API for comments'); try { return await this.getCommentsViaPython(cookies, videoId); } catch (pythonError) { logger.warn('[Weixin] Python API getComments failed:', pythonError); } } logger.warn('Weixin getComments - Python API not available'); return []; } async replyComment(cookies: string, videoId: string, commentId: string, content: string): Promise { logger.warn('Weixin replyComment not implemented'); return false; } async getAnalytics(cookies: string, dateRange?: DateRange): Promise { logger.warn('Weixin getAnalytics not implemented'); return { totalViews: 0, totalLikes: 0, totalComments: 0, totalShares: 0, periodViews: [], }; } }