"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 };