Twitter Download Button

Download images from twitter feeds

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
})();