// ==UserScript==
// @name Twitter Media Downloader
// @description Download all media images in original quality.
// @icon https://www.google.com/s2/favicons?sz=64&domain=x.com
// @version 1.0
// @author afkarxyz
// @namespace https://github.com/afkarxyz/misc-scripts/
// @supportURL https://github.com/afkarxyz/misc-scripts/issues
// @license MIT
// @match https://twitter.com/*
// @match https://x.com/*
// @grant GM_xmlhttpRequest
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// ==/UserScript==
(function() {
'use strict';
let extractedUrls = new Set();
let isScrolling = false;
let isPaused = false;
let isDownloading = false;
let zip = new JSZip();
let downloadedCount = 0;
const downloadIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" style="vertical-align: middle; cursor: pointer;">
<defs><style>.fa-secondary{opacity:.4}</style></defs>
<path class="fa-secondary" fill="currentColor" d="M0 256C0 397.4 114.6 512 256 512s256-114.6 256-256c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 106-86 192-192 192S64 362 64 256c0-17.7-14.3-32-32-32s-32 14.3-32 32z"/>
<path class="fa-primary" fill="currentColor" d="M390.6 185.4c12.5 12.5 12.5 32.8 0 45.3l-112 112c-12.5 12.5-32.8 12.5-45.3 0l-112-112c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L224 242.7 224 32c0-17.7 14.3-32 32-32s32 14.3 32 32l0 210.7 57.4-57.4c12.5-12.5 32.8-12.5 45.3 0z"/>
</svg>`;
function createOverlayElements() {
const overlay = document.createElement('div');
overlay.id = 'media-downloader-overlay';
overlay.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
z-index: 9999;
display: none;
flex-direction: column;
gap: 10px;
align-items: flex-end;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", sans-serif;
`;
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
`;
const buttonRow = document.createElement('div');
buttonRow.className = 'buttonRow';
buttonRow.style.cssText = `
display: flex;
gap: 10px;
justify-content: center;
`;
const statusText = document.createElement('div');
statusText.id = 'download-status';
statusText.style.cssText = `
color: white;
background-color: rgba(0, 0, 0, 0.7);
padding: 0px 10px;
border-radius: 4px;
margin-top: 5px;
display: none;
font-family: inherit;
font-size: 12px;
text-align: center;
width: 100%;
`;
const progressContainer = document.createElement('div');
progressContainer.id = 'progress-container';
progressContainer.style.cssText = `
width: 200px;
display: none;
flex-direction: column;
gap: 5px;
`;
const progressBar = document.createElement('div');
progressBar.id = 'progress-bar';
progressBar.style.cssText = `
width: 100%;
height: 2px;
background-color: #f0f0f0;
border-radius: 1px;
position: relative;
overflow: hidden;
`;
const progressFill = document.createElement('div');
progressFill.id = 'progress-fill';
progressFill.style.cssText = `
width: 0%;
height: 100%;
background-color: #1da1f2;
position: absolute;
transition: width 0.3s;
`;
const progressStats = document.createElement('div');
progressStats.style.cssText = `
display: flex;
justify-content: space-between;
color: white;
font-size: 12px;
font-family: inherit;
`;
const progressCount = document.createElement('div');
progressCount.id = 'progress-count';
progressCount.style.cssText = `
color: white;
background-color: rgba(0, 0, 0, 0.7);
padding: 2px 6px;
border-radius: 4px;
`;
const progressPercent = document.createElement('div');
progressPercent.id = 'progress-percent';
progressPercent.style.cssText = `
color: white;
background-color: rgba(0, 0, 0, 0.7);
padding: 2px 6px;
border-radius: 4px;
`;
const pauseResumeButton = createButton('Pause', '#1da1f2');
const stopButton = createButton('Stop', '#e0245e');
pauseResumeButton.id = 'pause-resume-button';
stopButton.id = 'stop-button';
pauseResumeButton.addEventListener('click', () => {
if (isDownloading) {
isPaused = !isPaused;
pauseResumeButton.textContent = isPaused ? 'Resume' : 'Pause';
if (!isPaused) {
scrollAndExtract();
}
}
});
stopButton.addEventListener('click', () => {
if (isDownloading) {
isDownloading = false;
isPaused = false;
finishDownload();
}
});
progressBar.appendChild(progressFill);
progressStats.appendChild(progressCount);
progressStats.appendChild(progressPercent);
progressContainer.appendChild(progressBar);
progressContainer.appendChild(progressStats);
buttonRow.appendChild(pauseResumeButton);
buttonRow.appendChild(stopButton);
buttonContainer.appendChild(buttonRow);
buttonContainer.appendChild(statusText);
overlay.appendChild(buttonContainer);
overlay.appendChild(progressContainer);
document.body.appendChild(overlay);
}
function createButton(text, bgColor) {
const button = document.createElement('button');
button.textContent = text;
const darkenColor = (color, amount) => {
return '#' + color.replace(/^#/, '').replace(/../g, color =>
('0' + Math.min(255, Math.max(0, parseInt(color, 16) - amount)).toString(16)).substr(-2)
);
};
const hoverColor = darkenColor(bgColor, 20);
button.style.cssText = `
padding: 5px 10px;
background-color: ${bgColor};
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
width: 80px;
font-family: inherit;
text-align: center;
transition: background-color 0.3s;
font-size: 11px;
`;
button.addEventListener('mouseenter', () => {
button.style.backgroundColor = hoverColor;
});
button.addEventListener('mouseleave', () => {
button.style.backgroundColor = bgColor;
});
return button;
}
function updateStatus(message) {
const statusElement = document.getElementById('download-status');
if (statusElement) {
statusElement.textContent = message;
statusElement.style.display = 'block';
statusElement.style.textAlign = 'center';
}
}
function showProgressBar(progress, total) {
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressCount = document.getElementById('progress-count');
const progressPercent = document.getElementById('progress-percent');
const buttonRow = document.querySelector('#media-downloader-overlay .buttonRow');
if (progressContainer && progressFill && progressCount && progressPercent && buttonRow) {
progressContainer.style.display = 'flex';
buttonRow.style.display = 'none';
progressFill.style.width = `${progress}%`;
progressPercent.textContent = `${Math.round(progress)}%`;
}
}
function insertDownloadIcon() {
const usernameDivs = document.querySelectorAll('[data-testid="UserName"]');
usernameDivs.forEach(usernameDiv => {
if (!usernameDiv.querySelector('.download-icon')) {
let verifiedButton = usernameDiv.querySelector('[aria-label*="verified"], [aria-label*="Verified"]')?.closest('button');
let targetElement = verifiedButton ? verifiedButton.parentElement : usernameDiv.querySelector('.css-1jxf684')?.closest('span');
if (targetElement) {
const iconDiv = document.createElement('div');
iconDiv.className = 'download-icon css-175oi2r r-1awozwy r-xoduu5';
iconDiv.style.cssText = `
display: inline-flex;
align-items: center;
margin-left: 6px;
transition: transform 0.1s ease;
`;
iconDiv.innerHTML = downloadIconSvg;
iconDiv.addEventListener('mouseenter', () => {
iconDiv.style.transform = 'scale(1.1)';
});
iconDiv.addEventListener('mouseleave', () => {
iconDiv.style.transform = 'scale(1)';
});
const wrapperDiv = document.createElement('div');
wrapperDiv.style.cssText = `
display: inline-flex;
align-items: center;
gap: 4px;
`;
wrapperDiv.appendChild(iconDiv);
targetElement.parentNode.insertBefore(wrapperDiv, targetElement.nextSibling);
iconDiv.addEventListener('click', async (e) => {
e.stopPropagation();
extractedUrls.clear();
downloadedCount = 0;
zip = new JSZip();
isDownloading = true;
isPaused = false;
const overlay = document.getElementById('media-downloader-overlay');
const progressContainer = document.getElementById('progress-container');
const buttonContainer = document.querySelector('#media-downloader-overlay > div');
const buttonRow = document.querySelector('#media-downloader-overlay .buttonRow');
if (overlay) {
overlay.style.display = 'flex';
progressContainer.style.display = 'none';
buttonContainer.style.display = 'flex';
buttonRow.style.display = 'flex';
updateStatus('Starting download...');
}
await scrollAndExtract();
});
}
}
});
}
function extractUrls() {
const elements = document.querySelectorAll('div[data-testid="cellInnerDiv"]');
let newUrlsFound = false;
elements.forEach(element => {
const style = element.getAttribute('style');
if (style && style.includes('translateY')) {
const imgElements = element.querySelectorAll('img[src*="https://pbs.twimg.com/media/"]');
imgElements.forEach(img => {
const src = img.getAttribute('src');
if (src && src.includes('format=jpg&name=')) {
const largeSrc = src.replace(/name=\w+x\w+/, 'name=large');
if (!extractedUrls.has(largeSrc)) {
extractedUrls.add(largeSrc);
newUrlsFound = true;
downloadImage(largeSrc);
}
}
});
}
});
updateStatus(`${extractedUrls.size} Images Collected`);
return newUrlsFound;
}
function downloadImage(url) {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'arraybuffer',
onload: function(response) {
const filename = `${url.split('/').pop().split('?')[0]}.jpg`;
zip.file(filename, response.response);
downloadedCount++;
}
});
}
async function smoothScroll(distance, duration) {
const start = window.pageYOffset;
const startTime = 'now' in window.performance ? performance.now() : new Date().getTime();
function scroll() {
if (isPaused || !isDownloading) return;
const now = 'now' in window.performance ? performance.now() : new Date().getTime();
const time = Math.min(1, ((now - startTime) / duration));
window.scrollTo(0, start + (distance * time));
if (time < 1) {
requestAnimationFrame(scroll);
}
}
scroll();
return new Promise(resolve => setTimeout(resolve, duration));
}
async function scrollAndExtract() {
if (isScrolling || !isDownloading) return;
isScrolling = true;
const scrollStep = 1000;
const scrollDuration = 1000;
const waitTime = 1000;
let consecutiveEmptyScrolls = 0;
const maxEmptyScrolls = 3;
while (isDownloading && !isPaused && consecutiveEmptyScrolls < maxEmptyScrolls) {
await smoothScroll(scrollStep, scrollDuration);
await new Promise(resolve => setTimeout(resolve, waitTime));
if (!isDownloading || isPaused) break;
const newUrlsFound = extractUrls();
if (!newUrlsFound) {
consecutiveEmptyScrolls++;
await smoothScroll(scrollStep * 2, scrollDuration);
await new Promise(resolve => setTimeout(resolve, waitTime));
if (!extractUrls()) consecutiveEmptyScrolls++;
} else {
consecutiveEmptyScrolls = 0;
}
}
isScrolling = false;
if (isDownloading && !isPaused) {
finishDownload();
}
}
function finishDownload() {
if (extractedUrls.size === 0) {
updateStatus('No Images Found');
return;
}
updateStatus('Zipping file...');
showProgressBar(0, extractedUrls.size);
const currentUrl = window.location.href;
const match = currentUrl.match(/https:\/\/(?:x|twitter)\.com\/([^\/]+)/);
const username = match ? match[1] : 'Unknown';
zip.generateAsync({type:"blob"}, metadata => {
showProgressBar(metadata.percent, extractedUrls.size);
})
.then(function(content) {
const a = document.createElement('a');
a.href = URL.createObjectURL(content);
a.download = `${username}-${extractedUrls.size}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
const overlay = document.getElementById('media-downloader-overlay');
if (overlay) {
overlay.style.display = 'none';
}
extractedUrls.clear();
isDownloading = false;
isPaused = false;
}, 2000);
});
}
const observer = new MutationObserver(() => {
insertDownloadIcon();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
insertDownloadIcon();
createOverlayElements();
})();