Greasy Fork

Greasy Fork is available in English.

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-08-27 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Twitter - clickable links to images and show uncropped thumbnails
// @namespace    twitter_linkify
// @version      3.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  http://greasyfork.icu/en/users/204542-marp
// @include      https://twitter.com/
// @include      https://twitter.com/*
// @include      https://pbs.twimg.com/media/*
// @exclude      https://twitter.com/settings
// @exclude      https://twitter.com/settings/*
// @run-at document-end
// ==/UserScript==




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;
    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);
        var observer = new MutationObserver(function(mutations) {
          mutations.forEach(function(mutation) {
            adjustSingleMargin(mutation.target);
          });    
        });
        var 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)
        var observer = new MutationObserver(function(mutations) {
          mutations.forEach(function(mutation) {
            adjustSingleBackgroundSize(mutation.target);
          });    
        });
        var 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).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);
}



// Two very different actions depending on if this is on twitter.com or twing.com
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" ) {
      // new Twitter UI - no need anymore to modify the SaveAs filename
      params.set("name", "orig");
      document.location.search = "?" + params.toString();
    }
  } else {
    // old Twitter UI - insert link with attrib "download" to modify the SaveAs filename (need to click the image)
    var image = document.querySelector('img');
    var url = image.src;
    insertLinkElement(document, image, getCleanImageURL(url)+":orig", getFilename(url));
  }

}
else 
{

  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);
  }
}