///
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: [],
};
}
}