// ==UserScript== // @name Twitter/X Media Batch Downloader // @description Batch download all images and videos from a Twitter/X account in original quality. // @icon https://raw.githubusercontent.com/afkarxyz/Twitter-X-Media-Batch-Downloader/refs/heads/main/Archived/icon.svg // @version 1.7 // @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 // @grant GM_setValue // @grant GM_getValue // @connect twitterxapis.vercel.app // @connect pbs.twimg.com // @connect video.twimg.com // @require https://cdn.jsdelivr.net/npm/jszip@3.7.1/dist/jszip.min.js // @downloadURL none // ==/UserScript== ;(() => { const mediaIcon = `` const imageIcon = `` const videoIcon = `` const zipIcon = `` const downloadIcon = `` const loadingIcon = `` let controlPanel = null let imageCounter let isDownloading = false async function fetchMetadata(username, url) { const authToken = GM_getValue('auth_token', '') return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url || `https://twitterxapis.vercel.app/metadata/${username}/${authToken}`, headers: { Accept: "application/json", }, onload: (response) => { try { const data = JSON.parse(response.responseText) if (data.error === "None") { reject(new Error("Invalid authentication token")) return } if (data.timeline) { data.timeline = data.timeline.map((item, index) => ({ ...item, tweet_id: item.tweet_id || `${index}`, })) } resolve(data) } catch (error) { reject(error) } }, onerror: (error) => { reject(error) }, }) }) } async function downloadFile(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, responseType: "blob", headers: { Accept: "image/jpeg,image/*,video/*", }, onload: (response) => { resolve(response.response) }, onerror: (error) => { reject(error) }, }) }) } function createCustomMenu(username) { const menuOverlay = document.createElement("div") menuOverlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.75); display: flex; justify-content: center; align-items: center; z-index: 10000; ` const menu = document.createElement("div") menu.style.cssText = ` background-color: rgba(35, 35, 35, 0.75); border-radius: 6px; width: 240px; padding: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; ` const title = document.createElement("h2") title.textContent = "Download Options" title.style.cssText = ` margin-top: 0; margin-bottom: 15px; font-size: 16px; font-weight: bold; color: white; text-align: center; ` const tokenContainer = document.createElement("div") tokenContainer.style.cssText = ` margin-bottom: 15px; ` const tokenInput = document.createElement("input") tokenInput.type = "text" tokenInput.value = GM_getValue('auth_token', '') tokenInput.placeholder = "Enter Auth Token" tokenInput.style.cssText = ` width: 100%; padding: 8px; margin-bottom: 8px; background-color: rgba(255, 255, 255, 0.1); border: none; border-radius: 4px; color: white; font-size: 14px; box-sizing: border-box; ` tokenInput.addEventListener('input', (e) => { GM_setValue('auth_token', e.target.value) }) const options = [ { name: "Media", icon: mediaIcon, url: `https://twitterxapis.vercel.app/metadata/${username}` }, { name: "Image", icon: imageIcon, url: `https://twitterxapis.vercel.app/metadata/image/${username}` }, { name: "Video", icon: videoIcon, url: `https://twitterxapis.vercel.app/metadata/video/${username}` }, ] options.forEach((option) => { const button = document.createElement("button") button.innerHTML = `${option.icon} ${option.name}` button.style.cssText = ` display: flex; align-items: center; gap: 10px; margin-bottom: 10px; padding: 10px; width: 100%; border: none; background-color: rgba(255, 255, 255, 0.1); color: white; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; font-size: 14px; ` button.addEventListener("mouseenter", () => { button.style.backgroundColor = "rgba(255, 255, 255, 0.2)" }) button.addEventListener("mouseleave", () => { button.style.backgroundColor = "rgba(255, 255, 255, 0.1)" }) button.addEventListener("click", async () => { menuOverlay.remove() try { const iconDiv = document.querySelector(".download-icon") if (iconDiv) { iconDiv.innerHTML = loadingIcon } const authToken = GM_getValue('auth_token', '') const metadata = await fetchMetadata(username, `${option.url}/${authToken}`) if (iconDiv) { iconDiv.innerHTML = downloadIcon } const controls = createControlPanel() controlPanel = controls imageCounter = controls.counter downloadMedia(metadata, option.icon) } catch (error) { console.error("Error fetching metadata:", error) alert(error.message === "Invalid authentication token" ? "Invalid authentication token. Please check your token and try again." : "Failed to fetch media data. Please try again later.") const iconDiv = document.querySelector(".download-icon") if (iconDiv) { iconDiv.innerHTML = downloadIcon } } }) menu.appendChild(button) }) tokenContainer.appendChild(tokenInput) menu.insertBefore(tokenContainer, menu.firstChild) menu.insertBefore(title, menu.firstChild) menuOverlay.appendChild(menu) document.body.appendChild(menuOverlay) menuOverlay.addEventListener("click", (e) => { if (e.target === menuOverlay) { menuOverlay.remove() } }) } function getFileExtension(url) { if (url.includes("video.twimg.com")) return ".mp4" return ".jpg" } function formatDate(dateString) { const date = new Date(dateString) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, "0") const day = String(date.getDate()).padStart(2, "0") const hours = String(date.getHours()).padStart(2, "0") const minutes = String(date.getMinutes()).padStart(2, "0") const seconds = String(date.getSeconds()).padStart(2, "0") return `${year}${month}${day}_${hours}${minutes}${seconds}` } async function downloadMedia(metadata, icon) { if (isDownloading || !controlPanel?.panel) return isDownloading = true const zip = new JSZip() const { account_info, timeline, total_urls } = metadata const { name, nick } = account_info 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") if (!progressContainer || !progressFill || !progressText || !imageCounter) { console.error("Required elements not found") isDownloading = false return } buttonsContainer?.style && (buttonsContainer.style.display = "none") progressContainer.style.display = "block" imageCounter.innerHTML = `${icon || mediaIcon} ${total_urls}` let successfulDownloads = 0 const batchSize = 5 const batches = [] const filenameCounts = new Map() for (let i = 0; i < timeline.length; i += batchSize) { const batch = timeline.slice(i, i + batchSize).map(async ({ url, date }) => { try { const blob = await downloadFile(url) const fileExt = getFileExtension(url) const formattedDate = formatDate(date) const baseFileName = `${name}_${formattedDate}` let fileName = baseFileName + fileExt if (filenameCounts.has(baseFileName)) { const count = filenameCounts.get(baseFileName) + 1 filenameCounts.set(baseFileName, count) fileName = `${baseFileName}_${String(count).padStart(2, "0")}${fileExt}` } else { filenameCounts.set(baseFileName, 0) } zip.file(fileName, blob) successfulDownloads++ const progress = Math.round((successfulDownloads / total_urls) * 100) progressFill.style.width = `${progress}%` progressText.textContent = `Downloading: (${successfulDownloads}/${total_urls}) ${progress}%` console.log(`Downloaded: ${fileName} (${successfulDownloads}/${total_urls})`) return true } catch (error) { console.error("Error downloading media:", error, url) return false } }) batches.push(Promise.all(batch)) await new Promise((resolve) => setTimeout(resolve, 100)) } for (const batch of batches) { await batch } console.log(`Total successful downloads: ${successfulDownloads}`) console.log(`Total expected files: ${total_urls}`) if (successfulDownloads > 0) { imageCounter.innerHTML = `${zipIcon} ${successfulDownloads}` 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 processedFiles = Math.round((progress / 100) * successfulDownloads) progressFill.style.width = `${progress}%` progressText.textContent = `Creating ZIP: (${processedFiles}/${successfulDownloads}) ${progress}%` }, ) const downloadUrl = URL.createObjectURL(zipBlob) const a = document.createElement("a") a.href = downloadUrl a.download = `${name}_(${nick})_${successfulDownloads}` 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: 6px; 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: 200px; } .control-panel.visible { transform: translateX(0); opacity: 1; pointer-events: all; } .control-panel.hiding { transform: translateX(calc(100% + 16px)); opacity: 0; pointer-events: none; } .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 counter = document.createElement("div") counter.className = "image-counter" counter.innerHTML = `${mediaIcon} 0` const progressContainer = document.createElement("div") progressContainer.className = "progress-container" progressContainer.innerHTML = `