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.

当前为 2020-03-08 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter - clickable links to images and show uncropped thumbnails
  3. // @namespace twitter_linkify
  4. // @version 2.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. // @include https://twitter.com/
  10. // @include https://twitter.com/*
  11. // @include 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 new_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("static")) ) ) {
  24. myNode.setAttribute("style", "position: static");
  25. }
  26. }
  27.  
  28. function new_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 new_createSingleImageLink(myDoc, myContext) {
  37.  
  38. if (myContext.nodeType === Node.ELEMENT_NODE) {
  39.  
  40. var singlematch;
  41. var singlelink;
  42. singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/') and ancestor::article]",
  43. myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  44. singlelink = singlematch.singleNodeValue;
  45. if (singlelink !== null) {
  46. // persistently remove "margin-..." styles (they "de-center" the images)
  47. singlematch=myDoc.evaluate(".//div[@aria-label='Image']",
  48. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  49. var singlenode = singlematch.singleNodeValue;
  50. if (singlenode !== null) {
  51. new_adjustSingleMargin(singlenode);
  52. var observer = new MutationObserver(function(mutations) {
  53. mutations.forEach(function(mutation) {
  54. new_adjustSingleMargin(mutation.target);
  55. });
  56. });
  57. var config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
  58. observer.observe(singlenode, config);
  59. }
  60. // persistently change image zoom from "cover" to "contain" - this ensures that the full thumbnail is visible
  61. singlematch=myDoc.evaluate(".//div[contains(@style,'background-image')]",
  62. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  63. singlenode = singlematch.singleNodeValue;
  64. if (singlenode !== null) {
  65. new_adjustSingleBackgroundSize(singlenode)
  66. var observer = new MutationObserver(function(mutations) {
  67. mutations.forEach(function(mutation) {
  68. new_adjustSingleBackgroundSize(mutation.target);
  69. });
  70. });
  71. var config = { attributes: true, attributeFilter: [ "style" ], attributeOldValue: false, childList: false, characterData: false, subtree: false };
  72. observer.observe(singlenode, config);
  73. }
  74. // change the link to point to the "orig" version of the image directly
  75. singlematch=myDoc.evaluate(".//img[contains(@src,'https://pbs.twimg.com/media/') and contains(@src,'name=')]",
  76. singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  77. var imagenode = singlematch.singleNodeValue;
  78. if (imagenode !== null) {
  79. var imgurl = new URL(imagenode.getAttribute("src"));
  80. var params = new URLSearchParams(imgurl.search.substring(1));
  81. params.set("name", "orig");
  82. imgurl.search = "?" + params.toString();
  83. singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
  84. imagenode, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  85. singlenode = singlematch.singleNodeValue;
  86. if (singlenode !== null) {
  87. singlenode.href = imgurl.href;
  88. }
  89. }
  90. }
  91. }
  92. }
  93.  
  94.  
  95. function new_processImages(myDoc, myContext) {
  96.  
  97. if (myContext.nodeType === Node.ELEMENT_NODE) {
  98.  
  99. var singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]",
  100. myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  101. var singlenode=singlematch.singleNodeValue;
  102. if (singlenode !== null) {
  103. new_createSingleImageLink(myDoc, singlenode); // applies if the added node is descendant or equal to a single image link
  104. } else {
  105. // this assumes that the added node CONTAINS image link(s), i.e. is an ancestor of image(s)
  106. var matches=myDoc.evaluate("./descendant-or-self::a[contains(@href,'/photo/')]",
  107. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  108. for(var i=0, el; (i<matches.snapshotLength); i++) {
  109. el=matches.snapshotItem(i);
  110. new_createSingleImageLink(myDoc, el);
  111. }
  112. }
  113. }
  114. }
  115.  
  116.  
  117.  
  118. function new_observeArticles(myDoc, myContext) {
  119.  
  120. if (myContext.nodeType === Node.ELEMENT_NODE) {
  121.  
  122. var singlematch;
  123. var matches;
  124. matches=myDoc.evaluate("./descendant-or-self::article[./ancestor::section/ancestor::div[@data-testid='primaryColumn']/ancestor::main]",
  125. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  126. for(var i=0, el; (i<matches.snapshotLength); i++) {
  127. el=matches.snapshotItem(i);
  128. new_processImages(myDoc, el);
  129.  
  130. var observer = new MutationObserver(function(mutations) {
  131. mutations.forEach(function(mutation) {
  132. mutation.addedNodes.forEach(function(addedNode) {
  133. new_processImages(mutation.target.ownerDocument, addedNode);
  134. });
  135. });
  136. });
  137. var config = { attributes: false, childList: true, characterData: false, subtree: true };
  138. observer.observe(el, config);
  139. }
  140. }
  141. }
  142.  
  143.  
  144. // CODE for OLD Twitter UI - fnctionality not tested anymore -> let me know if something is broken
  145. function old_createImageLinks(myDoc, myContext) {
  146.  
  147. //console.info("createImageLinks: ", myContext);
  148. if (myDoc===null) myDoc= myContext;
  149. if (myDoc===null) return;
  150. if (myContext===null) myContext= myDoc;
  151. var matches;
  152. var tmpstr;
  153.  
  154. matches=myDoc.evaluate(".//div[contains(@class,'AdaptiveMedia-photoContainer')]/img",
  155. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  156. for(var i=0, el; (i<matches.snapshotLength); i++) {
  157. el=matches.snapshotItem(i);
  158. if (el) {
  159. try {
  160. tmpstr=getCleanImageURL(el.getAttribute("src"), false);
  161. // only need ":small" variant for stream/thumbnail display (save bandwidth and increase performance)
  162. el.setAttribute("src", tmpstr+":small");
  163. // create correct link to ":orig" image - best way to access is by opening in new tab via "middle-click")
  164. insertLinkElement(myDoc, el, tmpstr+":orig", getFilename(tmpstr));
  165. // try to scale the thumbnail image so that it displays fully and uncropped within the available space
  166. // This does not do a real aspect ratio calc but uses a "trick" by analysing how Twitter was positioning the cropped image
  167. tmpstr=el.getAttribute("style");
  168. if (tmpstr !== null) {
  169. if (tmpstr.toLowerCase().includes("top") ) {
  170. el.setAttribute("style", "height: 100%; width: auto");
  171. }
  172. else if (tmpstr.includes("left")) {
  173. el.setAttribute("style", "height: auto; width: 100%");
  174. }
  175. }
  176. } catch (e) { console.warn("error: ", e); }
  177. }
  178. }
  179. }
  180.  
  181.  
  182. function insertLinkElement(myDoc, wrapElement, linkTarget, downloadName) {
  183. var newnode;
  184. var parentnode;
  185. newnode = myDoc.createElement("a");
  186. newnode.setAttribute("href", linkTarget);
  187. newnode.setAttribute("target", "_blank");
  188. newnode.setAttribute("download", downloadName);
  189. parentnode = wrapElement.parentNode;
  190. parentnode.replaceChild(newnode, wrapElement);
  191. newnode.appendChild(wrapElement);
  192. }
  193.  
  194.  
  195. function getCleanImageURL(imageurl) {
  196. var pos = imageurl.toLowerCase().lastIndexOf(":");
  197. var pos2 = imageurl.toLowerCase().indexOf("/");
  198. if (pos >= 0 && pos > pos2) {
  199. return imageurl.substring(0, pos);
  200. } else {
  201. return imageurl;
  202. }
  203. }
  204.  
  205.  
  206. function getFilename(imageurl) {
  207. return getCleanImageURL(imageurl).substring(imageurl.toLowerCase().lastIndexOf("/")+1);
  208. }
  209.  
  210.  
  211.  
  212. // Two very different actions depending on if this is on twitter.com or twing.com
  213. if (window.location.href.includes('pbs.twimg.com/media')){
  214.  
  215. var params = new URLSearchParams(document.location.search.substring(1));
  216.  
  217. if (params.has("name")) {
  218. if (params.get("name") !== "orig" ) {
  219. // new Twitter UI - no need anymore to modify the SaveAs filename
  220. params.set("name", "orig");
  221. document.location.search = "?" + params.toString();
  222. }
  223. } else {
  224. // old Twitter UI - insert link with attrib "download" to modify the SaveAs filename (need to click the image)
  225. var image = document.querySelector('img');
  226. var url = image.src;
  227. insertLinkElement(document, image, getCleanImageURL(url)+":orig", getFilename(url));
  228. }
  229.  
  230. }
  231. else
  232. {
  233.  
  234. var reactrootmatch = document.evaluate("//div[@id='react-root']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  235. var reactrootnode = reactrootmatch.singleNodeValue;
  236.  
  237. if (reactrootnode !== null) {
  238.  
  239. //new Twitter UI
  240.  
  241. // create an observer instance and iterate through each individual new node
  242. var observer = new MutationObserver(function(mutations) {
  243. mutations.forEach(function(mutation) {
  244. mutation.addedNodes.forEach(function(addedNode) {
  245. new_observeArticles(mutation.target.ownerDocument, addedNode);
  246. });
  247. });
  248. });
  249.  
  250. // configuration of the observer
  251. var config = { attributes: false, childList: true, characterData: false, subtree: true };
  252.  
  253. //process already loaded nodes (the initial posts before scrolling down for the first time)
  254. new_observeArticles(document, reactrootnode);
  255. //start the observer for new nodes
  256. observer.observe(reactrootnode, config);
  257.  
  258. } else {
  259.  
  260. // old Twitter UI
  261.  
  262. // create an observer instance and iterate through each individual new node
  263. var observer = new MutationObserver(function(mutations) {
  264. mutations.forEach(function(mutation) {
  265. mutation.addedNodes.forEach(function(addedNode) {
  266. old_createImageLinks(mutation.target.ownerDocument, addedNode);
  267. });
  268. });
  269. });
  270.  
  271. // configuration of the observer
  272. // NOTE: subtree is false as the wanted nodes are direct children of <ol id="posts"> -> notable performance improvement
  273. var config = { attributes: false, childList: true, characterData: false, subtree: false };
  274. // pass in the target node (<ol> element contains all stream posts), as well as the observer options
  275. var postsmatch = document.evaluate("//ol[@id='stream-items-id']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  276. var postsnode = postsmatch.singleNodeValue;
  277.  
  278. //process already loaded nodes (the initial posts before scrolling down for the first time)
  279. old_createImageLinks(document, postsnode);
  280.  
  281. //start the observer for new nodes
  282. observer.observe(postsnode, config);
  283.  
  284.  
  285. // also observe the overlay node - this is the node used when opening an individsual post as overlay
  286. // NOTE: subtree is true here as the wanted nodes are ancestors of the node used as observer root
  287. var config2 = { attributes: false, childList: true, characterData: false, subtree: true };
  288. // pass in the target node, as well as the observer options
  289. var overlaymatch = document.evaluate("//div[contains(@class,'PermalinkOverlay-content')]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  290. var overlaynode = overlaymatch.singleNodeValue;
  291. //start the observer for overlays
  292. observer.observe(overlaynode, config2);
  293. }
  294. }