1
0
Files
Userscripts/douyin-video-downloader.user.js
2025-11-21 14:50:26 +01:00

879 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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 = `
<div class="icon-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
</div>
`;
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 = `
<div class="icon-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
</div>
`;
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 = `
<button class="modal-close-btn" title="Schließen">×</button>
<h3>📥 Video herunterladen</h3>
<div id="modal-video-list"></div>
`;
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 = '<p class="no-video">Kein Video gefunden. Bitte warte, bis das Video geladen ist.</p>';
return;
}
const videoInfo = getVideoInfo();
listContainer.innerHTML = cachedUrls.map((url, index) => `
<div class="video-url-item">
<strong>Video ${index + 1} <span style="color: #999; font-weight: normal; font-size: 12px;">(${videoInfo.resolution}${videoInfo.duration})</span></strong>
<a href="${url}" target="_blank" class="video-url-link">${url}</a>
<div class="button-group">
<button class="copy-btn" data-url="${url}">URL kopieren</button>
<button class="download-btn" data-url="${url}">Herunterladen</button>
</div>
</div>
`).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();
}
})();