Twitter Download Button

Download images from twitter feeds

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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();
})();