BDSMLR - clickable links to original high-res images and display timestamps

Modifies images to link to their original ("-og") version. Works for (a) the dashboard, (b) blogs displayed on right sidebar in the dashboard, (c) blog streams (xxx.bdsmlr.com) and (d) individual posts (xxx.bdsmlr.com/post/yyyyyyyyyy). It does NOT work for the archive view. The script also displays the timestamp of the post in the upper right corner.

目前為 2019-03-26 提交的版本,檢視 最新版本

// ==UserScript==
// @name         BDSMLR - clickable links to original high-res images and display timestamps
// @namespace    bdsmlr_linkify
// @version      2.2.0
// @license      GNU AGPLv3
// @description  Modifies images to link to their original ("-og") version. Works for (a) the dashboard, (b) blogs displayed on right sidebar in the dashboard, (c) blog streams (xxx.bdsmlr.com) and (d) individual posts (xxx.bdsmlr.com/post/yyyyyyyyyy). It does NOT work for the archive view. The script also displays the timestamp of the post in the upper right corner.
// @author       marp
// @homepageURL  https://greasyfork.org/en/users/204542-marp
// @include      https://bdsmlr.com/
// @include      https://bdsmlr.com/dashboard
// @include      https://*.bdsmlr.com/
// @include      https://*.bdsmlr.com/post/*
// @include      https://bdsmlr.com/uploads/photos/*
// @include      https://bdsmlr.com/uploads/pictures/*
// @include      https://*.bdsmlr.com/uploads/photos/*
// @include      https://*.bdsmlr.com/uploads/pictures/*
// @run-at document-end
// ==/UserScript==

//console.info("START href: ", window.location.href);


//------------------------------------------------------------
// FIRST PART OF SCRIPT #2 - function that gets called by event oberver registers as part of 1st part #1 (see below)
//------------------------------------------------------------

function 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;
  var singlematch;
  var origpostlink;
  var origbloglink;
  var origblog;
  var imagematches;
  var imageurl;
  var imagesrc;
  var cdnmatches;
  var cdnnumber;

  matches = myDoc.evaluate("./descendant-or-self::div[contains(@class,'postholder')] | ./descendant-or-self::div[contains(@class,'post_content')]",
                           myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  for(var i=0, el; (i<matches.snapshotLength); i++) {
    el = matches.snapshotItem(i);
    if (el) {
      try {
        
        // try to find info about original poster (if this is a reblog) as well as the link to the individual (potentially reblogged) post
        // both info only seem to be present on dashboard and on rightside overlay blogs - but not always on individual blogs (xxx.bdsmlr.com) or on individual blog post URLs :-(
			  singlematch = myDoc.evaluate(".//div[contains(@class,'originalposter')]/a[contains(@href,'.bdsmlr.com/post/')]",
                                     el, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
        origpostlink = singlematch.singleNodeValue; // xxxx.bdsmlr.com/post/yyyyyyyy
        if (origpostlink) {
          origblog = origpostlink.getAttribute("href"); //everything after and including "/post" gets truncated away later anyway
        } else {
          origblog = null;
        }
        if (origblog === null) {
          //second method might find the originial blog URL (xxxx.bdsmlr.com)
          singlematch = myDoc.evaluate(".//div[contains(@class,'post_info')]//i[contains(@class,'retweet') or contains(@class,'rbthis')]" + 
                                       "/following-sibling::a[contains(@class,'adata') or contains(@class,'ndata')]",
                                       el, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
          origbloglink = singlematch.singleNodeValue; // xxxx.bdsmlr.com
          if (origbloglink) {
          	tmpstr = origbloglink.getAttribute("href");
            if ( tmpstr && (tmpstr.length > 10) &&
                 !(tmpstr.includes("//.bdsmlr.com") ) ) {
                origblog = tmpstr;
            }
          }
          if (origblog === null) {
            // if neither of the two above find anything then this is likely NOT a reblogged post but the original post -> get the orginial blog post URL
            singlematch = myDoc.evaluate(".//a[(contains(@class,'adata') or contains(@class,'ndata')) and contains(@href,'.bdsmlr.com/post/')]",
                                         el, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
            origpostlink = singlematch.singleNodeValue; // xxxx.bdsmlr.com
            if (origpostlink) {
          	  tmpstr = origpostlink.getAttribute("href");
              if ( tmpstr && (tmpstr.length > 10) &&
                   !(tmpstr.includes("//.bdsmlr.com") ) ) {
                  origblog = tmpstr;
              }
            }
            if (origblog === null) {
              if ( !window.location.href.startsWith("https://bdsmlr.com") ) {
                // if no link to neither original blog nor original blog post was found then we assume that this is the original blog post or blog itself (this is a rather shaky assumtion - fingers crossed...)
                origblog = window.location.href;
              }
              else {
                // however - if the current url is the dashboard then we're out of luck
                origblog = null;
              }  
            }
          }
        }
        

				// iterate over all links to images (i.e. does NOT (yet) create links to images where none exist in the first place)
        // skip over items that already have a link to a "non-cdn" bdsmlr url
        imagematches = myDoc.evaluate(".//div[contains(@class,'image_container') or contains(@class,'image_content')]" + 
                                             "//a[(@href='') or ((contains(@class,'magnify') or contains(@class,'image-link')) and contains(@href,'https://cdn') and contains(@href,'.bdsmlr.com'))]" +
                                               "/img",
                                          el, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
        for(var j=0, image, imagelink; (j<imagematches.snapshotLength); j++) {
          image=imagematches.snapshotItem(j);
          if (image) {
            imagesrc = image.src;
            imagelink = image.parentNode;
            imageurl = imagelink.getAttribute("href");
            if (imageurl === null || imageurl.length < 5) {
              imageurl = image.getAttribute("src");
              // No idea why this is needed... DevTools inspector always shows a valid image src attribute... but at script execution time... apparently not... seems to be some bdsmlr JavaScript post-processing...
              if (imageurl === null || imageurl.length < 5) {
                imageurl = image.getAttribute("data-echo"); 
              }  
            }
            if (imageurl && imageurl.length > 5) {
              // get url to "-og" image on save host - if the url already conatrins "-og" it will simply return the passed in argument (tmpstr == imageurl)
              tmpstr = getOriginalImageURL(imageurl);
              // analyze to which cdn server the url points
              cdnmatches = imageurl.toLowerCase().match("https:\/\/cdn([0-9]+)\.bdsmlr\.com\/");
              if (cdnmatches !== null) {
                cdnnumber = parseInt(cdnmatches[1], 10);
              }  else {
                cdnnumber = NaN;
              }
              // ONLY IF there is no -og url already OR if the cdn server is cdn 01, cdn 02 or cdn 03 -> apply this script's algorithm to locate -og image
              // -> i.e do not touch already existing -og urls to cdn04 or beyond servers (cdn05, etc.) 
              // => NOTE: script algorithm will still trigger if the image is not found (see the script logic that applies to "*bdsmlr.com/uploads/*") further below
              if ( (imageurl.length != tmpstr.length) || // will be unequal if there was no -og url to begin with (but instead created by getOriginalImageURL)
                    isNaN(cdnnumber) || cdnnumber <= 3 ) {
                // if we have the url of the original blog then we use a different mechanism to reconstruct the orig image URL
                // otherwise we stay with tmpstr as is
                if (origblog && origblog.length > 5) {
                  // if we have info about original poster -> construct link to "-og" version of image on orig posters blog (e.g. https://<origposter>.bdsmlr.com/<....>/imagename-og.jpg)
                  tmpstr = getOriginalPosterImageURL(imageurl, origblog);
                  tmpstr = getOriginalImageURL(tmpstr);
                  tmpstr = tmpstr + "invalidurl"; //create an invalid url which will trigger the sceond part of this script (see below - if statement on window.location.href)
                }
              }
              
         // hopefully temporary workaround to avoid bdsmlr circular redirections - such cirular redirs cause GreaseMonkey NOT to trigger at all! :-(
         // there seem to be no redirs in place on "https://bdsmlr.com/uploads/..." while there seem to be several circular ones on various cdn servers
              if (tmpstr.toLowerCase().startsWith("https://cdn")) {
                tmpstr = "https://" + tmpstr.substring(tmpstr.toLowerCase().indexOf(".bdsmlr.com") + 1);
              }  
              
              // get the link node and set the link target - pass on context information to second part of this script (below)
              image.parentNode.setAttribute("href", tmpstr + "?cdnnumber=" + cdnnumber + "&initialurl=" + encodeURIComponent(imageurl) + "&initialsrc=" + encodeURIComponent(imagesrc));
            }
          }
        }
          
      } catch (e) { console.warn("error: ", e); }
    }
	}

}


// try to find the timestamp info and display in upper right corner
function displayTimestamps(myDoc, myContext) {

//console.info("displayTimestamps: ", myContext);
  
  if (myDoc===null) myDoc = myContext;
  if (myDoc===null) return;
  if (myContext===null) myContext = myDoc;
  
  var matches;
  var tmpstr;
  var singlematch;
  var postinfo;
  var timestamp;
  var newnode;
  
  matches = myDoc.evaluate("./descendant-or-self::div[contains(@class,'feed') and @title]",
                           myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  for(var i=0, el; (i<matches.snapshotLength); i++) {
    el = matches.snapshotItem(i);
    if (el) {
      try {

        timestamp = el.getAttribute("title");
				if (timestamp && timestamp.length>5 && timestamp.length<70) {

          singlematch = myDoc.evaluate(".//div[contains(@class,'post_info')]",
                                       el, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
          postinfo = singlematch.singleNodeValue; 
          if (postinfo) {

            newnode = myDoc.createElement("div");
            newnode.setAttribute("style", "float:right; margin-right: 10px; padding-right: 10px;");
            newnode.innerHTML = timestamp;
            postinfo.appendChild(newnode);
          }
        }
  
      } catch (e) { console.warn("error: ", e); }
    }
	}
  
}



function getOriginalPosterImageURL(imageurl, originalposter) {
  if (originalposter === null) {
    return imageurl;
  }
  var pos = imageurl.toLowerCase().indexOf(".bdsmlr.com");
  var pos2 = originalposter.toLowerCase().indexOf(".bdsmlr.com");
  if (pos > 0 && pos2 > 0) {
    return originalposter.substring(0, pos2) + imageurl.substring(pos);
  } else {
    return imageurl;
  }
}


function getOriginalImageURL(imageurl) {
  if (imageurl === null) {
    return imageurl;
  }
  var pos = imageurl.lastIndexOf(".");
  var pos2 = imageurl.lastIndexOf("-og.");
  if (pos > 0 && (pos2+3)!=pos) {
    return imageurl.substring(0, pos) + "-og" + imageurl.substring(pos);
  } else {
    return imageurl;
  }
}




//------------------------------------------------------------
// SECOND PART OF SCRIPT
//------------------------------------------------------------


// this is a function used asychroniously via Promise objects
// a "parent" Promise is passed in and this function "chains" another "child" Promise (the fetch object) to it
// the (asynchronuous!) return value is NULL if the Url is not valid (404, etc) or the fetched URL if the request is sucesfull (OK 200)
function checkUrl(checkUrlPromise, baseimageurl, hostprefix, allowredirect) {
  var imageurl;
  
  if (baseimageurl.startsWith("https://")) {
    baseimageurl = baseimageurl.substring(8);
  }
  if (hostprefix !== null && hostprefix.length > 0) {
    imageurl = "https://" + hostprefix + "." + baseimageurl;
  } else {
    imageurl = "https://" + baseimageurl;
  }
  
  //return the newly "chained" Promise that now has a fetch promise as "child"
  return checkUrlPromise.then(
    function(promiseresult) {

      // if the prior/parent promise resolves into a valid Url - return that Url and skip the child/follow-up fetch
      if ( (promiseresult !==null) && (promiseresult.length > 10) ) {
        return promiseresult;
      } else {

        // the prior Promise did NOT resolve into a valid URL -> try to fetch this new URL
        return fetch(imageurl, (allowredirect ? { redirect: 'follow' } : { redirect: 'error' } ) ).then(
          function(response) {
            if (response.ok) {
              if (response.redirected && allowredirect) {
                return response.url;
              } else {
                return imageurl;
              }
            } else {
              return null;
            }
          },
          function(rejectreason) {
            return null;
          });
        
      }
    },
    function(rejectreason) {
      return null;
    }
  );
}  


// Two very different actions depending on if this is for the URL of an image or for a bdsmlr page (page = dashboard, blog stream or blog post)
// -> If this if statement evaluates to true it is in the context of an image -> execute SECOND part of script -> algorithm to try to find the "-og" version of the image
if ( window.location.href.includes('bdsmlr.com/uploads/') ) {
  
    // the "og search algorithm" only triggers if the current url is invalid - otherwise do nothing
  	if ( document.head.textContent !== null && 
          ( document.head.textContent.toLowerCase().includes('404 not found') ||
            document.head.textContent.toLowerCase().includes('403 forbidden') ||
            document.head.textContent.toLowerCase().includes('problem loading page') ||
            document.body.textContent.toLowerCase().includes('page isn’t redirecting properly') ||
            document.body.textContent.toLowerCase().includes('could not be found') ) ) {

      var tmpstr = window.location.href;

      // if exists, strip the suffix that was used to trigger this part of the script
      if (tmpstr.lastIndexOf('invalidurl') > 0) {
        tmpstr = tmpstr.substring(0, tmpstr.lastIndexOf("invalidurl")); 
      }

      var pos = tmpstr.lastIndexOf(".");
      var pos2 = tmpstr.lastIndexOf("-og.");
      var pos3 = tmpstr.indexOf(".");
      var baseimageurl = null;
      var blogprefix = null;
      var initialUrl = null;
      var initialSrc = null;
      var cdnnumber = -1;
      var checkUrlPromise = null;

      // retrieve "context" parameters that might have been passed in for the above part of the script
      if (window.location.search !== null) {
        var urlParams = new URLSearchParams(window.location.search);
        initialUrl = urlParams.get('initialurl');
        if (initialUrl !== null) {
          initialUrl = decodeURIComponent(initialUrl);
        }
        initialSrc = urlParams.get('initialsrc');
        if (initialSrc !== null) {
          initialSrc = decodeURIComponent(initialSrc);
        }
        cdnnumber = parseInt(urlParams.get('cdnnumber'));
      }

      // check on what kind of url this script was triggered
      if ( !(tmpstr.startsWith("https://bdsmlr.com")) &&
           !(tmpstr.startsWith("https://cdn")) && 
            (tmpstr.startsWith("https://")) ) { //this is a url to a specific blog (xxxx.bdsmlr.com/uploads/...)
        baseimageurl = tmpstr.substring(pos3+1);
        blogprefix = tmpstr.substring(8, pos3);
      }
      else if (tmpstr.startsWith("https://bdsmlr.com")) { 
        baseimageurl = tmpstr.substring(8);
      }
      else { // this should be a url to some kind of cdn server (cdnxx.bdsmlr.com/uploads/...)
        baseimageurl = tmpstr.substring(pos3+1);
      }

      // starting Promise whioch resolves to invalid URL (=null) 
      // this is the start of the to follow "daisy-chaining" of several fetch requests to test several potential URLs
      checkUrlPromise = Promise.resolve(null);

      if (pos == pos2+3) { // check if this is an -og url
        if ( blogprefix !== null && blogprefix.length > 0 ) { //this is a url to a specific blog (xxxx.bdsmlr.com/uploads/...)
          checkUrlPromise = checkUrl(checkUrlPromise, baseimageurl, blogprefix, false);
        }
        checkUrlPromise = checkUrl(checkUrlPromise, baseimageurl, null, false); // "bdsmlr.com/uploads/..."
        // try old cdn servers (cdn02, cdn03 and cdn04) and depending on cdnnumber context param -> also try newer cdn servers (cdn05 and beyond)
        var cdn = 4;
        if ( !isNaN(cdnnumber) && cdnnumber > 4 ) {
          cdn = cdnnumber;
        }
        while (cdn >= 2) {
          checkUrlPromise = checkUrl(checkUrlPromise, baseimageurl, "cdn" + cdn.toString().padStart(2, '0'), false); // "cdnXX.bdsmlr.com/uploads/..."
          cdn--;
        }  
      }

      // no valid -og URL -> try the initial URL that bdsmlr provided originally
      if ( (initialUrl !== null) && (initialUrl.length > 10) ) {
        checkUrlPromise = checkUrl(checkUrlPromise, initialUrl, null, true); // true -> allow bdsmlr to redirect for original URL
      }

      // still no valid URL -> try the URL of the image shown in the original stream or blog post
      if ( (initialSrc !== null) && (initialSrc.length > 10) && (initialSrc !== initialUrl) ) {
        checkUrlPromise = checkUrl(checkUrlPromise, initialSrc, null, true); // true -> allow bdsmlr to redirect for original image source link
      }

      checkUrlPromise.then(
        function(result) {
          if ( (result !== null) && (result.length > 10) ) {
            window.location.assign(result);
          }
        }
      );
    };
}


//------------------------------------------------------------
// FIRST PART OF SCRIPT #1 - initial statement and registration of event observer
//------------------------------------------------------------


// script runs NOT in the context of an image - i.e. dashboard, blog stream, individual post
// -> register event observer for future to-be-loaded posts (endless scrolling) and execute first part of script (createImageLinks & displayTimestamps) on already loaded posts
else
{
  
  // 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) {
        createImageLinks(mutation.target.ownerDocument, addedNode);
        displayTimestamps(mutation.target.ownerDocument, addedNode);
      });
    });    
  });

  // configuration of the observer
  // NOTE: subtree is false as the wanted nodes are direct children of <div class="newsfeed"> -> notable performance improvement
  // "theme1" is the class used by the feed root node for individual user's blog (xxxx.bdsmlr.com) -> seems unstable/temporary name -> might be changed by bdsmlr
  var config = { attributes: false, childList: true, characterData: false, subtree: false };
  // pass in the target node (<div> element contains all stream posts), as well as the observer options
  var postsmatch = document.evaluate(".//div[contains(@class,'newsfeed')] | .//div[contains(@class,'theme1')]", 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)
  createImageLinks(document, postsnode);
  displayTimestamps(document, postsnode);

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


  // also observe the right sidebar blog stream on the dashboard
  // pass in the target node, as well as the observer options
  var sidepostsmatch = document.evaluate(".//div[@id='rightposts']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  var sidepostsnode = sidepostsmatch.singleNodeValue;
  // sidebar does only exist on dashboard
  if (sidepostsnode) {
    //start the observer for overlays
    observer.observe(sidepostsnode, config);
  }

}