// ==UserScript==
// @name Twitter/X Media Batch Downloader
// @description Batch download all media images in original quality.
// @icon https://www.google.com/s2/favicons?sz=64&domain=x.com
// @version 1.2
// @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
// @connect pbs.twimg.com
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// ==/UserScript==
(function() {
'use strict';
const imageIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" style="vertical-align: middle; cursor: pointer;">
<path fill="currentColor" d="M448 80c8.8 0 16 7.2 16 16l0 319.8-5-6.5-136-176c-4.5-5.9-11.6-9.3-19-9.3s-14.4 3.4-19 9.3L202 340.7l-30.5-42.7C167 291.7 159.8 288 152 288s-15 3.7-19.5 10.1l-80 112L48 416.3l0-.3L48 96c0-8.8 7.2-16 16-16l384 0zM64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm80 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/>
</svg>`;
const zipIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="16" height="16">
<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM96 48c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm-6.3 71.8c3.7-14 16.4-23.8 30.9-23.8l14.8 0c14.5 0 27.2 9.7 30.9 23.8l23.5 88.2c1.4 5.4 2.1 10.9 2.1 16.4c0 35.2-28.8 63.7-64 63.7s-64-28.5-64-63.7c0-5.5 .7-11.1 2.1-16.4l23.5-88.2zM112 336c-8.8 0-16 7.2-16 16s7.2 16 16 16l32 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-32 0z"/>
</svg>`;
const downloadIcon = `<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>`;
const pauseResumeIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="16" height="16">
<path fill="currentColor" d="M116.5 71.4c-9.5-7.9-22.8-9.7-34.1-4.4S64 83.6 64 96l0 320c0 12.4 7.2 23.7 18.4 29s24.5 3.6 34.1-4.4l192-160c7.3-6.1 11.5-15.1 11.5-24.6s-4.2-18.5-11.5-24.6l-192-160zM448 96c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 320c0 17.7 14.3 32 32 32s32-14.3 32-32l0-320zm128 0c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 320c0 17.7 14.3 32 32 32s32-14.3 32-32l0-320z"/>
</svg>`;
const stopIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="16" height="16">
<path fill="currentColor" d="M0 128C0 92.7 28.7 64 64 64H320c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128z"/>
</svg>`;
let extractedUrls = [];
let isScrolling = false;
let isPaused = false;
let shouldStop = false;
let imageCounter;
let controlPanel = null;
let isDownloading = false;
async function downloadImages() {
if (isDownloading) return;
isDownloading = true;
const zip = new JSZip();
const username = window.location.pathname.split('/')[1];
const progressContainer = controlPanel.panel.querySelector('.progress-container');
const progressFill = progressContainer.querySelector('.progress-fill');
const progressText = progressContainer.querySelector('.progress-text');
const buttonsContainer = controlPanel.panel.querySelector('.buttons-container');
buttonsContainer.style.display = 'none';
progressContainer.style.display = 'block';
imageCounter.innerHTML = `${zipIcon} ${extractedUrls.length}`;
let successfulDownloads = 0;
const totalImages = extractedUrls.length;
const batchSize = 5;
const batches = [];
for (let i = 0; i < extractedUrls.length; i += batchSize) {
const batch = extractedUrls.slice(i, i + batchSize).map(async (url, batchIndex) => {
try {
const response = await fetch(url, {
method: 'GET',
credentials: 'omit',
headers: {
'Accept': 'image/jpeg,image/*',
}
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
const fileNumber = (i + batchIndex + 1).toString();
zip.file(`${username}_${fileNumber}.jpg`, blob);
successfulDownloads++;
const progress = Math.round((successfulDownloads / totalImages) * 100);
progressFill.style.width = `${progress}%`;
progressText.textContent = `Downloading: (${successfulDownloads}/${totalImages}) ${progress}%`;
return true;
} catch (error) {
console.error('Error downloading image:', error);
return false;
}
});
batches.push(Promise.all(batch));
}
for (const batch of batches) {
await batch;
}
if (successfulDownloads > 0) {
progressText.textContent = `Creating ZIP: (0/${successfulDownloads}) 0%`;
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 3 }
}, metadata => {
const progress = Math.round(metadata.percent);
const processedImages = Math.round((progress / 100) * successfulDownloads);
progressFill.style.width = `${progress}%`;
progressText.textContent = `Creating ZIP: (${processedImages}/${successfulDownloads}) ${progress}%`;
});
const downloadUrl = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `${username}_${successfulDownloads}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
}
isDownloading = false;
hideControlPanel();
}
function createControlPanel() {
const styles = `
.control-panel {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
background-color: rgba(35, 35, 35, 0.75);
padding: 12px;
border-radius: 12px;
transform: translateX(calc(100% + 16px));
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
pointer-events: none;
width: 250px;
}
.control-panel.visible {
transform: translateX(0);
opacity: 1;
pointer-events: all;
}
.control-panel.hiding {
transform: translateX(calc(100% + 16px));
opacity: 0;
pointer-events: none;
}
.buttons-container {
display: flex;
gap: 8px;
margin-bottom: 8px;
transition: display 0.3s ease;
justify-content: center;
width: 100%;
}
.control-button {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-family: inherit;
cursor: pointer;
transition: background-color 0.2s ease;
width: 120px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: white;
font-size: 14px;
flex: 1;
max-width: 120px;
}
.pause-button {
background-color: #1da1f2;
}
.pause-button:hover {
background-color: #1a91da;
}
.stop-button {
background-color: #dc2626;
}
.stop-button:hover {
background-color: #b31f1f;
}
.image-counter {
color: white;
text-align: center;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 20px;
}
.progress-container {
display: none;
margin-top: 8px;
width: 100%;
}
.progress-bar {
width: 100%;
height: 4px;
background-color: #1a1a1a;
border-radius: 2px;
}
.progress-fill {
width: 0%;
height: 100%;
background-color: #1da1f2;
border-radius: 2px;
transition: width 0.3s ease;
}
.progress-text {
color: white;
font-size: 12px;
text-align: center;
margin-top: 4px;
min-height: 16px;
}
`;
if (!document.querySelector('#control-panel-styles')) {
const styleSheet = document.createElement('style');
styleSheet.id = 'control-panel-styles';
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
}
const panel = document.createElement('div');
panel.className = 'control-panel';
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'buttons-container';
const pauseButton = document.createElement('button');
pauseButton.className = 'control-button pause-button';
pauseButton.innerHTML = `${pauseResumeIcon}Pause`;
pauseButton.onclick = () => {
isPaused = !isPaused;
pauseButton.innerHTML = `${pauseResumeIcon}${isPaused ? 'Resume' : 'Pause'}`;
};
const stopButton = document.createElement('button');
stopButton.className = 'control-button stop-button';
stopButton.innerHTML = `${stopIcon}Stop`;
stopButton.onclick = () => {
shouldStop = true;
};
const counter = document.createElement('div');
counter.className = 'image-counter';
counter.innerHTML = `${imageIcon} 0`;
const progressContainer = document.createElement('div');
progressContainer.className = 'progress-container';
progressContainer.innerHTML = `
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<div class="progress-text">0%</div>
`;
buttonsContainer.appendChild(pauseButton);
buttonsContainer.appendChild(stopButton);
panel.appendChild(buttonsContainer);
panel.appendChild(counter);
panel.appendChild(progressContainer);
document.body.appendChild(panel);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
panel.classList.add('visible');
});
});
return {
counter,
panel
};
}
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=360x360')) {
const largeSrc = src.replace('name=360x360', 'name=large');
if (!extractedUrls.includes(largeSrc)) {
extractedUrls.push(largeSrc);
newUrlsFound = true;
imageCounter.innerHTML = `${imageIcon} ${extractedUrls.length}`;
}
}
});
}
});
return newUrlsFound;
}
async function smoothScroll(distance, duration) {
const start = window.pageYOffset;
const startTime = 'now' in window.performance ? performance.now() : new Date().getTime();
function scroll() {
const now = 'now' in window.performance ? performance.now() : new Date().getTime();
const time = Math.min(1, ((now - startTime) / duration));
window.scrollTo({
top: start + (distance * time),
behavior: 'auto'
});
if (time < 1) {
requestAnimationFrame(scroll);
}
}
scroll();
}
async function scrollAndExtract() {
if (isScrolling) return;
isScrolling = true;
const getScrollStep = () => Math.max(window.innerHeight || document.documentElement.clientHeight);
const scrollDuration = 1000;
const waitTime = 1000;
while (!shouldStop) {
if (isPaused) {
await new Promise(resolve => setTimeout(resolve, 500));
continue;
}
const currentScrollStep = getScrollStep();
await smoothScroll(currentScrollStep, scrollDuration);
await new Promise(resolve => setTimeout(resolve, waitTime));
const newUrlsFound = extractUrls();
if (!newUrlsFound) {
await smoothScroll(currentScrollStep * 2, scrollDuration);
await new Promise(resolve => setTimeout(resolve, waitTime));
if (!extractUrls()) break;
}
}
isScrolling = false;
console.log('Finished extracting. Total unique URLs:', extractedUrls.size);
if (shouldStop || !isPaused) {
downloadImages();
}
}
function hideControlPanel() {
if (controlPanel?.panel) {
controlPanel.panel.classList.remove('visible');
controlPanel.panel.classList.add('hiding');
controlPanel.panel.addEventListener('transitionend', function handler(e) {
if (e.propertyName === 'opacity') {
controlPanel.panel.removeEventListener('transitionend', handler);
controlPanel.panel.remove();
controlPanel = null;
}
});
}
}
function resetState() {
extractedUrls = [];
isScrolling = false;
isPaused = false;
shouldStop = false;
imageCounter = null;
if (controlPanel?.panel) {
controlPanel.panel.remove();
controlPanel = null;
}
}
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: 4px;
margin-right: 4px;
gap: 4px;
padding: 0 2px;
transition: transform 0.2s, color 0.2s;
`;
iconDiv.innerHTML = downloadIcon;
iconDiv.addEventListener('mouseenter', () => {
iconDiv.style.transform = 'scale(1.1)';
iconDiv.style.color = '#1DA1F2';
});
iconDiv.addEventListener('mouseleave', () => {
iconDiv.style.transform = 'scale(1)';
iconDiv.style.color = '';
});
iconDiv.addEventListener('click', async (e) => {
e.stopPropagation();
const mediaTab = Array.from(document.querySelectorAll('[role="tab"]'))
.find(el => el.textContent.includes('Media'));
if (mediaTab) {
mediaTab.click();
await new Promise(resolve => setTimeout(resolve, 1000));
initializeImageExtractor();
}
});
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);
}
}
});
}
function initializeImageExtractor() {
const controls = createControlPanel();
controlPanel = controls;
imageCounter = controls.counter;
scrollAndExtract();
}
insertDownloadIcon();
let lastUrl = location.href;
new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
resetState();
setTimeout(insertDownloadIcon, 1000);
} else {
insertDownloadIcon();
}
}).observe(document.body, {
childList: true,
subtree: true
});
})();