// ==UserScript== // @name 抖音下载 // @namespace https://github.com/zhzLuke96/douyin-dl-user-js // @version 1.2.1 // @description 为web版抖音增加下载按钮 // @author zhzluke96 // @match https://*.douyin.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=douyin.com // @grant none // @license MIT // @supportURL https://github.com/zhzLuke96/douyin-dl-user-js/issues // @downloadURL none // ==/UserScript== (function () { "use strict"; class Config { static global = new Config(); features = { convert_webp_to_png: true, }; _key = "__douyin-dl-user-js__"; constructor() { try { this.load(); } catch (error) { console.error(error); } } toJSON() { return { features: this.features, }; } load() { if (localStorage.getItem(this._key)) { const data = JSON.parse(localStorage.getItem(this._key)); this.features = { ...this.features, ...data.features, }; } } save() { localStorage.setItem(this._key, JSON.stringify(this.toJSON())); } } class Downloader { constructor() {} /** * @param {Blob} blob */ async convertWebPToPNG(blob) { // 创建一个图像对象来加载WebP const img = new Image(); img.src = URL.createObjectURL(blob); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; }); // 创建canvas来转换图像 const canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; // 将图像绘制到canvas const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0); // 释放原始Blob URL URL.revokeObjectURL(img.src); return new Promise((resolve) => { // 将canvas转换为PNG Blob canvas.toBlob((pngBlob) => { resolve(pngBlob); }, "image/png"); canvas.onerror = (e) => { console.error("WebP转PNG失败,回退到原格式:", e); resolve(blob); // Fallback to original blob }; }); } /** * 预下载文件 * * PS: 这一步其实没有下载,而是通过浏览器的缓存读取了 * PSS: 并且如果浏览器没有缓存,似乎会报错,因为server那边会校验cookie,我们没带上(现在不知道要带上什么...在js里也没法重放请求...) * * @param imgSrc {string} * @param filename_input {string} * @returns {Promise<{ok: boolean, blob?: Blob, filename?: string, isImage?: boolean, isWebP?: boolean, pngBlob?: Blob | null, fileExt?: string, error?: string}>} */ async prepare_download_file(imgSrc, filename_input = "") { if (imgSrc.startsWith("//")) { const protocol = window.location.protocol; imgSrc = `${protocol}${imgSrc}`; } const url = new URL(imgSrc); const response = await fetch(imgSrc); if (!response.ok) { // Original script had: alert("Failed to fetch the file"); // We now return an error status for the caller to decide. return { ok: false, error: `Failed to fetch the file: ${response.status} ${response.statusText}`, }; } const contentType = response.headers.get("content-type"); if (!contentType) { return { ok: false, error: "Content-Type header missing" }; } const isImage = contentType.startsWith("image/"); const isWebP = contentType.includes("webp"); let fileExtGuess = contentType.split("/")[1]?.toLowerCase(); if (!fileExtGuess && isImage) fileExtGuess = "jpg"; // fallback for image/* else if (!fileExtGuess) fileExtGuess = "bin"; // fallback for unknown const determinedFileExt = isImage ? isWebP ? "png" // Target extension for WebP after conversion : fileExtGuess : fileExtGuess; let filename = filename_input || url.pathname.split("/").pop() || "download"; if (filename.endsWith(".image")) { filename = filename.slice(0, -".image".length); } // Ensure filename ends with the determined extension const currentExtPattern = new RegExp(`\\.${determinedFileExt}$`, "i"); if (!currentExtPattern.test(filename)) { // Remove any existing extension before appending the new one filename = filename.replace(/\.[^/.]+$/, ""); filename += `.${determinedFileExt}`; } const blob = await response.blob(); let pngBlob = null; if (isImage && isWebP && Config.global.features.convert_webp_to_png) { try { pngBlob = await this.convertWebPToPNG(blob); } catch (error) { console.error("[dy-dl]WebP转PNG失败", error); // If conversion fails, pngBlob remains null, original blob will be used } } return { blob, filename, isImage, isWebP, pngBlob, fileExt: determinedFileExt, ok: true, }; } /** * @param {Blob} blob * @param {string} filename */ async download_blob(blob, filename) { const link = document.createElement("a"); link.style.display = "none"; link.download = filename; link.href = URL.createObjectURL(blob); document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); } /** * 下载文件流程: * * 1. 预下载为 blob ,读取元信息 * 2. 如果是 webp 图片,尝试转为 png 图片 * 3. 下载 blob * * @param source {string} * @param filename_input {string} * @param fallback_src {string[]} 比如其他分辨率 */ async download_file(source, filename_input = "", fallback_src = []) { let url_sources = [source, ...fallback_src].filter( (x) => typeof x === "string" && x.length > 0 ); url_sources = Array.from(new Set(url_sources)); let firstAttemptFailedMessage = ""; for (const [index, url] of url_sources.entries()) { let blob, pngBlob, filename; try { const result = await this.prepare_download_file(url, filename_input); if (!result.ok) { const errorMessage = `[dy-dl]预下载失败 (${ result.error || "Unknown error" }),将重试其他地址: ${url}`; console.error(errorMessage); if (index === 0) { // Store message from first attempt firstAttemptFailedMessage = result.error?.includes( "Failed to fetch" ) ? "Failed to fetch the file" : ""; } continue; } blob = result.blob; pngBlob = result.pngBlob; // This will be the converted PNG if successful, or null filename = result.filename; } catch (error) { console.error(`[dy-dl]预下载异常,将重试其他地址: ${url}`, error); if (index === 0) { // Store message from first attempt firstAttemptFailedMessage = "Failed to fetch the file due to an exception"; } continue; } // Prefer PNG blob if available (i.e., WebP was converted) if (pngBlob) { try { await this.download_blob(pngBlob, filename); return; } catch (error) { console.error( `[dy-dl]下载转换后的PNG失败,回退原始版本: ${filename}`, error ); // Fall through to try downloading the original blob } } // Download original blob (or if PNG download failed) if (blob) { try { await this.download_blob(blob, filename); return; } catch (error) { console.error( `[dy-dl]下载blob失败,尝试其他版本: ${filename}`, error ); continue; } } } // If all downloads failed, show an alert. // If the first attempt failed with a "Failed to fetch" style error, replicate original alert. if (firstAttemptFailedMessage && url_sources.length === 1) { alert(firstAttemptFailedMessage); } else { alert(`[dy-dl]所有尝试下载都失败,请刷新重试`); } } } class Modal { /** * * @param {(root: HTMLElement) => any} callback */ constructor(callback) { this.overlay = document.createElement("div"); Object.assign(this.overlay.style, { position: "fixed", top: 0, left: 0, width: "100vw", height: "100vh", backgroundColor: "rgba(0, 0, 0, 0.5)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000, }); this.root = document.createElement("div"); Object.assign(this.root.style, { backgroundColor: "#fff", padding: "20px", borderRadius: "8px", minWidth: "300px", minHeight: "150px", boxShadow: "0 4px 20px rgba(0,0,0,0.3)", }); // 阻止事件冒泡,防止点击 root 也触发关闭 this.root.addEventListener("click", (e) => e.stopPropagation()); this.overlay.addEventListener("click", () => this.close()); this.overlay.appendChild(this.root); document.body.appendChild(this.overlay); if (typeof callback === "function") { callback(this.root); } } close() { this.overlay.remove(); } } class MediaHandler { /** @type {import("./types").DouyinPlayer.PlayerInstance | null} */ player = null; /** @type {import("./types").DouyinMedia.MediaRoot | null} */ current_media = null; downloading = false; /** @type {Downloader} */ downloader; /** @type {HTMLElement | null} */ $btn = null; // Corresponds to downloader_status.$btn from original, not actively used for UI updates by original logic /** * @param {Downloader} downloader */ constructor(downloader) { this.downloader = downloader; this.download_current_media = this._lock_download( this._download_current_media_logic.bind(this) ); } /** * @param {string} bigintStr */ static toShortId(bigintStr) { try { return BigInt(bigintStr).toString(36); } catch (error) { return bigintStr; } } /** * 文件名 * * [nickname] + [short_id] + [tags] + [desc] * max length: 64 * * @param {import("./types").DouyinMedia.MediaRoot} media */ _build_filename(media) { const { authorInfo: { nickname }, awemeId, desc, textExtra, } = media; const short_id = MediaHandler.toShortId(awemeId); const tag_list = textExtra?.map((x) => x.hashtagName).filter(Boolean) || []; const tags = tag_list.map((x) => "#" + x).join("_"); let rawDesc = desc || ""; tag_list.forEach((t) => { rawDesc = rawDesc.replace(new RegExp(`#${t}\\s*`, "g"), ""); }); rawDesc = rawDesc.trim().replace(/[#/\?<>\\:\*\|":]/g, ""); // Sanitize illegal characters const baseName = `${nickname}_${short_id}_${tags}_${rawDesc}`; return baseName.length > 64 ? baseName.slice(0, 64) : baseName; } _bind_player_events() { if (!this.player) return; const update = () => { if (this.player?.config?.awemeInfo) { this.current_media = this.player.config.awemeInfo; } }; update(); // Initial update this.player.on("play", update); this.player.on("seeked", update); // Potentially listen to other events like 'pause' or 'videochange' if available and needed } async _start_detect_player_change() { while (1) { // @ts-ignore // window.player is not typed here const currentPlayer = window.player; if (this.player !== currentPlayer) { this.player = currentPlayer; if (this.player) { this._bind_player_events(); } // console.log(`[dy-dl] player changed: ${this.player}`); } await new Promise((r) => setTimeout(r, 1000)); } } _flag_start_download() { this.downloading = true; // const { $btn } = this; // Original script had $btn in status but didn't use it for UI updates. // if ($btn) { // // TODO: progress // } return () => { this.downloading = false; // if ($btn) { // // TODO: progress // } }; } _lock_download(download_fn) { return async (...args) => { if (this.downloading) { alert("[dy-dl]正在下载中...请稍等或刷新页面"); return; } const releaseLock = this._flag_start_download(); try { await download_fn(...args); } finally { // Small delay before releasing lock, as in original script await new Promise((r) => setTimeout(r, 300)); releaseLock(); } }; } /** * 从 video 对象上取得所有 url * * TODO: 这里其实还有编码 256 没有取 * TODO: 不同 url 代表不同分辨率,现在我们也还没区分 * * @param {import("./types").DouyinMedia.DouyinPlayerVideo | null | undefined} video_obj */ _get_video_urls(video_obj) { if (video_obj === null || video_obj === undefined) { return []; } const sources = []; if (video_obj.playApi) { sources.push(video_obj.playApi); } if (Array.isArray(video_obj.playAddr)) { sources.push(...video_obj.playAddr.map((x) => x.src)); } if (video_obj.bitRateList) { video_obj.bitRateList.forEach((x) => { if (x.playApi) sources.push(x.playApi); }); } return Array.from(new Set(sources.filter(Boolean))); } /** * 抖音作品有两种形式: * 1. 单图、单视频 * 2. 图集 * * 如果是图集形式,必须从 images 这个数组里面取字段,其他字段都有可能是 fallback 值 */ async _download_current_media_logic() { if (!this.current_media) { alert("[dy-dl]无当前媒体信息,请尝试播放视频或等待加载。"); return; } const { video, images } = this.current_media; const filename_base = this._build_filename(this.current_media); if (Array.isArray(images) && images.length !== 0) { // 下载图集 // TODO 要是能支持 zip 打包会更好一点 let downloadedCount = 0; for (let idx = 0; idx < images.length; idx++) { const imageItem = images[idx]; const item_filename = `${filename_base}_${idx + 1}`; // 1-based index for files const image_video = imageItem?.video; if (image_video) { // 包含视频的图集项 const video_urls = this._get_video_urls(image_video); if (video_urls.length > 0) { await this.downloader.download_file( video_urls[0], item_filename, video_urls ); downloadedCount++; } else { console.warn("[dy-dl]图集内视频无有效URL,跳过下载", image_video); } continue; } // 单纯的图片图集项 const img_urls = imageItem?.urlList?.filter(Boolean); if (img_urls && img_urls.length > 0) { await this.downloader.download_file( img_urls[0], item_filename, img_urls ); downloadedCount++; } else { console.warn("[dy-dl]图集内图片无有效URL,跳过下载", imageItem); } } if (downloadedCount === 0 && images.length > 0) { alert("[dy-dl]图集下载失败,未找到有效媒体链接。"); } return; } else { // 单视频或单图片(老版本可能直接在video字段放图片信息,但新版通常是images) const video_urls = this._get_video_urls(video); if (video_urls.length !== 0) { await this.downloader.download_file( video_urls[0], filename_base, video_urls ); return; } } alert("[dy-dl]无法下载当前媒体,尝试刷新、暂停、播放等操作后重试。"); } // 下载封面 async download_thumb() { if (!this.current_media) { alert("[dy-dl]无当前媒体信息,请尝试播放视频或等待加载。"); return; } const { video, images = [], music } = this.current_media; // 第一个是压缩的,所以用第二个 const thumb = video.coverUrlList[1]; const filename_base = this._build_filename(this.current_media); this.downloader.download_file(thumb, `thumb_${filename_base}`); } // 显示媒体详情 async show_media_details() { if (!this.current_media) { alert("[dy-dl]无当前媒体信息,请尝试播放视频或等待加载。"); return; } // 点击后打开一个 modal 框,显示媒体详情,并提供下载链接 const modal = new Modal(); const { current_media } = this; const { video, images, music } = current_media; const is_video = video.bitRateList.length > 0; const is_images = images.length > 0; const video_details = `
`; const images_details_html = ` `; const music_details_html = ` `; const details = DOMPatcher.render_html(`