// ==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/[email protected]/dist/jszip.min.js
// ==/UserScript==
;(() => {
const mediaIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="16" height="16">
<path fill="currentColor" d="M256 48c-8.8 0-16 7.2-16 16l0 224c0 8.7 6.9 15.8 15.6 16l69.1-94.2c4.5-6.2 11.7-9.8 19.4-9.8s14.8 3.6 19.4 9.8L380 232.4l56-85.6c4.4-6.8 12-10.9 20.1-10.9s15.7 4.1 20.1 10.9L578.7 303.8c7.6-1.3 13.3-7.9 13.3-15.8l0-224c0-8.8-7.2-16-16-16L256 48zM192 64c0-35.3 28.7-64 64-64L576 0c35.3 0 64 28.7 64 64l0 224c0 35.3-28.7 64-64 64l-320 0c-35.3 0-64-28.7-64-64l0-224zm-56 64l24 0 0 48 0 88 0 112 0 8 0 80 192 0 0-80 48 0 0 80 48 0c8.8 0 16-7.2 16-16l0-64 48 0 0 64c0 35.3-28.7 64-64 64l-48 0-24 0-24 0-192 0-24 0-24 0-48 0c-35.3 0-64-28.7-64-64L0 192c0-35.3 28.7-64 64-64l48 0 24 0zm-24 48l-48 0c-8.8 0-16 7.2-16 16l0 48 64 0 0-64zm0 288l0-64-64 0 0 48c0 8.8 7.2 16 16 16l48 0zM48 352l64 0 0-64-64 0 0 64zM304 80a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/>
</svg>`
const imageIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16">
<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 videoIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16">
<path fill="currentColor" d="M352 432l-192 0 0-112 0-40 192 0 0 40 0 112zm0-200l-192 0 0-40 0-112 192 0 0 112 0 40zM64 80l48 0 0 88-64 0 0-72c0-8.8 7.2-16 16-16zM48 216l64 0 0 80-64 0 0-80zm64 216l-48 0c-8.8 0-16-7.2-16-16l0-72 64 0 0 88zM400 168l0-88 48 0c8.8 0 16 7.2 16 16l0 72-64 0zm0 48l64 0 0 80-64 0 0-80zm0 128l64 0 0 72c0 8.8-7.2 16-16 16l-48 0 0-88zM448 32L64 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-64z"/>
</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="18" height="18" 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 loadingIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" style="vertical-align: middle;">
<path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity="0.25"/>
<path fill="currentColor" d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z">
<animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>
</path>
</svg>`
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 = `
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<div class="progress-text">0%</div>
`
panel.appendChild(counter)
panel.appendChild(progressContainer)
document.body.appendChild(panel)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
panel.classList.add("visible")
})
})
return {
counter,
panel,
}
}
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 insertDownloadIcon() {
const usernameDivs = document.querySelectorAll('[data-testid="UserName"]')
usernameDivs.forEach((usernameDiv) => {
if (!usernameDiv.querySelector(".download-icon")) {
const verifiedButton = usernameDiv
.querySelector('[aria-label*="verified"], [aria-label*="Verified"]')
?.closest("button")
const 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;
margin-right: 6px;
gap: 6px;
padding: 0 3px;
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", (e) => {
e.stopPropagation()
const username = window.location.pathname.split("/")[1]
createCustomMenu(username)
})
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 resetState() {
imageCounter = null
if (controlPanel?.panel) {
controlPanel.panel.remove()
controlPanel = null
}
}
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,
})
})()