22
This commit is contained in:
484
scripts/nodejs/douyin_video_downloader.js
Normal file
484
scripts/nodejs/douyin_video_downloader.js
Normal 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 };
|
||||
Reference in New Issue
Block a user