Twitter Download Button

Download images from twitter feeds

// ==UserScript==
// @name         Twitter Download Button
// @namespace    https://github.com/deegdumdoodilly
// @version      2024-11-19
// @description  Download images from twitter feeds
// @author       Jessica Sherer
// @match        https://x.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // Checks if all necessary fields for a download button are in the tweetData object
var tweetDataComplete = function(tweetData){
	return tweetData.imageSource && tweetData.author && tweetData.timestamp && !tweetData.video;
}

// Converts a tweetData object to a string
var tweetToString = function(tweetData){
	return "author=" + tweetData.author + ", imageSource=" + !(!tweetData.imageSource) + ", timestamp:" + tweetData.timestamp;
}

var checkForImage = function(el, tweetData){
	// Recursively search through the element to find out if it's a tweet of an image.
	// If it is, then we also retrieve the necessary data to create a download button

	// "data-testid" is the most useful attribute we can check while searching.
	// A value of "tweetPhoto" indicates an image, but it could also be the thumbnail for
	// a video. These are distinguished by having a subelement with
	// data-testid="videoPlayer", so we have to check to verify that those don't exist

	// Return the tweetData object once we've acquired everything we need
	if(el.hasAttribute("data-testid")){
		let attributeValue = el.getAttribute("data-testid");
		if(attributeValue === "tweetPhoto"){
			// Might still contain a video player, so we search deeper
			// The only thing we need from this part of the tweet is the image src. Locate and terminate this branch
			try{
				if(el.children[0].children[0].children[1].children[0].children[0].getAttribute("data-testid") === "videoPlayer"){
					tweetData.video = true;
					return false;
				}
			}catch (error){

			}
			if(el.getElementsByTagName("img").length > 0){
				tweetData.imageSource += el.getElementsByTagName("img")[0].src + " ";
			}
		}else if(attributeValue === "tweetText"){
			// This part of the tweet is not useful to us, terminate this branch
			return true;
		}else if(attributeValue === "videoPlayer" ){
			// If there is an image, it's only a video thumbnail. Bail out.
			tweetData.video = true;
			return false;
		}else if(attributeValue === "User-Name"){
			// Contains both the username and the timestamp. Username is located here
			tweetData.author = el.children[1].children[0].children[0].children[0].children[0].children[0].innerHTML.substring(1);
			//tweetData.timestamp =  el.children[1].children[0].children[2].children[0].children[0].getAttribute("datetime");
		}
	}

	// Check all children as well
	for (let j = 0; j < el.childElementCount; j++){
		// Returns false if we learn this is a video player, in which case abort the search.
		if(!checkForImage(el.children[j], tweetData)){
			tweetData.video = true;
			break;
		}
	}
	// "true" here indicates that we have not found a reason to stop searching
	return true;
}

// Retrieves the author and timesteamp of a given tweet (lowest DOM element that contains both) and puts them in tweetdata
var getAuthorAndTimestamp = function(el, tweetData){
	tweetData.author = el.children[0].children[0].children[0].children[0].innerHTML.substring(1);
	tweetData.timestamp = el.children[2].children[0].children[0].getAttribute("datetime").replace(".000Z","");
}

// Converts a series of URLs into data blobs and downloads them (taken from https://stackoverflow.com/questions/6150289/how-can-i-convert-an-image-into-base64-string-using-javascript)
function toDataURL(event) {
	let downloadButton = this;
	let url = null; // Will either be defined by recursion or initial invocation
	let urls = []; // Used if there are multiple images to download
	let extraID = "_"; // Appended between author and timestamp. Set to an index indicator if there are multiple images
	if(!downloadButton.hasAttribute("remainingURLs")){ //  Click was invoked naturally
		// Itemize the images (might be only one)
		urls = downloadButton.getAttribute("sourceURLs").split(" ");
		// If there were multiple, make sure we are naming them correctly
		if(urls.length > 1){
			extraID = "-" + urls.length + "-";
		}
	}else{ // Recursive click
		// Use the images in remainingImages
		urls = downloadButton.getAttribute("remainingURLs").split(" ");
		if(urls[0] == ""){
			// Base case has been reached
			downloadButton.removeAttribute("remainingURLs");
			return;
		}
		// Name this one appropriately even if it's the last one
		extraID = "-" + urls.length + "-";
  	}
	console.log(urls.length);
	url = urls.pop();
	// If any images remain in urls, store them in the button to be handled recursively
	downloadButton.setAttribute("remainingURLs", urls.join(" "));

	// Set up the file read
	var xhr = new XMLHttpRequest();
	xhr.onload = function() {
		var reader = new FileReader();
		reader.onloadend = function() {
			// This will read the image from the url we provide.
			downloadButton.href = reader.result;
			// Set the filename
			downloadButton.download = downloadButton.getAttribute("author") + extraID + downloadButton.getAttribute("timestamp");
			// Artificially click the button a second time to trigger the real download. This has two effects, one is
			// to trigger the download, the other is to begin the recursive event to see if images remain.
			var clickEvent = new MouseEvent("click", {
				"view": window,
				"bubbles": true,
				"cancelable": false
			});
			downloadButton.dispatchEvent(clickEvent);
		}
		reader.readAsDataURL(xhr.response);
	};
	// Run and send
	xhr.open('GET', url);
	xhr.responseType = 'blob';
	xhr.send();
}

let addButton = function(tweetData, rootTweet){
	// Identify the "name=[size]" portion and replace it
	let sizeParameterIndex = tweetData.imageSource.lastIndexOf("&name=");
	if(sizeParameterIndex > 0){
		tweetData.imageSource = tweetData.imageSource.substring(0,sizeParameterIndex);
	}
	let sourceURL = tweetData.imageSource.substring(0,sizeParameterIndex) + "&name=orig";

	// First check if the button already exists
	if(rootTweet.getElementsByClassName("download-button").length > 0){
		// If so, we instead append the source URL to its current list of sources
		downloadButton = rootTweet.getElementsByClassName("download-button")[0];
		downloadButton.setAttribute("sourceURLs",downloadButton.getAttribute("sourceURLs") + " " + sourceURL);
		return;
	}


	// Create the download button.
	var downloadButton = document.createElement("a");
	downloadButton.id = "download_button";
	downloadButton.classList.add("download-button");

	// Store information in the attributes to be retrieved when the button is pressed
	downloadButton.setAttribute("sourceURLs", sourceURL);
	downloadButton.setAttribute("author", tweetData.author);
	downloadButton.setAttribute("timestamp", tweetData.timestamp.replace(":", "-").replace(".000Z",""));

	downloadButton.innerHTML = "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><g><path style=\"fill: rgb(213, 218, 223);\" d=\"M 11.99 15.975 L 17.5 10.961 L 16 9.461 L 13 12.461 L 13 3 L 11 3 L 11 12.461 L 8 9.461 L 6.5 10.961 L 12 15.961 L 11.99 15.975 Z M 21 15 L 20.98 18.51 C 20.98 19.89 19.86 21 18.48 21 L 5.5 21 C 4.11 21 3 19.88 3 18.5 L 3 15 L 5 15 L 5 18.5 C 5 18.78 5.22 19 5.5 19 L 18.48 19 C 18.76 19 18.98 18.78 18.98 18.5 L 19 15 L 21 15 Z\"/></g></svg>";
	downloadButton.style.cssText = "width: 1.25em; max-width: 100%; psoition: relative; height: 1.25em; display: inline-block; line-height: 27.95px; font-size: 15px; font-weight:400px; margin: 0px 10px;"
	downloadButton.addEventListener("click", toDataURL);

	// Find the share button and put it right next to there
	let buttons = rootTweet.getElementsByTagName("button");

	for (let j = buttons.length - 1; j >= 0; j--){
		if(buttons[j].hasAttribute("aria-label") && buttons[j].getAttribute("aria-label") === "Share post"){
			buttons[j].parentElement.parentElement.parentElement.insertBefore(downloadButton,buttons[j].parentElement.parentElement);
			console.log("Adding button to tweet: " + tweetToString(tweetData));
			return;
		}
	}
}

// One-time function to attach download buttons to all valid, currently loaded tweets
let addInitialDownloadButtons = function(){
	// All tweets are contained in an 'article' tag, so we iterate through those
	let tweets = document.getElementsByTagName("main")[0].getElementsByTagName("article");

	for (let i = 0; i < tweets.length; i++){
		// Object to store what we know about the given tweet
		let tweetData = {
			author: "",
			imageSource: "",
			timestamp: "",
			video: false
		};
		// So much of this is just reliant on twitter using consistent hierarchies in its DOM objects
		let rootTweet = tweets[i].children[0].children[0];
		if(rootTweet.getElementsByClassName("download-button").length > 0){
			// Already has a button
			console.log("Tweet already has button: " + rootTweet.getAttribute("tweetData"));
			continue;
		}

		// Search through the child tree for an image and an author
		checkForImage(rootTweet, tweetData);
		tweetData.imageSource.trimEnd();

		// Search through the child tree for a timestamp
		tweetData.timestamp = rootTweet.getElementsByTagName("time")[0].getAttribute("datetime");

		// If there's enough info for a download button, add it
		if(tweetDataComplete(tweetData)){
			addButton(tweetData, rootTweet);
		}
	}
}

// Callback function whenever a new element gets added to the document.
let addDownloadButtons = function(mutRecords){
	// Object to store what we know about the given tweet
	let tweetData = {
		author: "",
		imageSource: "",
		timestamp: "",
		video: false
	};
	// Go through each record, and within each record, check each added object
	for(let i = 0; i < mutRecords.length; i++){
		for(let j = 0; j < mutRecords[i].addedNodes.length; j++){
			let newNode = mutRecords[i].addedNodes[j];
			// Check to see if it meets all the qualifications (it is an image loaded under a tweetPhoto)
			if(newNode.tagName == "IMG" && newNode.parentElement.hasAttribute("data-testid") && newNode.parentElement.getAttribute("data-testid") == "tweetPhoto"){
				tweetData.imageSource = newNode.getAttribute("src");
				let rootTweet = newNode.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement;
				// So much of this is just reliant on twitter using consistent hierarchies in its DOM objects
				// In this case we know roughly where to look for the object that contains all the information we need
				if(rootTweet.childElementCount < 4){
					rootTweet = rootTweet.parentElement.parentElement;
					while(rootTweet.childElementCount < 4){
						rootTweet = rootTweet.parentElement;
					}
				}

				// Travel down to the object that holds the author and timestamp, and parse it into tweetData
				let authorAndTime = rootTweet.children[0].children[0].children[0].children[0].children[0].children[1].children[0];
				getAuthorAndTimestamp(authorAndTime, tweetData);

				// Store this in the tweet in case it's ever needed
				rootTweet.setAttribute("tweetData", tweetToString(tweetData));
				// Add the download button
				addButton(tweetData, rootTweet);
			}
		}
	}
}

// Updates whenever new items are added to the feed
const observer = new MutationObserver(addDownloadButtons);
observer.observe(document.getElementsByTagName("main")[0], {childList: true, subtree: true});

addInitialDownloadButtons();
})();