Reddit Video Downloader Pro

Professional Reddit video downloader with multiple extraction methods and intuitive UI

当前为 2025-09-26 提交的版本,查看 最新版本

// ==UserScript==
// @name         Reddit Video Downloader Pro
// @namespace    http://tampermonkey.net/
// @version      2.1.0
// @description  Professional Reddit video downloader with multiple extraction methods and intuitive UI
// @author       RedditVideoDownloader
// @match        https://www.reddit.com/*
// @match        https://old.reddit.com/*
// @match        https://new.reddit.com/*
// @match        https://i.redd.it/*
// @match        https://v.redd.it/*
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_notification
// ==/UserScript==

(function() {
    'use strict';

    // Configuration
    const CONFIG = {
        debug: GM_getValue('debug', false),
        autoDetect: GM_getValue('autoDetect', true),
        preferredQuality: GM_getValue('preferredQuality', 'highest'),
        downloadPath: GM_getValue('downloadPath', 'Downloads/Reddit'),
        showNotifications: GM_getValue('showNotifications', true),
        buttonStyle: GM_getValue('buttonStyle', 'modern')
    };

    // Utility functions
    const utils = {
        log: function(message, type = 'info') {
            if (!CONFIG.debug && type === 'debug') return;
            const prefix = `[Reddit Video DL] [${type.toUpperCase()}]`;
            console.log(`${prefix} ${message}`);
        },

        notify: function(message, type = 'info') {
            if (!CONFIG.showNotifications) return;
            if (typeof GM_notification !== 'undefined') {
                GM_notification({
                    title: 'Reddit Video Downloader',
                    text: message,
                    timeout: 3000,
                    onclick: () => window.focus()
                });
            } else {
                // Fallback for mobile Safari
                this.showToast(message, type);
            }
        },

        showToast: function(message, type = 'info') {
            const toast = document.createElement('div');
            toast.style.cssText = `
                position: fixed;
                top: 20px;
                right: 20px;
                background: ${type === 'error' ? '#f44336' : type === 'success' ? '#4CAF50' : '#2196F3'};
                color: white;
                padding: 12px 20px;
                border-radius: 8px;
                z-index: 10001;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                font-size: 14px;
                box-shadow: 0 4px 12px rgba(0,0,0,0.3);
                max-width: 300px;
                word-wrap: break-word;
                animation: slideIn 0.3s ease-out;
            `;
            
            // Add animation keyframes
            if (!document.querySelector('#toast-keyframes')) {
                const style = document.createElement('style');
                style.id = 'toast-keyframes';
                style.textContent = `
                    @keyframes slideIn {
                        from { transform: translateX(100%); opacity: 0; }
                        to { transform: translateX(0); opacity: 1; }
                    }
                `;
                document.head.appendChild(style);
            }

            toast.textContent = message;
            document.body.appendChild(toast);

            setTimeout(() => {
                toast.style.animation = 'slideIn 0.3s ease-out reverse';
                setTimeout(() => toast.remove(), 300);
            }, 3000);
        },

        sanitizeFilename: function(filename) {
            return filename.replace(/[^\w\s.-]/gi, '_').replace(/\s+/g, '_');
        },

        formatFileSize: function(bytes) {
            if (bytes === 0) return '0 Bytes';
            const k = 1024;
            const sizes = ['Bytes', 'KB', 'MB', 'GB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        },

        getPostId: function(url = window.location.href) {
            const matches = url.match(/\/comments\/([a-z0-9]+)/i);
            return matches ? matches[1] : null;
        },

        makeRequest: function(url, options = {}) {
            return new Promise((resolve, reject) => {
                if (typeof GM_xmlhttpRequest !== 'undefined') {
                    GM_xmlhttpRequest({
                        method: options.method || 'GET',
                        url: url,
                        headers: options.headers || {},
                        onload: response => resolve(response),
                        onerror: error => reject(error),
                        ...options
                    });
                } else {
                    // Fallback for environments without GM_xmlhttpRequest
                    fetch(url, options)
                        .then(response => resolve({
                            status: response.status,
                            responseText: response.text(),
                            response: response
                        }))
                        .catch(reject);
                }
            });
        }
    };

    // Video extraction methods
    const extractors = {
        // Method 1: Reddit JSON API
        extractFromAPI: async function(postId) {
            try {
                utils.log(`Extracting video from API for post: ${postId}`, 'debug');
                const apiUrl = `https://www.reddit.com/comments/${postId}.json`;
                const response = await utils.makeRequest(apiUrl);
                
                if (response.status !== 200) {
                    throw new Error(`API request failed: ${response.status}`);
                }

                const data = JSON.parse(response.responseText);
                const post = data[0].data.children[0].data;

                // Check multiple possible video locations
                let videoData = null;

                // Reddit hosted video
                if (post.secure_media?.reddit_video) {
                    videoData = {
                        videoUrl: post.secure_media.reddit_video.fallback_url,
                        audioUrl: post.secure_media.reddit_video.fallback_url.replace('DASH_', 'DASH_audio_'),
                        duration: post.secure_media.reddit_video.duration,
                        width: post.secure_media.reddit_video.width,
                        height: post.secure_media.reddit_video.height,
                        hasAudio: post.secure_media.reddit_video.has_audio
                    };
                }

                // Alternative location
                if (!videoData && post.media?.reddit_video) {
                    videoData = {
                        videoUrl: post.media.reddit_video.fallback_url,
                        audioUrl: post.media.reddit_video.fallback_url.replace('DASH_', 'DASH_audio_'),
                        duration: post.media.reddit_video.duration,
                        width: post.media.reddit_video.width,
                        height: post.media.reddit_video.height,
                        hasAudio: post.media.reddit_video.has_audio
                    };
                }

                // Crosspost check
                if (!videoData && post.crosspost_parent_list?.length > 0) {
                    const crosspost = post.crosspost_parent_list[0];
                    if (crosspost.secure_media?.reddit_video) {
                        videoData = {
                            videoUrl: crosspost.secure_media.reddit_video.fallback_url,
                            audioUrl: crosspost.secure_media.reddit_video.fallback_url.replace('DASH_', 'DASH_audio_'),
                            duration: crosspost.secure_media.reddit_video.duration,
                            width: crosspost.secure_media.reddit_video.width,
                            height: crosspost.secure_media.reddit_video.height,
                            hasAudio: crosspost.secure_media.reddit_video.has_audio
                        };
                    }
                }

                if (videoData) {
                    videoData.title = post.title;
                    videoData.subreddit = post.subreddit;
                    videoData.author = post.author;
                    videoData.extractionMethod = 'API';
                }

                return videoData;
            } catch (error) {
                utils.log(`API extraction failed: ${error.message}`, 'error');
                return null;
            }
        },

        // Method 2: DOM analysis
        extractFromDOM: function() {
            try {
                utils.log('Extracting video from DOM', 'debug');
                
                // Look for video elements
                const videoElements = document.querySelectorAll('video');
                for (const video of videoElements) {
                    if (video.src || video.currentSrc) {
                        const videoUrl = video.src || video.currentSrc;
                        if (videoUrl.includes('v.redd.it') || videoUrl.includes('reddit.com')) {
                            return {
                                videoUrl: videoUrl,
                                width: video.videoWidth,
                                height: video.videoHeight,
                                duration: video.duration,
                                extractionMethod: 'DOM'
                            };
                        }
                    }
                }

                // Look for iframe embeds
                const iframes = document.querySelectorAll('iframe');
                for (const iframe of iframes) {
                    const src = iframe.src;
                    if (src.includes('v.redd.it')) {
                        return {
                            videoUrl: src,
                            extractionMethod: 'DOM-iframe'
                        };
                    }
                }

                return null;
            } catch (error) {
                utils.log(`DOM extraction failed: ${error.message}`, 'error');
                return null;
            }
        },

        // Method 3: Network monitoring
        setupNetworkMonitoring: function() {
            if (window.videoUrls) return; // Already monitoring
            
            window.videoUrls = new Set();
            
            // Intercept fetch requests
            const originalFetch = window.fetch;
            window.fetch = function(...args) {
                const url = args[0];
                if (typeof url === 'string' && (url.includes('v.redd.it') || url.includes('reddit.com') && url.includes('video'))) {
                    window.videoUrls.add(url);
                    utils.log(`Network captured: ${url}`, 'debug');
                }
                return originalFetch.apply(this, args);
            };

            // Intercept XMLHttpRequest
            const originalXHR = window.XMLHttpRequest.prototype.open;
            window.XMLHttpRequest.prototype.open = function(method, url) {
                if (typeof url === 'string' && (url.includes('v.redd.it') || url.includes('reddit.com') && url.includes('video'))) {
                    window.videoUrls.add(url);
                    utils.log(`XHR captured: ${url}`, 'debug');
                }
                return originalXHR.apply(this, arguments);
            };
        },

        // Method 4: URL pattern matching
        extractFromURL: function(url = window.location.href) {
            try {
                // Direct v.redd.it links
                if (url.includes('v.redd.it')) {
                    return {
                        videoUrl: url,
                        extractionMethod: 'URL-direct'
                    };
                }

                // Extract from Reddit post URL structure
                const postId = utils.getPostId(url);
                if (postId) {
                    // Try common v.redd.it patterns
                    const possibleUrls = [
                        `https://v.redd.it/${postId}/DASH_720.mp4`,
                        `https://v.redd.it/${postId}/DASH_480.mp4`,
                        `https://v.redd.it/${postId}/DASH_360.mp4`
                    ];

                    return {
                        videoUrl: possibleUrls[0], // Return highest quality guess
                        alternativeUrls: possibleUrls,
                        extractionMethod: 'URL-pattern'
                    };
                }

                return null;
            } catch (error) {
                utils.log(`URL extraction failed: ${error.message}`, 'error');
                return null;
            }
        }
    };

    // Download manager
    const downloader = {
        downloadVideo: async function(videoData, options = {}) {
            try {
                const filename = options.filename || this.generateFilename(videoData);
                const url = videoData.videoUrl;

                utils.log(`Starting download: ${filename}`, 'info');
                utils.notify('Starting video download...', 'info');

                if (typeof GM_download !== 'undefined') {
                    // Use GM_download if available (Tampermonkey)
                    GM_download(url, filename, {
                        onerror: (error) => {
                            utils.log(`Download failed: ${error}`, 'error');
                            utils.notify('Download failed!', 'error');
                        },
                        onload: () => {
                            utils.log('Download completed successfully', 'success');
                            utils.notify('Video downloaded successfully!', 'success');
                        }
                    });
                } else {
                    // Fallback method for mobile Safari
                    await this.downloadFallback(url, filename);
                }

                // Download audio separately if available
                if (videoData.hasAudio && videoData.audioUrl && options.includeAudio) {
                    setTimeout(() => {
                        const audioFilename = filename.replace(/\.[^/.]+$/, '_audio.mp4');
                        if (typeof GM_download !== 'undefined') {
                            GM_download(videoData.audioUrl, audioFilename);
                        }
                    }, 1000);
                }

            } catch (error) {
                utils.log(`Download error: ${error.message}`, 'error');
                utils.notify('Download failed!', 'error');
            }
        },

        downloadFallback: async function(url, filename) {
            try {
                // Create download link for mobile Safari
                const link = document.createElement('a');
                link.href = url;
                link.download = filename;
                link.target = '_blank';
                
                // For mobile Safari, we need to handle the download differently
                if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
                    // Open in new tab for iOS
                    window.open(url, '_blank');
                    utils.notify('Video opened in new tab. Long press to save.', 'info');
                } else {
                    // Standard download for other browsers
                    document.body.appendChild(link);
                    link.click();
                    document.body.removeChild(link);
                }

            } catch (error) {
                utils.log(`Fallback download failed: ${error.message}`, 'error');
                // Last resort: copy URL to clipboard
                this.copyToClipboard(url);
                utils.notify('Video URL copied to clipboard', 'info');
            }
        },

        copyToClipboard: function(text) {
            if (navigator.clipboard) {
                navigator.clipboard.writeText(text);
            } else {
                // Fallback for older browsers
                const textArea = document.createElement('textarea');
                textArea.value = text;
                document.body.appendChild(textArea);
                textArea.select();
                document.execCommand('copy');
                document.body.removeChild(textArea);
            }
        },

        generateFilename: function(videoData) {
            const title = videoData.title ? utils.sanitizeFilename(videoData.title) : 'reddit_video';
            const subreddit = videoData.subreddit || 'unknown';
            const timestamp = new Date().toISOString().slice(0, 10);
            
            return `${subreddit}_${title}_${timestamp}.mp4`.substring(0, 100); // Limit filename length
        }
    };

    // UI components
    const ui = {
        createDownloadButton: function(videoData) {
            const button = document.createElement('button');
            button.className = 'reddit-video-dl-btn';
            button.innerHTML = `
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
                    <polyline points="7,10 12,15 17,10"/>
                    <line x1="12" y1="15" x2="12" y2="3"/>
                </svg>
                Download Video
            `;

            button.style.cssText = `
                display: inline-flex;
                align-items: center;
                gap: 6px;
                background: linear-gradient(135deg, #FF4500, #FF6B35);
                color: white;
                border: none;
                border-radius: 8px;
                padding: 8px 16px;
                font-size: 13px;
                font-weight: 600;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                cursor: pointer;
                transition: all 0.2s ease;
                box-shadow: 0 2px 8px rgba(255, 69, 0, 0.3);
                margin: 4px;
                z-index: 1000;
                position: relative;
            `;

            // Hover effects
            button.addEventListener('mouseenter', () => {
                button.style.transform = 'translateY(-1px)';
                button.style.boxShadow = '0 4px 12px rgba(255, 69, 0, 0.4)';
            });

            button.addEventListener('mouseleave', () => {
                button.style.transform = 'translateY(0)';
                button.style.boxShadow = '0 2px 8px rgba(255, 69, 0, 0.3)';
            });

            button.addEventListener('click', async (e) => {
                e.preventDefault();
                e.stopPropagation();
                
                button.disabled = true;
                button.innerHTML = `
                    <svg width="16" height="16" viewBox="0 0 24 24">
                        <circle cx="12" cy="12" r="3" fill="currentColor">
                            <animateTransform attributeName="transform" type="rotate" 
                                values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"/>
                        </circle>
                    </svg>
                    Downloading...
                `;
                
                try {
                    await downloader.downloadVideo(videoData);
                } finally {
                    setTimeout(() => {
                        button.disabled = false;
                        button.innerHTML = `
                            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
                                <polyline points="7,10 12,15 17,10"/>
                                <line x1="12" y1="15" x2="12" y2="3"/>
                            </svg>
                            Download Video
                        `;
                    }, 2000);
                }
            });

            return button;
        },

        createQualityMenu: function(videoData) {
            if (!videoData.alternativeUrls) return null;

            const menu = document.createElement('div');
            menu.className = 'reddit-video-quality-menu';
            menu.style.cssText = `
                position: absolute;
                top: 100%;
                right: 0;
                background: white;
                border: 1px solid #ccc;
                border-radius: 8px;
                box-shadow: 0 4px 12px rgba(0,0,0,0.15);
                padding: 8px 0;
                min-width: 120px;
                z-index: 1001;
                display: none;
            `;

            const qualities = ['720p', '480p', '360p'];
            videoData.alternativeUrls.forEach((url, index) => {
                const item = document.createElement('div');
                item.textContent = qualities[index] || `Quality ${index + 1}`;
                item.style.cssText = `
                    padding: 8px 16px;
                    cursor: pointer;
                    font-size: 14px;
                    color: #333;
                `;

                item.addEventListener('click', () => {
                    downloader.downloadVideo({ ...videoData, videoUrl: url });
                    menu.style.display = 'none';
                });

                item.addEventListener('mouseenter', () => {
                    item.style.background = '#f5f5f5';
                });

                item.addEventListener('mouseleave', () => {
                    item.style.background = 'transparent';
                });

                menu.appendChild(item);
            });

            return menu;
        },

        insertDownloadButtons: function(videoData) {
            // Remove existing buttons
            document.querySelectorAll('.reddit-video-dl-btn').forEach(btn => btn.remove());

            const button = this.createDownloadButton(videoData);
            const qualityMenu = this.createQualityMenu(videoData);

            // Find insertion points
            const insertionPoints = [
                // New Reddit
                document.querySelector('[data-testid="post-content"]'),
                document.querySelector('div[data-click-id="body"]'),
                // Old Reddit
                document.querySelector('.usertext-body'),
                document.querySelector('.entry'),
                // Mobile
                document.querySelector('.Post'),
                document.querySelector('[data-testid="post_author_link"]')?.parentElement,
                // Fallback - any video element
                document.querySelector('video')?.parentElement
            ].filter(Boolean);

            if (insertionPoints.length > 0) {
                const container = document.createElement('div');
                container.style.cssText = `
                    display: flex;
                    align-items: center;
                    gap: 8px;
                    margin: 8px 0;
                    position: relative;
                `;

                container.appendChild(button);
                if (qualityMenu) {
                    container.appendChild(qualityMenu);
                    
                    // Toggle quality menu
                    button.addEventListener('contextmenu', (e) => {
                        e.preventDefault();
                        qualityMenu.style.display = qualityMenu.style.display === 'block' ? 'none' : 'block';
                    });
                }

                insertionPoints[0].appendChild(container);
                
                utils.log(`Download button inserted using method: ${videoData.extractionMethod}`, 'debug');
                return true;
            }

            return false;
        }
    };

    // Main functionality
    const main = {
        init: function() {
            utils.log('Initializing Reddit Video Downloader Pro', 'info');
            
            // Setup network monitoring
            extractors.setupNetworkMonitoring();
            
            // Initial scan
            this.scanForVideos();
            
            // Monitor for navigation changes (SPA)
            this.setupNavigationMonitoring();
            
            // Setup mutation observer for dynamic content
            this.setupMutationObserver();
        },

        scanForVideos: async function() {
            utils.log('Scanning for videos...', 'debug');

            const postId = utils.getPostId();
            if (!postId) {
                utils.log('No post ID found, skipping scan', 'debug');
                return;
            }

            let videoData = null;

            // Try multiple extraction methods
            videoData = await extractors.extractFromAPI(postId);
            
            if (!videoData) {
                videoData = extractors.extractFromDOM();
            }
            
            if (!videoData) {
                videoData = extractors.extractFromURL();
            }

            if (videoData) {
                utils.log(`Video found: ${videoData.extractionMethod}`, 'success');
                
                // Insert download button
                const inserted = ui.insertDownloadButtons(videoData);
                if (!inserted) {
                    utils.log('Failed to insert download button', 'warn');
                }
            } else {
                utils.log('No video found on this page', 'debug');
            }
        },

        setupNavigationMonitoring: function() {
            let currentUrl = window.location.href;
            
            // Monitor URL changes
            const urlObserver = new MutationObserver(() => {
                if (window.location.href !== currentUrl) {
                    currentUrl = window.location.href;
                    utils.log('Navigation detected, rescanning...', 'debug');
                    
                    setTimeout(() => {
                        this.scanForVideos();
                    }, 1000);
                }
            });

            urlObserver.observe(document.body, {
                childList: true,
                subtree: true
            });

            // Also listen for popstate events
            window.addEventListener('popstate', () => {
                setTimeout(() => {
                    this.scanForVideos();
                }, 1000);
            });
        },

        setupMutationObserver: function() {
            const observer = new MutationObserver((mutations) => {
                let shouldRescan = false;
                
                mutations.forEach((mutation) => {
                    if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                        // Check if new video elements were added
                        for (const node of mutation.addedNodes) {
                            if (node.nodeType === Node.ELEMENT_NODE) {
                                if (node.tagName === 'VIDEO' || node.querySelector('video')) {
                                    shouldRescan = true;
                                    break;
                                }
                            }
                        }
                    }
                });

                if (shouldRescan) {
                    utils.log('New video content detected, rescanning...', 'debug');
                    setTimeout(() => {
                        this.scanForVideos();
                    }, 500);
                }
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
        }
    };

    // Initialize when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => main.init());
    } else {
        main.init();
    }

    // Global access for debugging
    window.RedditVideoDownloader = {
        main,
        extractors,
        downloader,
        ui,
        utils,
        CONFIG
    };

})();