Files
yaoyaoai/scripts/nodejs/douyin_video_downloader.js
949036910@qq.com 22f24a565c 11
2026-05-29 00:42:58 +08:00

497 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
/** 视频页导航超时(毫秒),可通过 AICLIENT_DOUYIN_PAGE_TIMEOUT_MS 覆盖 */
const PAGE_NAV_TIMEOUT_MS =
Number(process.env.AICLIENT_DOUYIN_PAGE_TIMEOUT_MS) || 60_000;
/** 等待 aweme detail API 拦截的超时(毫秒) */
const API_INTERCEPT_TIMEOUT_MS =
Number(process.env.AICLIENT_DOUYIN_API_WAIT_MS) || 120_000;
/** 打开视频页后额外等待,便于接口与播放器初始化 */
const PAGE_SETTLE_MS =
Number(process.env.AICLIENT_DOUYIN_PAGE_SETTLE_MS) || 5_000;
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);
}
}, API_INTERCEPT_TIMEOUT_MS);
});
}
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: PAGE_NAV_TIMEOUT_MS,
});
await sleep(PAGE_SETTLE_MS);
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: PAGE_NAV_TIMEOUT_MS,
});
await sleep(PAGE_SETTLE_MS);
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: PAGE_NAV_TIMEOUT_MS,
});
await sleep(PAGE_SETTLE_MS);
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 };