您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Professional Reddit video downloader with multiple extraction methods and intuitive UI
当前为
// ==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 }; })();