Greasy Fork is available in English.
Universal video downloader with special X/Twitter support. Downloads M3U8, MP4, and intercepts network streams.
当前为
// ==UserScript== // @name Universal Video Downloader (X/Twitter Support + M3U8) // @namespace http://tampermonkey.net/ // @version 7.1 // @description Universal video downloader with special X/Twitter support. Downloads M3U8, MP4, and intercepts network streams. // @author Minoa // @license MIT // @match *://*/* // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/m3u8-parser.min.js // @require https://cdn.jsdelivr.net/npm/@warren-bank/[email protected]/dist/umd/ffmpeg.js // @resource classWorkerURL https://cdn.jsdelivr.net/npm/@warren-bank/[email protected]/dist/umd/258.ffmpeg.js // @resource coreURL https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.js // @resource wasmURL https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.wasm // @grant GM_addStyle // @grant GM_getResourceURL // @grant GM_xmlhttpRequest // ==/UserScript== (function() { 'use strict'; // --- Configuration & State --- var floatingButton = null; var pressTimer = null; var isLongPress = false; var detectedM3U8s = []; // Array of objects var detectedM3U8Urls = []; // Array of strings (URLs) var allDetectedVideos = new Map(); // Key: URL, Value: Object var downloadedBlobs = new Map(); var debugMode = false; var debugConsole = null; var debugLogs = []; var longPressStartTime = 0; var ffmpegInstance = null; var ffmpegLoaded = false; var wasmBinaryCache = null; // Detect Platform const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; const isTwitter = window.location.hostname.includes('twitter.com') || window.location.hostname.includes('x.com'); // Color scheme const COLORS = { button: 'rgba(85, 66, 61, 0.7)', buttonHover: 'rgba(107, 86, 81, 0.85)', icon: '#ffc0ad', text: '#fff3ec', twitter: 'rgba(29, 161, 242, 1.0)' }; // --- Styles --- GM_addStyle(` #universal-video-share-container { position: fixed !important; top: 15px !important; left: 15px !important; width: 50px !important; height: 50px !important; z-index: 2147483647 !important; pointer-events: auto !important; isolation: isolate !important; } #universal-video-share-float { position: absolute !important; top: 2px !important; left: 2px !important; width: 46px !important; height: 46px !important; background: ${COLORS.button} !important; backdrop-filter: blur(12px) !important; -webkit-backdrop-filter: blur(12px) !important; color: ${COLORS.icon} !important; border: 2px solid rgba(255, 255, 255, 0.3) !important; border-radius: 50% !important; font-size: 18px !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; transition: all 0.2s ease !important; user-select: none !important; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4) !important; z-index: 2147483647 !important; } #universal-video-share-float:hover { transform: scale(1.1) !important; } #progress-circle { pointer-events: none !important; } .universal-video-notification { z-index: 2147483646 !important; } #video-selector-popup { z-index: 2147483645 !important; } /* Twitter Button */ .tmd-down { margin-left: 2px !important; order: 99; display: flex; align-items: center; justify-content: center; width: 34.75px; height: 34.75px; border-radius: 9999px; transition: background-color 0.2s; cursor: pointer; } .tmd-down:hover { background-color: rgba(29, 161, 242, 0.1); } .tmd-down svg { color: rgb(113, 118, 123); width: 20px; height: 20px; } .tmd-down:hover svg { color: ${COLORS.twitter}; } .tmd-down.downloading svg { animation: spin 1s linear infinite; color: ${COLORS.twitter}; } @keyframes spin { 0% {transform: rotate(0deg);} 100% {transform: rotate(360deg);} } `); // --- Helpers --- function sanitizeFilename(filename) { return filename.replace(/[<>:"\/\\|?*\x00-\x1F]/g, '').replace(/\s+/g, '_').substring(0, 100); } function getFilenameFromPageTitle(extension = 'mp4') { const pageTitle = document.title || 'video'; return sanitizeFilename(pageTitle) + '.' + extension; } function isElementVisible(el) { if (!el) return false; const rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) && rect.width > 0 && rect.height > 0 ); } // --- FFmpeg Logic --- const getWasmBinary = async () => { if (wasmBinaryCache) return wasmBinaryCache.slice(0); // Robust resource loading let wasmURL = ''; try { wasmURL = GM_getResourceURL('wasmURL'); } catch(e) { debugLog('GM_getResourceURL failed, using fallback'); } // Fallback if GM_getResourceURL returns undefined or fails if (!wasmURL) { wasmURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.wasm'; } debugLog('[FFMPEG] Loading WASM from: ' + wasmURL.substring(0, 30) + '...'); let wasmBinary = null; if (wasmURL.startsWith('data:')) { const base64 = wasmURL.substring(wasmURL.indexOf(',') + 1); const binaryString = atob(base64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i); wasmBinary = bytes.buffer; } else { // Fetch blob or http url const res = await fetch(wasmURL); wasmBinary = await res.arrayBuffer(); } if (wasmBinary) wasmBinaryCache = wasmBinary.slice(0); return wasmBinary; }; async function initFFmpeg() { if (ffmpegLoaded && ffmpegInstance) return ffmpegInstance; debugLog('[FFMPEG] Initializing...'); showNotification('⚙️ Loading FFmpeg...', 'info'); try { ffmpegInstance = new window.FFmpegWASM.FFmpeg(); ffmpegInstance.on('log', ({ message }) => debugLog('[FFMPEG LOG] ' + message)); ffmpegInstance.on('progress', ({ progress }) => updateProgress(Math.round(progress * 100))); // Get URLs with fallbacks const classWorkerURL = GM_getResourceURL('classWorkerURL') || 'https://cdn.jsdelivr.net/npm/@warren-bank/[email protected]/dist/umd/258.ffmpeg.js'; const coreURL = GM_getResourceURL('coreURL') || 'https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.js'; await ffmpegInstance.load({ classWorkerURL: classWorkerURL, coreURL: coreURL, wasmBinary: await getWasmBinary() }); ffmpegLoaded = true; showNotification('✅ FFmpeg loaded', 'success'); return ffmpegInstance; } catch(e) { showNotification('❌ FFmpeg failed: ' + e.message, 'error'); throw e; } } async function convertTStoMP4(tsBlob, baseFilename) { try { const ffmpeg = await initFFmpeg(); const inputName = 'input.ts'; const outputName = baseFilename.endsWith('.mp4') ? baseFilename : baseFilename + '.mp4'; await ffmpeg.writeFile(inputName, new Uint8Array(await tsBlob.arrayBuffer())); showNotification('🔄 Converting...', 'info'); await ffmpeg.exec(['-i', inputName, '-c', 'copy', '-movflags', 'faststart', outputName]); const data = await ffmpeg.readFile(outputName, 'binary'); await ffmpeg.deleteFile(inputName); await ffmpeg.deleteFile(outputName); return { blob: new Blob([data.buffer], { type: 'video/mp4' }), filename: outputName }; } catch(e) { debugLog('[ERROR] Convert failed: ' + e.message); throw e; } } // --- Network Sniffing --- (function setupNetworkDetection() { const handleUrl = (url) => { if (!url) return; if (url.includes('.m3u8') || url.includes('.m3u')) detectM3U8(url); else if (url.match(/\.(mp4|webm|mov)(\?|$)/i)) checkUrlForVideo(url); }; const originalFetch = window.fetch; window.fetch = function(...args) { const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; handleUrl(url); return originalFetch.apply(this, args).then(res => { // Clone response to check for M3U8 content type or text res.clone().text().then(t => { if(t && t.startsWith("#EXTM3U")) detectM3U8(url); }).catch(()=>{}); return res; }); }; const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(...args) { this.addEventListener("load", () => { handleUrl(args[1]); if (this.responseText && this.responseText.trim().startsWith("#EXTM3U")) detectM3U8(args[1]); }); return originalOpen.apply(this, args); }; })(); function checkUrlForVideo(url) { if (url.startsWith('blob:') || url.includes('.m3u8')) return; const fullUrl = new URL(url, location.href).href; if (!allDetectedVideos.has(fullUrl)) { allDetectedVideos.set(fullUrl, { url: fullUrl, type: 'video', timestamp: Date.now(), title: 'Video File', isOnScreen: false }); checkForVideos(); } } async function detectM3U8(url) { try { if (url.startsWith('blob:')) return; url = new URL(url, location.href).href; // Avoid duplicates but allow refreshing if it's been a while if (detectedM3U8Urls.includes(url)) return; detectedM3U8Urls.push(url); debugLog('[M3U8] Detected: ' + url); const response = await fetch(url); const content = await response.text(); const parser = new m3u8Parser.Parser(); parser.push(content); parser.end(); // If Master Playlist, find best variant and recurse if (parser.manifest.playlists && parser.manifest.playlists.length > 0) { const best = parser.manifest.playlists.sort((a,b) => (b.attributes.BANDWIDTH || 0) - (a.attributes.BANDWIDTH || 0))[0]; const nextUrl = new URL(best.uri, url).href; debugLog('[M3U8] Master playlist found, switching to variant: ' + nextUrl); detectM3U8(nextUrl); return; } const m3u8Data = { url: url, manifest: parser.manifest, title: 'Stream ' + (detectedM3U8s.length + 1), timestamp: Date.now() }; detectedM3U8s.push(m3u8Data); allDetectedVideos.set(url, { url: url, type: 'm3u8', timestamp: Date.now(), title: m3u8Data.title, m3u8Data: m3u8Data, isOnScreen: false }); checkForVideos(); } catch(e) { debugLog('[M3U8] Error: ' + e.message); } } // --- Video Gathering Logic --- function getUniqueVideos() { const videos = []; const seenUrls = new Set(); const domVideos = []; // 1. Scan DOM document.querySelectorAll('video, iframe').forEach(el => { let src = el.currentSrc || el.src; if (!src && el.tagName === 'VIDEO') { const source = el.querySelector('source'); if (source) src = source.src; } if (src) { domVideos.push({ element: el, src: src, isBlob: src.startsWith('blob:'), isVisible: isElementVisible(el) }); } }); // 2. Map Network Requests to DOM allDetectedVideos.forEach((data, url) => { data.isOnScreen = false; // Direct match const match = domVideos.find(v => v.src === url); if (match) { data.isOnScreen = match.isVisible; } // Indirect match (M3U8 vs Blob) else if (data.type === 'm3u8') { const visibleBlob = domVideos.find(v => v.isBlob && v.isVisible); // If we have a visible blob video and this M3U8 was detected recently (last 15s), assume match if (visibleBlob && (Date.now() - data.timestamp < 15000)) { data.isOnScreen = true; } } if (!seenUrls.has(url)) { seenUrls.add(url); videos.push(data); } }); // 3. Add direct MP4s from DOM if missed domVideos.forEach(v => { if (!v.isBlob && !seenUrls.has(v.src)) { seenUrls.add(v.src); videos.push({ url: v.src, type: 'video', timestamp: Date.now(), title: document.title, isOnScreen: v.isVisible }); } }); return videos.sort((a, b) => { if (a.isOnScreen && !b.isOnScreen) return -1; if (!a.isOnScreen && b.isOnScreen) return 1; return b.timestamp - a.timestamp; }); } // --- UI: Floating Button --- function createFloatingButton() { if (floatingButton) return floatingButton; const container = document.createElement('div'); container.id = 'universal-video-share-container'; container.innerHTML = ` <svg width="50" height="50" style="position:absolute;top:0;left:0;transform:rotate(-90deg);pointer-events:none;"> <circle id="progress-circle" cx="25" cy="25" r="22" fill="none" stroke="#4ade80" stroke-width="3" stroke-dasharray="138" stroke-dashoffset="138" stroke-linecap="round" style="transition: stroke-dashoffset 0.3s ease;"></circle> </svg> <div id="universal-video-share-float">▶</div> `; floatingButton = container.querySelector('#universal-video-share-float'); floatingButton.progressCircle = container.querySelector('#progress-circle'); const handlePress = (e) => { e.preventDefault(); isLongPress = false; longPressStartTime = Date.now(); pressTimer = setTimeout(() => { isLongPress = true; floatingButton.innerHTML = '🐛'; floatingButton.style.background = 'rgba(239, 68, 68, 0.8)'; debugMode = true; debugLog('[DEBUG] Enabled'); }, 3000); }; const handleRelease = (e) => { e.preventDefault(); clearTimeout(pressTimer); floatingButton.style.background = COLORS.button; if (Date.now() - longPressStartTime >= 3000) createDebugConsole(); else if (!isLongPress) handleShare(); }; floatingButton.addEventListener('mousedown', handlePress); floatingButton.addEventListener('touchstart', handlePress); floatingButton.addEventListener('mouseup', handleRelease); floatingButton.addEventListener('touchend', handleRelease); document.body.appendChild(container); return floatingButton; } function updateProgress(percent) { if (floatingButton) floatingButton.progressCircle.setAttribute('stroke-dashoffset', 138 - (138 * percent / 100)); } // --- UI: Video Selector --- function showVideoSelector(videos) { document.getElementById('video-selector-popup')?.remove(); const popup = document.createElement('div'); popup.id = 'video-selector-popup'; popup.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);backdrop-filter:blur(5px);display:flex;justify-content:center;align-items:center;padding:20px;'; const content = document.createElement('div'); content.style.cssText = 'background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:20px;max-width:500px;width:100%;max-height:80vh;overflow-y:auto;color:#fff;'; content.innerHTML = `<h3 style="margin:0 0 15px;">Select Video (${videos.length})</h3>`; const createList = (list, label) => { if (list.length === 0) return; const title = document.createElement('div'); title.textContent = label; title.style.cssText = 'font-size:12px;text-transform:uppercase;color:#888;margin:10px 0 5px;font-weight:bold;'; content.appendChild(title); list.forEach(v => { const item = document.createElement('div'); item.style.cssText = 'padding:12px;background:#2a2a2a;margin-bottom:8px;border-radius:6px;cursor:pointer;border:1px solid transparent;transition:0.2s;'; item.onmouseover = () => item.style.borderColor = '#4ade80'; item.onmouseout = () => item.style.borderColor = 'transparent'; item.innerHTML = ` <div style="display:flex;justify-content:space-between;margin-bottom:4px;"> <span style="background:${v.type==='m3u8'?'#ef4444':'#3b82f6'};padding:2px 6px;border-radius:4px;font-size:10px;font-weight:bold;">${v.type.toUpperCase()}</span> <span style="font-size:11px;color:#888;">${new Date(v.timestamp).toLocaleTimeString()}</span> </div> <div style="font-size:13px;word-break:break-all;">${v.title || 'Unknown Video'}</div> <div style="font-size:10px;color:#666;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${v.url}</div> `; item.onclick = () => { popup.remove(); processVideoAction(v); }; content.appendChild(item); }); }; createList(videos.filter(v => v.isOnScreen), '👁️ On Screen Now'); createList(videos.filter(v => !v.isOnScreen), '📚 Other Detected Videos'); const closeBtn = document.createElement('button'); closeBtn.textContent = 'Close'; closeBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;background:#333;border:none;color:#fff;border-radius:6px;cursor:pointer;'; closeBtn.onclick = () => popup.remove(); content.appendChild(closeBtn); popup.appendChild(content); document.body.appendChild(popup); } function handleShare() { const videos = getUniqueVideos(); if (videos.length === 0) return showNotification('❌ No videos found', 'error'); if (videos.length > 1 || isTwitter) showVideoSelector(videos); else processVideoAction(videos[0]); } function processVideoAction(videoData) { if (videoData.type === 'm3u8') { downloadM3U8(videoData); } else { // Check if it's a blob url if (videoData.url.startsWith('blob:')) { showNotification('❌ Cannot download blob URL directly. Please wait for M3U8 detection.', 'error'); return; } shareOrDownloadBlob(null, getFilenameFromPageTitle('mp4'), videoData); } } // --- Download Logic --- async function downloadM3U8(videoData) { const cached = downloadedBlobs.get(videoData.url); if (cached) return shareOrDownloadBlob(cached.blob, cached.filename, videoData); updateProgress(0); showNotification('📥 Downloading Stream...', 'info'); try { const manifest = videoData.m3u8Data.manifest; const baseUrl = videoData.url.substring(0, videoData.url.lastIndexOf('/') + 1); const segments = manifest.segments; if (!segments || !segments.length) throw new Error("No segments found"); const chunks = []; let total = 0; for (let i = 0; i < segments.length; i++) { const uri = segments[i].uri.startsWith('http') ? segments[i].uri : baseUrl + segments[i].uri; const res = await fetch(uri); const buff = await res.arrayBuffer(); chunks.push(buff); total += buff.byteLength; updateProgress(Math.floor(((i + 1) / segments.length) * 100)); } showNotification('🔄 Processing...', 'info'); const merged = new Blob(chunks, { type: 'video/mp2t' }); const converted = await convertTStoMP4(merged, getFilenameFromPageTitle('mp4')); downloadedBlobs.set(videoData.url, converted); shareOrDownloadBlob(converted.blob, converted.filename, videoData); } catch(e) { showNotification('❌ Error: ' + e.message, 'error'); updateProgress(0); } } async function shareOrDownloadBlob(blob, filename, videoData) { if (!blob && videoData.url) { try { showNotification('📥 Fetching video...', 'info'); const res = await fetch(videoData.url); blob = await res.blob(); filename = getFilenameFromPageTitle('mp4'); } catch(e) { window.open(videoData.url, '_blank'); return; } } if (navigator.share && navigator.canShare && navigator.canShare({ files: [new File([blob], filename, { type: 'video/mp4' })] })) { try { await navigator.share({ files: [new File([blob], filename, { type: 'video/mp4' })] }); showNotification('✅ Shared', 'success'); } catch(e) { triggerDownload(blob, filename); } } else { triggerDownload(blob, filename); } updateProgress(0); } function triggerDownload(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 1000); showNotification('✅ Downloaded', 'success'); } function showNotification(msg, type) { const div = document.createElement('div'); div.className = 'universal-video-notification'; div.textContent = msg; div.style.cssText = `position:fixed;top:75px;left:15px;background:${type==='error'?'#ef4444':'#4ade80'};color:#fff;padding:10px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.3);font-weight:bold;z-index:999999;`; document.body.appendChild(div); setTimeout(() => div.remove(), 3000); } function checkForVideos() { const videos = getUniqueVideos(); if (videos.length > 0) { const btn = createFloatingButton(); const hasOnScreen = videos.some(v => v.isOnScreen); btn.innerHTML = hasOnScreen ? '👁️' : (videos.length > 1 ? '⇓' : '▶'); btn.style.borderColor = hasOnScreen ? '#4ade80' : 'rgba(255,255,255,0.3)'; } else if (floatingButton) { floatingButton.parentNode.remove(); floatingButton = null; } } // --- X/Twitter Specific Module --- function initTwitterSupport() { if (!isTwitter) return; debugLog('[TWITTER] Initializing X/Twitter module'); const downloadIconSVG = `<g><path d="M12 16 17.7 10.3 16.29 8.88 13 12.18 V2.59 h-2 v9.59 L7.7 8.88 6.29 10.3 Z M21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z" fill="currentColor"></path></g>`; const processTweet = (article) => { const group = article.querySelector('div[role="group"]'); if (!group || group.querySelector('.tmd-down')) return; const btn = document.createElement('div'); btn.className = 'tmd-down'; btn.innerHTML = `<svg viewBox="0 0 24 24">${downloadIconSVG}</svg>`; btn.title = 'Download Media'; const shareBtn = group.lastElementChild; if (shareBtn) group.insertBefore(btn, shareBtn); btn.onclick = async (e) => { e.stopPropagation(); if (btn.classList.contains('downloading')) return; btn.classList.add('downloading'); // Find media const videos = article.querySelectorAll('video'); const images = article.querySelectorAll('img[src*="pbs.twimg.com/media"]'); let targetData = null; if (videos.length > 0) { // It's a video. Twitter uses blob URLs. We MUST find the matching M3U8. // Strategy: Find the most recent M3U8 detected. // Since the user just clicked, the M3U8 likely loaded recently or is playing. const m3u8s = Array.from(allDetectedVideos.values()) .filter(v => v.type === 'm3u8') .sort((a,b) => b.timestamp - a.timestamp); if (m3u8s.length > 0) { // Pick the most recent one. targetData = m3u8s[0]; } else { showNotification('❌ Video stream not detected yet. Play the video first.', 'error'); } } else if (images.length > 0) { const url = images[0].src.replace(/name=[^&]+/, 'name=orig'); targetData = { url: url, type: 'video', title: 'Twitter Image' }; } if (targetData) { processVideoAction(targetData); } setTimeout(() => btn.classList.remove('downloading'), 1000); }; }; const observer = new MutationObserver((mutations) => { for (const m of mutations) { m.addedNodes.forEach(node => { if (node.nodeType === 1) { if (node.matches('article[data-testid="tweet"]')) processTweet(node); node.querySelectorAll?.('article[data-testid="tweet"]').forEach(processTweet); } }); } }); observer.observe(document.body, { childList: true, subtree: true }); document.querySelectorAll('article[data-testid="tweet"]').forEach(processTweet); } // --- Debug Console --- function debugLog(msg) { const log = `[${new Date().toLocaleTimeString()}] ${msg}`; debugLogs.push(log); if (debugMode && debugConsole) { const el = document.createElement('div'); el.textContent = log; el.style.borderBottom = '1px solid #333'; debugConsole.appendChild(el); debugConsole.scrollTop = debugConsole.scrollHeight; } } function createDebugConsole() { if (debugConsole) return; debugConsole = document.createElement('div'); debugConsole.id = 'debug-console'; debugConsole.style.cssText = 'position:fixed;bottom:10px;left:10px;right:10px;height:200px;background:rgba(0,0,0,0.9);color:#0f0;font-family:monospace;font-size:10px;overflow-y:auto;padding:10px;border:1px solid #0f0;'; const close = document.createElement('button'); close.textContent = 'CLOSE'; close.style.cssText = 'position:sticky;top:0;float:right;background:red;color:white;border:none;cursor:pointer;'; close.onclick = () => { debugConsole.remove(); debugConsole = null; debugMode = false; }; debugConsole.appendChild(close); debugLogs.forEach(l => { const d = document.createElement('div'); d.textContent = l; d.style.borderBottom = '1px solid #333'; debugConsole.appendChild(d); }); document.body.appendChild(debugConsole); } function init() { debugLog('Script Initialized'); initTwitterSupport(); setInterval(checkForVideos, 2000); } init(); })();