Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality.
// ==UserScript== // @name Twitter/X Media Batch Downloader // @description Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality. // @icon https://raw.githubusercontent.com/afkarxyz/Twitter-X-Media-Batch-Downloader/refs/heads/main/Archived/icon.svg // @version 2.5 // @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== ;(() => { function createSVGIcon(pathD, viewBox = "0 0 640 512") { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") svg.setAttribute("xmlns", "http://www.w3.org/2000/svg") svg.setAttribute("viewBox", viewBox) svg.setAttribute("width", "16") svg.setAttribute("height", "16") const path = document.createElementNS("http://www.w3.org/2000/svg", "path") path.setAttribute("fill", "currentColor") path.setAttribute("d", pathD) svg.appendChild(path) return svg } const mediaIcon = createSVGIcon( "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", ) const imageIcon = createSVGIcon( "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", "0 0 512 512", ) const gifIcon = createSVGIcon( "M160 432l192 0 0-112 0-128 0-112L160 80l0 112 0 128 0 112zM112 80L64 80c-8.8 0-16 7.2-16 16l0 72 64 0 0-88zm0 136l-64 0 0 80 64 0 0-80zm0 128l-64 0 0 72c0 8.8 7.2 16 16 16l48 0 0-88zM400 80l0 88 64 0 0-72c0-8.8-7.2-16-16-16l-48 0zm64 136l-64 0 0 80 64 0 0-80zm0 128l-64 0 0 88 48 0c8.8 0 16-7.2 16-16l0-72zM64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96C0 60.7 28.7 32 64 32z", "0 0 512 512" ) const videoIcon = createSVGIcon( "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", "0 0 512 512", ) const iconClear = createSVGIcon( "M505 41c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0L335 143l-12.9-12.9c-20.2-20.2-51.4-24.6-76.3-10.7L16.4 246.9C6.3 252.5 0 263.2 0 274.8c0 8.5 3.4 16.6 9.3 22.6L214.7 502.7c6 6 14.1 9.3 22.6 9.3c11.6 0 22.3-6.3 27.9-16.4L392.6 266.2c13.9-25 9.5-56.1-10.7-76.3L369 177 505 41zM323.6 291.6l-90 162.1L137 357.1l18-53.9c2.1-6.3-3.9-12.2-10.1-10.1L90.9 311 58.4 278.5l162.1-90L323.6 291.6z", "0 0 512 512" ) const zipIcon = createSVGIcon( "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", "0 0 384 512", ) function createDownloadIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") svg.setAttribute("xmlns", "http://www.w3.org/2000/svg") svg.setAttribute("viewBox", "0 0 512 512") svg.setAttribute("width", "18") svg.setAttribute("height", "18") svg.style.verticalAlign = "middle" svg.style.cursor = "pointer" const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs") const style = document.createElementNS("http://www.w3.org/2000/svg", "style") style.textContent = ".fa-secondary{opacity:.4}" defs.appendChild(style) svg.appendChild(defs) const secondaryPath = document.createElementNS("http://www.w3.org/2000/svg", "path") secondaryPath.setAttribute("class", "fa-secondary") secondaryPath.setAttribute("fill", "currentColor") secondaryPath.setAttribute( "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", ) svg.appendChild(secondaryPath) const primaryPath = document.createElementNS("http://www.w3.org/2000/svg", "path") primaryPath.setAttribute("class", "fa-primary") primaryPath.setAttribute("fill", "currentColor") primaryPath.setAttribute( "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.appendChild(primaryPath) return svg } const downloadIcon = createDownloadIcon() function createLoadingIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") svg.setAttribute("xmlns", "http://www.w3.org/2000/svg") svg.setAttribute("viewBox", "0 0 24 24") svg.setAttribute("width", "20") svg.setAttribute("height", "20") svg.style.verticalAlign = "middle" const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path") path1.setAttribute("fill", "currentColor") path1.setAttribute("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") path1.setAttribute("opacity", "0.25") svg.appendChild(path1) const path2 = document.createElementNS("http://www.w3.org/2000/svg", "path") path2.setAttribute("fill", "currentColor") path2.setAttribute( "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", ) const animateTransform = document.createElementNS("http://www.w3.org/2000/svg", "animateTransform") animateTransform.setAttribute("attributeName", "transform") animateTransform.setAttribute("dur", "0.75s") animateTransform.setAttribute("repeatCount", "indefinite") animateTransform.setAttribute("type", "rotate") animateTransform.setAttribute("values", "0 12 12;360 12 12") path2.appendChild(animateTransform) svg.appendChild(path2) return svg } const loadingIcon = createLoadingIcon() let controlPanel = null let imageCounter let isDownloading = false let errorPopup = null function getCachedUrls(username) { const cache = GM_getValue(`${username}_cache`, {}) const downloadedUrls = GM_getValue(`${username}_downloaded`, {}) return { cache, downloadedUrls } } function cacheUrls(username, metadata) { const { downloadedUrls } = getCachedUrls(username) const newUrls = [] let newUrlCount = 0 metadata.timeline.forEach(item => { if (!downloadedUrls[item.tweet_id]) { newUrls.push(item) newUrlCount++ } }) return { newUrls, newUrlCount } } function markUrlsAsDownloaded(username, urls) { const { downloadedUrls } = getCachedUrls(username) const updatedDownloadedUrls = { ...downloadedUrls } urls.forEach(item => { updatedDownloadedUrls[item.tweet_id] = true }) GM_setValue(`${username}_downloaded`, updatedDownloadedUrls) } function clearCache(username) { GM_setValue(`${username}_cache`, {}) GM_setValue(`${username}_downloaded`, {}) } function createPopup(message, buttons = [], isError = false) { if (errorPopup) { errorPopup.remove() } const overlay = document.createElement("div") overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 10000; ` const popup = document.createElement("div") popup.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: rgba(35, 35, 35, 0.9); padding: 20px; border-radius: 8px; z-index: 10001; width: 300px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: white; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); ` const title = document.createElement("h3") title.textContent = isError ? "Check Your Auth Token!" : "Confirmation" title.style.cssText = ` margin: 0 0 12px 0; font-size: 18px; font-weight: bold; text-align: center; color: ${isError ? '#ff4444' : '#ffffff'}; ` popup.appendChild(title) const messageElement = document.createElement("p") messageElement.style.cssText = ` margin: 0 0 12px 0; font-size: 14px; line-height: 1.4; text-align: center; ` const messageParts = message.split(/<br\s*\/?>/i) messageParts.forEach((part, index) => { messageElement.appendChild(document.createTextNode(part)) if (index < messageParts.length - 1) { messageElement.appendChild(document.createElement("br")) } }) popup.appendChild(messageElement) if (isError) { const link = document.createElement("a") link.href = "https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader?tab=readme-ov-file#how-to-obtain-auth-token" link.target = "_blank" link.textContent = "How to Obtain Auth Token" link.style.cssText = ` display: block; text-align: center; color: #1da1f2; text-decoration: none; font-size: 14px; margin-bottom: 12px; ` popup.appendChild(link) } const buttonsContainer = document.createElement("div") buttonsContainer.style.cssText = ` display: flex; gap: 8px; justify-content: center; ` buttons.forEach(({ text, onClick, color }) => { const button = document.createElement("button") button.textContent = text button.style.cssText = ` width: 120px; padding: 8px; background-color: ${color}; border: none; border-radius: 4px; color: white; font-size: 14px; text-align: center; cursor: pointer; transition: background-color 0.2s; ` button.addEventListener("mouseenter", () => { let hoverColor = color if (color === "#28a745") hoverColor = "#218838" else if (color === "#1da1f2") hoverColor = "#1991db" else if (color === "#dc3545") hoverColor = "#c82333" button.style.backgroundColor = hoverColor }) button.addEventListener("mouseleave", () => { button.style.backgroundColor = color }) button.addEventListener("click", () => { onClick() overlay.remove() errorPopup = null }) buttonsContainer.appendChild(button) }) popup.appendChild(buttonsContainer) overlay.appendChild(popup) document.body.appendChild(overlay) errorPopup = overlay overlay.addEventListener("click", (e) => { if (e.target === overlay) { overlay.remove() errorPopup = null } }) } 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 { if (response.responseText.toLowerCase().startsWith("<!doctype")) { reject(new Error("Invalid authentication token")) return } const raw = response.responseText const data = JSON.parse(raw.replace(/"tweet_id":(\d+)/g, '"tweet_id":"$1"')) 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(new Error("Invalid authentication token")) } }, onerror: () => reject(new Error("Invalid authentication token")), }) }) } async function downloadFile(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, responseType: "blob", headers: { Accept: "image/jpeg,image/*,video/*" }, onload: (response) => resolve(response.response), onerror: reject, }) }) } function formatNumber(num) { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } 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.5); 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.9); 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 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: "GIF", icon: gifIcon, url: `https://twitterxapis.vercel.app/metadata/gif/${username}` }, { name: "Video", icon: videoIcon, url: `https://twitterxapis.vercel.app/metadata/video/${username}` }, { name: "Clear Cache", icon: iconClear, action: () => { createPopup( "Are you sure you want to clear the cache?", [ { text: "Yes", onClick: () => { clearCache(username) createPopup( "Cache cleared successfully!", [ { text: "OK", onClick: () => {}, color: "#1da1f2" } ] ) }, color: "#dc3545" }, { text: "No", onClick: () => {}, color: "#1da1f2" } ] ) } } ] 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; ` menu.appendChild(title) menu.appendChild(tokenInput) options.forEach(({ name, icon, url, action }) => { const button = document.createElement("button") 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; ` const iconContainer = document.createElement("div") iconContainer.style.cssText = ` display: flex; align-items: center; justify-content: center; width: 16px; height: 16px; ` const iconClone = icon.cloneNode(true) iconContainer.appendChild(iconClone) const textContainer = document.createElement("div") textContainer.style.cssText = ` display: flex; align-items: center; gap: 4px; ` const buttonText = document.createTextNode(name) textContainer.appendChild(buttonText) button.appendChild(iconContainer) button.appendChild(textContainer) 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 () => { if (action) { action() return } try { const buttonText = textContainer.firstChild const originalText = buttonText.textContent buttonText.textContent = "Fetching..." while (iconContainer.firstChild) { iconContainer.removeChild(iconContainer.firstChild) } iconContainer.appendChild(loadingIcon.cloneNode(true)) const allButtons = menu.querySelectorAll('button') allButtons.forEach(btn => btn.disabled = true) const authToken = GM_getValue("auth_token", "") const metadata = await fetchMetadata(username, `${url}/${authToken}`) const { newUrls, newUrlCount } = cacheUrls(username, metadata) const isFirstDownload = newUrlCount === metadata.total_urls || newUrls.length === 0 while (iconContainer.firstChild) { iconContainer.removeChild(iconContainer.firstChild) } buttonText.textContent = originalText const countText = document.createTextNode(` ${formatNumber(metadata.total_urls)}`) iconContainer.appendChild(icon.cloneNode(true)) textContainer.appendChild(countText) const existingButtons = menu.querySelector('.confirmation-buttons') if (existingButtons) { existingButtons.remove() } const downloadOptions = document.createElement('div') downloadOptions.className = 'confirmation-buttons' downloadOptions.style.cssText = ` display: flex; flex-direction: column; gap: 8px; margin-top: 12px; ` if (newUrlCount > 0 && !isFirstDownload) { const downloadNewButton = document.createElement("button") downloadNewButton.textContent = `Download New (${formatNumber(newUrlCount)})` downloadNewButton.style.cssText = ` width: 100%; padding: 6px 16px; border: none; border-radius: 4px; background-color: #28a745; color: white; cursor: pointer; font-size: 14px; text-align: center; transition: background-color 0.2s; ` downloadNewButton.addEventListener("mouseenter", () => downloadNewButton.style.backgroundColor = "#218838") downloadNewButton.addEventListener("mouseleave", () => downloadNewButton.style.backgroundColor = "#28a745") downloadNewButton.addEventListener("click", () => { createPopup( `Do you want to download ${formatNumber(newUrlCount)} new files?`, [ { text: "Download", onClick: () => { const newMetadata = { ...metadata, timeline: newUrls, total_urls: newUrlCount } menuOverlay.remove() controlPanel = createControlPanel() imageCounter = controlPanel.counter downloadMedia(newMetadata, icon, username) }, color: "#28a745" }, { text: "Cancel", onClick: () => {}, color: "#dc3545" } ] ) }) downloadOptions.appendChild(downloadNewButton) } const downloadAllButton = document.createElement("button") downloadAllButton.textContent = isFirstDownload || !newUrls.length ? `Download (${formatNumber(metadata.total_urls)})` : `Download All (${formatNumber(metadata.total_urls)})` downloadAllButton.style.cssText = ` width: 100%; padding: 6px 16px; border: none; border-radius: 4px; background-color: #1da1f2; color: white; cursor: pointer; font-size: 14px; text-align: center; transition: background-color 0.2s; ` downloadAllButton.addEventListener("mouseenter", () => downloadAllButton.style.backgroundColor = "#1991db") downloadAllButton.addEventListener("mouseleave", () => downloadAllButton.style.backgroundColor = "#1da1f2") downloadAllButton.addEventListener("click", () => { createPopup( `Do you want to download ${formatNumber(metadata.total_urls)} files?`, [ { text: "Download", onClick: () => { menuOverlay.remove() controlPanel = createControlPanel() imageCounter = controlPanel.counter downloadMedia(metadata, icon, username) }, color: "#1da1f2" }, { text: "Cancel", onClick: () => {}, color: "#dc3545" } ] ) }) downloadOptions.appendChild(downloadAllButton) const cancelButton = document.createElement("button") cancelButton.textContent = "Cancel" cancelButton.style.cssText = ` width: 100%; padding: 6px 16px; border: none; border-radius: 4px; background-color: #dc3545; color: white; cursor: pointer; font-size: 14px; text-align: center; transition: background-color 0.2s; ` cancelButton.addEventListener("mouseenter", () => cancelButton.style.backgroundColor = "#c82333") cancelButton.addEventListener("mouseleave", () => cancelButton.style.backgroundColor = "#dc3545") cancelButton.addEventListener("click", () => menuOverlay.remove()) downloadOptions.appendChild(cancelButton) menu.appendChild(downloadOptions) allButtons.forEach(btn => btn.disabled = false) } catch (error) { console.error("Error fetching metadata:", error) createPopup( "It might be invalid or expired.<br>Also, ensure that your account is still logged in.", [ { text: "Close", onClick: () => {}, color: "#1da1f2" } ], true ) while (iconContainer.firstChild) { iconContainer.removeChild(iconContainer.firstChild) } const buttonText = textContainer.firstChild buttonText.textContent = name iconContainer.appendChild(iconClone) const allButtons = menu.querySelectorAll('button') allButtons.forEach(btn => btn.disabled = false) } }) menu.appendChild(button) }) 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" if (url.includes(".gif")) return ".gif" return ".jpg" } function formatDate(dateString) { const date = new Date(dateString) const pad = (num) => String(num).padStart(2, "0") return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` } 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; } .cancel-button { background-color: #dc3545; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-size: 12px; text-align: center; cursor: pointer; transition: background-color 0.2s; margin-top: 12px; display: block; margin-left: auto; margin-right: auto; width: 80px; } .cancel-button:hover { background-color: #c82333; }` 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.appendChild(mediaIcon.cloneNode(true)) counter.appendChild(document.createTextNode(" 0")) const progressContainer = document.createElement("div") progressContainer.className = "progress-container" const progressBar = document.createElement("div") progressBar.className = "progress-bar" const progressFill = document.createElement("div") progressFill.className = "progress-fill" const progressText = document.createElement("div") progressText.className = "progress-text" progressText.textContent = "0%" const cancelButton = document.createElement("button") cancelButton.className = "cancel-button" cancelButton.textContent = "Cancel" cancelButton.addEventListener("click", () => { isDownloading = false hideControlPanel() }) progressBar.appendChild(progressFill) progressContainer.appendChild(progressBar) progressContainer.appendChild(progressText) progressContainer.appendChild(cancelButton) panel.appendChild(counter) panel.appendChild(progressContainer) document.body.appendChild(panel) requestAnimationFrame(() => { requestAnimationFrame(() => { panel.classList.add("visible") }) }) return { counter, panel } } function formatDownloadDate() { const now = new Date() const year = now.getFullYear() const month = String(now.getMonth() + 1).padStart(2, '0') const day = String(now.getDate()).padStart(2, '0') const hours = String(now.getHours()).padStart(2, '0') const minutes = String(now.getMinutes()).padStart(2, '0') const seconds = String(now.getSeconds()).padStart(2, '0') return `${year}${month}${day}_${hours}${minutes}${seconds}` } async function downloadMedia(metadata, icon, username) { if (isDownloading || !controlPanel?.panel) return isDownloading = true const { account_info, timeline, total_urls } = metadata const { name } = account_info const BATCH_SIZE = 5 const FILES_PER_ZIP = 500 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 } if (buttonsContainer?.style) buttonsContainer.style.display = "none" progressContainer.style.display = "block" while (imageCounter.firstChild) { imageCounter.removeChild(imageCounter.firstChild) } imageCounter.appendChild(icon.cloneNode(true)) imageCounter.appendChild(document.createTextNode(` ${formatNumber(total_urls)}`)) let successfulDownloads = [] const filenameCounts = new Map() const batches = [] for (let i = 0; i < timeline.length; i += BATCH_SIZE) { if (!isDownloading) { console.log("Download cancelled") return } const batch = timeline.slice(i, i + BATCH_SIZE).map(async (item) => { if (!isDownloading) return false try { const blob = await downloadFile(item.url) const fileExt = getFileExtension(item.url) const formattedDate = formatDate(item.date) const baseFileName = `${name}_${formattedDate}_${item.tweet_id}` 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) } successfulDownloads.push({ blob, fileName, item }) const progress = Math.round((successfulDownloads.length / total_urls) * 100) progressFill.style.width = `${progress}%` progressText.textContent = `Downloading: (${formatNumber(successfulDownloads.length)}/${formatNumber(total_urls)}) ${progress}%` return true } catch (error) { console.error("Error downloading media:", error, item.url) return false } }) batches.push(Promise.all(batch)) await new Promise((resolve) => setTimeout(resolve, 100)) } for (const batch of batches) { if (!isDownloading) return await batch } if (successfulDownloads.length > 0 && isDownloading) { markUrlsAsDownloaded(username, successfulDownloads.map(download => download.item)) while (imageCounter.firstChild) { imageCounter.removeChild(imageCounter.firstChild) } imageCounter.appendChild(zipIcon.cloneNode(true)) imageCounter.appendChild(document.createTextNode(` ${formatNumber(successfulDownloads.length)}`)) if (successfulDownloads.length === 1) { const { blob, fileName } = successfulDownloads[0] const downloadUrl = URL.createObjectURL(blob) const a = document.createElement("a") a.href = downloadUrl a.download = fileName document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(downloadUrl) } else { const totalParts = Math.ceil(successfulDownloads.length / FILES_PER_ZIP) const downloadDate = formatDownloadDate() for (let partIndex = 0; partIndex < totalParts; partIndex++) { if (!isDownloading) return const startIndex = partIndex * FILES_PER_ZIP const endIndex = Math.min((partIndex + 1) * FILES_PER_ZIP, successfulDownloads.length) const partFiles = successfulDownloads.slice(startIndex, endIndex) const zip = new JSZip() partFiles.forEach(({ blob, fileName }) => { zip.file(fileName, blob) }) const zipBlob = await zip.generateAsync( { type: "blob", compression: "DEFLATE", compressionOptions: { level: 3 }, }, (metadata) => { if (!isDownloading) return const progress = Math.round(metadata.percent) const processedFiles = Math.round((progress / 100) * partFiles.length) const totalProcessed = startIndex + processedFiles progressFill.style.width = `${progress}%` progressText.textContent = `Creating ZIP Part ${partIndex + 1}/${totalParts}: (${formatNumber(processedFiles)}/${formatNumber(partFiles.length)}) ${progress}%` }, ) if (isDownloading) { const downloadUrl = URL.createObjectURL(zipBlob) const partSuffix = totalParts > 1 ? `_Part_${String(partIndex + 1).padStart(2, '0')}` : '' const a = document.createElement("a") a.href = downloadUrl a.download = `${name}_${downloadDate}_${successfulDownloads.length}${partSuffix}.zip` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(downloadUrl) await new Promise(resolve => setTimeout(resolve, 1000)) } } } } isDownloading = false hideControlPanel() } 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 extractUsername() { const pathParts = window.location.pathname.split('/').filter(part => part); if (pathParts.length > 0) { return pathParts[0]; } return null; } function insertDownloadIcon() { const usernameDivs = document.querySelectorAll('[data-testid="UserName"]') usernameDivs.forEach((usernameDiv) => { if (!usernameDiv.querySelector(".download-icon")) { const username = extractUsername() if (!username) return 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.appendChild(downloadIcon.cloneNode(true)) 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() 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, }) })()