| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535 |
- /// <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';
- // 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<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;
- }
- }
-
- 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(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<boolean> {
- 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<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 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<string>,
- options?: { headless?: boolean }
- ): Promise<PublishResult> {
- // 优先尝试使用 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<CommentData[]> {
- 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<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: [],
- };
- }
- }
|