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