| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096 |
- /// <reference lib="dom" />
- 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<QRCodeInfo> {
- 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<LoginStatusResult> {
- 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<boolean> {
- 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<boolean> {
- 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<AccountProfile> {
- 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<boolean> {
- 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<PublishResult> {
- 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<string, unknown>;
- 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<string>,
- options?: { headless?: boolean }
- ): Promise<PublishResult> {
- // 只使用 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<CommentData[]> {
- 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<CommentData[]> {
- // 优先尝试使用 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<boolean> {
- logger.warn('Weixin replyComment not implemented');
- return false;
- }
- async getAnalytics(cookies: string, dateRange?: DateRange): Promise<AnalyticsData> {
- logger.warn('Weixin getAnalytics not implemented');
- return {
- totalViews: 0,
- totalLikes: 0,
- totalComments: 0,
- totalShares: 0,
- periodViews: [],
- };
- }
- }
|