485 lines
15 KiB
JavaScript
485 lines
15 KiB
JavaScript
"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 };
|