Greasy Fork

Greasy Fork is available in English.

Twitter - clickable links to images and show uncropped thumbnails

Linkifies all images in the Twitter Home stream. These links point to the :orig version while the stream content is modified to use the :small variant to increase performance. Thumbnail images in the stream are modified to display uncropped.

目前为 2019-07-25 提交的版本。查看 最新版本

// ==UserScript==
// @name         Twitter - clickable links to images and show uncropped thumbnails
// @namespace    twitter_linkify
// @version      2.0.0
// @license      GNU AGPLv3
// @description  Linkifies all images in the Twitter Home stream. These links point to the :orig version while the stream content is modified to use the :small variant to increase performance. 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 new_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
  myNode.setAttribute("style", "");
}

function new_adjustSingleBackgroundSize(myNode) {
  myNode.style.backgroundSize = "contain";
}


function new_createSingleImageLink(myDoc, myContext) {

	if (myContext.nodeType === Node.ELEMENT_NODE) {

    var singlematch;
    singlematch=myDoc.evaluate("./ancestor-or-self::a[contains(@href,'/photo/')]/ancestor::div[@data-testid='tweet']",
                         myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
    if (singlematch.singleNodeValue !== null) {
      
      var singlenode;
      // persistently remove "margin-..." styles (they "de-center" the images)
      singlematch=myDoc.evaluate("./descendant-or-self::div[@aria-label='Image']",
                           myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
      singlenode = singlematch.singleNodeValue;
      if (singlenode !== null) {
        new_adjustSingleMargin(singlenode);
        var observer = new MutationObserver(function(mutations) {
          mutations.forEach(function(mutation) {
            new_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 whole thumbnail is visible
      singlematch=myDoc.evaluate("./descendant-or-self::div[contains(@style,'background-image')]",
                           myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
      singlenode = singlematch.singleNodeValue;
      if (singlenode !== null) {
        new_adjustSingleBackgroundSize(singlenode)
        var observer = new MutationObserver(function(mutations) {
          mutations.forEach(function(mutation) {
            new_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("./descendant-or-self::img[contains(@src,'https://pbs.twimg.com/media/') and contains(@src,'name=')]",
                           myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
      singlenode = singlematch.singleNodeValue;
      if (singlenode !== null) {
        var imgurl = new URL(singlenode.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/')]",
                           singlenode, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
        singlenode = singlematch.singleNodeValue;
        if (singlenode !== null) {
          singlenode.href = imgurl.href;
        }
      }
      
    } 
  }
}


function new_createImageLinks(myDoc, myContext) {

	if (myContext.nodeType === Node.ELEMENT_NODE) {
  
    new_createSingleImageLink(myDoc, myContext); // applies if the added node is descendant or equal to a single image 
    
    // this does the opposite and assumes that the added node CONTAINS image(s), i.e. is an ancestor of image(s)
    var matches=myDoc.evaluate("./descendant::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);
			new_createSingleImageLink(myDoc, el);
    }
  }
}



function new_observeArticles(myDoc, myContext) {

  if (myContext.nodeType === Node.ELEMENT_NODE) {

    var singlematch;
    var matches;
    matches=myDoc.evaluate("./descendant-or-self::article",
                               myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
    for(var i=0, el; (i<matches.snapshotLength); i++) {
      el=matches.snapshotItem(i);

      singlematch=myDoc.evaluate("./ancestor::section/ancestor::div[@data-testid='primaryColumn']/ancestor::main",
                           el, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
      if (singlematch.singleNodeValue !== null) {
      
        new_createImageLinks(myDoc, el);

        var observer = new MutationObserver(function(mutations) {
          mutations.forEach(function(mutation) {
            mutation.addedNodes.forEach(function(addedNode) {
              new_createImageLinks(mutation.target.ownerDocument, addedNode);
            });
          });    
        });
        var config = { attributes: false, childList: true, characterData: false, subtree: true };
        observer.observe(el, config);
      }
    }
  }
}



function old_createImageLinks(myDoc, myContext) {

//console.info("createImageLinks: ", myContext);
  
  if (myDoc===null) myDoc= myContext;
  if (myDoc===null) return;
  if (myContext===null) myContext= myDoc;
  
  var matches;
  var tmpstr;

  matches=myDoc.evaluate(".//div[contains(@class,'AdaptiveMedia-photoContainer')]/img",
                         myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  for(var i=0, el; (i<matches.snapshotLength); i++) {
    el=matches.snapshotItem(i);
    if (el) {
      try {
//        console.info("matched-element: ", el);
        tmpstr=getCleanImageURL(el.getAttribute("src"), false);
//        console.info("cleanurl: ", tmpstr);
        // only need ":small" variant for stream/thumbnail display (save bandwidth and increase performance)
        el.setAttribute("src", tmpstr+":small");
        // create correct link to ":orig" image - best way to access is by opening in new tab via "middle-click")
        insertLinkElement(myDoc, el, tmpstr+":orig", getFilename(tmpstr));
        // try to scale the thumbnail image so that it displays fully and uncropped within the available space
        // This does not do a real aspect ratio calc but uses a "trick" by analysing how Twitter was positioning the cropped image
        tmpstr=el.getAttribute("style");
        if (tmpstr !== null) {
          if (tmpstr.toLowerCase().includes("top") ) {
            el.setAttribute("style", "height: 100%; width: auto");
          } 
          else if (tmpstr.includes("left")) {
            el.setAttribute("style", "height: auto; width: 100%");
          } 
        }
      } catch (e) { console.warn("error: ", e); }
    }
	}
}


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) {

    //new Twitter UI

      // 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) {
            new_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)
      new_observeArticles(document, reactrootnode);
    
      //start the observer for new nodes
      observer.observe(reactrootnode, config);

    
  } else {

    
    // old Twitter UI

      // 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) {
            old_createImageLinks(mutation.target.ownerDocument, addedNode);
          });
        });    
      });

      // configuration of the observer
      // NOTE: subtree is false as the wanted nodes are direct children of <ol id="posts"> -> notable performance improvement
      var config = { attributes: false, childList: true, characterData: false, subtree: false };
      // pass in the target node (<ol> element contains all stream posts), as well as the observer options
      var postsmatch = document.evaluate("//ol[@id='stream-items-id']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
      var postsnode = postsmatch.singleNodeValue;

      //process already loaded nodes (the initial posts before scrolling down for the first time)
      old_createImageLinks(document, postsnode);

      //start the observer for new nodes
      observer.observe(postsnode, config);


      // also observe the overlay node - this is the node used when opening an individsual post as overlay
      // NOTE: subtree is true here as the wanted nodes are ancestors of the node used as observer root
      var config2 = { attributes: false, childList: true, characterData: false, subtree: true };
      // pass in the target node, as well as the observer options
      var overlaymatch = document.evaluate("//div[contains(@class,'PermalinkOverlay-content')]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
      var overlaynode = overlaymatch.singleNodeValue;
      //start the observer for overlays
      observer.observe(overlaynode, config2);
  }
  
}