🏠 Home 

抖音下载

为web版抖音增加下载按钮


Install this script?
// ==UserScript==
// @name            抖音下载
// @namespace       https://github.com/zhzLuke96/douyin-dl-user-js
// @version         1.0.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
// ==/UserScript==
(function () {
"use strict";
/**
*
* @param node {HTMLElement}
* @returns {HTMLElement}
*/
function findImage(node) {
let img;
while (node) {
img = node.querySelector("img");
if (img) return img;
node = node.parentNode;
}
return img;
}
/**
*
* @param html {string}
* @returns {HTMLElement}
*/
function render_html(html) {
const div = document.createElement("div");
div.innerHTML = html;
return div.children[0];
}
/**
*
* @param imgSrc {string}
* @param filename_input {string}
*/
async function 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) {
alert("Failed to fetch the file");
return;
}
const contentType = response.headers.get("content-type");
const fileExt = contentType.split("/")[1].toLowerCase() ?? ".jpeg";
let filename =
filename_input || url.pathname.split("/").pop() || "download";
if (filename.endsWith(".image")) {
// 去掉 .image 路由参数,一部分图片会走这个路由,去掉,我们使用从resp中拿到的 fileExt
filename = filename.slice(0, -".image".length);
}
if (!filename.toLowerCase().endsWith(fileExt.toLowerCase())) {
filename += `.${fileExt}`;
}
const blob = await response.blob();
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);
}
// 创建一个 MutationObserver 来观察 DOM 变化
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// 遍历新增的节点
mutation.addedNodes.forEach(
(
/**
* @type {HTMLElement}
*/
node
) => {
// 确保新增节点是 tooltip
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (node.classList.contains("semi-portal")) {
const tooltipNode = node.querySelector(".semi-tooltip-wrapper");
if (tooltipNode) {
// 调用处理函数,添加按钮
setTimeout(() => {
handleTooltip(tooltipNode);
});
}
}
if (
node.parentElement === document.body &&
node.classList.length === 0
) {
// 全屏 modal
setTimeout(() => {
handleModal(node);
});
}
if (node.localName === "xg-controls") {
handleXgControl(node);
}
}
);
});
});
function handleModal(modalNode) {
// 全屏 modal
const close_icon = modalNode.querySelector("#svg_icon_ic_close");
const img = modalNode.querySelector("img");
const container = img?.parentElement;
if (!close_icon || !img || !container) return;
// 在 icon 前面增加一个下载按钮
const downloadButton = document.createElement("div");
downloadButton.textContent = "下载图片";
downloadButton.className = "LV01TNDE";
downloadButton.addEventListener("click", () => {
const imgSrc = img.src;
download_file(imgSrc);
});
downloadButton.style.position = "absolute";
downloadButton.style.bottom = "35px";
downloadButton.style.right = "35px";
downloadButton.style.color = "#fff";
downloadButton.style.fontSize = "16px";
container.appendChild(downloadButton);
}
// 处理 tooltip 节点的逻辑
function handleTooltip(tooltipNode) {
const tooltipContent = tooltipNode.querySelector(".semi-tooltip-content");
if (!tooltipContent) return;
// 确认是否包含 "添加到表情"
if (!tooltipContent.textContent.includes("添加到表情")) return;
// 从父节点查找 img 节点
const imgNode = findImage(tooltipNode);
if (!imgNode.src) return;
// 如果按钮已存在,则不重复添加
if (tooltipContent.querySelector(".download-button")) return;
// 创建下载按钮
const downloadButton = document.createElement("div");
downloadButton.textContent = "下载表情包";
downloadButton.className = "LV01TNDE";
// 添加下载事件
downloadButton.addEventListener("click", () => {
const imgSrc = imgNode.src;
download_file(imgSrc);
});
// 将按钮添加到 tooltip
tooltipContent.appendChild(downloadButton);
}
// 开始观察文档的 DOM 变化
observer.observe(document.body, {
childList: true,
subtree: true,
});
console.log("[dy-dl]已启动");
/**
*
* @param {string} bigintStr
*/
function 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
*/
function build_filename(media) {
const {
authorInfo: { nickname },
awemeId,
desc,
textExtra,
} = media;
const short_id = toShortId(awemeId);
const tag_list = textExtra.map((x) => x.hashtagName);
const tags = tag_list.map((x) => "#" + x).join("_");
let rawDesc = desc;
// 去除 desc 中的 tag
tag_list.forEach((t) => {
rawDesc = rawDesc.replace(`#${t}`, "");
});
rawDesc = rawDesc.trim();
// NOTE: 这里没有关注特殊字符的原因是浏览器一般能自动处理
return `${nickname}_${short_id}_${tags}_${rawDesc}`.slice(0, 64);
}
// ========== 视频下载 =============
/**
* @type {{
*   player: import("./types").DouyinPlayer.PlayerInstance | null,
*   current_media: import("./types").DouyinMedia.MediaRoot | null
* }}
*/
const downloader_status = {
player: null,
current_media: null,
};
function bind_player_events() {
const { player } = downloader_status;
if (!player) return;
const update = () => {
// 更新当前视频
downloader_status.current_media = player.config.awemeInfo;
};
update();
player.on("play", update);
player.on("seeked", update);
}
async function start_detect_player_change() {
while (1) {
if (downloader_status.player !== window.player) {
downloader_status.player = window.player;
bind_player_events();
// console.log(`[dy-dl] player changed: ${downloader_status.player}`);
}
await new Promise((r) => setTimeout(r, 1000));
}
}
start_detect_player_change();
const download_current_media = async () => {
if (!downloader_status.current_media) return;
const { video, images } = downloader_status.current_media;
const filename = build_filename(downloader_status.current_media);
const video_url =
video?.playerApi ?? video?.playerAddr ?? video.bitRateList?.[0]?.playApi;
if (video_url) {
// download video
download_file(video_url, filename);
return;
}
if (images) {
// 下载图集
// TODO 要是能支持 zip 打包会更好一点
for (let idx = 0; idx < images.length; idx++) {
const image = images[idx];
// 包含视频的图集
const video = image?.video;
if (video) {
const video_url =
video?.playApi ??
video.playAddr?.[0]?.src ??
video.bitRateList[0]?.playApi;
await download_file(video_url, `${filename}_${idx}`);
continue;
}
// 单纯的图片图集
const img_url = image?.urlList?.[0];
if (!img_url) continue;
await download_file(img_url, `${filename}_${idx}`);
}
return;
}
alert("[dy-dl]无法下载当前视频,尝试刷新、暂停、播放等操作后重试。");
};
/**
*
* @param {HTMLElement} xg_control_node
* @returns
*/
function handleXgControl(xg_control_node) {
const right_gird = xg_control_node.querySelector(".xg-right-grid");
if (!right_gird) return;
const downloadButton = render_html(`
<xg-icon class="xgplayer-playback-setting" data-state="normal" data-index="6"><div class="xgplayer-setting-playbackRatio">下载</div><div class="xgplayer-slider xgplayer-box-douyin "><div class="xgplayer-setting-content"><div class="xgplayer-playratio-wrap"><div data-id="0.75" class="xgplayer-playratio-item">0.75x</div><div data-id="1.0" class="select xgplayer-playratio-item">1.0x</div><div data-id="1.25" class="xgplayer-playratio-item">1.25x</div><div data-id="1.5" class="xgplayer-playratio-item">1.5x</div><div data-id="1.75" class="xgplayer-playratio-item">1.75x</div><div data-id="2.0" class="xgplayer-playratio-item">2.0x</div><div data-id="3.0" class="xgplayer-playratio-item">3.0x</div></div></div></div></xg-icon>
`);
downloadButton.addEventListener("click", download_current_media);
right_gird.appendChild(downloadButton);
}
})();