| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686 |
- 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<string, unknown>;
- }
- 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<void> {
- // 如果已有浏览器上下文,先关闭
- 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<string, unknown> = {
- 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<void> {
- 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<void> {
- 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<string> {
- 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<void> {
- if (!this.page) throw new Error('Page not initialized');
- await this.page.waitForSelector(selector, { timeout });
- }
-
- /**
- * 点击元素
- */
- protected async click(selector: string): Promise<void> {
- if (!this.page) throw new Error('Page not initialized');
- await this.page.click(selector);
- }
-
- /**
- * 输入文本
- */
- protected async type(selector: string, text: string): Promise<void> {
- if (!this.page) throw new Error('Page not initialized');
- await this.page.fill(selector, text);
- }
-
- /**
- * 截图
- */
- protected async screenshot(path: string): Promise<void> {
- if (!this.page) throw new Error('Page not initialized');
- await this.page.screenshot({ path });
- }
- /**
- * 截图并返回 Base64 格式
- */
- protected async screenshotBase64(): Promise<string> {
- 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<string> {
- if (!this.page) throw new Error('Page not initialized');
- return await this.page.content();
- }
- // ==================== AI 辅助发布方法 ====================
- /**
- * AI 分析发布页面状态
- * @returns 发布状态分析结果
- */
- protected async aiAnalyzePublishStatus(): Promise<PublishStatusAnalysis | null> {
- 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<PageOperationGuide | null> {
- 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<boolean> {
- 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<string>;
- 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<string>,
- onProgress?: (progress: number, message: string) => void
- ): Promise<PublishResult & { needCaptcha?: boolean; captchaType?: string }> {
- // 如果 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<QRCodeInfo>;
-
- /**
- * 检查扫码状态
- */
- abstract checkQRCodeStatus(qrcodeKey: string): Promise<LoginStatusResult>;
-
- /**
- * 检查登录状态
- */
- abstract checkLoginStatus(cookies: string): Promise<boolean>;
-
- /**
- * 获取账号信息
- */
- abstract getAccountInfo(cookies: string): Promise<AccountProfile>;
-
- /**
- * 发布视频
- */
- abstract publishVideo(cookies: string, params: PublishParams): Promise<PublishResult>;
-
- /**
- * 获取评论列表
- */
- abstract getComments(cookies: string, videoId: string): Promise<CommentData[]>;
-
- /**
- * 回复评论
- */
- abstract replyComment(cookies: string, commentId: string, content: string): Promise<boolean>;
-
- /**
- * 获取数据统计
- */
- abstract getAnalytics(cookies: string, dateRange: DateRange): Promise<AnalyticsData>;
- }
|