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.

当前为 2022-11-07 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter - clickable links to images and show uncropped thumbnails
  3. // @namespace twitter_linkify
  4. // @version 3.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. // @run-at document-end
  15. // ==/UserScript==
  16.  
  17.  
  18.  
  19.  
  20. function adjustSingleMargin(myNode) {
  21. // I SHOULD remove only margin-... values - but there never seems to be anything else - so go easy way and remove ALL style values
  22. var myStyle = myNode.getAttribute("style");
  23. if ( (myStyle !== null) && ( myStyle.includes("margin") || !(myStyle.includes("absolute")) ) ) {
  24. myNode.setAttribute("style", "position: absolute; top: 0px; bottom: 0px; left: 0px; right: 0px");
  25. }
  26. }
  27.  
  28. function adjustSingleBackgroundSize(myNode) {
  29. var myStyle = myNode.getAttribute("style");
  30. if ( (myStyle !== null) && ( !(myStyle.includes("contain")) ) ) {
  31. myNode.style.backgroundSize = "contain";
  32. }
  33. }
  34.  
  35.  
  36. function createSingleImageLink(myDoc, myContext) {
  37.  
  38. if (myContext.nodeType === Node.ELEMENT_NODE) {
  39.  
  40. var singlematch;
  41. var singlelink;
  42. var observer;
  43. var config;
  44. singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/') and ancestor::article]",
  45. myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  46. singlelink = singlematch.singleNodeValue;
  47. if (singlelink !== null) {
  48.  
  49. // persistently remove "margin-..." styles (they "de-center" the images)
  50. singlematch=myDoc.evaluate(".//div[@aria-label='Image' or @data-testid='tweetPhoto']",
  51. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  52. var singlenode = singlematch.singleNodeValue;
  53. if (singlenode !== null) {
  54. adjustSingleMargin(singlenode);
  55. observer = new MutationObserver(function(mutations) {
  56. mutations.forEach(function(mutation) {
  57. adjustSingleMargin(mutation.target);
  58. });
  59. });
  60. config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
  61. observer.observe(singlenode, config);
  62. }
  63.  
  64. // persistently change image zoom from "cover" to "contain" - this ensures that the full thumbnail is visible
  65. singlematch=myDoc.evaluate(".//div[contains(@style,'background-image')]",
  66. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  67. singlenode = singlematch.singleNodeValue;
  68. if (singlenode !== null) {
  69. adjustSingleBackgroundSize(singlenode)
  70. observer = new MutationObserver(function(mutations) {
  71. mutations.forEach(function(mutation) {
  72. adjustSingleBackgroundSize(mutation.target);
  73. });
  74. });
  75. config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
  76. observer.observe(singlenode, config);
  77. }
  78.  
  79. // change the link to point to the "orig" version of the image directly
  80. singlematch=myDoc.evaluate(".//img[contains(@src,'https://pbs.twimg.com/media/') and contains(@src,'name=')]",
  81. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  82. var imagenode = singlematch.singleNodeValue;
  83. if (imagenode !== null) {
  84. var imgurl = new URL(imagenode.getAttribute("src"));
  85. var params = new URLSearchParams(imgurl.search.substring(1));
  86. params.set("name", "orig");
  87. imgurl.search = "?" + params.toString();
  88. singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
  89. imagenode, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  90. singlenode = singlematch.singleNodeValue;
  91. if (singlenode !== null) {
  92. singlenode.href = imgurl.href;
  93. }
  94. }
  95.  
  96. }
  97. }
  98. }
  99.  
  100.  
  101. function processImages(myDoc, myContext) {
  102.  
  103. //console.info("processImages-0 ", myContext);
  104.  
  105. if (myContext.nodeType === Node.ELEMENT_NODE) {
  106.  
  107. var singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
  108. myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  109. var singlenode=singlematch.singleNodeValue;
  110. if (singlenode !== null) {
  111.  
  112. createSingleImageLink(myDoc, singlenode); // applies if the added node is descendant or equal to a single image link
  113.  
  114. } else {
  115.  
  116. // this assumes that the added node CONTAINS image link(s), i.e. is an ancestor of image(s)
  117. var matches=myDoc.evaluate("./descendant-or-self::a[contains(@href,'/photo/')]",
  118. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  119. for(var i=0, el; (i<matches.snapshotLength); i++) {
  120. el=matches.snapshotItem(i);
  121. createSingleImageLink(myDoc, el);
  122. }
  123.  
  124. }
  125. }
  126. }
  127.  
  128.  
  129. 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
  130. var blurStylesStop = false;
  131. function processBlurring(myDoc, myContext) {
  132.  
  133. if (myContext.nodeType === Node.ELEMENT_NODE) {
  134.  
  135. if (!blurStylesStop) {
  136. // Find all CSS that implement blurring - example match: ".r-yfv4eo { filter: blur(30px); }"
  137. // Keep the style names of these matches in an array
  138. // NOTE: This code assumes that all these CSS have selectors without element types, i.e. ".r-yfv4eo" instead of "div.r-yfv4eo"
  139. 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));
  140. }
  141.  
  142. var matches;
  143. var pos;
  144. for (const bs of blurStyles) {
  145. matches = myDoc.evaluate("./descendant-or-self::div[contains(@class, '"+bs+"')]", myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  146. for(var i=0, el; (i<matches.snapshotLength); i++) {
  147. el=matches.snapshotItem(i);
  148. el.className = el.className.replace(bs, ''); //remove the blurring
  149. // remove the overlay with the info text and button to show/ide (assumption: it is always the next sibling element)
  150. if (el.nextSibling !== null) {
  151. el.nextSibling.remove();
  152. blurStylesStop = true; // found and used the correct blurring style - stop searching and rebuilding the style list (performance)
  153. }
  154. }
  155.  
  156. }
  157. }
  158. }
  159.  
  160.  
  161.  
  162. function observeArticles(myDoc, myContext) {
  163.  
  164. if (myContext.nodeType === Node.ELEMENT_NODE) {
  165.  
  166. var singlematch;
  167. var matches;
  168. matches=myDoc.evaluate("./descendant-or-self::article[./ancestor::section/ancestor::div[@data-testid='primaryColumn']/ancestor::main]",
  169. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  170. for(var i=0, el; (i<matches.snapshotLength); i++) {
  171. el=matches.snapshotItem(i);
  172.  
  173. processImages(myDoc, el);
  174. processBlurring(myDoc, el);
  175.  
  176. var observer = new MutationObserver(function(mutations) {
  177. mutations.forEach(function(mutation) {
  178. mutation.addedNodes.forEach(function(addedNode) {
  179. processImages(mutation.target.ownerDocument, addedNode);
  180. processBlurring(mutation.target.ownerDocument, addedNode);
  181. });
  182. });
  183. });
  184. var config = { attributes: false, childList: true, characterData: false, subtree: true };
  185. observer.observe(el, config);
  186. }
  187. }
  188. }
  189.  
  190.  
  191. function insertLinkElement(myDoc, wrapElement, linkTarget, downloadName) {
  192. var newnode;
  193. var parentnode;
  194.  
  195. newnode = myDoc.createElement("a");
  196. newnode.setAttribute("href", linkTarget);
  197. newnode.setAttribute("target", "_blank");
  198. newnode.setAttribute("download", downloadName);
  199. parentnode = wrapElement.parentNode;
  200. parentnode.replaceChild(newnode, wrapElement);
  201. newnode.appendChild(wrapElement);
  202. }
  203.  
  204.  
  205. function getCleanImageURL(imageurl) {
  206. var pos = imageurl.toLowerCase().lastIndexOf(":");
  207. var pos2 = imageurl.toLowerCase().indexOf("/");
  208. if (pos >= 0 && pos > pos2) {
  209. return imageurl.substring(0, pos);
  210. } else {
  211. return imageurl;
  212. }
  213. }
  214.  
  215.  
  216. function getFilename(imageurl) {
  217. return getCleanImageURL(imageurl).substring(imageurl.toLowerCase().lastIndexOf("/")+1);
  218. }
  219.  
  220.  
  221.  
  222. // Two very different actions depending on if this is on twitter.com or twing.com
  223. if (window.location.href.includes('pbs.twimg.com/media')){
  224.  
  225. var params = new URLSearchParams(document.location.search.substring(1));
  226.  
  227. if (params.has("name")) {
  228. if (params.get("name") !== "orig" ) {
  229. // new Twitter UI - no need anymore to modify the SaveAs filename
  230. params.set("name", "orig");
  231. document.location.search = "?" + params.toString();
  232. }
  233. } else {
  234. // old Twitter UI - insert link with attrib "download" to modify the SaveAs filename (need to click the image)
  235. var image = document.querySelector('img');
  236. var url = image.src;
  237. insertLinkElement(document, image, getCleanImageURL(url)+":orig", getFilename(url));
  238. }
  239.  
  240. }
  241. else
  242. {
  243.  
  244. var reactrootmatch = document.evaluate("//div[@id='react-root']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  245. var reactrootnode = reactrootmatch.singleNodeValue;
  246.  
  247. if (reactrootnode !== null) {
  248. // create an observer instance and iterate through each individual new node
  249. var observer = new MutationObserver(function(mutations) {
  250. mutations.forEach(function(mutation) {
  251. mutation.addedNodes.forEach(function(addedNode) {
  252. observeArticles(mutation.target.ownerDocument, addedNode);
  253. });
  254. });
  255. });
  256.  
  257. // configuration of the observer
  258. var config = { attributes: false, childList: true, characterData: false, subtree: true };
  259.  
  260. //process already loaded nodes (the initial posts before scrolling down for the first time)
  261. observeArticles(document, reactrootnode);
  262.  
  263. //start the observer for new nodes
  264. observer.observe(reactrootnode, config);
  265. }
  266. }