☰

A comment on A question about 抖音下载 by (Deleted user 1511385) was reported 03.09.2025 for Spam

(Deleted user 1511385) said:

用AI改了一下可以转为JPG并支持调整质量,转为PNG有点浪费文件大小了。
AI真是方便啊,哈哈。
// ==UserScript==
// @name 抖音下载
// @namespace https://github.com/zhzLuke96/douyin-dl-user-js
// @version 1.3.0
// @description 为web版抖音增加下载按钮, 并支持将图片保存为高质量JPG。
// @author zhzluke96 (Modified by Gemini)
// @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 https://update.greasyfork.org/scripts/522326/%E6%8A%96%E9%9F%B3%E4%B8%8B%E8%BD%BD.user.js
// @updateURL https://update.greasyfork.org/scripts/522326/%E6%8A%96%E9%9F%B3%E4%B8%8B%E8%BD%BD.meta.js
// ==/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_to_jpg: true, // Changed from convert_webp_to_png
jpeg_quality: 0.95, // Added JPEG quality setting
};

_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() {}

/**
* Converts an image Blob to a JPG Blob with adjustable quality.
* @param {Blob} blob The source image blob.
* @param {number} quality The quality for the output JPG (0.0 to 1.0).
* @returns {Promise} A promise that resolves with the new JPG Blob.
*/
async convertImageToJPG(blob, quality) {
const img = new Image();
img.src = URL.createObjectURL(blob);

await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});

const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(img.src);

return new Promise((resolve) => {
canvas.toBlob(
(jpgBlob) => {
resolve(jpgBlob);
},
"image/jpeg",
quality
);
canvas.onerror = (e) => {
console.error("Image to JPG conversion failed, falling back to original:", 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, jpgBlob?: 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) {
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 && Config.global.features.convert_to_jpg
? "jpg" // Target extension after conversion
: 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 jpgBlob = null;
if (isImage && Config.global.features.convert_to_jpg) {
try {
jpgBlob = await this.convertImageToJPG(blob, Config.global.features.jpeg_quality);
} catch (error) {
console.error("[dy-dl] Image to JPG conversion failed", error);
// If conversion fails, jpgBlob remains null, original blob will be used
}
}

return {
blob,
filename,
isImage,
isWebP,
jpgBlob,
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. 如果是图片,尝试转为 jpg 图片
* 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, jpgBlob, 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) {
firstAttemptFailedMessage = result.error?.includes(
"Failed to fetch"
)
? "Failed to fetch the file"
: "";
}
continue;
}
blob = result.blob;
jpgBlob = result.jpgBlob; // This will be the converted JPG if successful
filename = result.filename;
} catch (error) {
console.error(`[dy-dl]预下载异常,将重试其他地址: ${url}`, error);
if (index === 0) {
firstAttemptFailedMessage =
"Failed to fetch the file due to an exception";
}
continue;
}

// Prefer JPG blob if available (i.e., image was converted)
if (jpgBlob) {
try {
await this.download_blob(jpgBlob, filename);
return;
} catch (error) {
console.error(
`[dy-dl]下载转换后的JPG失败,回退原始版本: ${filename}`,
error
);
// Fall through to try downloading the original blob
}
}

// Download original blob (or if JPG 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 (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 },
createTime,
desc,
textExtra,
} = media;
// 格式化 createTime 为 YYYYMMDD
const update = createTime
? new Date(createTime * 1000).toISOString().slice(0, 10).replace(/-/g, "")
: "unknown";
const tag_list =
textExtra?.map((x) => x.hashtagName).filter(Boolean) || [];
let rawDesc = desc || "";
tag_list.forEach((t) => {
rawDesc = rawDesc.replace(new RegExp(`#${t}\\s*`, "g"), "");
});
rawDesc = rawDesc.trim().replace(/[#/\?<>\\:\*\|":]/g, "");

const baseName = `@${nickname}_${update}_${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 捕获了...没想到什么好办法解决...
$fullscreenElement.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.width} × ${video.height}



`;
})();

const video_details_html = is_video
? html`视频

${video.bitRateList
.map(
(item) => html` `
)

.join("")}
清晰度分辨率格式FPSBitrate (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
? `下载`
: "-"}
`
: "";
// NOTE: music.duration 的单位就是秒
const music_details_html = music
?
html`音乐

标题:${music.title}
作者:${music.author}
时长:${music.duration} 秒


播放:${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 选中
console
${JSON.stringify(current_media, null, 2)}
`,
};
// --- 3. 构建最终的 UI ---
const tabKeys = Object.keys(tabs);
const details = DOMPatcher.render_html(html`


${tabKeys
.map(
(key) =>
`${tabs[key].title}`

)
.join("")}

${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;

const btn = new TooltipsButton(
"插件",
[
{
html: `
快捷键:M`,
},
{
label: "需求/反馈",
callback: () => {
window.open(
"https://github.com/zhzLuke96/douyin-dl-user-js/issues",
"_blank",
"noopener,noreferrer"
);
},
},
{
render: () => {
const jpg_settings_html = `



转为JPG



`;
const settings_node = DOMPatcher.render_html(jpg_settings_html);

const $checkbox = settings_node.querySelector('.jpg-enabled-checkbox');
const $sliderContainer = settings_node.querySelector('.jpg-quality-slider-container');
const $slider = settings_node.querySelector('.jpg-quality-slider');
const $label = settings_node.querySelector('.jpg-quality-label');

// Initial state
$checkbox.checked = Config.global.features.convert_to_jpg;
$slider.value = Config.global.features.jpeg_quality * 100;
$label.textContent = `${$slider.value}%`;
$sliderContainer.style.display = $checkbox.checked ? 'flex' : 'none';


// Event Listeners
$checkbox.addEventListener('change', () => {
Config.global.features.convert_to_jpg = $checkbox.checked;
$sliderContainer.style.display = $checkbox.checked ? 'flex' : 'none';
Config.global.save();
});

const updateQuality = () => {
const qualityValue = parseInt($slider.value);
$label.textContent = `${qualityValue}%`;
Config.global.features.jpeg_quality = qualityValue / 100;
Config.global.save();
};

$slider.addEventListener('input', () => {
$label.textContent = `${$slider.value}%`;
});

$slider.addEventListener('change', updateQuality);

return settings_node;
},
},
{
label: "媒体详情",
callback: () => {
this.handler.show_media_details();
},
},
{
label: "下载封面",
callback: () => {
this.handler.download_thumb();
},
},
{
label: "下载",
callback: () => {
this.handler.download_current_media();
},
},
],
(e) => {
// Click on main button does nothing, handled by items
}
);
const downloadButton = btn.render();
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);
}
}


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]已启动");
})();

This report has been upheld by a moderator.

Blatant comment spam