Twitter - clickable links to images and show uncropped thumbnails

Supports new Twitter UI. Linkifies all image posts in Twitter Home, other blog streams and single post views. These links point to the "orig" version of the image. Thumbnail images in the stream are modified to display uncropped.

当前为 2020-01-07 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter - clickable links to images and show uncropped thumbnails
  3. // @namespace twitter_linkify
  4. // @version 2.3.1
  5. // @license GNU AGPLv3
  6. // @description Supports new Twitter UI. Linkifies all image posts in Twitter Home, other blog streams and single post views. These links point to the "orig" version of the image. 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. }