// ==UserScript==
// @name Douyin Video Downloader
// @namespace https://git.ponywave.de/Akamaru/Userscripts
// @version 1.0
// @description Extrahiert und lädt MP4-Videos von Douyin herunter
// @author Akamaru
// @match https://www.douyin.com/
// @match https://www.douyin.com/?*
// @match https://www.douyin.com/follow
// @match https://www.douyin.com/user/*
// @match https://www.douyin.com/video/*
// @icon https://www.google.com/s2/favicons?domain=douyin.com&sz=32
// @grant none
// @updateURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/douyin-video-downloader.user.js
// @downloadURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/douyin-video-downloader.user.js
// ==/UserScript==
(function() {
'use strict';
// Styles
const style = document.createElement('style');
style.textContent = `
/* Detail page button styles */
#douyin-download-btn.detail-mode {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.2s;
}
#douyin-download-btn.detail-mode:hover {
transform: scale(1.1);
}
#douyin-download-btn.detail-mode .icon-wrapper {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
margin-bottom: 4px;
}
#douyin-download-btn.detail-mode svg {
width: 24px;
height: 24px;
color: white;
}
/* Feed page button styles */
#douyin-download-btn.feed-mode {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: transform 0.2s;
padding: 8px 0;
}
#douyin-download-btn.feed-mode:hover {
transform: scale(1.05);
}
#douyin-download-btn.feed-mode .icon-wrapper {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
border-radius: 50%;
margin-bottom: 6px;
transition: background 0.2s;
}
#douyin-download-btn.feed-mode:hover .icon-wrapper {
background: rgba(255, 255, 255, 0.25);
}
#douyin-download-btn.feed-mode svg {
width: 20px;
height: 20px;
color: white;
}
#douyin-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
#douyin-modal {
background: #1f1f1f;
color: white;
padding: 24px;
border-radius: 16px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
position: relative;
}
#douyin-modal h3 {
margin: 0 0 20px 0;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close-btn {
position: absolute;
top: 16px;
right: 16px;
background: transparent;
border: none;
color: #999;
font-size: 28px;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
transition: color 0.2s;
}
.modal-close-btn:hover {
color: #ff4444;
}
.video-url-item {
margin: 12px 0;
padding: 16px;
background: rgba(255, 255, 255, 0.08);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.video-url-item strong {
display: block;
margin-bottom: 10px;
font-size: 14px;
color: #fe2c55;
}
.video-url-link {
display: block;
color: #4CAF50;
text-decoration: none;
word-break: break-all;
font-size: 12px;
margin: 8px 0;
padding: 8px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
}
.video-url-link:hover {
background: rgba(0, 0, 0, 0.5);
}
.button-group {
display: flex;
gap: 8px;
margin-top: 10px;
}
.copy-btn, .download-btn {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
}
.copy-btn {
background: #2196F3;
color: white;
}
.copy-btn:hover {
background: #1976D2;
}
.download-btn {
background: #4CAF50;
color: white;
}
.download-btn:hover {
background: #45a049;
}
.no-video {
text-align: center;
color: #ff9800;
font-size: 14px;
padding: 20px;
}
`;
document.head.appendChild(style);
let cachedUrls = [];
let feedVideoUrls = new Map(); // Map video ID -> URL for feed videos
// Store the latest video URL for feed
let latestFeedVideoUrl = null;
let latestFeedVideoTime = 0;
// Intercept fetch to capture video URLs in feed mode
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
// Check if this is a video request
if (url && (url.includes('douyinvod.com') || url.includes('zjcdn.com')) && url.includes('video')) {
// Extract video ID from URL (__vid parameter)
try {
const urlObj = new URL(url);
const videoId = urlObj.searchParams.get('__vid');
// Clean URL (remove hash and fragment)
const cleanUrl = url.split('#')[0];
if (videoId) {
feedVideoUrls.set(videoId, cleanUrl);
console.log('[Douyin Downloader] Cached video URL for ID:', videoId);
}
// Also store as latest video (fallback for videos without __vid)
latestFeedVideoUrl = cleanUrl;
latestFeedVideoTime = Date.now();
console.log('[Douyin Downloader] Cached latest video URL');
} catch (e) {
// Ignore URL parse errors
}
}
return originalFetch.apply(this, args);
};
// Detect page type
function getPageType() {
const path = window.location.pathname;
const urlParams = new URLSearchParams(window.location.search);
const hasModalId = urlParams.has('modal_id');
if (path.startsWith('/video/')) {
return 'detail';
}
// User page - treat as feed (modal will open on video click)
if (path.startsWith('/user/')) {
return 'user';
}
// Feed pages: /, /?*, /follow
if (path === '/' || path === '/follow') {
return 'feed';
}
return 'unknown';
}
// Extract video URLs
function extractVideoURLs() {
const pageType = getPageType();
if (pageType === 'feed' || pageType === 'user') {
// In feed mode: get URL from intercepted fetch cache
let videoId = null;
// Check if it's a user modal (has modal_id in URL)
const urlParams = new URLSearchParams(window.location.search);
const modalId = urlParams.get('modal_id');
if (modalId) {
// User modal: use modal_id from URL
videoId = modalId;
} else {
// Regular feed: use data-e2e-vid from active video
const activeVideo = document.querySelector('[data-e2e="feed-active-video"]');
if (!activeVideo) return [];
videoId = activeVideo.getAttribute('data-e2e-vid');
}
// Try to get cached URL by video ID
if (videoId) {
const cachedUrl = feedVideoUrls.get(videoId);
if (cachedUrl) {
return [cachedUrl];
}
}
// Fallback 1: Use latest intercepted video URL (for videos without __vid)
// Only use if it's recent (within last 10 seconds)
if (latestFeedVideoUrl && (Date.now() - latestFeedVideoTime) < 10000) {
console.log('[Douyin Downloader] Using latest video URL as fallback');
return [latestFeedVideoUrl];
}
// Fallback 2: try to find from video element (though it's usually a blob)
const videoContainer = modalId ?
document.querySelector('[class*="modal-video-container"]') :
document.querySelector('[data-e2e="feed-active-video"]');
if (videoContainer) {
const video = videoContainer.querySelector('video');
if (video && video.currentSrc && !video.currentSrc.startsWith('blob:')) {
return [video.currentSrc];
}
}
return [];
} else {
// In detail mode: get from source elements
const videoSources = document.querySelectorAll('video source');
const urls = [];
videoSources.forEach(source => {
const src = source.src;
if (src && (src.includes('v3-dy-o.zjcdn.com') || src.includes('.douyinvod.com'))) {
urls.push(src);
}
});
return [...new Set(urls)];
}
}
// Add download button for detail page
function addDownloadButtonDetail() {
// Check if button already exists
if (document.getElementById('douyin-download-btn')) {
return;
}
// Find the interaction buttons container
const buttonContainer = document.querySelector('[data-e2e="detail-video-info"] .bm6Yr1Fm .fN2jqmuV');
if (!buttonContainer) {
return;
}
// Create download button wrapper
const downloadWrapper = document.createElement('div');
downloadWrapper.className = 'fcEX2ARL';
downloadWrapper.style.cssText = 'margin-top: 12px;';
const downloadBtn = document.createElement('div');
downloadBtn.id = 'douyin-download-btn';
downloadBtn.className = 'detail-mode';
downloadBtn.setAttribute('tabindex', '0');
downloadBtn.innerHTML = `
`;
downloadWrapper.appendChild(downloadBtn);
downloadBtn.addEventListener('click', showModal);
// Insert button after the share button
buttonContainer.appendChild(downloadWrapper);
}
// Add download button for feed page
function addDownloadButtonFeed() {
// Check if button already exists
if (document.getElementById('douyin-download-btn')) {
return;
}
// Determine container: user modal or feed
const urlParams = new URLSearchParams(window.location.search);
const modalId = urlParams.get('modal_id');
let videoContainer;
if (modalId) {
// User modal: find button container in modal
videoContainer = document.querySelector('[class*="modal-video-container"]');
} else {
// Regular feed: find active video
videoContainer = document.querySelector('[data-e2e="feed-active-video"]');
}
if (!videoContainer) {
return;
}
// Find the button container
const buttonContainer = videoContainer.querySelector('.WU6dkKao');
if (!buttonContainer) {
return;
}
// Create download button wrapper (matching feed button structure)
const downloadWrapper = document.createElement('div');
downloadWrapper.className = 'JPLz9DCE';
const downloadBtn = document.createElement('div');
downloadBtn.id = 'douyin-download-btn';
downloadBtn.className = 'feed-mode';
downloadBtn.setAttribute('tabindex', '0');
downloadBtn.innerHTML = `
`;
downloadWrapper.appendChild(downloadBtn);
downloadBtn.addEventListener('click', showModal);
// Try to insert after share button (feed pages)
const shareButton = buttonContainer.querySelector('[data-e2e="video-player-share"]');
if (shareButton) {
const shareWrapper = shareButton.closest('.JPLz9DCE');
if (shareWrapper && shareWrapper.parentElement) {
shareWrapper.parentElement.insertBefore(downloadWrapper, shareWrapper.nextSibling);
return;
}
}
// Fallback: Insert before "more" button (user modals)
const moreButton = buttonContainer.querySelector('[data-e2e="video-play-more"]');
if (moreButton) {
const moreWrapper = moreButton.closest('.JPLz9DCE') || moreButton.parentElement;
if (moreWrapper && moreWrapper.parentElement) {
moreWrapper.parentElement.insertBefore(downloadWrapper, moreWrapper);
return;
}
}
// Last fallback: Append to container
buttonContainer.appendChild(downloadWrapper);
}
// Main function to add download button based on page type
function addDownloadButton() {
const pageType = getPageType();
if (pageType === 'detail') {
addDownloadButtonDetail();
} else if (pageType === 'feed' || pageType === 'user') {
addDownloadButtonFeed();
}
}
// Remove download button (used when video changes in feed)
function removeDownloadButton() {
const btn = document.getElementById('douyin-download-btn');
if (btn) {
const wrapper = btn.closest('.JPLz9DCE, .fcEX2ARL');
if (wrapper) {
wrapper.remove();
} else {
btn.remove();
}
}
}
// Show modal with download links
function showModal() {
// Update URLs when opening modal
cachedUrls = extractVideoURLs();
// Remove old modal if exists
const oldModal = document.getElementById('douyin-modal-overlay');
if (oldModal) {
oldModal.remove();
}
// Create modal overlay
const overlay = document.createElement('div');
overlay.id = 'douyin-modal-overlay';
// Create modal
const modal = document.createElement('div');
modal.id = 'douyin-modal';
modal.innerHTML = `
📥 Video herunterladen
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
// Populate video list
updateModalContent();
// Close modal on overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
}
});
// Close modal on close button click
modal.querySelector('.modal-close-btn').addEventListener('click', () => {
overlay.remove();
});
// Close on ESC key
const handleEsc = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', handleEsc);
}
};
document.addEventListener('keydown', handleEsc);
}
// Format duration from seconds to MM:SS
function formatDuration(seconds) {
if (!seconds || isNaN(seconds)) return 'N/A';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// Get video info
function getVideoInfo() {
const video = document.querySelector('video');
if (!video) return { duration: 'N/A', resolution: 'N/A' };
return {
duration: formatDuration(video.duration),
resolution: `${video.videoWidth}x${video.videoHeight}`
};
}
// Update modal content with video URLs
function updateModalContent() {
const listContainer = document.getElementById('modal-video-list');
if (!listContainer) return;
if (cachedUrls.length === 0) {
listContainer.innerHTML = 'Kein Video gefunden. Bitte warte, bis das Video geladen ist.
';
return;
}
const videoInfo = getVideoInfo();
listContainer.innerHTML = cachedUrls.map((url, index) => `
Video ${index + 1} (${videoInfo.resolution} • ${videoInfo.duration})
${url}
`).join('');
// Add copy button functionality
listContainer.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', function() {
const url = this.getAttribute('data-url');
navigator.clipboard.writeText(url).then(() => {
const originalText = this.textContent;
this.textContent = '✓ Kopiert!';
this.style.background = '#45a049';
setTimeout(() => {
this.textContent = originalText;
this.style.background = '';
}, 2000);
});
});
});
// Add download button functionality
listContainer.querySelectorAll('.download-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const url = this.getAttribute('data-url');
const originalText = this.textContent;
try {
this.textContent = 'Lädt...';
this.disabled = true;
// Get video title from page
const titleElement = document.querySelector('[data-e2e="detail-video-info"] h1');
let videoTitle = 'douyin-video';
if (titleElement) {
// Extract text and remove hashtags
videoTitle = titleElement.textContent
.replace(/#[^\s#]+/g, '') // Remove hashtags
.trim()
.replace(/[/\\?%*:|"<>]/g, '-') // Replace invalid filename chars
.substring(0, 100); // Limit length
}
// Fetch video as blob
const response = await fetch(url);
const blob = await response.blob();
// Create download
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${videoTitle}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Cleanup
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
this.textContent = '✓ Download gestartet!';
setTimeout(() => {
this.textContent = originalText;
this.disabled = false;
}, 2000);
} catch (error) {
console.error('Download error:', error);
this.textContent = '✗ Fehler';
setTimeout(() => {
this.textContent = originalText;
this.disabled = false;
}, 2000);
}
});
});
}
// Initialize for detail page
function initDetail() {
// Try to add button immediately
setTimeout(addDownloadButton, 1000);
// Debounced function to add button and update URLs
let debounceTimer;
const debouncedCheck = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
addDownloadButton();
cachedUrls = extractVideoURLs();
}, 1000);
};
// Watch for DOM changes to add button when video info loads
const observer = new MutationObserver(debouncedCheck);
const videoContainer = document.querySelector('#pagelet-worklist') || document.body;
observer.observe(videoContainer, {
childList: true,
subtree: true
});
// Periodic check as fallback
setInterval(() => {
addDownloadButton();
cachedUrls = extractVideoURLs();
}, 10000);
}
// Initialize for feed page
function initFeed() {
// Try to add button multiple times (for modals that load slowly)
const tryAddButton = () => {
addDownloadButton();
if (!document.getElementById('douyin-download-btn')) {
// Button not added yet, retry
setTimeout(tryAddButton, 500);
}
};
setTimeout(tryAddButton, 500);
// Watch for video changes in feed
let currentVideoId = null;
let currentUrl = window.location.href;
const checkActiveVideo = () => {
// Check if URL changed (for user modals)
if (window.location.href !== currentUrl) {
currentUrl = window.location.href;
removeDownloadButton();
setTimeout(() => {
addDownloadButton();
cachedUrls = extractVideoURLs();
}, 500);
return;
}
// Check for feed video changes
const activeVideo = document.querySelector('[data-e2e="feed-active-video"]');
if (!activeVideo) return;
const newVideoId = activeVideo.getAttribute('data-e2e-vid');
// Video changed, re-add button
if (newVideoId && newVideoId !== currentVideoId) {
currentVideoId = newVideoId;
removeDownloadButton();
setTimeout(() => {
addDownloadButton();
cachedUrls = extractVideoURLs();
}, 500);
}
};
// Debounced check
let debounceTimer;
const debouncedCheck = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(checkActiveVideo, 300);
};
// Observe feed container for video changes (only on actual feed pages)
const feedObserver = new MutationObserver(debouncedCheck);
const observeFeed = () => {
const feedContainer = document.querySelector('#slidelist');
if (feedContainer) {
feedObserver.observe(feedContainer, {
attributes: true,
attributeFilter: ['data-e2e'],
subtree: true
});
return true;
}
return false;
};
// Try to observe feed, but don't retry if we're on a user page with modal
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has('modal_id')) {
// Only retry if not on user modal
const tryObserve = () => {
if (!observeFeed()) {
setTimeout(tryObserve, 500);
}
};
tryObserve();
}
// Watch for URL changes (for user modals)
setInterval(() => {
if (window.location.href !== currentUrl) {
checkActiveVideo();
}
}, 1000);
// Periodic check as fallback
setInterval(() => {
checkActiveVideo();
}, 5000);
}
// Initialize for user page (watches for modal_id changes)
function initUser() {
let currentModalId = new URLSearchParams(window.location.search).get('modal_id');
let lastUrl = window.location.href;
let modalObserver = null;
// Watch for button container to appear and add button
const observeForButtonContainer = () => {
// Cleanup previous observer
if (modalObserver) {
modalObserver.disconnect();
modalObserver = null;
}
const tryAddButton = () => {
addDownloadButton();
if (document.getElementById('douyin-download-btn')) {
// Button added successfully, stop observing
if (modalObserver) {
modalObserver.disconnect();
modalObserver = null;
}
return true;
}
return false;
};
// Try immediately
if (tryAddButton()) return;
// Observe DOM for button container to appear
modalObserver = new MutationObserver(() => {
tryAddButton();
});
modalObserver.observe(document.body, {
childList: true,
subtree: true
});
// Fallback: retry a few times with increasing delays
let retryCount = 0;
const maxRetries = 10;
const retryWithDelay = () => {
if (retryCount >= maxRetries) return;
if (document.getElementById('douyin-download-btn')) return;
retryCount++;
addDownloadButton();
if (!document.getElementById('douyin-download-btn')) {
setTimeout(retryWithDelay, 500);
}
};
setTimeout(retryWithDelay, 500);
};
// Initial button add if modal is already open
if (currentModalId) {
setTimeout(observeForButtonContainer, 500);
}
// Watch for URL/modal changes
const checkModalChange = () => {
const newUrl = window.location.href;
if (newUrl === lastUrl) return;
lastUrl = newUrl;
const newModalId = new URLSearchParams(window.location.search).get('modal_id');
if (newModalId !== currentModalId) {
currentModalId = newModalId;
removeDownloadButton();
if (newModalId) {
// Modal opened or changed - wait for DOM to stabilize then add button
setTimeout(observeForButtonContainer, 300);
}
}
};
// Check for URL changes frequently (modal navigation)
setInterval(checkModalChange, 300);
}
// Main init function
function init() {
const pageType = getPageType();
if (pageType === 'detail') {
initDetail();
} else if (pageType === 'feed') {
initFeed();
} else if (pageType === 'user') {
initUser();
}
}
// Wait for page to load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();