Initial clean project import

This commit is contained in:
cat-shark
2026-06-19 18:41:41 +08:00
commit a13b804c7a
1306 changed files with 220568 additions and 0 deletions
+768
View File
@@ -0,0 +1,768 @@
/**
* 视频下载器 - 基于浏览器自动化+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<string, string>,
redirectCount: number = 0
): Promise<void> {
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<void> {
// 如果有外部提供的Context,则不需要初始化
if (this.externalContext) {
console.log('使用外部提供的浏览器上下文');
return;
}
// 否则使用内部管理器
await this.browserManager.initBrowser();
}
/**
* 从视频URL提取aweme_id(支持短链接重定向)
*/
async extractAwemeIdAsync(url: string): Promise<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];
}
}
// 如果是短链接(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_idURL格式可能不支持');
return null;
}
/**
* 解析短链接获取重定向后的真实URL
*/
private resolveShortUrl(shortUrl: string): Promise<string> {
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_idURL格式可能不支持');
return null;
}
/**
* 拦截API响应获取视频信息
*/
async interceptVideoInfo(page: Page, awemeId: string): Promise<VideoInfo | null> {
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<void> {
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<DownloadResult> {
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<DownloadResult> {
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<void> {
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<void> {
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<void> {
// 如果使用的是外部提供的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 };