/// 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'; import { aiService } from '../../ai/index.js'; import { getPythonServiceBaseUrl } from '../../services/PythonServiceConfigService.js'; // 服务器根目录(用于构造绝对路径) 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; } } /** * 关闭页面上可能存在的弹窗对话框 */ private async closeModalDialogs(): Promise { if (!this.page) return false; let closedAny = false; try { const modalSelectors = [ // 微信视频号常见弹窗关闭按钮 '.weui-desktop-dialog__close', '.weui-desktop-btn__default:has-text("取消")', '.weui-desktop-btn__default:has-text("关闭")', '.weui-desktop-dialog-close', '[class*="dialog"] [class*="close"]', '[class*="modal"] [class*="close"]', '[role="dialog"] button[aria-label="close"]', 'button:has-text("关闭")', 'button:has-text("取消")', 'button:has-text("我知道了")', '.close-btn', '.icon-close', ]; for (const selector of modalSelectors) { try { const closeBtn = this.page.locator(selector).first(); if (await closeBtn.count() > 0 && await closeBtn.isVisible()) { logger.info(`[Weixin] Found modal close button: ${selector}`); await closeBtn.click({ timeout: 2000 }); closedAny = true; await this.page.waitForTimeout(500); } } catch (e) { // 忽略错误,继续尝试下一个选择器 } } // 尝试按 ESC 键关闭弹窗 if (!closedAny) { const hasModal = await this.page.locator('[class*="dialog"], [class*="modal"], [role="dialog"]').count(); if (hasModal > 0) { logger.info('[Weixin] Trying ESC key to close modal...'); await this.page.keyboard.press('Escape'); await this.page.waitForTimeout(500); closedAny = true; } } if (closedAny) { logger.info('[Weixin] Successfully closed modal dialog'); } } catch (error) { logger.warn('[Weixin] Error closing modal:', error); } return closedAny; } 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('https://channels.weixin.qq.com/platform/home'); await this.page.waitForLoadState('networkidle'); await this.page.waitForTimeout(2000); // 从页面提取账号信息 const accountData = await this.page.evaluate(() => { const result: { accountId?: string; accountName?: string; avatarUrl?: string; fansCount?: number; worksCount?: number } = {}; try { // ===== 1. 优先使用精确选择器获取视频号 ID ===== // 方法1: 通过 #finder-uid-copy 的 data-clipboard-text 属性获取 const finderIdCopyEl = document.querySelector('#finder-uid-copy'); if (finderIdCopyEl) { const clipboardText = finderIdCopyEl.getAttribute('data-clipboard-text'); if (clipboardText && clipboardText.length >= 10) { result.accountId = clipboardText; console.log('[WeixinVideo] Found finder ID from data-clipboard-text:', result.accountId); } else { const text = finderIdCopyEl.textContent?.trim(); if (text && /^[a-zA-Z0-9_]+$/.test(text) && text.length >= 10) { result.accountId = text; console.log('[WeixinVideo] Found finder ID from #finder-uid-copy text:', result.accountId); } } } // 方法2: 通过 .finder-uniq-id 选择器获取 if (!result.accountId) { const finderUniqIdEl = document.querySelector('.finder-uniq-id'); if (finderUniqIdEl) { const clipboardText = finderUniqIdEl.getAttribute('data-clipboard-text'); if (clipboardText && clipboardText.length >= 10) { result.accountId = clipboardText; console.log('[WeixinVideo] Found finder ID from .finder-uniq-id data-clipboard-text:', result.accountId); } else { const text = finderUniqIdEl.textContent?.trim(); if (text && /^[a-zA-Z0-9_]+$/.test(text) && text.length >= 10) { result.accountId = text; console.log('[WeixinVideo] Found finder ID from .finder-uniq-id text:', result.accountId); } } } } // 方法3: 从页面文本中正则匹配 if (!result.accountId) { const bodyText = document.body.innerText || ''; const finderIdPatterns = [ /视频号ID[::\s]*([a-zA-Z0-9_]+)/, /视频号[::\s]*ID[::\s]*([a-zA-Z0-9_]+)/, ]; for (const pattern of finderIdPatterns) { const match = bodyText.match(pattern); if (match && match[1] && match[1].length >= 10) { result.accountId = match[1]; console.log('[WeixinVideo] Found finder ID from regex:', result.accountId); break; } } } // ===== 2. 获取账号名称 ===== const nicknameEl = document.querySelector('h2.finder-nickname') || document.querySelector('.finder-nickname'); if (nicknameEl) { const text = nicknameEl.textContent?.trim(); if (text && text.length >= 2 && text.length <= 30) { result.accountName = text; console.log('[WeixinVideo] Found name:', result.accountName); } } // ===== 3. 获取头像 ===== const avatarEl = document.querySelector('img.avatar') as HTMLImageElement; if (avatarEl?.src && avatarEl.src.startsWith('http')) { result.avatarUrl = avatarEl.src; } else { const altAvatarEl = document.querySelector('img[alt="视频号头像"]') as HTMLImageElement; if (altAvatarEl?.src && altAvatarEl.src.startsWith('http')) { result.avatarUrl = altAvatarEl.src; } } // ===== 4. 获取视频数和关注者数 ===== const contentInfo = document.querySelector('.finder-content-info'); if (contentInfo) { const infoDivs = contentInfo.querySelectorAll('div'); infoDivs.forEach(div => { const text = div.textContent || ''; const numEl = div.querySelector('.finder-info-num'); if (numEl) { const num = parseInt(numEl.textContent?.trim() || '0', 10); if (text.includes('视频') || text.includes('作品')) { result.worksCount = num; } else if (text.includes('关注者') || text.includes('粉丝')) { result.fansCount = num; } } }); } // 备选:从页面整体文本中匹配 if (result.fansCount === undefined || result.worksCount === undefined) { const bodyText = document.body.innerText || ''; if (result.fansCount === undefined) { const fansMatch = bodyText.match(/关注者\s*(\d+(?:\.\d+)?[万wW]?)/); if (fansMatch) { let count = parseFloat(fansMatch[1]); if (fansMatch[1].includes('万') || fansMatch[1].toLowerCase().includes('w')) { count = count * 10000; } result.fansCount = Math.floor(count); } } if (result.worksCount === undefined) { const worksMatch = bodyText.match(/视频\s*(\d+)/); if (worksMatch) { result.worksCount = parseInt(worksMatch[1], 10); } } } } catch (e) { console.error('[WeixinVideo] Extract error:', e); } return result; }); logger.info('[Weixin] Extracted account data:', accountData); // 如果首页没有获取到视频号 ID,尝试访问账号设置页面 let finalAccountId = accountData.accountId; if (!finalAccountId || finalAccountId.length < 10) { logger.info('[Weixin] Finder ID not found on home page, trying account settings page...'); try { await this.page.goto('https://channels.weixin.qq.com/platform/account'); await this.page.waitForLoadState('networkidle'); await this.page.waitForTimeout(2000); const settingsId = await this.page.evaluate(() => { const bodyText = document.body.innerText || ''; const patterns = [ /视频号ID[::\s]*([a-zA-Z0-9_]+)/, /视频号[::\s]*ID[::\s]*([a-zA-Z0-9_]+)/, /唯一标识[::\s]*([a-zA-Z0-9_]+)/, ]; for (const pattern of patterns) { const match = bodyText.match(pattern); if (match && match[1]) { return match[1]; } } return null; }); if (settingsId) { finalAccountId = settingsId; logger.info('[Weixin] Found finder ID from settings page:', finalAccountId); } } catch (e) { logger.warn('[Weixin] Failed to fetch from settings page:', e); } } await this.closeBrowser(); return { accountId: finalAccountId || `weixin_video_${Date.now()}`, accountName: accountData.accountName || '视频号账号', avatarUrl: accountData.avatarUrl || '', fansCount: accountData.fansCount || 0, worksCount: accountData.worksCount || 0, }; } catch (error) { logger.error('Weixin getAccountInfo error:', error); await this.closeBrowser(); return { accountId: `weixin_video_${Date.now()}`, accountName: '视频号账号', avatarUrl: '', fansCount: 0, worksCount: 0, }; } } /** * 检查 Python 发布服务是否可用 */ private async checkPythonServiceAvailable(): Promise { try { const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, ''); const response = await fetch(`${pythonUrl}/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 extra = (params.extra || {}) as Record; const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, ''); const response = await fetch(`${pythonUrl}/publish/ai-assisted`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ platform: 'weixin', cookie: cookies, user_id: (extra as any).userId, publish_task_id: (extra as any).publishTaskId, publish_account_id: (extra as any).publishAccountId, proxy: (extra as any).publishProxy || null, 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.error('[Weixin] Python service not available'); return { success: false, errorMessage: 'Python 发布服务不可用,请确保 Python 服务已启动', }; } logger.info('[Weixin] Using Python service for publishing'); try { const result = await this.publishVideoViaPython(cookies, params, onProgress); // 检查是否需要验证码 if (!result.success && result.errorMessage?.includes('验证码')) { logger.info('[Weixin] Python detected captcha, need headful browser'); return { success: false, errorMessage: `CAPTCHA_REQUIRED:${result.errorMessage}`, }; } return result; } catch (pythonError) { logger.error('[Weixin] Python publish failed:', pythonError); return { success: false, errorMessage: pythonError instanceof Error ? pythonError.message : '发布失败', }; } /* ========== Playwright 方式已注释,只使用 Python API ========== const useHeadless = options?.headless ?? true; try { logger.info('[Weixin Publish] Initializing browser...'); await this.initBrowser({ headless: useHeadless }); if (!this.page) { throw new Error('浏览器初始化失败,page 为 null'); } logger.info('[Weixin Publish] Setting cookies...'); await this.setCookies(cookies); // 再次检查 page 状态 if (!this.page) throw new Error('Page not initialized after setCookies'); onProgress?.(5, '正在打开上传页面...'); await this.page.goto(this.publishUrl, { waitUntil: 'domcontentloaded', timeout: 60000, }); await this.page.waitForTimeout(3000); // 先关闭可能存在的弹窗 await this.closeModalDialogs(); // 检查是否需要登录 const currentUrl = this.page.url(); if (currentUrl.includes('login')) { await this.closeBrowser(); return { success: false, errorMessage: '账号登录已过期,请重新登录', }; } // 再次关闭可能的弹窗 await this.closeModalDialogs(); // 检查是否在发布页面,如果不在则尝试点击"发表视频"按钮 const pageUrl = this.page.url(); if (!pageUrl.includes('post/create')) { logger.info('[Weixin Publish] Not on publish page, looking for "发表视频" button...'); onProgress?.(8, '正在进入发布页面...'); // 使用AI寻找发表视频按钮 try { const screenshot = await this.screenshotBase64(); const guide = await aiService.getPageOperationGuide(screenshot, 'weixin_video', '找到"发表视频"按钮并点击进入视频发布页面'); logger.info(`[Weixin Publish] AI guide for publish button:`, guide); if (guide.hasAction && guide.targetSelector) { await this.page.click(guide.targetSelector, { timeout: 5000 }); await this.page.waitForTimeout(3000); } } catch (e) { logger.warn(`[Weixin Publish] AI could not find publish button: ${e}`); } // 尝试常见的发表视频按钮选择器 const publishBtnSelectors = [ 'button:has-text("发表视频")', 'a:has-text("发表视频")', '[class*="publish"]:has-text("发表")', '[class*="create"]:has-text("发表")', '.post-video-btn', ]; for (const selector of publishBtnSelectors) { try { const btn = this.page.locator(selector).first(); if (await btn.count() > 0 && await btn.isVisible()) { logger.info(`[Weixin Publish] Found publish button: ${selector}`); await btn.click({ timeout: 5000 }); await this.page.waitForTimeout(3000); break; } } catch (e) { // 继续尝试下一个选择器 } } // 关闭可能弹出的弹窗 await this.closeModalDialogs(); } onProgress?.(10, '正在上传视频...'); // 上传视频 - 优先使用 AI 截图分析找到上传入口 let uploadTriggered = false; // 方法1: AI 截图分析找到上传入口 logger.info('[Weixin Publish] Using AI to find upload entry...'); try { const screenshot = await this.screenshotBase64(); const guide = await aiService.getPageOperationGuide(screenshot, 'weixin_video', '找到视频上传入口并点击上传按钮'); logger.info(`[Weixin Publish] AI analysis result:`, guide); if (guide.hasAction && guide.targetSelector) { logger.info(`[Weixin Publish] AI suggested selector: ${guide.targetSelector}`); try { const [fileChooser] = await Promise.all([ this.page.waitForEvent('filechooser', { timeout: 10000 }), this.page.click(guide.targetSelector), ]); await fileChooser.setFiles(params.videoPath); uploadTriggered = true; logger.info('[Weixin Publish] Upload triggered via AI selector'); } catch (e) { logger.warn(`[Weixin Publish] AI selector click failed: ${e}`); } } } catch (e) { logger.warn(`[Weixin Publish] AI analysis failed: ${e}`); } // 方法2: 尝试点击常见的上传区域触发 file chooser if (!uploadTriggered) { logger.info('[Weixin Publish] Trying common upload selectors...'); const uploadSelectors = [ // 微信视频号发布页面 - 带"+"号的上传区域 '[class*="add-media"]', '[class*="add-video"]', '[class*="media-add"]', '[class*="video-add"]', '[class*="plus"]', '[class*="add-btn"]', '[class*="add-icon"]', // 视频封面/媒体区域 '[class*="video-cover"]', '[class*="media-cover"]', '[class*="cover-upload"]', '[class*="media-upload"]', '[class*="post-media"]', // 通用上传区域 '[class*="upload-area"]', '[class*="upload-btn"]', '[class*="upload-video"]', '[class*="video-upload"]', '[class*="upload-content"]', '[class*="upload-zone"]', '[class*="upload-wrap"]', '[class*="uploader"]', // 拖拽区域 '[class*="drag"]', '[class*="drop"]', // 匹配上传提示文字 'div:has-text("上传时长")', 'div:has-text("点击上传")', 'div:has-text("拖拽上传")', 'div:has-text("MP4")', // 带虚线边框的容器 '[style*="dashed"]', '[class*="dashed"]', // 微信视频号特有选择器 '[class*="post-cover"]', '.weui-desktop-upload__area', '[class*="finder-upload"]', '[class*="finder-post"]', // 通用触发器 '.upload-trigger', '.video-uploader', '.add-video-btn', ]; for (const selector of uploadSelectors) { if (uploadTriggered) break; try { const element = this.page.locator(selector).first(); if (await element.count() > 0 && await element.isVisible()) { logger.info(`[Weixin Publish] Trying selector: ${selector}`); const [fileChooser] = await Promise.all([ this.page.waitForEvent('filechooser', { timeout: 5000 }), element.click(), ]); await fileChooser.setFiles(params.videoPath); uploadTriggered = true; logger.info(`[Weixin Publish] Upload triggered via selector: ${selector}`); } } catch (e) { // 继续尝试下一个选择器 } } } // 方法3: 直接设置 file input if (!uploadTriggered) { logger.info('[Weixin Publish] Trying file input method...'); const fileInputs = await this.page.$$('input[type="file"]'); logger.info(`[Weixin Publish] Found ${fileInputs.length} file inputs`); for (const fileInput of fileInputs) { try { const accept = await fileInput.getAttribute('accept'); if (!accept || accept.includes('video') || accept.includes('*')) { await fileInput.setInputFiles(params.videoPath); uploadTriggered = true; logger.info('[Weixin Publish] Upload triggered via file input'); break; } } catch (e) { logger.warn(`[Weixin Publish] File input method failed: ${e}`); } } } // 方法4: 如果AI给出了坐标,尝试基于坐标点击 if (!uploadTriggered) { logger.info('[Weixin Publish] Trying AI position-based click...'); try { const screenshot = await this.screenshotBase64(); const guide = await aiService.getPageOperationGuide(screenshot, 'weixin_video', '请找到页面上的视频上传区域或"发表视频"按钮,返回该元素的中心坐标'); logger.info(`[Weixin Publish] AI position analysis:`, guide); if (guide.hasAction && guide.targetPosition) { const { x, y } = guide.targetPosition; logger.info(`[Weixin Publish] Clicking at position: ${x}, ${y}`); // 先尝试普通点击(可能是"发表视频"按钮) await this.page.mouse.click(x, y); await this.page.waitForTimeout(2000); // 检查是否触发了文件选择器或跳转到了发布页 const newUrl = this.page.url(); if (newUrl.includes('post/create')) { logger.info('[Weixin Publish] Navigated to publish page, retrying upload...'); // 重新尝试上传 const uploadArea = this.page.locator('[class*="upload"], [class*="drag"]').first(); if (await uploadArea.count() > 0) { const [fileChooser] = await Promise.all([ this.page.waitForEvent('filechooser', { timeout: 10000 }), uploadArea.click(), ]); await fileChooser.setFiles(params.videoPath); uploadTriggered = true; logger.info('[Weixin Publish] Upload triggered after navigation'); } } } } catch (e) { logger.warn(`[Weixin Publish] Position-based click failed: ${e}`); } } // 方法5: 点击页面左侧区域(微信视频号发布页面的上传区域在左侧) if (!uploadTriggered) { logger.info('[Weixin Publish] Trying left area click (upload area is usually on the left)...'); try { const viewport = this.page.viewportSize(); if (viewport) { // 微信视频号发布页面的上传区域在页面左侧中央 // 根据截图布局,大约在 x=400-550, y=250-400 的区域 const clickPositions = [ { x: viewport.width * 0.35, y: viewport.height * 0.35 }, // 左侧偏上 { x: viewport.width * 0.35, y: viewport.height * 0.4 }, // 左侧中央 { x: 450, y: 300 }, // 固定位置尝试 { x: 540, y: 350 }, // 固定位置尝试 ]; for (const pos of clickPositions) { if (uploadTriggered) break; try { logger.info(`[Weixin Publish] Trying click at: ${pos.x}, ${pos.y}`); const [fileChooser] = await Promise.all([ this.page.waitForEvent('filechooser', { timeout: 5000 }), this.page.mouse.click(pos.x, pos.y), ]); await fileChooser.setFiles(params.videoPath); uploadTriggered = true; logger.info(`[Weixin Publish] Upload triggered via position click: ${pos.x}, ${pos.y}`); } catch (e) { // 继续尝试下一个位置 } } } } catch (e) { logger.warn(`[Weixin Publish] Left area click failed: ${e}`); } } if (!uploadTriggered) { // 截图调试 try { if (this.page) { const screenshotPath = `uploads/debug/weixin_no_upload_${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); logger.info(`[Weixin Publish] Screenshot saved: ${screenshotPath}`); } } catch {} throw new Error('未找到上传入口'); } onProgress?.(20, '视频上传中...'); // 等待上传完成 const maxWaitTime = 300000; // 5分钟 const startTime = Date.now(); let lastAiCheckTime = 0; const aiCheckInterval = 10000; // 每10秒使用AI检测一次 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 { // 继续等待 } // 检查上传进度(通过DOM) let progressDetected = false; 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}%`); progressDetected = true; if (progress >= 100) { logger.info('[Weixin Publish] Upload progress reached 100%'); break; } } } // 使用AI检测上传进度(每隔一段时间检测一次) if (!progressDetected && Date.now() - lastAiCheckTime > aiCheckInterval) { lastAiCheckTime = Date.now(); try { const screenshot = await this.screenshotBase64(); const uploadStatus = await aiService.analyzeUploadProgress(screenshot, 'weixin_video'); logger.info(`[Weixin Publish] AI upload status:`, uploadStatus); if (uploadStatus.isComplete) { logger.info('[Weixin Publish] AI detected upload complete'); break; } if (uploadStatus.isFailed) { throw new Error(`视频上传失败: ${uploadStatus.statusDescription}`); } if (uploadStatus.progress !== null) { onProgress?.(20 + Math.floor(uploadStatus.progress * 0.4), `视频上传中: ${uploadStatus.progress}%`); if (uploadStatus.progress >= 100) { logger.info('[Weixin Publish] AI detected progress 100%'); break; } } } catch (aiError) { logger.warn('[Weixin Publish] AI progress check failed:', aiError); } } 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; let lastProgressCheckTime = 0; const progressCheckInterval = 5000; // 每5秒检测一次发布进度 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, }; } // 检查发布进度条(DOM方式) const publishProgressText = await this.page.locator('[class*="progress"], [class*="loading"]').first().textContent().catch(() => ''); if (publishProgressText) { const match = publishProgressText.match(/(\d+)%/); if (match) { const progress = parseInt(match[1]); onProgress?.(90 + Math.floor(progress * 0.1), `发布中: ${progress}%`); logger.info(`[Weixin Publish] Publish progress: ${progress}%`); } } // AI检测发布进度(定期检测) if (Date.now() - lastProgressCheckTime > progressCheckInterval) { lastProgressCheckTime = Date.now(); try { const screenshot = await this.screenshotBase64(); const publishStatus = await aiService.analyzePublishProgress(screenshot, 'weixin_video'); logger.info(`[Weixin Publish] AI publish status:`, publishStatus); if (publishStatus.isComplete) { logger.info('[Weixin Publish] AI detected publish complete'); onProgress?.(100, '发布成功!'); await this.closeBrowser(); return { success: true, videoUrl: this.page.url() }; } if (publishStatus.isFailed) { throw new Error(`发布失败: ${publishStatus.statusDescription}`); } if (publishStatus.progress !== null) { onProgress?.(90 + Math.floor(publishStatus.progress * 0.1), `发布中: ${publishStatus.progress}%`); } if (publishStatus.isPublishing) { logger.info(`[Weixin Publish] Still publishing: ${publishStatus.statusDescription}`); } } catch (aiError) { logger.warn('[Weixin Publish] AI publish progress check failed:', aiError); } } // 检查错误提示 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 : '发布失败', }; } ========== Playwright 方式已注释结束 ========== */ } /** * 通过 Python API 获取评论 */ private async getCommentsViaPython(cookies: string, videoId: string): Promise { logger.info('[Weixin] Getting comments via Python API...'); const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, ''); const response = await fetch(`${pythonUrl}/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: [], }; } }