This commit is contained in:
fengchuanhn@gmail.com
2026-05-17 12:54:13 +08:00
parent 1b8ca10ec9
commit 3db327d93b
38 changed files with 2820 additions and 14 deletions

View File

@@ -0,0 +1,484 @@
"use strict";
/**
* 抖音视频下载 — Puppeteer 版(参考 zhenqianba Electron DouyinVideoDownloader
* 直链走 HTTP分享链接用浏览器拦截 aweme detail API 后下载。
*/
const fs = require("fs");
const path = require("path");
const https = require("https");
const http = require("http");
const { DouyinBrowserManager, getRuntimeDataPath } = require("./douyin_browser_manager.js");
const DIRECT_VIDEO_PATTERN =
/https?:\/\/[^\s"'<>]+?\.(mp4|mov|m4v|webm|avi|mkv)(\?[^\s"'<>]*)?/i;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class DouyinVideoDownloader {
constructor(externalBrowser) {
this.externalBrowser = externalBrowser || null;
this.browserManager = DouyinBrowserManager.getInstance();
this.userDataPath = getRuntimeDataPath("browser-data", "douyin");
}
getDownloadDir() {
const downloadDir = getRuntimeDataPath("downloads", "videos");
if (!fs.existsSync(downloadDir)) {
fs.mkdirSync(downloadDir, { recursive: true });
}
return downloadDir;
}
inferVideoExtension(videoUrl) {
try {
const pathname = new URL(videoUrl).pathname.toLowerCase();
const matchedExt = pathname.match(/\.(mp4|mov|m4v|webm|avi|mkv)$/i);
if (matchedExt) return `.${matchedExt[1].toLowerCase()}`;
} catch (e) {
console.warn("inferVideoExtension failed:", e.message);
}
return ".mp4";
}
isDirectVideoUrl(videoUrl) {
return DIRECT_VIDEO_PATTERN.test(String(videoUrl || "").trim());
}
downloadRemoteFile(fileUrl, savePath, headers, redirectCount = 0) {
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) => {
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();
if (fs.existsSync(savePath)) 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", fail);
fileStream.on("error", (err) => {
request.destroy();
fail(err);
});
request.setTimeout(120000, () => {
request.destroy();
fail(new Error("Download timed out after 120 seconds"));
});
});
}
async initBrowser() {
if (this.externalBrowser) {
console.log("使用外部提供的浏览器实例");
return;
}
await this.browserManager.initBrowser();
}
async extractAwemeIdAsync(url) {
let normalizedUrl = String(url || "").trim();
if (!normalizedUrl.startsWith("http")) {
normalizedUrl = "https://" + normalizedUrl;
}
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) return match[1];
}
if (
normalizedUrl.includes("v.douyin.com") ||
normalizedUrl.includes("iesdouyin.com")
) {
try {
const realUrl = await this.resolveShortUrl(normalizedUrl);
if (realUrl && realUrl !== normalizedUrl) {
for (const pattern of patterns) {
const match = realUrl.match(pattern);
if (match) return match[1];
}
}
} catch (e) {
console.error("解析短链接失败:", e.message);
}
}
return null;
}
resolveShortUrl(shortUrl) {
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,
};
const req = https.request(options, (res) => {
if (
res.statusCode &&
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location
) {
resolve(res.headers.location);
} else {
resolve(shortUrl);
}
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("短链接解析超时"));
});
req.end();
});
}
interceptVideoInfo(page, awemeId) {
return new Promise((resolve) => {
let found = false;
const handler = async (response) => {
if (found) return;
const url = response.url();
if (
url.includes("aweme/v1/web/aweme/detail") &&
url.includes(awemeId)
) {
try {
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch {
const jsonStr = text.replace(/^[^{]*/, "");
data = JSON.parse(jsonStr);
}
if (data.aweme_detail) {
found = true;
page.off("response", handler);
resolve(data.aweme_detail);
}
} catch (error) {
console.error("解析 API 响应失败:", error.message);
}
}
};
page.on("response", handler);
setTimeout(() => {
if (!found) {
page.off("response", handler);
console.log("API 拦截超时,将尝试其他方法");
resolve(null);
}
}, 30000);
});
}
async downloadVideo(videoUrl, savePath) {
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",
};
return this.downloadRemoteFile(videoUrl, savePath, headers);
}
async downloadDirectVideo(videoUrl) {
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) {
let page = null;
try {
console.log("开始下载抖音视频:", videoUrl);
const awemeId = await this.extractAwemeIdAsync(videoUrl);
if (!awemeId) {
return { success: false, error: "无法从URL中提取视频ID请检查链接格式" };
}
console.log("提取到视频ID:", awemeId);
const downloadDir = this.getDownloadDir();
const videoPath = path.join(
downloadDir,
`douyin_${awemeId}_${Date.now()}.mp4`
);
await this.initBrowser();
if (this.externalBrowser) {
page = await this.externalBrowser.newPage();
} else {
page = await this.browserManager.newPage();
}
const videoInfoPromise = this.interceptVideoInfo(page, awemeId);
const directVideoUrl = `https://www.douyin.com/video/${awemeId}`;
console.log("访问视频页面:", directVideoUrl);
try {
await page.goto(directVideoUrl, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
const currentUrl = page.url();
const isLoginPage =
currentUrl.includes("/login") || currentUrl.includes("login?");
if (isLoginPage) {
console.log("页面跳转到登录页,尝试引导登录...");
await this.ensureLoggedIn(page);
await page.goto(directVideoUrl, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
if (page.url().includes("/login")) {
return {
success: false,
error:
"无法访问视频页面,请先扫码登录抖音(设置 AICLIENT_PUPPETEER_HEADLESS=0 显示浏览器窗口)",
};
}
}
} catch (gotoError) {
console.log(
"页面 goto 超时(已忽略),继续等待 API:",
gotoError.message
);
}
const videoInfo = await videoInfoPromise;
if (!videoInfo) {
try {
const pageContent = await page.content();
if (pageContent.includes("aweme_detail")) {
return {
success: true,
videoPath,
videoInfo: {
aweme_id: awemeId,
desc: `抖音视频 ${awemeId}`,
},
audioPath: videoPath.replace(".mp4", "_audio.wav"),
};
}
} catch (pageError) {
console.error("页面解析失败:", pageError.message);
}
return {
success: false,
error: "无法获取视频信息,可能视频不存在或访问受限",
};
}
const videoUrls = videoInfo.video?.play_addr?.url_list;
if (!videoUrls || !videoUrls.length) {
return { success: false, error: "无法获取视频下载链接,可能视频版权限制" };
}
const downloadUrl = videoUrls[0];
console.log("开始下载视频文件...");
await this.downloadVideo(downloadUrl, videoPath);
console.log("视频下载完成:", videoPath, "size:", fs.statSync(videoPath).size);
return {
success: true,
videoPath,
videoInfo,
audioPath: videoPath.replace(".mp4", "_audio.wav"),
};
} catch (error) {
console.error("下载视频失败:", error);
let errorMessage = error instanceof Error ? error.message : String(error);
if (
errorMessage.includes("3221225781") ||
errorMessage.includes("0xC0000135")
) {
errorMessage =
"浏览器启动失败(错误码: 0xC0000135。请安装 VC++ 运行库: https://aka.ms/vs/17/release/vc_redist.x64.exe";
} else if (/puppeteer|browser|Could not find Chrome/i.test(errorMessage)) {
errorMessage =
"浏览器启动失败。请在 src-tauri/resources/nodejs 执行 npm install或设置 AICLIENT_CHROMIUM_PATH";
} else if (/timeout/i.test(errorMessage)) {
errorMessage = "网络超时,请检查网络连接或重试";
}
return { success: false, error: errorMessage };
} finally {
if (page) {
try {
await page.close();
} catch (closeError) {
console.log("关闭页面失败:", closeError.message);
}
}
}
}
async ensureLoggedIn(page) {
try {
if (this.externalBrowser) {
console.log("使用外部浏览器,跳过登录检查");
return;
}
const loginCookieNames = [
"sessionid",
"uid_tt",
"LOGIN_STATUS",
"sid_guard",
"sid_tt",
];
let cookies = await page.cookies("https://www.douyin.com");
let loginCookies = cookies.filter(
(c) => loginCookieNames.includes(c.name) && c.value
);
if (loginCookies.length > 0) {
console.log("检测到已登录 Cookie");
return;
}
await page.goto("https://www.douyin.com/", {
waitUntil: "domcontentloaded",
timeout: 30000,
});
await sleep(2000);
const isLoggedIn = await page.evaluate(() => {
const loginBtnSelectors = [
'[data-e2e="login-button"]',
".login-btn",
'button[class*="login"]',
];
const userInfoSelectors = [
'[data-e2e="user-info"]',
'[data-e2e="user-avatar"]',
'[class*="userInfo"]',
".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;
}
const headless =
process.env.AICLIENT_PUPPETEER_HEADLESS !== "0" &&
process.env.AICLIENT_PUPPETEER_HEADLESS !== "false";
if (headless) {
console.log(
"无头模式下无法扫码登录。请设置环境变量 AICLIENT_PUPPETEER_HEADLESS=0 后重试"
);
return;
}
console.log("=".repeat(60));
console.log("请在浏览器窗口中使用抖音 APP 扫码登录");
console.log("=".repeat(60));
const loginTimeout = 120000;
const checkInterval = 3000;
const startTime = Date.now();
while (Date.now() - startTime < loginTimeout) {
await sleep(checkInterval);
cookies = await page.cookies("https://www.douyin.com");
loginCookies = cookies.filter(
(c) => loginCookieNames.includes(c.name) && c.value
);
if (loginCookies.length > 0) {
console.log("登录成功");
break;
}
}
} catch (error) {
console.error("登录引导失败:", error.message);
}
}
async close() {
if (this.externalBrowser) return;
try {
await this.browserManager.close();
} catch (error) {
console.error("关闭浏览器失败:", error.message);
}
}
}
DouyinVideoDownloader.DIRECT_VIDEO_PATTERN = DIRECT_VIDEO_PATTERN;
module.exports = { DouyinVideoDownloader };