|
|
@@ -1,440 +0,0 @@
|
|
|
-/// <reference lib="dom" />
|
|
|
-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';
|
|
|
-
|
|
|
-// 服务器根目录(用于构造绝对路径)
|
|
|
-const SERVER_ROOT = path.resolve(process.cwd());
|
|
|
-
|
|
|
-/**
|
|
|
- * B站平台适配器
|
|
|
- */
|
|
|
-export class BilibiliAdapter extends BasePlatformAdapter {
|
|
|
- readonly platform: PlatformType = 'bilibili';
|
|
|
- readonly loginUrl = 'https://member.bilibili.com/';
|
|
|
- readonly publishUrl = 'https://member.bilibili.com/platform/upload/video/frame';
|
|
|
-
|
|
|
- protected getCookieDomain(): string {
|
|
|
- return '.bilibili.com';
|
|
|
- }
|
|
|
-
|
|
|
- async getQRCode(): Promise<QRCodeInfo> {
|
|
|
- try {
|
|
|
- await this.initBrowser();
|
|
|
-
|
|
|
- if (!this.page) throw new Error('Page not initialized');
|
|
|
-
|
|
|
- await this.page.goto('https://passport.bilibili.com/login');
|
|
|
- await this.waitForSelector('.login-scan-img img', 30000);
|
|
|
-
|
|
|
- const qrcodeImg = await this.page.$('.login-scan-img img');
|
|
|
- const qrcodeUrl = await qrcodeImg?.getAttribute('src');
|
|
|
-
|
|
|
- if (!qrcodeUrl) {
|
|
|
- throw new Error('Failed to get QR code');
|
|
|
- }
|
|
|
-
|
|
|
- return {
|
|
|
- qrcodeUrl,
|
|
|
- qrcodeKey: `bilibili_${Date.now()}`,
|
|
|
- expireTime: Date.now() + 180000,
|
|
|
- };
|
|
|
- } catch (error) {
|
|
|
- logger.error('Bilibili getQRCode error:', error);
|
|
|
- throw error;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async checkQRCodeStatus(_qrcodeKey: string): Promise<LoginStatusResult> {
|
|
|
- try {
|
|
|
- if (!this.page) {
|
|
|
- return { status: 'expired', message: '二维码已过期' };
|
|
|
- }
|
|
|
-
|
|
|
- const currentUrl = this.page.url();
|
|
|
-
|
|
|
- if (currentUrl.includes('member.bilibili.com')) {
|
|
|
- const cookies = await this.getCookies();
|
|
|
- await this.closeBrowser();
|
|
|
- return { status: 'confirmed', message: '登录成功', cookies };
|
|
|
- }
|
|
|
-
|
|
|
- return { status: 'waiting', message: '等待扫码' };
|
|
|
- } catch (error) {
|
|
|
- logger.error('Bilibili checkQRCodeStatus error:', error);
|
|
|
- return { status: 'error', message: '检查状态失败' };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async checkLoginStatus(cookies: string): Promise<boolean> {
|
|
|
- try {
|
|
|
- await this.initBrowser();
|
|
|
- await this.setCookies(cookies);
|
|
|
-
|
|
|
- if (!this.page) throw new Error('Page not initialized');
|
|
|
-
|
|
|
- await this.page.goto('https://member.bilibili.com/platform/home');
|
|
|
- await this.page.waitForLoadState('networkidle');
|
|
|
-
|
|
|
- const avatar = await this.page.$('.user-avatar');
|
|
|
- await this.closeBrowser();
|
|
|
-
|
|
|
- return !!avatar;
|
|
|
- } catch (error) {
|
|
|
- logger.error('Bilibili checkLoginStatus error:', error);
|
|
|
- await this.closeBrowser();
|
|
|
- return false;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async getAccountInfo(cookies: string): Promise<AccountProfile> {
|
|
|
- try {
|
|
|
- await this.initBrowser();
|
|
|
- await this.setCookies(cookies);
|
|
|
-
|
|
|
- if (!this.page) throw new Error('Page not initialized');
|
|
|
-
|
|
|
- await this.page.goto('https://member.bilibili.com/platform/home');
|
|
|
- await this.page.waitForLoadState('networkidle');
|
|
|
-
|
|
|
- const accountName = await this.page.$eval('.user-name', el => el.textContent?.trim() || '');
|
|
|
- const avatarUrl = await this.page.$eval('.user-avatar img', el => el.getAttribute('src') || '');
|
|
|
-
|
|
|
- await this.closeBrowser();
|
|
|
-
|
|
|
- return {
|
|
|
- accountId: '',
|
|
|
- accountName,
|
|
|
- avatarUrl,
|
|
|
- fansCount: 0,
|
|
|
- worksCount: 0,
|
|
|
- };
|
|
|
- } catch (error) {
|
|
|
- logger.error('Bilibili getAccountInfo error:', error);
|
|
|
- await this.closeBrowser();
|
|
|
- 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<string>,
|
|
|
- options?: { headless?: boolean }
|
|
|
- ): Promise<PublishResult> {
|
|
|
- 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('passport') || currentUrl.includes('login')) {
|
|
|
- await this.closeBrowser();
|
|
|
- return {
|
|
|
- success: false,
|
|
|
- errorMessage: '账号登录已过期,请重新登录',
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- onProgress?.(10, '正在上传视频...');
|
|
|
-
|
|
|
- // 上传视频 - 优先使用 AI 截图分析找到上传入口
|
|
|
- let uploadTriggered = false;
|
|
|
-
|
|
|
- // 方法1: AI 截图分析找到上传入口
|
|
|
- logger.info('[Bilibili Publish] Using AI to find upload entry...');
|
|
|
- try {
|
|
|
- const screenshot = await this.screenshotBase64();
|
|
|
- const guide = await aiService.getPageOperationGuide(screenshot, 'bilibili', '找到视频上传入口并点击上传按钮');
|
|
|
- logger.info(`[Bilibili Publish] AI analysis result:`, guide);
|
|
|
-
|
|
|
- if (guide.hasAction && guide.targetSelector) {
|
|
|
- logger.info(`[Bilibili 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('[Bilibili Publish] Upload triggered via AI selector');
|
|
|
- } catch (e) {
|
|
|
- logger.warn(`[Bilibili Publish] AI selector click failed: ${e}`);
|
|
|
- }
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- logger.warn(`[Bilibili Publish] AI analysis failed: ${e}`);
|
|
|
- }
|
|
|
-
|
|
|
- // 方法2: 尝试点击常见的上传区域触发 file chooser
|
|
|
- if (!uploadTriggered) {
|
|
|
- logger.info('[Bilibili Publish] Trying common upload selectors...');
|
|
|
- const uploadSelectors = [
|
|
|
- // B站常见上传区域选择器
|
|
|
- '[class*="upload-area"]',
|
|
|
- '[class*="upload-btn"]',
|
|
|
- '[class*="bcc-upload"]',
|
|
|
- '[class*="video-upload"]',
|
|
|
- '[class*="upload-container"]',
|
|
|
- '.upload-trigger',
|
|
|
- '.video-uploader',
|
|
|
- 'div[class*="uploader"]',
|
|
|
- 'button:has-text("上传")',
|
|
|
- 'div:has-text("上传视频"):not(:has(div))',
|
|
|
- 'span:has-text("点击上传")',
|
|
|
- '[class*="upload-select"]',
|
|
|
- '.bili-upload',
|
|
|
- ];
|
|
|
-
|
|
|
- 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(`[Bilibili 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(`[Bilibili Publish] Upload triggered via selector: ${selector}`);
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- // 继续尝试下一个选择器
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 方法3: 直接设置 file input
|
|
|
- if (!uploadTriggered) {
|
|
|
- logger.info('[Bilibili Publish] Trying file input method...');
|
|
|
- const fileInputs = await this.page.$$('input[type="file"]');
|
|
|
- logger.info(`[Bilibili 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('[Bilibili Publish] Upload triggered via file input');
|
|
|
- break;
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- logger.warn(`[Bilibili Publish] File input method failed: ${e}`);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (!uploadTriggered) {
|
|
|
- // 截图调试
|
|
|
- try {
|
|
|
- if (this.page) {
|
|
|
- const screenshotPath = `uploads/debug/bilibili_no_upload_${Date.now()}.png`;
|
|
|
- await this.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
|
- logger.info(`[Bilibili Publish] Screenshot saved: ${screenshotPath}`);
|
|
|
- }
|
|
|
- } catch {}
|
|
|
- throw new Error('未找到上传入口');
|
|
|
- }
|
|
|
-
|
|
|
- // 等待上传完成
|
|
|
- onProgress?.(20, '视频上传中...');
|
|
|
- const maxWaitTime = 300000; // 5分钟
|
|
|
- const startTime = Date.now();
|
|
|
-
|
|
|
- while (Date.now() - startTime < maxWaitTime) {
|
|
|
- // 检查上传进度
|
|
|
- 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}%`);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 检查上传完成
|
|
|
- const uploadSuccess = await this.page.locator('.upload-success, [class*="success"], [class*="complete"]').count();
|
|
|
- if (uploadSuccess > 0) {
|
|
|
- logger.info('[Bilibili Publish] Video upload completed');
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- await this.page.waitForTimeout(2000);
|
|
|
- }
|
|
|
-
|
|
|
- onProgress?.(60, '正在填写视频信息...');
|
|
|
-
|
|
|
- // 填写标题
|
|
|
- const titleInput = this.page.locator('.video-title input, input[placeholder*="标题"]').first();
|
|
|
- if (await titleInput.count() > 0) {
|
|
|
- await titleInput.fill(params.title);
|
|
|
- }
|
|
|
-
|
|
|
- // 填写简介
|
|
|
- if (params.description) {
|
|
|
- const descInput = this.page.locator('textarea[placeholder*="简介"], .desc-input textarea').first();
|
|
|
- if (await descInput.count() > 0) {
|
|
|
- await descInput.fill(params.description);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 选择分区
|
|
|
- try {
|
|
|
- const typeBtn = this.page.locator('.type-btn, [class*="select-type"]').first();
|
|
|
- if (await typeBtn.count() > 0) {
|
|
|
- await typeBtn.click();
|
|
|
- await this.page.waitForTimeout(500);
|
|
|
- // 默认选择生活区
|
|
|
- const lifeCategory = this.page.locator('[data-tid="160"], :has-text("生活")').first();
|
|
|
- if (await lifeCategory.count() > 0) {
|
|
|
- await lifeCategory.click();
|
|
|
- }
|
|
|
- }
|
|
|
- } catch {
|
|
|
- logger.warn('[Bilibili Publish] Failed to select category');
|
|
|
- }
|
|
|
-
|
|
|
- onProgress?.(80, '正在发布...');
|
|
|
-
|
|
|
- // 点击发布
|
|
|
- const submitBtn = this.page.locator('.submit-add, button:has-text("投稿"), [class*="submit"]').first();
|
|
|
- if (await submitBtn.count() > 0) {
|
|
|
- await submitBtn.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 successHint = await this.page.locator('.success-hint, [class*="success"]:has-text("成功")').count();
|
|
|
- if (successHint > 0) {
|
|
|
- onProgress?.(100, '发布成功!');
|
|
|
- return await this.finishPublishSuccess();
|
|
|
- }
|
|
|
-
|
|
|
- // 检查错误提示
|
|
|
- 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(`[Bilibili Publish] AI status: ${aiStatus.status}, confidence: ${aiStatus.confidence}%`);
|
|
|
-
|
|
|
- if (aiStatus.status === 'success' && aiStatus.confidence >= 70) {
|
|
|
- onProgress?.(100, '发布成功!');
|
|
|
- return await this.finishPublishSuccess();
|
|
|
- }
|
|
|
-
|
|
|
- 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: `bilibili_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('[Bilibili 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, '发布成功!');
|
|
|
- return await this.finishPublishSuccess();
|
|
|
- }
|
|
|
-
|
|
|
- throw new Error('发布超时,请手动检查是否发布成功');
|
|
|
-
|
|
|
- } catch (error) {
|
|
|
- logger.error('Bilibili publishVideo error:', error);
|
|
|
- await this.closeBrowser();
|
|
|
- return {
|
|
|
- success: false,
|
|
|
- errorMessage: error instanceof Error ? error.message : '发布失败',
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async getComments(_cookies: string, videoId: string): Promise<CommentData[]> {
|
|
|
- logger.info(`Bilibili getComments for video ${videoId}`);
|
|
|
- return [];
|
|
|
- }
|
|
|
-
|
|
|
- async replyComment(_cookies: string, commentId: string, content: string): Promise<boolean> {
|
|
|
- logger.info(`Bilibili replyComment ${commentId}: ${content}`);
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- async getAnalytics(_cookies: string, _dateRange: DateRange): Promise<AnalyticsData> {
|
|
|
- return {
|
|
|
- fansCount: 0,
|
|
|
- fansIncrease: 0,
|
|
|
- viewsCount: 0,
|
|
|
- likesCount: 0,
|
|
|
- commentsCount: 0,
|
|
|
- sharesCount: 0,
|
|
|
- };
|
|
|
- }
|
|
|
-}
|