// ==UserScript== // @name 抖音下载 // @namespace https://github.com/zhzLuke96/douyin-dl-user-js // @version 1.2.3 // @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"; /** * 模板字符串函数 * 用于占位标记用来触发编辑器高亮和格式化,没有实际作用 * * @type {function(strings: TemplateStringsArray, ...values: any[]): string}} */ const html = (strings, ...values) => strings.reduce((acc, str, i) => acc + str + (values[i] || ""), ""); 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 { /** * callback会在创建element之后调用 * @param {(root: HTMLElement, overlay: Element) => 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, this.overlay); } } 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 } = 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; } // 假设 current_media 的类型是 DouyinMedia.MediaRoot const current_media = this.current_media; // 点击后打开一个 modal 框,显示媒体详情,并提供下载链接 const modal = new Modal((root, overlay) => { // issues #18 https://github.com/zhzLuke96/douyin-dl-user-js/issues/18 // overlay.style.zIndex = 999999; // 需要放在 slidelist 里,不然全屏之后看不见 const $fullscreenElement = document.fullscreenElement; if ($fullscreenElement) { // FIXME: 这里有个问题,滚轮事件有可能被 parent 捕获了...没想到什么好办法解决... $slidelist.appendChild(overlay); } }); // --- 1. 辅助函数 (Helpers for rendering) --- const render_helpers = { formatTimestamp: (ts) => ts ? new Date(ts * 1000).toLocaleString() : "N/A", formatCount: (num) => { if (num === undefined || num === null) return "N/A"; if (num > 10000) return (num / 10000).toFixed(1) + " 万"; return num.toString(); }, renderKeyValue: (label, value) => html`
${label} ${value || "-"}
`, renderCopyableValue: (label, value) => { if (!value) return render_helpers.renderKeyValue(label, value); return html`
${label} ${value}
`; }, }; // --- 2. 准备每个 Tab 的内容 --- const { video, images, music, authorInfo, stats, desc, createTime, awemeId, shareInfo, awemeControl, } = current_media; const tabs = {}; // Tab 1: 媒体资源 const is_video = video && video.bitRateList.length > 0; const is_images = images && images.length > 0; const cover_details_html = (() => { if (!video || !video.coverUrlList || video.coverUrlList.length === 0) { return ""; } // 优先使用索引为 1 的高清封面,如果不存在则回退到索引 0 const cover_url = video.coverUrlList[1] || video.coverUrlList[0]; const filename_base = this._build_filename(current_media); const cover_filename = `cover_${filename_base}.jpeg`; return html`
视频封面
Video Cover

分辨率: ${video.width} × ${video.height}

新标签打开 下载封面
`; })(); const video_details_html = is_video ? html`
视频 ${video.bitRateList .map( (item) => html` ` ) .join("")}
清晰度 分辨率 格式 FPS Bitrate (kbps) 大小 播放链接
${item.gearName} ${item.width}×${item.height} ${item.format} ${item.fps} ${(item.bitRate / 1000).toFixed(1)} ${item.dataSize ? (item.dataSize / 1024 / 1024).toFixed(2) + " MB" : "-"} ${item.playApi ? `播放` : "-"}
` : ""; const images_details_html = is_images ? html`
图集 ${images .map((img, idx) => { const isVideo = !!img.video; const thumbUrl = isVideo ? img.video.coverUrlList?.[0] || "" : img.urlList?.[0] || ""; const downloadUrl = isVideo ? img.video.playAddr?.[0]?.src || "" : img.downloadUrlList?.[0] || ""; const resolution = isVideo ? `${img.video.width}×${img.video.height}` : `${img.width}×${img.height}`; const sizeMB = isVideo && img.video.dataSize ? (img.video.dataSize / 1024 / 1024).toFixed(2) + " MB" : "-"; return html` `; }) .join("")}
序号 类型 分辨率 大小 预览 下载
${idx + 1} ${isVideo ? "视频" : "图片"} ${resolution} ${sizeMB} ${downloadUrl ? `下载` : "-"}
` : ""; const music_details_html = music ? html`
音乐
cover
标题:${music.title}
作者:${music.author}
时长:${(music.duration / 1000).toFixed(1)} 秒
播放:${music.playUrl?.urlList?.[0] ? `试听` : "-"}
` : ""; tabs.media = { title: "媒体资源", content: `
${cover_details_html}${video_details_html}${images_details_html}${music_details_html}
`, }; // Tab 2: 作者信息 if (authorInfo) { tabs.author = { title: "作者信息", content: html`

${authorInfo.nickname}

访问主页
${render_helpers.renderKeyValue( "认证信息", authorInfo.customVerify || authorInfo.enterpriseVerifyReason )} ${render_helpers.renderCopyableValue("UID", authorInfo.uid)} ${render_helpers.renderCopyableValue("SecUID", authorInfo.secUid)} ${render_helpers.renderKeyValue( "粉丝数", render_helpers.formatCount(authorInfo.followerCount) )} ${render_helpers.renderKeyValue( "获赞数", render_helpers.formatCount(authorInfo.totalFavorited) )}
`, }; } // Tab 3: 作品信息 tabs.post = { title: "作品信息", content: html`
描述

${desc || "无"}

详情 ${render_helpers.renderKeyValue( "发布时间", render_helpers.formatTimestamp(createTime) )} ${render_helpers.renderCopyableValue( "分享链接", shareInfo.shareUrl )} ${render_helpers.renderKeyValue( "点赞数", render_helpers.formatCount(stats.diggCount) )} ${render_helpers.renderKeyValue( "评论数", render_helpers.formatCount(stats.commentCount) )} ${render_helpers.renderKeyValue( "收藏数", render_helpers.formatCount(stats.collectCount) )} ${render_helpers.renderKeyValue( "分享数", render_helpers.formatCount(stats.shareCount) )}
`, }; // Tab 4: 高级信息 tabs.advanced = { title: "高级信息", content: html`
ID ${render_helpers.renderCopyableValue("Aweme ID", awemeId)} ${render_helpers.renderCopyableValue( "Group ID", current_media.groupId )}
权限控制 ${render_helpers.renderKeyValue( "允许评论", awemeControl?.canComment ? "是" : "否" )} ${render_helpers.renderKeyValue( "允许分享", awemeControl?.canShare ? "是" : "否" )} ${render_helpers.renderKeyValue( "允许下载", current_media.download?.allowDownload ? "是" : "否" )} ${render_helpers.renderKeyValue( "是否私密", current_media.isPrivate ? "是" : "否" )}
`, }; // Tab 5: 完整 JSON tabs.json = { title: "完整 JSON", content: html`
JSON
${JSON.stringify(current_media, null, 2)}
`, }; // --- 3. 构建最终的 UI --- const tabKeys = Object.keys(tabs); const details = DOMPatcher.render_html(html`
${tabKeys .map( (key) => `
${tabs[key].content}
` ) .join("")}
`); // --- 4. 添加交互逻辑 --- const navButtons = details.querySelectorAll(".dy-dl-nav-button"); const tabPanels = details.querySelectorAll(".dy-dl-tab-panel"); function switchTab(tabId) { navButtons.forEach((btn) => btn.classList.toggle("active", btn.dataset.tabId === tabId) ); tabPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.tabId === tabId) ); } navButtons.forEach((button) => { button.addEventListener("click", () => switchTab(button.dataset.tabId)); }); if (tabKeys.length > 0) { switchTab(tabKeys[0]); } const $json_select = details.querySelector("#json_select"); if ($json_select) { $json_select.addEventListener("click", () => { const $code = details.querySelector("code"); if (!$code) return; window.getSelection().selectAllChildren($code); }); } const $json_console = details.querySelector("#json_console"); if ($json_console) { $json_console.addEventListener("click", () => { console.log(JSON.parse(JSON.stringify(this.current_media))); }); } details.querySelectorAll(".dy-dl-copy-btn").forEach((button) => { button.addEventListener("click", (e) => { const textToCopy = e.target.dataset.copyText; navigator.clipboard .writeText(textToCopy) .then(() => { e.target.textContent = "已复制!"; setTimeout(() => { e.target.textContent = "复制"; }, 2000); }) .catch((err) => { console.error("复制失败: ", err); alert("复制失败!"); }); }); }); modal.root.appendChild(details); } init() { this._start_detect_player_change(); } } class TooltipsButton { /** * 带有 hover 的按钮 * * NOTE: dy-dl-video-btn 是用于标记是否注入用的 * * @param {TooltipsButton} that * @returns {string} */ static _html_base = (that) => html`
${that.label}
`; /** * * @param {string} label * @param {{label?: string, callback?: Function, html?: string, render?: Function}[]} items * @param {Function} onclick */ constructor(label, items, onclick) { this.label = label; this.items = items; this.onclick = onclick; } render() { const root = DOMPatcher.render_html(TooltipsButton._html_base(this)); /** * @type {HTMLElement} */ const $gear = root.querySelector(".gear"); const $items_list = root.querySelector(".virtual"); const $btn = root.querySelector(".btn"); // 绑定 hover $gear.addEventListener("mouseenter", () => $gear.classList.add("hover")); $gear.addEventListener("mouseleave", () => $gear.classList.remove("hover") ); // 渲染 items for (const item of this.items) { if (item.html) { $items_list.appendChild(DOMPatcher.render_html(item.html)); continue; } if (item.render) { $items_list.appendChild(item.render()); continue; } const $item = DOMPatcher.render_html( `
${item.label}
` ); $item.addEventListener("click", item.callback); $items_list.appendChild($item); } $btn.addEventListener("click", this.onclick); return root; } } class DOMPatcher { /** @type {Downloader} */ downloader; /** @type {MediaHandler} */ handler; /** @type {MutationObserver} */ observer; /** * @param {Downloader} downloader * @param {MediaHandler} handler */ constructor(downloader, handler) { this.downloader = downloader; this.handler = handler; this.observer = new MutationObserver(this._handleMutations.bind(this)); } /** * * @param node {HTMLElement} * @returns {HTMLImageElement | null} */ static findImage(node) { let img; let current = node; while (current) { img = current.querySelector("img"); if (img) return img; current = current.parentNode instanceof HTMLElement ? current.parentNode : null; } return null; } /** * * @param html {string} * @returns {HTMLElement} */ static render_html(html) { const div = document.createElement("div"); div.innerHTML = html.trim(); return /** @type {HTMLElement} */ (div.children[0]); } /** * @param {MutationRecord[]} mutations */ _handleMutations(mutations) { mutations.forEach((mutation) => { mutation.addedNodes.forEach((/** @type {Node} */ node) => { if (node.nodeType !== Node.ELEMENT_NODE) { return; } const elementNode = /** @type {HTMLElement} */ (node); // Tooltip for emoticons if (elementNode.classList.contains("semi-portal")) { const tooltipNode = elementNode.querySelector( ".semi-tooltip-wrapper" ); if (tooltipNode) { setTimeout(() => { // Delay to ensure content is populated this._handleTooltip(/** @type {HTMLElement} */ (tooltipNode)); }); return; } } // Fullscreen image modal // Heuristic: direct body child, no classes, contains an img if ( elementNode.parentElement === document.body && elementNode.classList.length === 0 ) { setTimeout(() => { // Delay for modal rendering this._handleModal(elementNode); }); return; } // Video player controls if ( elementNode.localName === "xg-controls" || elementNode.querySelector("xg-controls") ) { // FIXME: 这里有个问题,feed里面还有直播流,直播画面不应该有下载按钮,因为没用(不过有也没什么,不点就行了...) this._handleXgControl(/** @type {HTMLElement} */ (elementNode)); return; } }); }); } /** * @param {HTMLElement} modalNode */ _handleModal(modalNode) { const close_icon = modalNode.querySelector("#svg_icon_ic_close"); const img = modalNode.querySelector("img"); // Modals often have a specific container for the image, let's try to find it. // This might be fragile. The original used img.parentElement. const container = img?.closest('div[style*="transform: scale(1)"] > div') || img?.parentElement; if (!close_icon || !img || !container) return; if (container.querySelector(".dy-dl-modal-btn")) return; // Button already exists const downloadButton = document.createElement("div"); downloadButton.textContent = "下载图片"; downloadButton.className = "LV01TNDE dy-dl-modal-btn"; // Added a specific class for checking downloadButton.addEventListener("click", (e) => { e.stopPropagation(); // Prevent modal from closing const imgSrc = img.src; this.downloader.download_file(imgSrc, "douyin_image"); }); // Styling from original script downloadButton.style.position = "absolute"; downloadButton.style.bottom = "35px"; downloadButton.style.right = "35px"; downloadButton.style.color = "#fff"; downloadButton.style.backgroundColor = "rgba(0,0,0,0.5)"; downloadButton.style.padding = "5px 10px"; downloadButton.style.borderRadius = "4px"; downloadButton.style.fontSize = "16px"; downloadButton.style.zIndex = "999999"; // Ensure it's on top of other modal elements downloadButton.style.cursor = "pointer"; container.appendChild(downloadButton); } /** * @param {HTMLElement} tooltipNode */ _handleTooltip(tooltipNode) { const tooltipContent = tooltipNode.querySelector(".semi-tooltip-content"); if (!tooltipContent) return; if (!tooltipContent.textContent?.includes("添加到表情")) return; const imgNode = DOMPatcher.findImage(tooltipNode); // Search upwards from tooltip wrapper if (!imgNode?.src) return; if (tooltipContent.querySelector(".download-button")) return; // Button already exists const downloadButton = document.createElement("div"); downloadButton.textContent = "下载表情包"; downloadButton.className = "LV01TNDE download-button"; // Class from original downloadButton.style.cursor = "pointer"; // Make it look clickable downloadButton.style.paddingTop = "4px"; // Add some spacing downloadButton.addEventListener("click", (e) => { e.stopPropagation(); // Prevent other tooltip actions const imgSrc = imgNode.src; this.downloader.download_file(imgSrc, "douyin_emoticon"); }); tooltipContent.appendChild(downloadButton); } /** * @param {HTMLElement} xg_control_node */ _handleXgControl(xg_control_node) { const right_grid = xg_control_node.querySelector(".xg-right-grid"); if (!right_grid) return; if (right_grid.querySelector(".dy-dl-video-btn")) return; // Button already exists const btn = new TooltipsButton( "插件", [ { html: `
快捷键:M`, }, { label: "需求/反馈", callback: () => { window.open( "https://github.com/zhzLuke96/douyin-dl-user-js/issues", "_blank", "noopener,noreferrer" ); }, }, { render: () => { const encode_to_png_switch = DOMPatcher.render_html( `
` ); const $input = encode_to_png_switch.querySelector("input"); $input.checked = Config.global.features.convert_webp_to_png; $input.addEventListener("click", () => { Config.global.features.convert_webp_to_png = $input.checked; Config.global.save(); }); return encode_to_png_switch; }, }, { label: "媒体详情", callback: () => { this.handler.show_media_details(); }, }, { label: "下载封面", callback: () => { this.handler.download_thumb(); }, }, { label: "下载", callback: () => { this.handler.download_current_media(); }, }, ], (e) => { // TODO: 没用... 会被劫持,所以移动到上面的 item 中去了 // e.stopPropagation(); // this.downloadCurrentMediaFn(); } ); const downloadButton = btn.render(); // Try to insert before volume or settings for better placement const qualitySwitch = right_grid.querySelector( ".xgplayer-quality-setting" ); const volumeControl = right_grid.querySelector(".xgplayer-volume"); if (qualitySwitch) { right_grid.insertBefore(downloadButton, qualitySwitch); } else if (volumeControl) { right_grid.insertBefore(downloadButton, volumeControl); } else { right_grid.appendChild(downloadButton); // Fallback } } startObserving() { this.observer.observe(document.body, { childList: true, subtree: true, }); // Initial scan for already existing elements document .querySelectorAll("xg-controls") .forEach((controls) => this._handleXgControl(/** @type {HTMLElement} */ (controls)) ); } } class HotkeyManager { constructor() {} /** * @param {string} key * @param {Function} fn */ addHotkey(key, fn) { document.addEventListener("keydown", (ev) => { if (ev.key.toLowerCase() !== key.toLowerCase()) return; const activeElement = /** @type {HTMLElement} */ ( document.activeElement ); if (activeElement) { // Check if activeElement is not null const tagName = activeElement.tagName; const isInputElement = tagName === "INPUT" || tagName === "TEXTAREA" || activeElement.isContentEditable; if (isInputElement) return; } ev.preventDefault(); fn(); }); } } // ========== Main Script Logic ============= const downloader = new Downloader(); const mediaHandler = new MediaHandler(downloader); // Pass the already bound method from mediaHandler instance const domPatcher = new DOMPatcher(downloader, mediaHandler); const hotkeyManager = new HotkeyManager(); mediaHandler.init(); // Starts player detection domPatcher.startObserving(); // Starts DOM observation and initial scan // Pass the already bound method from mediaHandler instance for the hotkey hotkeyManager.addHotkey("m", mediaHandler.download_current_media); console.log("[dy-dl]已启动"); })();