Twitter - clickable links to images and show uncropped thumbnails

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.

当前为 2023-09-29 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter - clickable links to images and show uncropped thumbnails
  3. // @namespace twitter_linkify
  4. // @version 4.2
  5. // @license GNU AGPLv3
  6. // @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.
  7. // @author marp
  8. // @homepageURL https://greasyfork.org/en/users/204542-marp
  9. // @match https://twitter.com/
  10. // @match https://twitter.com/*
  11. // @match https://pbs.twimg.com/media/*
  12. // @exclude https://twitter.com/settings
  13. // @exclude https://twitter.com/settings/*
  14. // @grant GM_xmlhttpRequest
  15. // @connect pbs.twimg.com
  16. // @run-at document-end
  17. // ==/UserScript==
  18.  
  19. // jshint esversion:8
  20.  
  21.  
  22. function adjustSingleMargin(myNode) {
  23. // I SHOULD remove only margin-... values - but there never seems to be anything else - so go easy way and remove ALL style values
  24. var myStyle = myNode.getAttribute("style");
  25. if ( (myStyle !== null) && ( myStyle.includes("margin") || !(myStyle.includes("absolute")) ) ) {
  26. myNode.setAttribute("style", "position: absolute; top: 0px; bottom: 0px; left: 0px; right: 0px");
  27. }
  28. }
  29.  
  30. function adjustSingleBackgroundSize(myNode) {
  31. var myStyle = myNode.getAttribute("style");
  32. if ( (myStyle !== null) && ( !(myStyle.includes("contain")) ) ) {
  33. myNode.style.backgroundSize = "contain";
  34. }
  35. }
  36.  
  37.  
  38. function createSingleImageLink(myDoc, myContext) {
  39.  
  40. if (myContext.nodeType === Node.ELEMENT_NODE) {
  41.  
  42. var singlematch;
  43. var singlelink;
  44. var observer;
  45. var config;
  46. singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/') and ancestor::article]",
  47. myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  48. singlelink = singlematch.singleNodeValue;
  49. if (singlelink !== null) {
  50.  
  51. // persistently remove "margin-..." styles (they "de-center" the images)
  52. singlematch=myDoc.evaluate(".//div[@aria-label='Image' or @data-testid='tweetPhoto']",
  53. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  54. var singlenode = singlematch.singleNodeValue;
  55. if (singlenode !== null) {
  56. adjustSingleMargin(singlenode);
  57. observer = new MutationObserver(function(mutations) {
  58. mutations.forEach(function(mutation) {
  59. adjustSingleMargin(mutation.target);
  60. });
  61. });
  62. config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
  63. observer.observe(singlenode, config);
  64. }
  65.  
  66. // persistently change image zoom from "cover" to "contain" - this ensures that the full thumbnail is visible
  67. singlematch=myDoc.evaluate(".//div[contains(@style,'background-image')]",
  68. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  69. singlenode = singlematch.singleNodeValue;
  70. if (singlenode !== null) {
  71. adjustSingleBackgroundSize(singlenode)
  72. observer = new MutationObserver(function(mutations) {
  73. mutations.forEach(function(mutation) {
  74. adjustSingleBackgroundSize(mutation.target);
  75. });
  76. });
  77. config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
  78. observer.observe(singlenode, config);
  79. }
  80.  
  81. // change the link to point to the "orig" version of the image directly
  82. singlematch=myDoc.evaluate(".//img[contains(@src,'https://pbs.twimg.com/media/') and contains(@src,'name=')]",
  83. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  84. var imagenode = singlematch.singleNodeValue;
  85. if (imagenode !== null) {
  86. var imgurl = new URL(imagenode.getAttribute("src"));
  87. var params = new URLSearchParams(imgurl.search.substring(1));
  88. if (!(params.has("format", "webp"))) {
  89. // WebP links require special treatment as the original image file might be of differenmt format (Twitter network traffic optimization, it seems).
  90. // WebP image url are left unchanged and are then "post-processed" by another part of this script, which triggers on the image file itself (see code towards end of script)
  91. // The idea is to open a working webp image link which the scedonf scfipt part can then post-process.
  92. // A "name=orig" url can lead to a 404 error, which cannot be post-processed in Chromium browsers.
  93. params.set("name", "orig");
  94. }
  95. imgurl.search = "?" + params.toString();
  96. singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
  97. imagenode, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  98. singlenode = singlematch.singleNodeValue;
  99. if (singlenode !== null) {
  100. singlenode.href = imgurl.href;
  101. }
  102. }
  103.  
  104. }
  105. }
  106. }
  107.  
  108.  
  109. function processImages(myDoc, myContext) {
  110.  
  111. //console.info("processImages-0 ", myContext);
  112.  
  113. if (myContext.nodeType === Node.ELEMENT_NODE) {
  114.  
  115. var singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
  116. myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  117. var singlenode=singlematch.singleNodeValue;
  118. if (singlenode !== null) {
  119.  
  120. createSingleImageLink(myDoc, singlenode); // applies if the added node is descendant or equal to a single image link
  121.  
  122. } else {
  123.  
  124. // this assumes that the added node CONTAINS image link(s), i.e. is an ancestor of image(s)
  125. var matches=myDoc.evaluate("./descendant-or-self::a[contains(@href,'/photo/')]",
  126. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  127. for(var i=0, el; (i<matches.snapshotLength); i++) {
  128. el=matches.snapshotItem(i);
  129. createSingleImageLink(myDoc, el);
  130. }
  131.  
  132. }
  133. }
  134. }
  135.  
  136.  
  137. 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
  138. var blurStylesStop = false;
  139. function processBlurring(myDoc, myContext) {
  140.  
  141. if (myContext.nodeType === Node.ELEMENT_NODE) {
  142.  
  143. if (!blurStylesStop) {
  144. // Find all CSS that implement blurring - example match: ".r-yfv4eo { filter: blur(30px); }"
  145. // Keep the style names of these matches in an array
  146. // NOTE: This code assumes that all these CSS have selectors without element types, i.e. ".r-yfv4eo" instead of "div.r-yfv4eo"
  147. 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));
  148. }
  149.  
  150. var matches;
  151. var pos;
  152. for (const bs of blurStyles) {
  153. matches = myDoc.evaluate("./descendant-or-self::div[contains(@class, '"+bs+"')]", myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  154. for(var i=0, el; (i<matches.snapshotLength); i++) {
  155. el=matches.snapshotItem(i);
  156. el.className = el.className.replace(bs, ''); //remove the blurring
  157. // remove the overlay with the info text and button to show/ide (assumption: it is always the next sibling element)
  158. if (el.nextSibling !== null) {
  159. el.nextSibling.remove();
  160. blurStylesStop = true; // found and used the correct blurring style - stop searching and rebuilding the style list (performance)
  161. }
  162. }
  163.  
  164. }
  165. }
  166. }
  167.  
  168.  
  169.  
  170. function observeArticles(myDoc, myContext) {
  171.  
  172. if (myContext.nodeType === Node.ELEMENT_NODE) {
  173.  
  174. var singlematch;
  175. var matches;
  176. matches=myDoc.evaluate("./descendant-or-self::article[./ancestor::section/ancestor::div[@data-testid='primaryColumn']/ancestor::main]",
  177. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  178. for(var i=0, el; (i<matches.snapshotLength); i++) {
  179. el=matches.snapshotItem(i);
  180.  
  181. processImages(myDoc, el);
  182. processBlurring(myDoc, el);
  183.  
  184. var observer = new MutationObserver(function(mutations) {
  185. mutations.forEach(function(mutation) {
  186. mutation.addedNodes.forEach(function(addedNode) {
  187. processImages(mutation.target.ownerDocument, addedNode);
  188. processBlurring(mutation.target.ownerDocument, addedNode);
  189. });
  190. });
  191. });
  192. var config = { attributes: false, childList: true, characterData: false, subtree: true };
  193. observer.observe(el, config);
  194. }
  195. }
  196. }
  197.  
  198.  
  199. function insertLinkElement(myDoc, wrapElement, linkTarget, downloadName) {
  200. var newnode;
  201. var parentnode;
  202.  
  203. newnode = myDoc.createElement("a");
  204. newnode.setAttribute("href", linkTarget);
  205. newnode.setAttribute("target", "_blank");
  206. newnode.setAttribute("download", downloadName);
  207. parentnode = wrapElement.parentNode;
  208. parentnode.replaceChild(newnode, wrapElement);
  209. newnode.appendChild(wrapElement);
  210. }
  211.  
  212.  
  213. function getCleanImageURL(imageurl) {
  214. var pos = imageurl.toLowerCase().lastIndexOf(":");
  215. var pos2 = imageurl.toLowerCase().indexOf("/");
  216. if (pos >= 0 && pos > pos2) {
  217. return imageurl.substring(0, pos);
  218. } else {
  219. return imageurl;
  220. }
  221. }
  222.  
  223.  
  224. function getFilename(imageurl) {
  225. return getCleanImageURL(imageurl).substring(imageurl.toLowerCase().lastIndexOf("/")+1);
  226. }
  227.  
  228.  
  229. // This ASYNC method returns a promise to retrieve the HTTP response header data for the supplied URL.
  230. // It uses an "HTTP HEAD" request which does NOT download the response payload (to minimize network traffic)
  231. async function checkUrlHeaderOnlyPromise(url) {
  232. return new Promise((resolve, reject) => {
  233. GM_xmlhttpRequest({
  234. method: 'HEAD',
  235. url: url,
  236. onload: function(response) {
  237. if ((response.readyState >= 2) && (response.status == 200)) {
  238. resolve( { url: response.finalUrl, origurl: url} );
  239. } else {
  240. reject( { url: url, origurl: url } );
  241. }
  242. },
  243. ontimeout: function(response) {
  244. reject( { url: url, origurl: url } );
  245. },
  246. onerror: function(response) {
  247. reject( { url: url, origurl: url } );
  248. }
  249. });
  250. });
  251. }
  252.  
  253.  
  254.  
  255. // Helper function for part ofd script that executed on direct image links
  256. function findOrigUrl(mycheckurl, ischeckwebp, mydocument) {
  257. var checkURL = new URL(mycheckurl);
  258. var checkPromises1 = new Array( (ischeckwebp ? 3 : 2) );
  259.  
  260. checkURL.searchParams.set("format", "jpg");
  261. checkPromises1[0] = checkUrlHeaderOnlyPromise(checkURL.href);
  262. checkURL.searchParams.set("format", "png");
  263. checkPromises1[1] = checkUrlHeaderOnlyPromise(checkURL.href);
  264. if (ischeckwebp) {
  265. checkURL.searchParams.set("format", "webp");
  266. checkPromises1[2] = checkUrlHeaderOnlyPromise(checkURL.href);
  267. }
  268. // wait until at least one URL has successfully resolved (i.e. HTTP headers loaded without error)
  269. Promise.any(checkPromises1).then(
  270. // SUCCESS -> DONE, navigate to the working url
  271. (result1) => { mydocument.location.href = result1.url; },
  272. // 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
  273. () => {
  274. var checkPromises2 = new Array(4);
  275. checkURL.searchParams.set("format", "jpeg");
  276. checkPromises2[0] = checkUrlHeaderOnlyPromise(checkURL.href);
  277. checkURL.searchParams.set("format", "jfif");
  278. checkPromises2[1] = checkUrlHeaderOnlyPromise(checkURL.href);
  279. checkURL.searchParams.set("format", "pjpeg");
  280. checkPromises2[2] = checkUrlHeaderOnlyPromise(checkURL.href);
  281. checkURL.searchParams.set("format", "pjp");
  282. checkPromises2[3] = checkUrlHeaderOnlyPromise(checkURL.href);
  283. // wait until at least one URL has successfully resolved (i.e. HTTP headers loaded without error)
  284. Promise.any(checkPromises2).then(
  285. // SUCCESS -> DONE, navigate to the working url
  286. (result2) => { mydocument.location.href = result2.url; },
  287. // FAILURE -> found no working alternative image url -> do nothing, stay on current url.
  288. () => { /* do nothing */ }
  289. );
  290. }
  291. );
  292. }
  293.  
  294.  
  295. // TWO very different actions depending on if this is on twitter.com or twing.com
  296. // == 1: twing.com -> deal with direct image URLs
  297. if (window.location.href.includes('pbs.twimg.com/media')){
  298.  
  299. var params = new URLSearchParams(document.location.search.substring(1));
  300.  
  301. if (params.has("name")) {
  302.  
  303. if ( !(params.has("name", "orig")) ) {
  304.  
  305. if (params.has("format", "webp")) {
  306. // IMAGE URL being loaded is WebP -> orig image might be of different format -> test various formats and navigate if match found (asynch)
  307. var checkURL = new URL(document.location.href);
  308. checkURL.searchParams.set("name", "orig");
  309. findOrigUrl(checkURL.href, true, document);
  310. } else {
  311. // IMAGE URL being loaded is not WebP -> modify image URL to go to "orig" destination
  312. params.set("name", "orig");
  313. document.location.search = "?" + params.toString();
  314. }
  315. /*
  316. } else { // name = "orig"
  317.  
  318. if (params.has("format", "webp")) {
  319. // IMAGE URL with "orig" and "webp" format-> check and "brute-force resolve" 404 issue if webp image was force-fed by Twitter
  320. // This part of the script only works with Firefox. This part should not be needed anymore, but is left here as fallback solution, for now.
  321. var imagename = document.location.pathname.substring(1 + document.location.pathname.lastIndexOf("/"));
  322. var singlematch;
  323. var singlelink;
  324. singlematch = document.evaluate("//img[contains(@src,'" + imagename + "')]", // simple test, "srcset" is never used here
  325. document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  326. singlelink = singlematch.singleNodeValue;
  327. if (singlelink == null) {
  328. // if the image does not exist -> 404 -> wrong link -> try otrher image formats, starting with the most common two (jpg, png)
  329. findOrigUrl(document.location.href, false, document);
  330. }
  331. }
  332. */
  333. }
  334. }
  335.  
  336. }
  337. else
  338. {
  339.  
  340. // == 2: twitter.com -> modify Twitter pages
  341. var reactrootmatch = document.evaluate("//div[@id='react-root']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  342. var reactrootnode = reactrootmatch.singleNodeValue;
  343.  
  344. if (reactrootnode !== null) {
  345. // create an observer instance and iterate through each individual new node
  346. var observer = new MutationObserver(function(mutations) {
  347. mutations.forEach(function(mutation) {
  348. mutation.addedNodes.forEach(function(addedNode) {
  349. observeArticles(mutation.target.ownerDocument, addedNode);
  350. });
  351. });
  352. });
  353.  
  354. // configuration of the observer
  355. var config = { attributes: false, childList: true, characterData: false, subtree: true };
  356.  
  357. //process already loaded nodes (the initial posts before scrolling down for the first time)
  358. observeArticles(document, reactrootnode);
  359.  
  360. //start the observer for new nodes
  361. observer.observe(reactrootnode, config);
  362. }
  363. }