// ==UserScript== // @name X Media Downloader // @namespace http://tampermonkey.net/ // @version 1.0 // @author Ksanadu // @match https://twitter.com/* // @match https://x.com/* // @grant GM_download // @grant GM_xmlhttpRequest // @run-at document-start // @license MIT // @description Download all media content (images, videos) from Twitter/X posts with one click. // @downloadURL https://update.greasyfork.icu/scripts/560556/X%20Media%20Downloader.user.js // @updateURL https://update.greasyfork.icu/scripts/560556/X%20Media%20Downloader.meta.js // ==/UserScript== (function() { 'use strict'; const CLASS_NAME = 'x-batch-downloader'; const SVG_ICON = ` `; const style = document.createElement('style'); style.innerHTML = ` .${CLASS_NAME} { position: absolute !important; bottom: 6px !important; left: 6px !important; z-index: 2147483647 !important; display: flex !important; align-items: center !important; justify-content: center !important; width: 28px !important; height: 28px !important; padding: 5px !important; box-sizing: border-box !important; border-radius: 4px !important; background-color: rgba(0, 0, 0, 0.6) !important; border: 1px solid rgba(255, 255, 255, 0.3) !important; color: #ffffff !important; cursor: pointer !important; pointer-events: auto !important; transition: transform 0.2s !important; } .${CLASS_NAME}:hover { background-color: rgba(29, 161, 242, 0.9) !important; transform: scale(1.1); } .x-batch-loading { opacity: 0.7; animation: x-spin 1s linear infinite; } @keyframes x-spin { 100% { transform: rotate(360deg); } } `; document.head.appendChild(style); function globalScan() { document.querySelectorAll('video').forEach(video => { const container = video.closest('div[data-testid="videoComponent"]') || video.closest('div[data-testid="videoPlayer"]') || video.parentNode; injectButton(container); }); document.querySelectorAll('img[src*="format"]').forEach(img => { if (img.src.includes('/profile_images/') || img.src.includes('emoji')) return; let container = img.closest('div[data-testid="tweetPhoto"]'); if (!container) { const link = img.closest('a[href*="/status/"]'); if (link) container = img.parentNode; } if (!container && img.naturalWidth > 50) container = img.parentNode; if (container) injectButton(container); }); } function injectButton(container) { if (!container || container.querySelector(`.${CLASS_NAME}`)) return; const rect = container.getBoundingClientRect(); if (rect.width < 50 || rect.height < 50) return; const computedStyle = window.getComputedStyle(container); if (computedStyle.position === 'static') container.style.position = 'relative'; const btn = document.createElement('div'); btn.className = CLASS_NAME; btn.innerHTML = SVG_ICON; btn.title = 'Batch Download'; btn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); startBatchDownload(btn, container); }; container.appendChild(btn); } setInterval(globalScan, 1500); const observer = new MutationObserver(() => globalScan()); if (document.body) observer.observe(document.body, { childList: true, subtree: true }); async function startBatchDownload(btn, container) { const svg = btn.querySelector('svg'); svg.classList.add('x-batch-loading'); try { let statusId = 'unknown'; let userName = 'twitter'; let article = container.closest('article'); let link = container.closest('a[href*="/status/"]'); if (article) { const idLink = article.querySelector('a[href*="/status/"]'); if (idLink) statusId = idLink.href.split('/status/').pop().split('/')[0]; const userEl = article.querySelector('div[data-testid="User-Name"] a'); if (userEl) userName = userEl.getAttribute('href').replace('/', ''); } else if (link) { const parts = link.href.split('/'); const statusIndex = parts.indexOf('status'); if (statusIndex > -1) { statusId = parts[statusIndex + 1]; userName = parts[statusIndex - 1]; } } let mediaList = getFullTweetMedia(container) || getFullTweetMedia(link) || getFullTweetMedia(article); if (!mediaList || mediaList.length === 0) { mediaList = tryExtractFromDOM(container); } if (mediaList && mediaList.length > 0) { const uniqueList = mediaList.filter((v,i,a)=>a.findIndex(t=>(t.url===v.url))===i); await downloadFiles(uniqueList, statusId, userName); } else { alert('No media found.'); } } catch (err) { console.error(err); alert('Error: ' + err.message); } finally { svg.classList.remove('x-batch-loading'); } } function getFullTweetMedia(domNode) { if (!domNode) return null; const key = Object.keys(domNode).find(k => k.startsWith('__reactFiber$')); if (!key) return null; let fiber = domNode[key]; let attempts = 0; let foundMedia = null; while (fiber && attempts < 40) { const props = fiber.memoizedProps; if (props?.tweet?.extended_entities?.media) { return parseMedia(props.tweet.extended_entities.media); } if (props?.data?.tweet?.extended_entities?.media) { return parseMedia(props.data.tweet.extended_entities.media); } if (props?.item?.content?.tweet?.extended_entities?.media) { return parseMedia(props.item.content.tweet.extended_entities.media); } if (!foundMedia && props?.media?.media_url_https) { foundMedia = parseMedia([props.media]); } fiber = fiber.return; attempts++; } return foundMedia; } function parseMedia(mediaArray) { return mediaArray.map(media => { if (media.type === 'photo') { return { url: media.media_url_https + ':orig', ext: 'jpg' }; } else if (media.type === 'video' || media.type === 'animated_gif') { const variants = media.video_info.variants .filter(n => n.content_type === 'video/mp4') .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); if (variants.length > 0) return { url: variants[0].url, ext: 'mp4' }; } return null; }).filter(Boolean); } function tryExtractFromDOM(container) { const results = []; container.querySelectorAll('img[src*="format"]').forEach(img => { const u = new URL(img.src); if (u.pathname.includes('/media/')) { const format = u.searchParams.get('format') || 'jpg'; results.push({ url: `${u.origin}${u.pathname}?format=${format}&name=orig`, ext: format }); } }); container.querySelectorAll('video').forEach(v => { if (v.src && v.src.startsWith('http')) results.push({ url: v.src, ext: 'mp4' }); }); return results; } async function downloadFiles(list, id, user) { for (let i = 0; i < list.length; i++) { const item = list[i]; const name = `twitter_${user}_${id}_${i+1}.${item.ext}`; await downloadAsBlob(item.url, name); } } function downloadAsBlob(url, filename) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, responseType: "blob", onload: res => { if (res.status === 200) { const u = URL.createObjectURL(res.response); const a = document.createElement('a'); a.href = u; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(u), 1000); resolve(); } else reject(new Error(res.status)); }, onerror: reject }); }); } })();