// ==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(); } })();