// ==UserScript==
// @name Twitter - clickable links to images and show uncropped thumbnails
// @namespace twitter_linkify
// @version 4.1
// @license GNU AGPLv3
// @description All image posts in Twitter Home, other blog streams and single post views link to the high-res "orig" version. Thumbnail images in the stream are modified to display uncropped.
// @author marp
// @homepageURL https://gf.qytechs.cn/en/users/204542-marp
// @match https://twitter.com/
// @match https://twitter.com/*
// @match https://pbs.twimg.com/media/*
// @exclude https://twitter.com/settings
// @exclude https://twitter.com/settings/*
// @grant GM_xmlhttpRequest
// @connect pbs.twimg.com
// @run-at document-end
// ==/UserScript==
// jshint esversion:8
function adjustSingleMargin(myNode) {
// I SHOULD remove only margin-... values - but there never seems to be anything else - so go easy way and remove ALL style values
var myStyle = myNode.getAttribute("style");
if ( (myStyle !== null) && ( myStyle.includes("margin") || !(myStyle.includes("absolute")) ) ) {
myNode.setAttribute("style", "position: absolute; top: 0px; bottom: 0px; left: 0px; right: 0px");
}
}
function adjustSingleBackgroundSize(myNode) {
var myStyle = myNode.getAttribute("style");
if ( (myStyle !== null) && ( !(myStyle.includes("contain")) ) ) {
myNode.style.backgroundSize = "contain";
}
}
function createSingleImageLink(myDoc, myContext) {
if (myContext.nodeType === Node.ELEMENT_NODE) {
var singlematch;
var singlelink;
var observer;
var config;
singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/') and ancestor::article]",
myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
singlelink = singlematch.singleNodeValue;
if (singlelink !== null) {
// persistently remove "margin-..." styles (they "de-center" the images)
singlematch=myDoc.evaluate(".//div[@aria-label='Image' or @data-testid='tweetPhoto']",
singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var singlenode = singlematch.singleNodeValue;
if (singlenode !== null) {
adjustSingleMargin(singlenode);
observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
adjustSingleMargin(mutation.target);
});
});
config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
observer.observe(singlenode, config);
}
// persistently change image zoom from "cover" to "contain" - this ensures that the full thumbnail is visible
singlematch=myDoc.evaluate(".//div[contains(@style,'background-image')]",
singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
singlenode = singlematch.singleNodeValue;
if (singlenode !== null) {
adjustSingleBackgroundSize(singlenode)
observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
adjustSingleBackgroundSize(mutation.target);
});
});
config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
observer.observe(singlenode, config);
}
// change the link to point to the "orig" version of the image directly
singlematch=myDoc.evaluate(".//img[contains(@src,'https://pbs.twimg.com/media/') and contains(@src,'name=')]",
singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var imagenode = singlematch.singleNodeValue;
if (imagenode !== null) {
var imgurl = new URL(imagenode.getAttribute("src"));
var params = new URLSearchParams(imgurl.search.substring(1));
params.set("name", "orig");
imgurl.search = "?" + params.toString();
singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
imagenode, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
singlenode = singlematch.singleNodeValue;
if (singlenode !== null) {
singlenode.href = imgurl.href;
}
}
}
}
}
function processImages(myDoc, myContext) {
//console.info("processImages-0 ", myContext);
if (myContext.nodeType === Node.ELEMENT_NODE) {
var singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var singlenode=singlematch.singleNodeValue;
if (singlenode !== null) {
createSingleImageLink(myDoc, singlenode); // applies if the added node is descendant or equal to a single image link
} else {
// this assumes that the added node CONTAINS image link(s), i.e. is an ancestor of image(s)
var matches=myDoc.evaluate("./descendant-or-self::a[contains(@href,'/photo/')]",
myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
for(var i=0, el; (i<matches.snapshotLength); i++) {
el=matches.snapshotItem(i);
createSingleImageLink(myDoc, el);
}
}
}
}
var blurStyles = null; // some styles are added on-demand, but once we get the style for the image blurring, we stop updating the list and use this cache for performance reasons
var blurStylesStop = false;
function processBlurring(myDoc, myContext) {
if (myContext.nodeType === Node.ELEMENT_NODE) {
if (!blurStylesStop) {
// Find all CSS that implement blurring - example match: ".r-yfv4eo { filter: blur(30px); }"
// Keep the style names of these matches in an array
// NOTE: This code assumes that all these CSS have selectors without element types, i.e. ".r-yfv4eo" instead of "div.r-yfv4eo"
blurStyles = Array.from(myDoc.styleSheets).filter(ss => { try { return ss.cssRules.length > 0; } catch (e) { return false; } } ).flatMap(ss => Array.from(ss.cssRules).filter(css => css instanceof CSSStyleRule && css.cssText.indexOf('blur(')>=0)).map(css => css.selectorText.substring(1));
}
var matches;
var pos;
for (const bs of blurStyles) {
matches = myDoc.evaluate("./descendant-or-self::div[contains(@class, '"+bs+"')]", myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
for(var i=0, el; (i<matches.snapshotLength); i++) {
el=matches.snapshotItem(i);
el.className = el.className.replace(bs, ''); //remove the blurring
// remove the overlay with the info text and button to show/ide (assumption: it is always the next sibling element)
if (el.nextSibling !== null) {
el.nextSibling.remove();
blurStylesStop = true; // found and used the correct blurring style - stop searching and rebuilding the style list (performance)
}
}
}
}
}
function observeArticles(myDoc, myContext) {
if (myContext.nodeType === Node.ELEMENT_NODE) {
var singlematch;
var matches;
matches=myDoc.evaluate("./descendant-or-self::article[./ancestor::section/ancestor::div[@data-testid='primaryColumn']/ancestor::main]",
myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
for(var i=0, el; (i<matches.snapshotLength); i++) {
el=matches.snapshotItem(i);
processImages(myDoc, el);
processBlurring(myDoc, el);
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(addedNode) {
processImages(mutation.target.ownerDocument, addedNode);
processBlurring(mutation.target.ownerDocument, addedNode);
});
});
});
var config = { attributes: false, childList: true, characterData: false, subtree: true };
observer.observe(el, config);
}
}
}
function insertLinkElement(myDoc, wrapElement, linkTarget, downloadName) {
var newnode;
var parentnode;
newnode = myDoc.createElement("a");
newnode.setAttribute("href", linkTarget);
newnode.setAttribute("target", "_blank");
newnode.setAttribute("download", downloadName);
parentnode = wrapElement.parentNode;
parentnode.replaceChild(newnode, wrapElement);
newnode.appendChild(wrapElement);
}
function getCleanImageURL(imageurl) {
var pos = imageurl.toLowerCase().lastIndexOf(":");
var pos2 = imageurl.toLowerCase().indexOf("/");
if (pos >= 0 && pos > pos2) {
return imageurl.substring(0, pos);
} else {
return imageurl;
}
}
function getFilename(imageurl) {
return getCleanImageURL(imageurl).substring(imageurl.toLowerCase().lastIndexOf("/")+1);
}
// This ASYNC method returns a promise to retrieve the HTTP response header data for the supplied URL.
// It uses an "HTTP HEAD" request which does NOT download the response payload (to minimize network traffic)
async function checkUrlHeaderOnlyPromise(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'HEAD',
url: url,
onload: function(response) {
if ((response.readyState >= 2) && (response.status == 200)) {
resolve( { url: response.finalUrl, origurl: url} );
} else {
reject( { url: url, origurl: url } );
}
},
ontimeout: function(response) {
reject( { url: url, origurl: url } );
},
onerror: function(response) {
reject( { url: url, origurl: url } );
}
});
});
}
// TWO very different actions depending on if this is on twitter.com or twing.com
// == 1: twing.com -> deal with direct image URLs
if (window.location.href.includes('pbs.twimg.com/media')){
var params = new URLSearchParams(document.location.search.substring(1));
if (params.has("name")) {
if (params.get("name") !== "orig" ) {
// IMAGE URL being loaded - if not already, modify image URL to go to "orig" destination
params.set("name", "orig");
document.location.search = "?" + params.toString();
} else {
if (params.has("format")) {
if (params.get("format").toLowerCase() == "webp" ) {
// IMAGE URL with "orig" and "webp" format-> check and "brute-force resolve" 404 issue if webp image was force-fed by Twitter
var imagename = document.location.pathname.substring(1 + document.location.pathname.lastIndexOf("/"));
var singlematch;
var singlelink;
singlematch = document.evaluate("//img[contains(@src,'" + imagename + "')]", // simple test, "srcset" is never used here
document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
singlelink = singlematch.singleNodeValue;
if (singlelink == null) {
// if the image does not exist -> 404 -> wrong link -> try otrher image formats, starting with the most common two (jpg, png)
var checkPromises1 = new Array(2);
var checkURL1 = new URL(document.location.href);
checkURL1.searchParams.set("format", "jpg");
checkPromises1[0] = checkUrlHeaderOnlyPromise(checkURL1.href);
checkURL1.searchParams.set("format", "png");
checkPromises1[1] = checkUrlHeaderOnlyPromise(checkURL1.href);
// wait until at least one URL has successfully resolved (i.e. HTTP headers loaded without error)
Promise.any(checkPromises1).then(
// SUCCESS -> DONE, navigate to the working url
(result1) => { document.location.href = result1.url; },
// FAILURE -> try the remaining, more exotic image formats (list of all formats determined by file types supported in "Open File" dialog when uploading an image to Twitter
() => {
var checkPromises2 = new Array(4);
var checkURL2 = new URL(document.location.href);
checkURL2.searchParams.set("format", "jpeg");
checkPromises2[0] = checkUrlHeaderOnlyPromise(checkURL2.href);
checkURL2.searchParams.set("format", "jfif");
checkPromises2[1] = checkUrlHeaderOnlyPromise(checkURL2.href);
checkURL2.searchParams.set("format", "pjpeg");
checkPromises2[2] = checkUrlHeaderOnlyPromise(checkURL2.href);
checkURL2.searchParams.set("format", "pjp");
checkPromises2[3] = checkUrlHeaderOnlyPromise(checkURL2.href);
// wait until at least one URL has successfully resolved (i.e. HTTP headers loaded without error)
Promise.any(checkPromises2).then(
// SUCCESS -> DONE, navigate to the working url
(result2) => { document.location.href = result2.url; },
// FAILURE -> found no working alternative image url -> do nothing, stay on original (not working) url.
() => { /* do nothing */ }
);
}
);
}
}
}
}
}
}
else
{
// == 2: twitter.com -> modify Twitter pages
var reactrootmatch = document.evaluate("//div[@id='react-root']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
var reactrootnode = reactrootmatch.singleNodeValue;
if (reactrootnode !== null) {
// create an observer instance and iterate through each individual new node
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(addedNode) {
observeArticles(mutation.target.ownerDocument, addedNode);
});
});
});
// configuration of the observer
var config = { attributes: false, childList: true, characterData: false, subtree: true };
//process already loaded nodes (the initial posts before scrolling down for the first time)
observeArticles(document, reactrootnode);
//start the observer for new nodes
observer.observe(reactrootnode, config);
}
}