879 lines
29 KiB
JavaScript
879 lines
29 KiB
JavaScript
// ==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();
|
||
}
|
||
})();
|