/** * 视频下载器 - 基于浏览器自动化+API拦截方案 * 替换原有的在线API分析,实现真正的视频内容下载和识别 */ import { app } from 'electron'; import path from 'path'; import fs from 'fs'; import http from 'http'; import https from 'https'; import { Page, BrowserContext } from 'playwright'; import { DouyinBrowserManager } from './browserManager'; interface VideoInfo { aweme_id: string; desc: string; author: { uid: string; nickname: string; }; video: { play_addr: { url_list: string[]; }; duration: number; }; } interface DownloadResult { success: boolean; videoPath?: string; audioPath?: string; videoInfo?: VideoInfo; error?: string; } class DouyinVideoDownloader { private static readonly DIRECT_VIDEO_PATTERN = /https?:\/\/[^\s"'<>]+?\.(mp4|mov|m4v|webm|avi|mkv)(\?[^\s"'<>]*)?/i; private browserManager: DouyinBrowserManager; private externalContext: BrowserContext | null; // 外部传入的浏览器上下文 private userDataPath: string; constructor(externalContext?: BrowserContext) { this.externalContext = externalContext || null; this.browserManager = DouyinBrowserManager.getInstance(); // 设置用户数据目录以保持登录状态 this.userDataPath = path.join(app.getPath('userData'), 'douyin-browser-data'); } private getDownloadDir(): string { const downloadDir = path.join(app.getPath('userData'), 'downloads', 'videos'); if (!fs.existsSync(downloadDir)) { fs.mkdirSync(downloadDir, { recursive: true }); } return downloadDir; } private inferVideoExtension(videoUrl: string): string { try { const pathname = new URL(videoUrl).pathname.toLowerCase(); const matchedExt = pathname.match(/\.(mp4|mov|m4v|webm|avi|mkv)$/i)?.[1]?.toLowerCase(); if (matchedExt) { return `.${matchedExt}`; } } catch (error) { console.warn('inferVideoExtension failed:', error); } return '.mp4'; } private isDirectVideoUrl(videoUrl: string): boolean { return DouyinVideoDownloader.DIRECT_VIDEO_PATTERN.test(videoUrl.trim()); } private downloadRemoteFile( fileUrl: string, savePath: string, headers: Record, redirectCount: number = 0 ): Promise { return new Promise((resolve, reject) => { if (redirectCount > 5) { reject(new Error('Too many redirects while downloading video')); return; } const client = fileUrl.startsWith('https://') ? https : http; const fileStream = fs.createWriteStream(savePath); let settled = false; const fail = (error: Error) => { if (settled) { return; } settled = true; fileStream.destroy(); if (fs.existsSync(savePath)) { fs.unlinkSync(savePath); } reject(error); }; const request = client.get(fileUrl, { headers }, (response) => { const statusCode = response.statusCode ?? 0; const location = response.headers.location; if (statusCode >= 300 && statusCode < 400 && location) { fileStream.close(); fs.unlinkSync(savePath); const redirectUrl = location.startsWith('http') ? location : new URL(location, fileUrl).toString(); this.downloadRemoteFile(redirectUrl, savePath, headers, redirectCount + 1) .then(resolve) .catch(reject); return; } if (statusCode !== 200) { fail(new Error(`HTTP ${statusCode}: ${response.statusMessage}`)); return; } response.pipe(fileStream); fileStream.on('finish', () => { if (settled) { return; } settled = true; fileStream.close(); resolve(); }); }); request.on('error', (error) => { fail(error); }); fileStream.on('error', (error) => { request.destroy(); fail(error); }); request.setTimeout(120000, () => { request.destroy(); fail(new Error('Download timed out after 120 seconds')); }); }); } /** * 初始化浏览器(使用管理器或外部上下文) */ async initBrowser(): Promise { // 如果有外部提供的Context,则不需要初始化 if (this.externalContext) { console.log('使用外部提供的浏览器上下文'); return; } // 否则使用内部管理器 await this.browserManager.initBrowser(); } /** * 从视频URL提取aweme_id(支持短链接重定向) */ async extractAwemeIdAsync(url: string): Promise { // 标准化URL格式 let normalizedUrl = url.trim(); if (!normalizedUrl.startsWith('http')) { normalizedUrl = 'https://' + normalizedUrl; } console.log('标准化URL:', normalizedUrl); // 支持多种抖音URL格式 const patterns = [ /\/video\/(\d+)/, /modal_id=(\d+)/, /share\/video\/(\d+)/, /item_id=(\d+)/, /aweme_id=(\d+)/, ]; for (const pattern of patterns) { const match = normalizedUrl.match(pattern); if (match) { console.log('提取到aweme_id:', match[1]); return match[1]; } } // 如果是短链接(v.douyin.com),需要请求获取重定向后的真实URL if (normalizedUrl.includes('v.douyin.com') || normalizedUrl.includes('iesdouyin.com')) { console.log('检测到短链接,尝试获取重定向后的真实URL...'); try { const realUrl = await this.resolveShortUrl(normalizedUrl); if (realUrl && realUrl !== normalizedUrl) { console.log('重定向后的真实URL:', realUrl); // 再次尝试从真实URL提取ID for (const pattern of patterns) { const match = realUrl.match(pattern); if (match) { console.log('从重定向URL提取到aweme_id:', match[1]); return match[1]; } } } } catch (error) { console.error('解析短链接失败:', error); } } console.log('未能提取aweme_id,URL格式可能不支持'); return null; } /** * 解析短链接获取重定向后的真实URL */ private resolveShortUrl(shortUrl: string): Promise { return new Promise((resolve, reject) => { const url = new URL(shortUrl); const options = { hostname: url.hostname, path: url.pathname + url.search, method: 'HEAD', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, timeout: 30000 // 增加短链接解析超时到30秒 }; const req = https.request(options, (res) => { // 如果有重定向,返回重定向的URL if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { resolve(res.headers.location); } else { // 没有重定向,返回原URL resolve(shortUrl); } }); req.on('error', (error) => { console.error('请求短链接失败:', error); reject(error); }); req.on('timeout', () => { req.destroy(); reject(new Error('短链接解析超时')); }); req.end(); }); } /** * 同步版本的ID提取(兼容旧代码) */ extractAwemeId(url: string): string | null { // 标准化URL格式 let normalizedUrl = url.trim(); if (!normalizedUrl.startsWith('http')) { normalizedUrl = 'https://' + normalizedUrl; } console.log('标准化URL:', normalizedUrl); // 支持多种抖音URL格式 const patterns = [ /\/video\/(\d+)/, /modal_id=(\d+)/, /share\/video\/(\d+)/, /item_id=(\d+)/, /aweme_id=(\d+)/, ]; for (const pattern of patterns) { const match = normalizedUrl.match(pattern); if (match) { console.log('提取到aweme_id:', match[1]); return match[1]; } } // 对于短链接,返回null让调用者使用异步版本 if (normalizedUrl.includes('v.douyin.com') || normalizedUrl.includes('iesdouyin.com')) { console.log('检测到短链接,需要使用异步方法解析'); return null; } console.log('未能提取aweme_id,URL格式可能不支持'); return null; } /** * 拦截API响应获取视频信息 */ async interceptVideoInfo(page: Page, awemeId: string): Promise { return new Promise((resolve) => { let found = false; // 监听API响应 page.on('response', async (response) => { if (found) return; const url = response.url(); // 拦截视频详情API if (url.includes('aweme/v1/web/aweme/detail') && url.includes(awemeId)) { try { const text = await response.text(); console.log('API响应:', text.substring(0, 200)); // 处理可能的响应格式 let data: any; try { data = JSON.parse(text); } catch { // 尝试处理带前缀的响应 const jsonStr = text.replace(/^[^{]*/, ''); data = JSON.parse(jsonStr); } if (data.aweme_detail) { found = true; resolve(data.aweme_detail as VideoInfo); } } catch (error) { console.error('解析API响应失败:', error); } } }); // 设置超时(增加到30秒,给API足够的时间响应) setTimeout(() => { if (!found) { console.log('API拦截超时,将尝试其他方法获取视频信息'); resolve(null); } }, 30000); }); } /** * 下载视频文件 */ async downloadVideo(videoUrl: string, savePath: string): Promise { const headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Referer': 'https://www.douyin.com/', 'Accept': '*/*', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate, br' }; return this.downloadRemoteFile(videoUrl, savePath, headers); } async downloadDirectVideo(videoUrl: string): Promise { try { if (!this.isDirectVideoUrl(videoUrl)) { return { success: false, error: 'Unsupported direct video url' }; } const downloadDir = this.getDownloadDir(); const extension = this.inferVideoExtension(videoUrl); const videoPath = path.join(downloadDir, `direct_${Date.now()}${extension}`); const headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': '*/*', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' }; await this.downloadRemoteFile(videoUrl, videoPath, headers); return { success: true, videoPath, audioPath: videoPath.replace(path.extname(videoPath), '_audio.wav') }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } } /** * 下载抖音视频 */ async downloadDouyinVideo(videoUrl: string): Promise { let page: Page | null = null; let browserInitialized = false; try { console.log('开始下载抖音视频:', videoUrl); // 提取视频ID(使用异步版本支持短链接重定向) const awemeId = await this.extractAwemeIdAsync(videoUrl); if (!awemeId) { return { success: false, error: '无法从URL中提取视频ID,请检查链接格式' }; } console.log('提取到视频ID:', awemeId); // 创建下载目录 const downloadDir = path.join(app.getPath('userData'), 'downloads', 'videos'); if (!fs.existsSync(downloadDir)) { fs.mkdirSync(downloadDir, { recursive: true }); } // 设置下载路径 const videoFileName = `douyin_${awemeId}_${Date.now()}.mp4`; const videoPath = path.join(downloadDir, videoFileName); console.log('视频保存路径:', videoPath); try { // 初始化浏览器(使用管理器或外部上下文) console.log('初始化浏览器...'); await this.initBrowser(); // 使用外部Context或内部管理器创建页面 if (this.externalContext) { page = await this.externalContext.newPage(); console.log('使用外部浏览器上下文创建页面成功'); } else { page = await this.browserManager.newPage(); console.log('使用内部浏览器管理器创建页面成功'); } // 设置页面超时和错误处理 page.setDefaultTimeout(60000); // 增加超时时间到60秒 // 监听页面关闭事件 page.on('close', () => { console.log('页面已关闭'); page = null; }); // 不再强制前置登录检查 - 公开视频无需登录即可访问 // 只在真正跳转到登录页时才引导用户登录 console.log('准备拦截视频信息...'); // 拦截视频信息(先注册监听,再导航) const videoInfoPromise = this.interceptVideoInfo(page, awemeId); // 直接构造标准视频URL(避免短链接重定向到 jingxuan+modal_id 不触发API) // 由于 awemeId 已经提取,直接访问 /video/{awemeId} 标准路径 const directVideoUrl = `https://www.douyin.com/video/${awemeId}`; console.log('访问视频页面(标准路径):', directVideoUrl); try { await page.goto(directVideoUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); console.log('页面DOM加载完成'); const currentUrl = page.url(); console.log('当前页面URL:', currentUrl); // 只有跳到真正的 /login 页面才引导登录 const isLoginPage = currentUrl.includes('/login') || currentUrl.includes('login?'); if (isLoginPage) { console.log('⚠️ 页面跳转到了登录页,开始引导登录...'); await this.ensureLoggedIn(page); console.log('登录后重新访问视频页面...'); await page.goto(directVideoUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); const retryUrl = page.url(); console.log('重试后的页面URL:', retryUrl); if (retryUrl.includes('/login')) { return { success: false, error: '无法访问视频页面,请先扫码登录抖音后重试' }; } } else if (currentUrl.includes('/video/')) { console.log('✅ 视频页面加载成功,等待API响应...'); } else { console.log('⚠️ 页面重定向到:', currentUrl, ',继续等待API响应...'); } } catch (gotoError) { console.log('页面goto超时(已忽略),继续等待API响应:', gotoError instanceof Error ? gotoError.message : gotoError); } console.log('等待API响应...'); // 获取视频信息 const videoInfo = await videoInfoPromise; if (!videoInfo) { console.log('未获取到视频信息,尝试其他方法...'); // 备用方法:尝试直接从页面获取信息 try { const pageContent = await page.content(); console.log('页面内容长度:', pageContent.length); // 如果页面内容包含视频信息,尝试解析 if (pageContent.includes('aweme_detail')) { // 简单的备用处理 return { success: true, videoPath, videoInfo: { aweme_id: awemeId, desc: `抖音视频 ${awemeId}`, author: { uid: '', nickname: '未知作者' }, video: { play_addr: { url_list: [] }, duration: 0 } }, audioPath: videoPath.replace('.mp4', '_audio.wav') }; } } catch (pageError) { console.error('页面解析失败:', pageError); } return { success: false, error: '无法获取视频信息,可能视频不存在或访问受限' }; } console.log('获取到视频信息:', videoInfo.desc); // 获取视频下载链接 const videoUrls = videoInfo.video?.play_addr?.url_list; if (!videoUrls || videoUrls.length === 0) { return { success: false, error: '无法获取视频下载链接,可能视频版权限制' }; } const downloadUrl = videoUrls[0]; console.log('找到下载链接:', downloadUrl.substring(0, 100) + '...'); // 下载视频文件 console.log('开始下载视频文件...'); await this.downloadVideo(downloadUrl, videoPath); console.log('视频下载完成:', videoPath); console.log('文件大小:', fs.statSync(videoPath).size, 'bytes'); return { success: true, videoPath, videoInfo, audioPath: videoPath.replace('.mp4', '_audio.wav') }; } finally { // 页面处理完成,关闭页面 if (page) { try { console.log('关闭页面...'); await page.close(); console.log('页面已关闭'); } catch (closeError) { console.log('页面关闭时出错:', closeError instanceof Error ? closeError.message : closeError); } } } } catch (error) { console.error('下载视频失败:', error); let errorMessage = '未知错误'; if (error instanceof Error) { errorMessage = error.message; if (errorMessage.includes('3221225781') || errorMessage.includes('0xC0000135') || errorMessage.includes('0xc0000135')) { errorMessage = '浏览器启动失败(错误码: 0xC0000135)。\n' + '可能原因:\n' + '1. 系统缺少 Visual C++ 运行时库,请安装: https://aka.ms/vs/17/release/vc_redist.x64.exe\n' + '2. 系统版本过低,Chromium 要求 Windows 10 或更高版本\n' + '3. 杀毒软件拦截了浏览器进程\n' + '安装 VC++ 运行库后重启应用即可。'; } else if (errorMessage.includes('playwright') || errorMessage.includes('browser')) { errorMessage = '浏览器启动失败,请检查系统环境和权限设置'; } else if (errorMessage.includes('timeout')) { errorMessage = '网络超时,请检查网络连接或重试'; } else if (errorMessage.includes('404') || errorMessage.includes('403')) { errorMessage = '视频不存在或访问受限'; } } return { success: false, error: errorMessage }; } } /** * 引导用户登录(只在真正需要时调用) * 基于Cookie检测是否已登录,未登录则引导扫码 */ async ensureLoggedIn(page: Page): Promise { try { // 如果是外部浏览器上下文(来自已登录的UI),直接信任其登录状态 if (this.externalContext) { console.log('✅ 使用外部已登录的浏览器上下文,跳过登录检查'); return; } // 先通过Cookie快速判断是否已登录(避免重新访问首页) try { const cookies = await page.context().cookies(['https://www.douyin.com']); const loginCookies = cookies.filter(c => ['sessionid', 'uid_tt', 'LOGIN_STATUS', 'sid_guard', 'sid_tt'].includes(c.name) && c.value ); if (loginCookies.length > 0) { console.log('✅ 检测到已登录Cookie,无需重新登录'); return; } } catch (e) { // Cookie检测失败,继续走DOM检测 } // Cookie不存在,访问首页检测登录状态 console.log('检查登录状态,开始访问抖音首页...'); await page.goto('https://www.douyin.com/', { waitUntil: 'domcontentloaded', timeout: 30000 }); console.log('抖音首页加载完成,等待页面元素...'); try { await page.waitForSelector('body', { timeout: 10000 }); } catch (e) { console.log('页面加载超时,继续执行'); } await page.waitForTimeout(2000); // 通过DOM元素检测登录状态 const isLoggedIn = await page.evaluate(() => { // 检测登录按钮(未登录时出现) const loginBtnSelectors = [ '[data-e2e="login-button"]', '.login-btn', '.e2e-login-btn', 'button[class*="login"]' ]; // 检测用户信息(已登录时出现) const userInfoSelectors = [ '[data-e2e="user-info"]', '[data-e2e="user-avatar"]', '[class*="userInfo"]', '[class*="UserInfo"]', '[class*="avatar"]', '.user-name', '.header-user', '.user-avatar', '.nickname' ]; const hasLoginButton = loginBtnSelectors.some(s => document.querySelector(s)); const hasUserInfo = userInfoSelectors.some(s => document.querySelector(s)); // 如果有用户信息,或者没有登录按钮,认为已登录 return hasUserInfo || !hasLoginButton; }); if (isLoggedIn) { console.log('✅ 用户已登录'); return; } console.log('❌ 用户未登录,开始引导登录...'); console.log('='.repeat(60)); console.log('📱 请在浏览器中扫码登录抖音'); console.log('='.repeat(60)); console.log('1. ✅ 浏览器窗口已打开'); console.log('2. 📱 请使用抖音APP扫描页面上的二维码'); console.log('3. ✅ 登录成功后会自动继续处理'); console.log('4. ⏰ 登录超时时间:2分钟'); console.log('='.repeat(60)); // 等待登录(最多2分钟) let loginSuccess = false; const loginTimeout = 120000; const checkInterval = 3000; const startTime = Date.now(); while (Date.now() - startTime < loginTimeout) { await page.waitForTimeout(checkInterval); const cookies = await page.context().cookies(['https://www.douyin.com']); const loginCookies = cookies.filter(c => ['sessionid', 'uid_tt', 'LOGIN_STATUS', 'sid_guard'].includes(c.name) && c.value ); if (loginCookies.length > 0) { loginSuccess = true; console.log('✅ 登录成功,继续处理...'); break; } } if (!loginSuccess) { console.log('⚠️ 登录超时,以未登录状态继续尝试...'); } // 保存登录状态 try { const statePath = path.join(this.userDataPath, 'douyin-state.json'); await page.context().storageState({ path: statePath }); } catch (e) { } } catch (error) { console.error('登录引导失败:', error); // 继续执行 } } /** * 保存当前浏览器状态 */ async saveBrowserState(): Promise { try { const statePath = path.join(this.userDataPath, 'douyin-state.json'); const { context } = this.browserManager.getBrowserInfo(); if (context) { await context.storageState({ path: statePath }); console.log('浏览器状态已保存到:', statePath); } } catch (error) { console.error('保存浏览器状态失败:', error); } } /** * 关闭浏览器 */ async close(): Promise { // 如果使用的是外部提供的Context,不要关闭它,由调用者管理 if (this.externalContext) { console.log('使用外部浏览器上下文,不关闭浏览器(由调用者管理)'); return; } try { await this.browserManager.close(); } catch (error) { console.error('关闭浏览器失败:', error); } // 🔧 关键修复:无论如何都要强制杀死 Chromium 进程(确保浏览器真的关闭) // [临时禁用] 暂时注释掉强制杀死浏览器进程的逻辑 /* try { const { execSync } = require('child_process'); execSync('taskkill /F /IM chrome.exe /IM chromium.exe 2>nul', { shell: true }); console.log('✅ 已强制杀死所有 Chromium 进程'); } catch (killError) { console.log('杀死进程失败(忽略):', killError); } */ } } export { DouyinVideoDownloader, VideoInfo, DownloadResult };