import type { Browser, BrowserContext, Page } from 'playwright'; import type { PlatformType, QRCodeInfo, LoginStatusResult, ProxyConfig, } from '@media-manager/shared'; import { BrowserManager } from '../browser.js'; import { logger } from '../../utils/logger.js'; import { aiService, type PublishStatusAnalysis, type PageOperationGuide } from '../../ai/index.js'; export interface WorkItem { videoId?: string; title: string; coverUrl: string; videoUrl?: string; duration: string; publishTime: string; status: string; playCount: number; likeCount: number; commentCount: number; shareCount: number; } export interface AccountProfile { accountId: string; accountName: string; avatarUrl: string; fansCount: number; worksCount: number; worksList?: WorkItem[]; } export interface PublishParams { videoPath: string; title: string; description?: string; coverPath?: string; tags?: string[]; scheduledTime?: string | Date; // 定时发布时间 location?: string; // 位置信息 extra?: Record; } export interface PublishResult { success: boolean; videoUrl?: string; platformVideoId?: string; errorMessage?: string; } export interface DateRange { startDate: string; endDate: string; } export interface AnalyticsData { fansCount: number; fansIncrease: number; viewsCount: number; likesCount: number; commentsCount: number; sharesCount: number; income?: number; } export interface CommentData { commentId: string; authorId: string; authorName: string; authorAvatar: string; content: string; likeCount: number; commentTime: string; parentCommentId?: string; } export interface InitBrowserOptions { proxyConfig?: ProxyConfig; headless?: boolean; // 是否使用无头模式 } /** * 平台适配器基类 */ export abstract class BasePlatformAdapter { abstract readonly platform: PlatformType; abstract readonly loginUrl: string; protected browser: Browser | null = null; protected context: BrowserContext | null = null; protected page: Page | null = null; protected isHeadless: boolean = false; // 记录当前是否为无头模式 /** * 初始化浏览器 * @param options.proxyConfig 代理配置 * @param options.headless 是否使用无头模式(后台运行),默认 false */ async initBrowser(options?: InitBrowserOptions | ProxyConfig): Promise { // 如果已有浏览器上下文,先关闭 if (this.context) { await this.closeBrowser(); } // 兼容旧的调用方式(直接传 proxyConfig) let proxyConfig: ProxyConfig | undefined; let headless = false; if (options && 'headless' in options) { proxyConfig = options.proxyConfig; headless = options.headless ?? false; } else { proxyConfig = options as ProxyConfig | undefined; } this.isHeadless = headless; this.browser = await BrowserManager.getBrowser({ headless }); const contextOptions: Record = { viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', }; if (proxyConfig?.enabled) { contextOptions.proxy = { server: `${proxyConfig.type}://${proxyConfig.host}:${proxyConfig.port}`, username: proxyConfig.username, password: proxyConfig.password, }; } this.context = await this.browser.newContext(contextOptions); this.page = await this.context.newPage(); } /** * 关闭浏览器 * 对于 headful 模式,会关闭整个浏览器窗口 */ async closeBrowser(): Promise { if (this.page) { await this.page.close(); this.page = null; } if (this.context) { await this.context.close(); this.context = null; } // 关闭对应的浏览器实例(特别是 headful 模式的浏览器窗口) if (!this.isHeadless) { await BrowserManager.closeBrowser({ headless: false }); } this.browser = null; } /** * 设置 Cookie * 支持两种格式: * 1. JSON 数组格式:[{name, value, domain, path}] * 2. 字符串格式:name=value; name2=value2; */ async setCookies(cookies: string): Promise { if (!this.context) { throw new Error('Browser context not initialized'); } try { let cookieList: Array<{ name: string; value: string; domain?: string; path?: string }>; // 尝试解析为 JSON try { cookieList = JSON.parse(cookies); } catch { // JSON 解析失败,尝试解析为 name=value 格式的字符串 cookieList = this.parseCookieString(cookies); } // 确保每个 cookie 都有必要的字段 const formattedCookies = cookieList.map(c => ({ name: c.name, value: c.value, domain: c.domain || this.getCookieDomain(), path: c.path || '/', })); await this.context.addCookies(formattedCookies); logger.info(`Set ${formattedCookies.length} cookies`); } catch (error) { logger.error('Failed to set cookies:', error); throw error; } } /** * 解析 name=value; 格式的 cookie 字符串 */ private parseCookieString(cookieStr: string): Array<{ name: string; value: string; domain?: string; path?: string }> { const cookies: Array<{ name: string; value: string; domain?: string; path?: string }> = []; const pairs = cookieStr.split(';').map(s => s.trim()).filter(s => s); for (const pair of pairs) { const eqIndex = pair.indexOf('='); if (eqIndex > 0) { const name = pair.substring(0, eqIndex).trim(); const value = pair.substring(eqIndex + 1).trim(); if (name && !['path', 'domain', 'expires', 'max-age', 'secure', 'httponly', 'samesite'].includes(name.toLowerCase())) { cookies.push({ name, value }); } } } return cookies; } /** * 获取平台对应的 cookie domain */ protected getCookieDomain(): string { return '.douyin.com'; // 子类可以覆盖 } /** * 获取 Cookie */ async getCookies(): Promise { if (!this.context) { throw new Error('Browser context not initialized'); } const cookies = await this.context.cookies(); return JSON.stringify(cookies); } /** * 等待元素 */ protected async waitForSelector(selector: string, timeout: number = 10000): Promise { if (!this.page) throw new Error('Page not initialized'); await this.page.waitForSelector(selector, { timeout }); } /** * 点击元素 */ protected async click(selector: string): Promise { if (!this.page) throw new Error('Page not initialized'); await this.page.click(selector); } /** * 输入文本 */ protected async type(selector: string, text: string): Promise { if (!this.page) throw new Error('Page not initialized'); await this.page.fill(selector, text); } /** * 截图 */ protected async screenshot(path: string): Promise { if (!this.page) throw new Error('Page not initialized'); await this.page.screenshot({ path }); } /** * 截图并返回 Base64 格式 */ protected async screenshotBase64(): Promise { if (!this.page) throw new Error('Page not initialized'); const buffer = await this.page.screenshot({ type: 'jpeg', quality: 80 }); return buffer.toString('base64'); } /** * 获取页面 HTML */ protected async getPageHtml(): Promise { if (!this.page) throw new Error('Page not initialized'); return await this.page.content(); } // ==================== AI 辅助发布方法 ==================== /** * AI 分析发布页面状态 * @returns 发布状态分析结果 */ protected async aiAnalyzePublishStatus(): Promise { if (!aiService.isAvailable()) { logger.debug('[AI Publish] AI service not available'); return null; } try { const screenshot = await this.screenshotBase64(); const result = await aiService.analyzePublishStatus(screenshot, this.platform); logger.info(`[AI Publish] Status analysis: ${result.status}, confidence: ${result.confidence}%`); return result; } catch (error) { logger.error('[AI Publish] Failed to analyze status:', error); return null; } } /** * AI 获取发布操作指导 * @param currentStatus 当前状态描述 * @returns 操作指导 */ protected async aiGetPublishOperationGuide(currentStatus: string): Promise { if (!aiService.isAvailable()) { logger.debug('[AI Publish] AI service not available'); return null; } try { const html = await this.getPageHtml(); const result = await aiService.analyzePublishPageHtml(html, this.platform, currentStatus); logger.info(`[AI Publish] Operation guide: hasAction=${result.hasAction}, action=${result.actionType}`); return result; } catch (error) { logger.error('[AI Publish] Failed to get operation guide:', error); return null; } } /** * 将AI返回的选择器转换为Playwright兼容的格式 * @param selector 原始选择器 * @returns 转换后的选择器 */ private convertToPlaywrightSelector(selector: string): string { let converted = selector; // 将 :contains('text') 或 :contains("text") 转换为 :has-text("text") converted = converted.replace(/:contains\(['"]([^'"]+)['"]\)/g, ':has-text("$1")'); converted = converted.replace(/:contains\(([^)]+)\)/g, ':has-text("$1")'); // 移除不支持的伪类选择器 // 如果选择器包含不支持的语法,尝试简化 if (converted.includes(':contains')) { // 如果还有 :contains,提取文本用于 text= 选择器 const match = converted.match(/:contains\(['"]?([^'")\]]+)['"]?\)/); if (match) { return `text="${match[1]}"`; } } return converted; } /** * AI 辅助执行操作 * @param guide 操作指导 * @returns 是否执行成功 */ protected async aiExecuteOperation(guide: PageOperationGuide): Promise { if (!this.page || !guide.hasAction) return false; try { switch (guide.actionType) { case 'click': if (guide.targetSelector) { // 转换选择器为Playwright兼容格式 const selector = this.convertToPlaywrightSelector(guide.targetSelector); logger.info(`[AI Publish] Clicking: ${selector} (original: ${guide.targetSelector})`); try { await this.page.click(selector, { timeout: 10000 }); return true; } catch (selectorError) { // 如果选择器失败,尝试使用位置点击 if (guide.targetPosition) { logger.info(`[AI Publish] Selector failed, trying position click: ${guide.targetPosition.x}, ${guide.targetPosition.y}`); await this.page.mouse.click(guide.targetPosition.x, guide.targetPosition.y); return true; } // 尝试使用文本匹配 if (guide.targetDescription) { const textSelector = `text="${guide.targetDescription}"`; logger.info(`[AI Publish] Trying text selector: ${textSelector}`); try { await this.page.click(textSelector, { timeout: 5000 }); return true; } catch { // 继续抛出原始错误 } } throw selectorError; } } break; case 'input': if (guide.targetSelector && guide.inputText) { const selector = this.convertToPlaywrightSelector(guide.targetSelector); logger.info(`[AI Publish] Inputting to: ${selector}`); await this.page.fill(selector, guide.inputText); return true; } break; case 'wait': logger.info('[AI Publish] Waiting...'); await this.page.waitForTimeout(3000); return true; case 'scroll': logger.info('[AI Publish] Scrolling...'); await this.page.evaluate(() => window.scrollBy(0, 300)); return true; } } catch (error) { logger.error(`[AI Publish] Failed to execute operation:`, error); } return false; } /** * AI 辅助发布监控循环 * 监控发布过程,检测验证码和发布结果 * @param options 配置选项 * @returns 发布结果 */ protected async aiAssistedPublishMonitor(options: { maxAttempts?: number; checkInterval?: number; onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; captchaDescription?: string; imageBase64?: string; }) => Promise; onProgress?: (progress: number, message: string) => void; }): Promise<{ success: boolean; message: string; needManualIntervention?: boolean }> { const maxAttempts = options.maxAttempts || 30; const checkInterval = options.checkInterval || 3000; for (let attempt = 0; attempt < maxAttempts; attempt++) { // 等待页面稳定 await this.page?.waitForTimeout(checkInterval); // AI 分析当前页面状态 const status = await this.aiAnalyzePublishStatus(); if (!status) { logger.warn('[AI Publish] AI analysis unavailable, continuing...'); continue; } logger.info(`[AI Publish] Attempt ${attempt + 1}/${maxAttempts}: status=${status.status}`); switch (status.status) { case 'success': options.onProgress?.(100, '发布成功'); return { success: true, message: status.pageDescription }; case 'failed': options.onProgress?.(0, status.errorMessage || '发布失败'); return { success: false, message: status.errorMessage || '发布失败' }; case 'need_captcha': if (options.onCaptchaRequired) { options.onProgress?.(50, `检测到验证码: ${status.captchaDescription || '请输入验证码'}`); // 如果是图片验证码,截图发送 let imageBase64: string | undefined; if (status.captchaType === 'image' || status.captchaType === 'slider') { imageBase64 = await this.screenshotBase64(); } try { const captchaCode = await options.onCaptchaRequired({ taskId: `captcha_${Date.now()}`, type: status.captchaType === 'sms' ? 'sms' : 'image', captchaDescription: status.captchaDescription, imageBase64, }); // 用户输入了验证码,尝试 AI 指导输入 if (captchaCode && status.nextAction?.targetSelector) { await this.page?.fill(status.nextAction.targetSelector, captchaCode); await this.page?.waitForTimeout(500); // 尝试点击确认按钮 const guide = await this.aiGetPublishOperationGuide('已输入验证码,需要点击确认'); if (guide?.hasAction && guide.actionType === 'click' && guide.targetSelector) { await this.page?.click(guide.targetSelector); } } } catch (captchaError) { logger.error('[AI Publish] Captcha handling failed:', captchaError); return { success: false, message: '验证码处理失败', needManualIntervention: true }; } } else { // 没有验证码处理回调,需要人工介入 logger.warn('[AI Publish] Captcha required but no handler provided'); return { success: false, message: '需要验证码', needManualIntervention: true }; } break; case 'need_action': if (status.nextAction) { options.onProgress?.(Math.min(80, 50 + attempt * 2), status.pageDescription); // 尝试执行 AI 建议的操作 const guide = await this.aiGetPublishOperationGuide(status.pageDescription); if (guide?.hasAction) { await this.aiExecuteOperation(guide); } } break; case 'uploading': case 'processing': options.onProgress?.(Math.min(90, 30 + attempt * 2), status.pageDescription); // 继续等待 break; } } // 超过最大尝试次数 logger.warn('[AI Publish] Max attempts reached'); return { success: false, message: '发布超时,请检查发布状态' }; } /** * AI 辅助处理 Python API 发布结果 * 当 Python 返回截图时,使用 AI 分析发布状态 * @param result Python API 返回的结果 * @param onCaptchaRequired 验证码回调 * @param onProgress 进度回调 * @returns 处理后的发布结果 */ protected async aiProcessPythonPublishResult( result: { success?: boolean; screenshot_base64?: string; page_url?: string; video_id?: string; video_url?: string; need_captcha?: boolean; captcha_type?: string; status?: string; error?: string; }, onCaptchaRequired?: (captchaInfo: { taskId: string; type: 'sms' | 'image'; phone?: string; imageBase64?: string; }) => Promise, onProgress?: (progress: number, message: string) => void ): Promise { // 如果 Python 返回成功 if (result.success) { onProgress?.(100, '发布成功'); return { success: true, platformVideoId: result.video_id || `${this.platform}_${Date.now()}`, videoUrl: result.video_url || '', }; } // 如果返回了截图,使用 AI 分析 if (result.screenshot_base64 && aiService.isAvailable()) { logger.info(`[${this.platform} Python] Got screenshot, analyzing with AI...`); const aiStatus = await aiService.analyzePublishStatus(result.screenshot_base64, this.platform); logger.info(`[${this.platform} Python] AI analysis: status=${aiStatus.status}, confidence=${aiStatus.confidence}%`); // AI 判断发布成功 if (aiStatus.status === 'success' && aiStatus.confidence >= 70) { onProgress?.(100, '发布成功'); return { success: true, platformVideoId: result.video_id || `${this.platform}_${Date.now()}`, videoUrl: result.video_url || result.page_url || '', }; } // AI 检测到需要验证码 if (aiStatus.status === 'need_captcha') { logger.info(`[${this.platform} Python] AI detected captcha: ${aiStatus.captchaDescription}`); if (onCaptchaRequired) { onProgress?.(50, `AI 检测到验证码: ${aiStatus.captchaDescription || '请输入验证码'}`); try { const captchaCode = await onCaptchaRequired({ taskId: `${this.platform}_captcha_${Date.now()}`, type: aiStatus.captchaType === 'sms' ? 'sms' : 'image', imageBase64: result.screenshot_base64, }); // 验证码已获取,但 Python 发布已结束,需要通过 Playwright 继续 logger.info(`[${this.platform} Python] Got captcha code, need to continue with Playwright`); } catch { logger.error(`[${this.platform} Python] Captcha handling failed`); } } return { success: false, needCaptcha: true, captchaType: aiStatus.captchaType || 'image', errorMessage: aiStatus.captchaDescription || '需要验证码', }; } // AI 判断发布失败 if (aiStatus.status === 'failed' && aiStatus.confidence >= 70) { return { success: false, errorMessage: aiStatus.errorMessage || 'AI 检测到发布失败', }; } } // Python 返回需要验证码 if (result.need_captcha || result.status === 'need_captcha') { logger.info(`[${this.platform} Python] Captcha required: type=${result.captcha_type}`); onProgress?.(0, `检测到需要验证码,切换到浏览器模式...`); return { success: false, needCaptcha: true, captchaType: result.captcha_type || 'image', errorMessage: result.error || '需要验证码', }; } // 其他失败情况 return { success: false, errorMessage: result.error || '发布失败', }; } // ==================== 抽象方法 - 子类必须实现 ==================== /** * 获取扫码登录二维码 */ abstract getQRCode(): Promise; /** * 检查扫码状态 */ abstract checkQRCodeStatus(qrcodeKey: string): Promise; /** * 检查登录状态 */ abstract checkLoginStatus(cookies: string): Promise; /** * 获取账号信息 */ abstract getAccountInfo(cookies: string): Promise; /** * 发布视频 */ abstract publishVideo(cookies: string, params: PublishParams): Promise; /** * 获取评论列表 */ abstract getComments(cookies: string, videoId: string): Promise; /** * 回复评论 */ abstract replyComment(cookies: string, commentId: string, content: string): Promise; /** * 获取数据统计 */ abstract getAnalytics(cookies: string, dateRange: DateRange): Promise; }