NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Twitter Image Download Button // @namespace https://onee3.org // @version 1.2.6 // @description Show a button to download the full-size images in Twitter // @copyright 2017, Frederick888 (https://openuserjs.org/users/Frederick888) // @author Frederick888 // @license GPL-3.0-or-later // @homepageURL https://github.com/Frederick888/twitter-image-download-button // @supportURL https://github.com/Frederick888/twitter-image-download-button/issues // @contributionURL https://github.com/Frederick888/twitter-image-download-button/pulls // @updateURL https://openuserjs.org/meta/Frederick888/Twitter_Image_Download_Button.meta.js // @match https://twitter.com/* // @match https://mobile.twitter.com/* // @match https://x.com/* // @match https://mobile.x.com/* // @grant none // @require https://onee3.org/libs/fileserver/2.0.5/FileSaver.min.js // @require https://onee3.org/libs/jszip/3.7.1/jszip.min.js // @require https://onee3.org/libs/jszip-utils/0.1.0/jszip-utils.min.js // ==/UserScript== function urlToPromise(url) { return new Promise(function (resolve, reject) { JSZipUtils.getBinaryContent(url, function (err, data) { if (err) { reject(err); } else { resolve(data); } }); }); } function fileMime(filename) { let extension = filename.replace(/.*(\.\w+)$/g, "$1"); switch (extension) { case ".jpg": case ".jpeg": case ".jpe": return "image/jpeg"; case ".gif": return "image/gif"; case ".png": return "image/png"; default: return ""; } } function saveFile(data, filename) { let a = document.getElementById('download-tag'); if (!a) { a = document.createElement('a'); a.id = 'download-tag'; a.style.display = 'none'; document.body.appendChild(a); } var blob = new Blob([data], {type: fileMime(filename)}), url = window.URL.createObjectURL(blob); a.href = url; a.download = filename; a.click(); window.URL.revokeObjectURL(url); } function downloadZip(imageUrls, filenamePrefix) { let zip = new JSZip(); imageUrls.forEach(function (imageUrl) { imageUrl = new URL(imageUrl); imageUrl.searchParams.set('name', 'orig'); console.log('Downloading ' + imageUrl); let filename = filenamePrefix + '-' + imageUrl.pathname.replace(/^\/media\//, '') + '.' + imageUrl.searchParams.get('format'); zip.file(filename, urlToPromise(imageUrl), { binary: true }); }); zip.generateAsync({ type: "blob" }).then(function callback(blob) { if (navigator.userAgent.indexOf('Firefox') || blob.size < 1048576) { // Firefox has no limit on data URLs saveFile(blob, filenamePrefix + '.zip'); } else { FileSaver.saveAs(blob, filenamePrefix + '.zip'); } }, function (e) { alert("Download error!"); console.log(e); }); } function downloadImage(imageUrl, filenamePrefix) { imageUrl = new URL(imageUrl); imageUrl.searchParams.set('name', 'orig'); console.log('Downloading ' + imageUrl); let xhr = new XMLHttpRequest(); xhr.open("GET", imageUrl, true); xhr.responseType = "arraybuffer"; xhr.onload = function (_ev) { let filename = filenamePrefix + '-' + imageUrl.pathname.replace(/^\/media\//, '') + '.' + imageUrl.searchParams.get('format'); let blob = new Blob([xhr.response], { type: fileMime(filename) }); if (navigator.userAgent.indexOf('Firefox') || blob.size < 1048576) { saveFile(blob, filename); } else { FileSaver.saveAs(blob, filename); } }; xhr.send(); } function htmlToElement(html) { var template = document.createElement('template'); html = html.trim(); // Never return a text node of whitespace as the result template.innerHTML = html; return template.content.firstChild; } function isHomePage() { return window.location.pathname.match(/^(\/home)?(\?.*)?$/); } function isTweetPage() { return window.location.pathname.match(/^\/.*\/status\/\d+(\?.*)?$/); } function isPhotoModalPage() { return window.location.pathname.match(/^\/.*\/status\/\d+\/photo\/\d+/); } let ButtonLocation = { HOME_PAGE: 1, TWEET_PAGE: 2, PHOTO_MODAL: 3, }; function getButtonLocation(container) { if (container.classList.contains('r-16y2uox')) { return ButtonLocation.PHOTO_MODAL; } if (isTweetPage()) { return ButtonLocation.TWEET_PAGE; } return ButtonLocation.HOME_PAGE; } function mainLoop() { // cspell: disable let buttonHomePage = htmlToElement(` <div class="css-175oi2r r-18u37iz r-1h0z5md r-13awgt0"> <button role="button" class="css-175oi2r r-1777fci r-bt1l66 r-bztko3 r-lrvibr r-1loqt21 r-1ny4l3l download-button" data-testid="download" type="button"> <div dir="ltr" class="css-146c3p1 r-bcqeeo r-1ttztb7 r-qvutc0 r-37j5jr r-a023e6 r-rjixqe r-16dba41 r-1awozwy r-6koalj r-1h0z5md r-o7ynqc r-clp7b1 r-3s2u2q" style="text-overflow: unset; color: rgb(113, 118, 123);"> <div class="css-175oi2r r-xoduu5"> <div class="css-175oi2r r-xoduu5 r-1p0dtai r-1d2f490 r-u8s1d r-zchlnj r-ipm5af r-1niwhzg r-sdzlij r-xf4iuw r-o7ynqc r-6416eg r-1ny4l3l"></div> <svg class="r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1hdv0qi" fill="#000000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve"><path d="M20.953,11.003l-9.95,9.95c-1.097,1.096-2.537,1.645-3.978,1.645s-2.881-0.548-3.978-1.645 c-2.193-2.194-2.193-5.762-0.001-7.956l5.561-5.56c1.175-1.176,3.225-1.177,4.401,0c0.589,0.588,0.912,1.369,0.912,2.201 s-0.323,1.613-0.912,2.201l-4.316,4.316c-0.234,0.234-0.613,0.234-0.848,0s-0.234-0.614,0-0.849l4.316-4.316 c0.361-0.361,0.56-0.842,0.56-1.353s-0.198-0.991-0.56-1.352c-0.723-0.723-1.983-0.722-2.706,0l-5.56,5.56 c-1.725,1.725-1.725,4.533,0.001,6.258c1.726,1.727,4.533,1.726,6.259,0l9.949-9.95c1.725-1.725,1.725-4.533-0.001-6.258 c-1.725-1.726-4.532-1.725-6.259,0l-1.281,1.283c-0.234,0.234-0.614,0.234-0.849,0c-0.234-0.234-0.234-0.614-0.001-0.849 l1.282-1.283c2.195-2.192,5.763-2.193,7.956,0C23.146,5.241,23.146,8.81,20.953,11.003z"/></svg> </div> <div class="css-175oi2r r-xoduu5 r-1udh08x"> <span data-testid="app-text-transition-container" style="transition-property: transform; transition-duration: 0.3s; transform: translate3d(0px, 0px, 0px);"> <span class="css-1jxf684 r-1ttztb7 r-qvutc0 r-1tl8opc r-n6v787 r-1cwl3u0 r-1k6nrdp r-n7gxbd" style="text-overflow: unset;"> <span class="css-1jxf684 r-bcqeeo r-1ttztb7 r-qvutc0 r-1tl8opc download-button-counter" style="text-overflow: unset;">1</span> </span> </span> </div> </div> </button> </div> `); let buttonModal = htmlToElement(` <div class="css-175oi2r r-18u37iz r-1h0z5md r-13awgt0"> <button role="button" class="css-175oi2r r-1777fci r-bt1l66 r-bztko3 r-lrvibr r-1loqt21 r-1ny4l3l download-button" data-testid="download" type="button"> <div dir="ltr" class="css-146c3p1 r-bcqeeo r-1ttztb7 r-qvutc0 r-37j5jr r-a023e6 r-rjixqe r-16dba41 r-1awozwy r-6koalj r-1h0z5md r-o7ynqc r-clp7b1 r-3s2u2q" style="text-overflow: unset; color: rgb(255, 255, 255);"> <div class="css-175oi2r r-xoduu5"> <div class="css-175oi2r r-xoduu5 r-1p0dtai r-1d2f490 r-u8s1d r-zchlnj r-ipm5af r-1niwhzg r-sdzlij r-xf4iuw r-o7ynqc r-6416eg r-1ny4l3l"></div> <svg class="r-4qtqp9 r-yyyyoo r-50lct3 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1srniue" fill="#000000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve"><path d="M20.953,11.003l-9.95,9.95c-1.097,1.096-2.537,1.645-3.978,1.645s-2.881-0.548-3.978-1.645 c-2.193-2.194-2.193-5.762-0.001-7.956l5.561-5.56c1.175-1.176,3.225-1.177,4.401,0c0.589,0.588,0.912,1.369,0.912,2.201 s-0.323,1.613-0.912,2.201l-4.316,4.316c-0.234,0.234-0.613,0.234-0.848,0s-0.234-0.614,0-0.849l4.316-4.316 c0.361-0.361,0.56-0.842,0.56-1.353s-0.198-0.991-0.56-1.352c-0.723-0.723-1.983-0.722-2.706,0l-5.56,5.56 c-1.725,1.725-1.725,4.533,0.001,6.258c1.726,1.727,4.533,1.726,6.259,0l9.949-9.95c1.725-1.725,1.725-4.533-0.001-6.258 c-1.725-1.726-4.532-1.725-6.259,0l-1.281,1.283c-0.234,0.234-0.614,0.234-0.849,0c-0.234-0.234-0.234-0.614-0.001-0.849 l1.282-1.283c2.195-2.192,5.763-2.193,7.956,0C23.146,5.241,23.146,8.81,20.953,11.003z"/></svg> </div> <div class="css-175oi2r r-xoduu5 r-1udh08x"> <span data-testid="app-text-transition-container" style="transition-property: transform; transition-duration: 0.3s; transform: translate3d(0px, 0px, 0px);"> <span class="css-1jxf684 r-1ttztb7 r-qvutc0 r-1tl8opc r-n6v787 r-1cwl3u0 r-1k6nrdp r-n7gxbd" style="text-overflow: unset;"> <span class="css-1jxf684 r-bcqeeo r-1ttztb7 r-qvutc0 r-1tl8opc download-button-counter" style="text-overflow: unset;">1</span> </span> </span> </div> </div> </button> </div> `); let buttonTweetPage = htmlToElement(` <div class="css-175oi2r r-18u37iz r-1h0z5md r-13awgt0"> <button role="button" class="css-175oi2r r-1777fci r-bt1l66 r-bztko3 r-lrvibr r-1loqt21 r-1ny4l3l download-button" data-testid="download" type="button"> <div dir="ltr" class="css-146c3p1 r-bcqeeo r-1ttztb7 r-qvutc0 r-37j5jr r-a023e6 r-rjixqe r-16dba41 r-1awozwy r-6koalj r-1h0z5md r-o7ynqc r-clp7b1 r-3s2u2q" style="text-overflow: unset; color: rgb(113, 118, 123);"> <div class="css-175oi2r r-xoduu5"> <div class="css-175oi2r r-xoduu5 r-1p0dtai r-1d2f490 r-u8s1d r-zchlnj r-ipm5af r-1niwhzg r-sdzlij r-xf4iuw r-o7ynqc r-6416eg r-1ny4l3l"></div> <svg class="r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1hdv0qi" fill="#000000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve"><path d="M20.953,11.003l-9.95,9.95c-1.097,1.096-2.537,1.645-3.978,1.645s-2.881-0.548-3.978-1.645 c-2.193-2.194-2.193-5.762-0.001-7.956l5.561-5.56c1.175-1.176,3.225-1.177,4.401,0c0.589,0.588,0.912,1.369,0.912,2.201 s-0.323,1.613-0.912,2.201l-4.316,4.316c-0.234,0.234-0.613,0.234-0.848,0s-0.234-0.614,0-0.849l4.316-4.316 c0.361-0.361,0.56-0.842,0.56-1.353s-0.198-0.991-0.56-1.352c-0.723-0.723-1.983-0.722-2.706,0l-5.56,5.56 c-1.725,1.725-1.725,4.533,0.001,6.258c1.726,1.727,4.533,1.726,6.259,0l9.949-9.95c1.725-1.725,1.725-4.533-0.001-6.258 c-1.725-1.726-4.532-1.725-6.259,0l-1.281,1.283c-0.234,0.234-0.614,0.234-0.849,0c-0.234-0.234-0.234-0.614-0.001-0.849 l1.282-1.283c2.195-2.192,5.763-2.193,7.956,0C23.146,5.241,23.146,8.81,20.953,11.003z"/></svg> </div> <div class="css-175oi2r r-xoduu5 r-1udh08x"> <span data-testid="app-text-transition-container" style="transform: translate3d(0px, 0px, 0px); transition-property: transform; transition-duration: 0.3s;"> <span class="css-1jxf684 r-1ttztb7 r-qvutc0 r-1tl8opc r-n6v787 r-1cwl3u0 r-1k6nrdp r-n7gxbd" style="text-overflow: unset;"> <span class="css-1jxf684 r-bcqeeo r-1ttztb7 r-qvutc0 r-1tl8opc download-button-counter" style="text-overflow: unset;">1</span> </span> </span> </div> </div> </button> </div> `); // home: article.css-175oi2r.r-18u37iz.r-1udh08x.r-i023vh.r-1qhn6m8.r-o7ynqc.r-6416eg.r-1ny4l3l.r-1loqt21 // tweet main: article.css-175oi2r.r-18u37iz.r-1udh08x.r-i023vh.r-1qhn6m8.r-1ny4l3l // tweet comments: article.css-175oi2r.r-18u37iz.r-1udh08x.r-i023vh.r-1qhn6m8.r-o7ynqc.r-6416eg.r-1ny4l3l.r-1loqt21 let articles = document.querySelectorAll('article.css-175oi2r'); // div.css-175oi2r.r-16y2uox.r-1wbh5a2 let modals = document.querySelectorAll('div.css-175oi2r[aria-modal="true"] > div > div:first-child'); // cspell: enable let containers; if (isPhotoModalPage() && modals.length > 0) { containers = Array.from(articles).concat(Array.from(modals)); } else { containers = articles; } containers.forEach((container) => { let downloadButton = container.querySelector('.download-button'); let imageBoxes = Array.from(container.querySelectorAll('img.css-9pa8cd')) .filter((imageBox) => { return imageBox.getAttribute('src').indexOf('pbs.twimg.com/media') > 0; }); if (downloadButton && parseInt(downloadButton.getAttribute('count-images')) === imageBoxes.length) { return; } if (imageBoxes.length > 0) { let filenamePrefix; if (imageBoxes[0].closest('div.css-175oi2r > a[href*="/status/"]')) { filenamePrefix = imageBoxes[0].closest('div.css-175oi2r > a[href*="/status/"]').getAttribute('href').replace(/^\/(.*)\/status\/(\d+).*?$/, '$1-$2'); } else { filenamePrefix = window.location.pathname.replace(/^\/(.*)\/status\/(\d+)\/.*?$/, '$1-$2'); } let buttonGroup = container.querySelector(':scope div.css-175oi2r > div.css-175oi2r.r-1kbdv8c.r-18u37iz[role="group"]'); let shareButton = buttonGroup.querySelector(':scope > div:last-child'); let bookmarkInnerButton = buttonGroup.querySelector(':scope > div > [data-testid="bookmark"]'); if (!downloadButton) { if (getButtonLocation(container) == ButtonLocation.HOME_PAGE) { bookmarkInnerButton.parentNode.before(buttonHomePage); } else if (getButtonLocation(container) == ButtonLocation.TWEET_PAGE) { bookmarkInnerButton.parentNode.before(buttonTweetPage); } else if (getButtonLocation(container) == ButtonLocation.PHOTO_MODAL) { shareButton.before(buttonModal); } downloadButton = container.querySelector('.download-button'); } if (downloadButton) { downloadButton.setAttribute('count-images', imageBoxes.length); let downloadButtonCounter = container.querySelector('.download-button-counter'); if (downloadButtonCounter) { downloadButtonCounter.innerHTML = getButtonLocation(container) == ButtonLocation.PHOTO_MODAL ? 1 : imageBoxes.length; } let clonedButton = downloadButton.cloneNode(true); if (imageBoxes.length === 1) { downloadButton.parentNode.replaceChild(clonedButton, downloadButton); clonedButton.addEventListener('click', (_event) => { let imageUrl = imageBoxes[0].getAttribute('src'); downloadImage(imageUrl, filenamePrefix); }); } else if (getButtonLocation(container) == ButtonLocation.PHOTO_MODAL) { downloadButton.parentNode.replaceChild(clonedButton, downloadButton); clonedButton.addEventListener('click', (_event) => { let modalUl = container.querySelector('ul'); let ulWidth = parseInt(modalUl.style.getPropertyValue('width').replace(/px$/, '')); let transformX = parseInt(modalUl.style.getPropertyValue('transform').replace(/.*translate3d\(([\.-\d]+)px.*/, '$1')); let imageIndex = Math.round(Math.abs(transformX) * parseInt(clonedButton.getAttribute('count-images')) / ulWidth); let imageUrl = imageBoxes[imageIndex].getAttribute('src'); downloadImage(imageUrl, filenamePrefix); }); } else { downloadButton.parentNode.replaceChild(clonedButton, downloadButton); clonedButton.addEventListener('click', (_event) => { let imageUrls = imageBoxes.map((imageBox) => { return imageBox.getAttribute('src'); }); downloadZip(imageUrls, filenamePrefix); }); } } } }); } (function () { 'use strict'; let style = htmlToElement(` <style> .download-button:hover > div { color: rgb(255, 102, 0); } .download-button:hover > div > div > div:first-of-type { background-color: rgba(255, 102, 0, 0.1); } div.css-175oi2r[aria-modal="true"] .download-button:hover > div { color: rgb(255, 255, 255); } div.css-175oi2r[aria-modal="true"] .download-button:hover > div > div > div:first-of-type { background-color: rgba(255, 255, 255, 0.1); } </style> `); let head = document.getElementsByTagName('head')[0]; head.appendChild(style); if (typeof MutationObserver === 'function') { let observerConfig = { attributes: true, characterData: true, childList: true, subtree: true, }; let body = document.getElementsByTagName('body')[0]; let observer = new MutationObserver(mainLoop); observer.observe(body, observerConfig); } else { mainLoop(); setInterval(mainLoop, 200); } })();