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.

目前為 2019-08-06 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Twitter - clickable links to images and show uncropped thumbnails
// @namespace    twitter_linkify
// @version      2.1.0
// @license      GNU AGPLv3
// @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.
// @author       marp
// @homepageURL  https://gf.qytechs.cn/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
  var myStyle = myNode.getAttribute("style");
  if ((myStyle !== null) && (myStyle.includes("margin"))) {
    myNode.setAttribute("style", "");
  }
}

function new_adjustSingleBackgroundSize(myNode) {
  var myStyle = myNode.getAttribute("style");
  if ((myStyle !== null) && !(myStyle.includes("contain"))) {
    myNode.style.backgroundSize = "contain";
  }
}


function new_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']",
                           singlelink, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
      var 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 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) {
        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(".//img[contains(@src,'https://pbs.twimg.com/media/') and contains(@src,'name=')]",
                           singlelink, 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_processImages(myDoc, 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) {
    
      new_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);
				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[./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);
      
      new_processImages(myDoc, el);

      var observer = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {
          mutation.addedNodes.forEach(function(addedNode) {
            new_processImages(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);
  }
  
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址