///
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());
/**
* 百家号平台适配器
*/
export class BaijiahaoAdapter extends BasePlatformAdapter {
readonly platform: PlatformType = 'baijiahao';
readonly loginUrl = 'https://baijiahao.baidu.com/';
readonly publishUrl = 'https://baijiahao.baidu.com/builder/rc/edit?type=videoV2&is_from_cms=1';
protected getCookieDomain(): string {
return '.baidu.com';
}
/**
* 检查 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('baijiahao');
}
return false;
} catch {
return false;
}
}
async getQRCode(): Promise {
try {
await this.initBrowser();
if (!this.page) throw new Error('Page not initialized');
// 访问百家号登录页面
await this.page.goto('https://baijiahao.baidu.com/', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// 等待二维码出现
await this.page.waitForSelector('img[src*="qrcode"]', { timeout: 15000 });
// 获取二维码图片
const qrcodeImg = await this.page.$('img[src*="qrcode"]');
const qrcodeUrl = await qrcodeImg?.getAttribute('src');
if (!qrcodeUrl) {
throw new Error('Failed to get QR code');
}
return {
qrcodeUrl,
qrcodeKey: `baijiahao_${Date.now()}`,
expireTime: Date.now() + 300000,
};
} catch (error) {
logger.error('Baijiahao getQRCode error:', error);
throw error;
}
}
async checkQRCodeStatus(qrcodeKey: string): Promise {
try {
if (!this.page) {
return { status: 'expired', message: '二维码已过期' };
}
// 检查是否登录成功(URL 变化或出现用户信息)
const currentUrl = this.page.url();
if (currentUrl.includes('/builder/rc/home') || currentUrl.includes('/builder/rc/content')) {
const cookies = await this.getCookies();
await this.closeBrowser();
return {
status: 'success',
message: '登录成功',
cookies,
};
}
// 检查是否需要手机验证
const phoneInput = await this.page.$('input[type="tel"]');
if (phoneInput) {
return { status: 'scanned', message: '需要手机验证' };
}
return { status: 'pending', message: '等待扫码' };
} catch (error) {
logger.error('Baijiahao checkQRCodeStatus error:', error);
return { status: 'expired', message: '检查状态失败' };
}
}
async checkLoginStatus(cookies: string): Promise {
try {
await this.initBrowser({ headless: true });
await this.setCookies(cookies);
if (!this.page) throw new Error('Page not initialized');
// 访问百家号后台首页
await this.page.goto('https://baijiahao.baidu.com/builder/rc/home', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await this.page.waitForTimeout(2000);
const currentUrl = this.page.url();
const isLoggedIn = currentUrl.includes('/builder/rc/') && !currentUrl.includes('login');
await this.closeBrowser();
return isLoggedIn;
} catch (error) {
logger.error('Baijiahao checkLoginStatus error:', error);
await this.closeBrowser();
return false;
}
}
/**
* 关闭页面上可能存在的弹窗对话框
*/
private async closeModalDialogs(): Promise {
if (!this.page) return false;
let closedAny = false;
try {
const modalSelectors = [
// 百家号常见弹窗关闭按钮
'.Dialog-close',
'.modal-close',
'[class*="dialog"] [class*="close"]',
'[class*="modal"] [class*="close"]',
'[role="dialog"] button[aria-label="close"]',
'.ant-modal-close',
'button:has-text("关闭")',
'button:has-text("取消")',
'button:has-text("我知道了")',
'button:has-text("暂不")',
'.close-btn',
];
for (const selector of modalSelectors) {
try {
const closeBtn = this.page.locator(selector).first();
if (await closeBtn.count() > 0 && await closeBtn.isVisible()) {
logger.info(`[Baijiahao] 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('[Baijiahao] Trying ESC key to close modal...');
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(500);
closedAny = true;
}
}
if (closedAny) {
logger.info('[Baijiahao] Successfully closed modal dialog');
}
} catch (error) {
logger.warn('[Baijiahao] Error closing modal:', error);
}
return closedAny;
}
async getAccountInfo(cookies: string): Promise {
try {
await this.initBrowser({ headless: true });
await this.setCookies(cookies);
if (!this.page) throw new Error('Page not initialized');
// 访问设置页面获取账号信息
await this.page.goto('https://baijiahao.baidu.com/builder/rc/home', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await this.page.waitForTimeout(3000);
// 尝试从页面获取账号信息
const accountInfo = await this.page.evaluate(() => {
// 尝试获取用户名
const nameEl = document.querySelector('.user-name, .user-info .name, [class*="author-name"]');
const name = nameEl?.textContent?.trim() || '';
// 尝试获取头像
const avatarEl = document.querySelector('.user-avatar img, .author-avatar img, [class*="avatar"] img');
const avatar = avatarEl?.getAttribute('src') || '';
return { name, avatar };
});
await this.closeBrowser();
return {
accountId: `baijiahao_${Date.now()}`,
accountName: accountInfo.name || '百家号账号',
avatarUrl: accountInfo.avatar || '',
fansCount: 0,
worksCount: 0,
};
} catch (error) {
logger.error('Baijiahao getAccountInfo error:', error);
await this.closeBrowser();
return {
accountId: '',
accountName: '百家号账号',
avatarUrl: '',
fansCount: 0,
worksCount: 0,
};
}
}
/**
* 通过 Python 服务发布视频(带 AI 辅助)
*/
private async publishVideoViaPython(
cookies: string,
params: PublishParams,
onProgress?: (progress: number, message: string) => void
): Promise {
logger.info('[Baijiahao Python] Starting publish via Python service with AI assist...');
onProgress?.(5, '正在通过 Python 服务发布...');
try {
// 准备 cookie 字符串
let cookieStr = cookies;
try {
const cookieArray = JSON.parse(cookies);
if (Array.isArray(cookieArray)) {
cookieStr = cookieArray.map((c: { name: string; value: string }) => `${c.name}=${c.value}`).join('; ');
}
} catch {
// 已经是字符串格式
}
// 将相对路径转换为绝对路径
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: 'baijiahao',
cookie: cookieStr,
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,
return_screenshot: true,
}),
signal: AbortSignal.timeout(600000), // 10分钟超时
});
const result = await response.json();
logger.info('[Baijiahao Python] Response:', { ...result, screenshot_base64: result.screenshot_base64 ? '[截图已省略]' : undefined });
// 使用通用的 AI 辅助处理方法
return await this.aiProcessPythonPublishResult(result, undefined, onProgress);
} catch (error) {
logger.error('[Baijiahao 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('[Baijiahao] Python service not available');
return {
success: false,
errorMessage: 'Python 发布服务不可用,请确保 Python 服务已启动',
};
}
logger.info('[Baijiahao] Using Python service for publishing');
try {
const result = await this.publishVideoViaPython(cookies, params, onProgress);
// 检查是否需要验证码
if (!result.success && result.errorMessage?.includes('验证码')) {
logger.info('[Baijiahao] Python detected captcha, need headful browser');
return {
success: false,
errorMessage: `CAPTCHA_REQUIRED:${result.errorMessage}`,
};
}
return result;
} catch (pythonError) {
logger.error('[Baijiahao] Python publish failed:', pythonError);
return {
success: false,
errorMessage: pythonError instanceof Error ? pythonError.message : '发布失败',
};
}
/* ========== Playwright 方式已注释,只使用 Python API ==========
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);
// 先关闭可能存在的弹窗
await this.closeModalDialogs();
// 检查是否需要登录
const currentUrl = this.page.url();
if (currentUrl.includes('login') || currentUrl.includes('passport')) {
await this.closeBrowser();
return {
success: false,
errorMessage: '账号登录已过期,请重新登录',
};
}
// 再次关闭可能的弹窗(登录后可能出现活动弹窗)
await this.closeModalDialogs();
onProgress?.(10, '正在上传视频...');
// 上传视频 - 优先使用 AI 截图分析找到上传入口
let uploadTriggered = false;
// 方法1: AI 截图分析找到上传入口
logger.info('[Baijiahao Publish] Using AI to find upload entry...');
try {
const screenshot = await this.screenshotBase64();
const guide = await aiService.getPageOperationGuide(screenshot, 'baijiahao', '找到视频上传入口并点击上传按钮');
logger.info(`[Baijiahao Publish] AI analysis result:`, guide);
if (guide.hasAction && guide.targetSelector) {
logger.info(`[Baijiahao 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('[Baijiahao Publish] Upload triggered via AI selector');
} catch (e) {
logger.warn(`[Baijiahao Publish] AI selector click failed: ${e}`);
}
}
} catch (e) {
logger.warn(`[Baijiahao Publish] AI analysis failed: ${e}`);
}
// 方法2: 尝试点击常见的上传区域触发 file chooser
if (!uploadTriggered) {
logger.info('[Baijiahao Publish] Trying common upload selectors...');
const uploadSelectors = [
// 百家号常见上传区域选择器 - 虚线框拖拽上传区域
'[class*="drag"]',
'[class*="drop"]',
'[class*="upload-area"]',
'[class*="upload-zone"]',
'[class*="upload-wrapper"]',
'[class*="upload-box"]',
'[class*="upload-btn"]',
'[class*="upload-video"]',
'[class*="video-upload"]',
'[class*="drag-upload"]',
'.upload-container',
'.video-uploader',
'div[class*="uploader"]',
// 匹配包含"点击上传"文字的区域
'div:has-text("点击上传")',
'div:has-text("拖动入此区域")',
'span:has-text("点击上传")',
// 带虚线边框的容器(通常是拖拽上传区域)
'[style*="dashed"]',
'[class*="dashed"]',
'[class*="border-dashed"]',
// 其他常见选择器
'button:has-text("上传")',
'[class*="add-btn"]',
'.bjh-upload',
'[class*="file-select"]',
// 通用的上传触发器
'[class*="trigger"]',
'[class*="picker"]',
];
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(`[Baijiahao 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(`[Baijiahao Publish] Upload triggered via selector: ${selector}`);
}
} catch (e) {
// 继续尝试下一个选择器
}
}
}
// 方法3: 直接设置 file input
if (!uploadTriggered) {
logger.info('[Baijiahao Publish] Trying file input method...');
const fileInputs = await this.page.$$('input[type="file"]');
logger.info(`[Baijiahao 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('[Baijiahao Publish] Upload triggered via file input');
break;
}
} catch (e) {
logger.warn(`[Baijiahao Publish] File input method failed: ${e}`);
}
}
}
// 方法4: 如果AI给出了坐标,尝试基于坐标点击
if (!uploadTriggered) {
logger.info('[Baijiahao Publish] Trying AI position-based click...');
try {
const screenshot = await this.screenshotBase64();
const guide = await aiService.getPageOperationGuide(screenshot, 'baijiahao', '请找到页面中央的虚线框上传区域(有"点击上传或将文件拖动入此区域"文字的区域),返回该区域的中心坐标');
logger.info(`[Baijiahao Publish] AI position analysis:`, guide);
if (guide.hasAction && guide.targetPosition) {
const { x, y } = guide.targetPosition;
logger.info(`[Baijiahao Publish] Clicking at position: ${x}, ${y}`);
const [fileChooser] = await Promise.all([
this.page.waitForEvent('filechooser', { timeout: 10000 }),
this.page.mouse.click(x, y),
]);
await fileChooser.setFiles(params.videoPath);
uploadTriggered = true;
logger.info('[Baijiahao Publish] Upload triggered via position click');
}
} catch (e) {
logger.warn(`[Baijiahao Publish] Position-based click failed: ${e}`);
}
}
// 方法5: 点击页面中央区域(百家号上传区域通常在中央)
if (!uploadTriggered) {
logger.info('[Baijiahao Publish] Trying center area click...');
try {
const viewport = this.page.viewportSize();
if (viewport) {
// 百家号的上传区域大约在页面中央偏上的位置
const centerX = viewport.width / 2;
const centerY = viewport.height * 0.35; // 上传区域通常在页面上半部分
logger.info(`[Baijiahao Publish] Clicking center area: ${centerX}, ${centerY}`);
const [fileChooser] = await Promise.all([
this.page.waitForEvent('filechooser', { timeout: 10000 }),
this.page.mouse.click(centerX, centerY),
]);
await fileChooser.setFiles(params.videoPath);
uploadTriggered = true;
logger.info('[Baijiahao Publish] Upload triggered via center click');
}
} catch (e) {
logger.warn(`[Baijiahao Publish] Center click failed: ${e}`);
}
}
if (!uploadTriggered) {
// 截图调试
try {
if (this.page) {
const screenshotPath = `uploads/debug/baijiahao_no_upload_${Date.now()}.png`;
await this.page.screenshot({ path: screenshotPath, fullPage: true });
logger.info(`[Baijiahao Publish] Screenshot saved: ${screenshotPath}`);
}
} catch {}
throw new Error('未找到上传入口');
}
// 等待上传完成
onProgress?.(30, '视频上传中...');
await this.page.waitForTimeout(5000);
// 等待视频处理
const maxWaitTime = 300000; // 5分钟
const startTime = Date.now();
let lastAiCheckTime = 0;
const aiCheckInterval = 10000; // 每10秒使用AI检测一次
while (Date.now() - startTime < maxWaitTime) {
// 检查上传进度(通过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?.(30 + Math.floor(progress * 0.3), `视频上传中: ${progress}%`);
progressDetected = true;
if (progress >= 100) {
logger.info('[Baijiahao Publish] Upload progress reached 100%');
break;
}
}
}
// 检查是否上传完成
const uploadSuccess = await this.page.locator('[class*="success"], [class*="complete"]').count();
if (uploadSuccess > 0) {
logger.info('[Baijiahao Publish] Upload success indicator found');
break;
}
// 使用AI检测上传进度(每隔一段时间检测一次)
if (!progressDetected && Date.now() - lastAiCheckTime > aiCheckInterval) {
lastAiCheckTime = Date.now();
try {
const screenshot = await this.screenshotBase64();
const uploadStatus = await aiService.analyzeUploadProgress(screenshot, 'baijiahao');
logger.info(`[Baijiahao Publish] AI upload status:`, uploadStatus);
if (uploadStatus.isComplete) {
logger.info('[Baijiahao Publish] AI detected upload complete');
break;
}
if (uploadStatus.isFailed) {
throw new Error(`视频上传失败: ${uploadStatus.statusDescription}`);
}
if (uploadStatus.progress !== null) {
onProgress?.(30 + Math.floor(uploadStatus.progress * 0.3), `视频上传中: ${uploadStatus.progress}%`);
if (uploadStatus.progress >= 100) {
logger.info('[Baijiahao Publish] AI detected progress 100%');
break;
}
}
} catch (aiError) {
logger.warn('[Baijiahao Publish] AI progress check failed:', aiError);
}
}
await this.page.waitForTimeout(2000);
}
onProgress?.(60, '正在填写视频信息...');
// 填写标题
const titleInput = this.page.locator('input[placeholder*="标题"], textarea[placeholder*="标题"]').first();
if (await titleInput.count() > 0) {
await titleInput.fill(params.title);
}
// 填写描述
if (params.description) {
const descInput = this.page.locator('textarea[placeholder*="简介"], textarea[placeholder*="描述"]').first();
if (await descInput.count() > 0) {
await descInput.fill(params.description);
}
}
onProgress?.(80, '正在发布...');
// 点击发布按钮
const publishBtn = this.page.locator('button:has-text("发布"), [class*="publish-btn"]').first();
if (await publishBtn.count() > 0) {
await publishBtn.click();
} else {
throw new Error('未找到发布按钮');
}
// 等待发布结果
onProgress?.(90, '等待发布完成...');
const publishMaxWait = 120000; // 2分钟
const publishStartTime = Date.now();
let lastProgressCheckTime = 0;
const progressCheckInterval = 5000; // 每5秒检测一次发布进度
while (Date.now() - publishStartTime < publishMaxWait) {
await this.page.waitForTimeout(3000);
// 检查是否跳转到内容管理页面
const currentUrl = this.page.url();
if (currentUrl.includes('/content') || currentUrl.includes('/rc/home')) {
onProgress?.(100, '发布成功!');
await this.closeBrowser();
return {
success: true,
videoUrl: currentUrl,
};
}
// 检查发布进度条(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(`[Baijiahao 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, 'baijiahao');
logger.info(`[Baijiahao Publish] AI publish status:`, publishStatus);
if (publishStatus.isComplete) {
logger.info('[Baijiahao 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.needAction && onCaptchaRequired) {
logger.info(`[Baijiahao Publish] Need action: ${publishStatus.actionDescription}`);
const imageBase64 = await this.screenshotBase64();
try {
await onCaptchaRequired({
taskId: `baijiahao_captcha_${Date.now()}`,
type: 'image',
imageBase64,
});
} catch {
logger.error('[Baijiahao] Captcha handling failed');
}
}
if (publishStatus.isPublishing) {
logger.info(`[Baijiahao Publish] Still publishing: ${publishStatus.statusDescription}`);
}
} catch (aiError) {
logger.warn('[Baijiahao 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状态
const aiStatus = await this.aiAnalyzePublishStatus();
if (aiStatus) {
if (aiStatus.status === 'success') {
onProgress?.(100, '发布成功!');
await this.closeBrowser();
return {
success: true,
videoUrl: this.page.url(),
};
}
if (aiStatus.status === 'failed') {
throw new Error(aiStatus.errorMessage || '发布失败');
}
}
// 超时
await this.closeBrowser();
return {
success: false,
errorMessage: '发布超时,请手动检查是否发布成功',
};
} catch (error) {
logger.error('[Baijiahao Publish] Error:', error);
await this.closeBrowser();
return {
success: false,
errorMessage: error instanceof Error ? error.message : '发布失败',
};
}
========== Playwright 方式已注释结束 ========== */
}
async getComments(cookies: string, videoId: string): Promise {
logger.warn('[Baijiahao] getComments not implemented');
return [];
}
async replyComment(cookies: string, commentId: string, content: string): Promise {
logger.warn('[Baijiahao] replyComment not implemented');
return false;
}
async getAnalytics(cookies: string, dateRange: DateRange): Promise {
logger.warn('[Baijiahao] getAnalytics not implemented');
return {
fansCount: 0,
fansIncrease: 0,
viewsCount: 0,
likesCount: 0,
commentsCount: 0,
sharesCount: 0,
};
}
}