DeviantArt - media download helper

DeviantArt - Download multi-image deviations. Download largest resampled media file (useful if no original download is available). Fix timeout of original image download. Download filenames are prefixed with artist's name.

// ==UserScript==
// @name         DeviantArt - media download helper
// @namespace    deviantart_mediadownloadhelper
// @version      2.1
// @license      GNU AGPLv3
// @description  DeviantArt - Download multi-image deviations. Download largest resampled media file (useful if no original download is available). Fix timeout of original image download. Download filenames are prefixed with artist's name.
// @author       marp
// @homepageURL  https://greasyfork.org/en/users/204542-marp
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @connect      www.deviantart.com
// @connect      wixmp.com
// @match        https://www.deviantart.com/*/art/*
// @run-at document-end
// ==/UserScript==

// jshint esversion:11


(function() {
  'use strict';

  // Download symbol, downwards arrow with one horizontal lines below arrow
  // the horizontal line is slightly wider and much thicker than original DA download symbol
  const extraDownload1Svg = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
                              '<path d="m 19.913,18 c 1.333333,0 1.333333,5 0,5 h -16 c -1.3333328,0 -1.3333328,-5 0,-5 z ' +
                                       'm -4,-17 c 0.507356,-4.4041e-4 0.934639,0.379128 0.994,0.883 L 16.913,2 v 6 h 3.414 ' +
                                                'c 0.853894,-5.62e-5 1.315031,1.0010965 0.76,1.65 l -0.088,0.09 -8.413,7.648 ' +
                                                'c -0.345621,0.314527 -0.862993,0.347745 -1.246,0.08 L 11.24,17.388 2.828,9.74 ' +
                                                'C 2.1934191,9.1629397 2.5321441,8.1071654 3.384,8.007 L 3.5,8 H 6.913 V 2 ' +
                                                'C 6.9131264,1.4926858 7.2931105,1.0658484 7.797,1.007 L 7.913,1 Z ' +
                                       'm -1,2 h -6 v 7 H 6.086 L 11.913,15.297 17.74,10 H 14.914 V 3 Z" ' +
                                     'fill-rule="evenodd"></path></svg>';

  // Download symbol, downwards arrow with two horizontal lines below arrow
  // the two horizontal lines are slightly wider than original DA download symbol
  const extraDownload2Svg = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
                              '<path d="m 19.913,21 c 1.333333,0 1.333333,2 0,2 h -16 c -1.3333328,0 -1.3333328,-2 0,-2 z ' +
                                       'm 0,-3 c 1.333333,0 1.333333,2 0,2 h -16 c -1.3333328,0 -1.3333328,-2 0,-2 z ' +
                                       'm -4,-17 c 0.507356,-4.4041e-4 0.934639,0.379128 0.994,0.883 L 16.913,2 v 6 h 3.414 ' +
                                                'c 0.853894,-5.62e-5 1.315031,1.0010965 0.76,1.65 l -0.088,0.09 -8.413,7.648 ' +
                                                'c -0.345621,0.314527 -0.862993,0.347745 -1.246,0.08 L 11.24,17.388 2.828,9.74 ' +
                                                'C 2.1934191,9.1629397 2.5321441,8.1071654 3.384,8.007 L 3.5,8 H 6.913 V 2 ' +
                                                'C 6.9131264,1.4926858 7.2931105,1.0658484 7.797,1.007 L 7.913,1 Z ' +
                                       'm -1,2 h -6 v 7 H 6.086 L 11.913,15.297 17.74,10 H 14.914 V 3 Z" ' +
                                    'fill-rule="evenodd"></path></svg>';

  const logPrefix = "TamperMonkey userscript " + GM_info.script.name + ": ";

  // This recreates "__INITIAL_STATE__", "__INITIAL_I18N__" and few other JSON objects that normally exist on the global window object.
  // The "initials" variable acts as container, replacing the default global window object.
  // The global window object is not accessed nor changed in any way.
  var initials = getInitials(document);

  const i18nKeyFreeDownload = "actionbar.freeDownload";
  const i18nKeyPrivateColl = "actionbar.collectPrivately.label";
  const i18nKeyMoreActions = "deviationview.overlay.moreActions";
  const ariaFreeDownload = initials.__INITIAL_I18N__.resources[i18nKeyFreeDownload]; // for "en": "Free download"
  const ariaPrivateColl = initials.__INITIAL_I18N__.resources[i18nKeyPrivateColl]; // for "en": "Add to Private Collection"
  const ariaMoreActions = initials.__INITIAL_I18N__.resources[i18nKeyMoreActions]; // for "en": "More Actions"

  const idExtraOriginalDownload = GM_info.script.namespace + "_extraOriginalDownload";
  const ariaExtraOriginalDownload = "download original media";

  const idExtraResampledDownload = GM_info.script.namespace + "_extraResampledDownload";
  const ariaExtraResampledDownload = "download resampled media";

  var deviationId;
  var deviationJson;
  var deviationExtendedJson;
  var deviationAuthorJson;

  var deviationOriginalInfos; // array of structs created by parseInitials and sub-functions
  var deviationResampledInfos; // array of structs created by parseInitials and sub-functions
  var numDeviations; // size of array

  var deviationsShown; // array of indices of deviations shown (length = 1 except for MultiImage deviations in "Scroll view" mode)

  var linkOriginalDownload; // anchor ("a") element with the sub-hierarchy created by insertExtraDownloadButton
  var linkResampledDownload; // anchor ("a") element with the sub-hierarchy created by insertExtraDownloadButton

  var observerMain;
  var observerMultiImage;
  var multinode;

  // Mamespace resolver for use with document.evaluate to search for "svg" tags
  // Must use "svg:svg" in XPath to locate "svg" tags in document
  function nsResolver(prefix) {
    if (prefix === 'svg') {
      return 'http://www.w3.org/2000/svg';
    } else {
      return null;
    }
  }


  // Creates a new html element using an HTML source code snippet.
  // This approach via a "template" element requires a new-ish browser.
  function createElementFromHtml(aDocument, html) {
    const template = aDocument.createElement('template');
    template.innerHTML = html;
    return template.content.firstElementChild;
  }


  // This ASYNC method returns a promise to retrieve the supplied URL via HTTP GET.
  //
  async function urlGetPromise(aUrl, aResponseType) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: aUrl,
        responseType: (aResponseType) ? aResponseType : undefined,
        onload: function(response) {
          if ((response.readyState >= 2) && (response.status == 200)) {
            resolve(response);
          } else {
            reject(response);
          }
        },
        ontimeout: function(response) {
          reject(response);
        },
        onerror: function(response) {
          reject(response);
        }
      });
    });
  }


  // Downloads a file from aUrl and triggers browser download save with filename aFilename
  // Performs the worse the larger the file (downloads entire file before triggering browser download/SaveAs dialog)
  function blobDownload(aDocument, aUrl, aFilename) {
    // internally download the media file as a blob (worse/slower the larger the file is -> !DELAY!)
    urlGetPromise(aUrl, "blob").then(
      // SUCCESS -> DONE, trigger browser download
      (result) => {
        const tmpAnchor = aDocument.createElement('a');
        const blob = new Blob([result.response], { type : 'application/octet-stream' });
        const blobUrl = URL.createObjectURL(blob);
        tmpAnchor.href = blobUrl;
        tmpAnchor.download = (aFilename?.length > 0) ? aFilename : "";
        tmpAnchor.click();
        URL.revokeObjectURL(blobUrl);
      },
      // FAILURE -> log error
      (errorresult) => {
        console.error(logPrefix + "blobDownload - blob download of media file failed.", { cause: errorresult });
      }
    );
  }


  //
  //
  function eventHandlerClickDownload(aEvent) {
    const el = aEvent.currentTarget;
    const doc = (el.ownerDocument) ? el.ownerDocument : document;

    // simple sanity-check if this is triggered on the correct element
    if (el?.tagName == "A" && el?.href == "javascript:;" && el?.getAttribute("num") >= 1) {
      aEvent.preventDefault(); // never let the link click be handled by the browser itself
      const num = el.getAttribute("num");

      for (var i=0; (i<num); i++) {
        const download = el.getAttribute("download"+i);
        const href = el.getAttribute("href"+i);
        const timeout = el.getAttribute("timeout"+i);

        // if time-out (plus 30sec buffer time) has elapsed -> refresh href url
        // Such a timeout href URL is currently only offered by DA for the original download of the first/main deviation
        // -> refresh mechanism is hard-coded here to extract exactly only this one URL from reloaded deviation page
        // -> If there's ever more than one line to be refreshed like this -> then this most be re-designed ENTIRELY (use of multiple promises/async)
        if (timeout && ( (Number.parseInt(timeout) - 30) < (Date.now() / 1000)) ) {
          const refreshinddex = i; // use const for async, using i directly is unsafe
          urlGetPromise(doc.URL).then(
            // SUCCESS -> DONE, now extract new url with new timeout from document HTML source
            (result) => {
              const rt = result.responseText;
              // the whole deviation page contains only one direct download link -> find it
              var start = rt.indexOf('href="https://www.deviantart.com/download/');
              if (start > 0) {
                start = start + 6; // skip href="
                const end = rt.indexOf('"', start); // find closing "
                // rt is HTML-stringified - here, this only means "&" -> "&amp;"
                const newhref = rt.substring(start, end).replaceAll("&amp;", "&");
                el.setAttribute("href"+refreshinddex, newhref);
                el.setAttribute("timeout"+refreshinddex, Number.parseInt(newhref.substring(newhref.indexOf("&ts=")+4)));
                el.click(); // click and trigger this event handler again (NOTE: this is within an async method from urlGetPromise)
                            // -> This is one part that needs to be REDESIGNED if ever more than one link to refresh
              } else {
                console.error(logPrefix + "triggerDownload - old URL has timed out, could not find new download url in document HTML");
              }
            },
            // FAILURE -> log error
            (errorresult) => {
              console.error(logPrefix + "triggerDownload - old URL has timed out, download of document with new URL failed.", { cause: errorresult });
            }
          );
          return; // finish event, do nothing else, ABORT FOR LOOP. After refresh promise is done, a new link click will be executed (see el.click() above)
        }

        //  -> if not same-origin, the suggested filename is ignored
        if (GM_info?.downloadMode == "native") {
          // TamperMonkey with download mode "native", or ViolentMonkey (which has only native mode)
          console.info(logPrefix + "eventHandlerClickDownload - using (hopefully) more efficient GM_download method");
          GM_download(href, (download?.length > 0) ? download : undefined);
        }
        else {
          // TamperMonkey with download mode "Browser API" or "disabled"
          // GM_download doesn't help in this case -> ugly workaround via Blob download (can cause delays)
          console.info(logPrefix + "eventHandlerClickDownload - using inefficient blob download method");
          // internally download the media file as a blob (worse the larger the file is -> !DELAY!)
          blobDownload(doc, href, download);
        }

      } // for
    } // if (sanity check)
  }


  // Creates a new sub-hierarchy with a download link ("a" element) and an inline SVG as download symbol.
  // It creates this new hierarchy based on an existing template structure.
  // The existing hierarchy must start with a "span" and must contain a "button" element with a unique "aria-label" and an SVG symbol
  // The "button" element is replaced by an anchor "a" element (but keeping all children of the "button", including the SVG.
  // RETURNS the "a" element, i.e. *NOT* the root of the newly created hierarchy.
  function insertExtraDownloadButton(aDocument, aAriaLabelLocator, aId, aAria, aSvg, aInfos) {
    var template = aDocument.evaluate("./main//section//span[./div//button[@aria-label='" + aAriaLabelLocator + "']//svg:svg]",
                                                aDocument.body, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    if (template === null) {
      // given aria label for existing hierarchy not found - fall back to secondary aria label
      template = aDocument.evaluate("./main//section//span[./div//button[@aria-label='" + ariaMoreActions + "']//svg:svg]",
                                                aDocument.body, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
      if (template === null) {
        // secondary aria label also not found -> error
        throw new Error(logPrefix + "insertExtraDownloadButton - template structure not found by aria-label: " + aAriaLabelLocator);
      }
    }
    // use existing sub-hierarchy containing button with specified aria-label as template for our own, i.e.: clone entire structure
    const clone = template.cloneNode(true);
    clone.id = aId + "_top";
    // check if this already exists -> delete old structure, if yes
    const old = aDocument.getElementById(clone.id);
    if (old) old.remove();
    // old link should be deleted as child of overall old - but better safe than sorry...
    const oldlink = aDocument.getElementById(aId);
    if (oldlink) oldlink.remove();
    // the "button" from the template needs to be replaced with an "a" anchor
    const buttons = clone.getElementsByTagName("button");
    // there must be only one single button in sub-hierarchy identified by aAriaLabelLocator
    if (buttons.length !== 1) {
      throw new Error(logPrefix + "insertExtraDownloadButton - template structure does not contain single button element", { cause: template });
    }
    const button = buttons[0];
    // prepare to replace "button" element with "a" element (but keeping/bringing along its children)

    const newLink = aDocument.createElement("a");
    newLink.className = button.className;
    newLink.ariaLabel = aAria;
    newLink.title = (aInfos.length > 1) ? "Download multiple media files" : (aInfos[0].description) ? aInfos[0].description : aAria;
    newLink.id = aId;
    newLink.target = "_blank";
    newLink.href = "javascript:;";
    for (var i=0, j=0; (i<aInfos.length); i++) {
      if (!(aInfos[i].url?.length > 0)) continue;
      newLink.setAttribute("href"+j, aInfos[i].url);
      newLink.setAttribute("download"+j, (aInfos[i].filename) ? aInfos[i].filename : "");
      if (aInfos[i].timeout > 0) {
        newLink.setAttribute("timeout"+j, aInfos[i].timeout);
      }
      j++;
    }
    newLink.setAttribute("num", j);

    // event handler for downloading -> the anchor link itself ignores suggested download name (it's not same-origin)
    newLink.addEventListener("click", eventHandlerClickDownload);
    // there is always at least one child (the svg element)
    while (button.hasChildNodes()) {
      newLink.appendChild(button.firstChild);
    }
    // replace the "button" with new "a" element
    button.parentNode.replaceChild(newLink, button);
    // replace the "svg" element with our own
    const svgs = clone.getElementsByTagName("svg");
    if (svgs.length !== 1) {
      throw new Error(logPrefix + "insertExtraDownloadButton - template structure does not contain single button element", { cause: template });
    }
    const newSvg = createElementFromHtml(aDocument, aSvg);
    svgs[0].parentNode.replaceChild(newSvg, svgs[0]);
    // insert the newly created sub-hierarchy into the DOM
    template.before(clone);
    return newLink;
  }


  // Returns the "span" element that forms the beginning of the DA original "Free Download" link button.
  // Returns "null" if the button is not found (e.g.: if the deviation has download of original image file disabled).
  function getFreeDownloadSpan(aDocument) {
    return aDocument.evaluate("./main//section//span[./div//a[@aria-label='" + ariaFreeDownload +
                              "' and contains(@href,'&ts=') and contains(@href,'www.deviantart.com/download')]//svg:svg]",
                              aDocument.body, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  }


  // DA UI uses react, and common way to initialize React with data is to use a JSON "window.__INITIAL_STATE__" global object.
  // It contains info this script needs - however, this object is deleted right after it is initially used by React.
  // To get at the contained data, we thus need to recreate it, i.e. rerun the inline script that originally creates it.
  // However, we don't want to contaminate the page's global scope, so we must rerun the script with a changed scope.
  // Here, we use a local target "window" var, and we enable strict mode to prevent any other vars from leaking into global scope.
  // This also return "window.__INITIAL_I18N__" - which does exist in the page's global scope window element.
  // However, accessing the page's window from a userscript is unsafe, so better via this method, in same manner like "window.__INITIAL_STATE__".
  function getInitials(aDocument) {
    const scriptnode = aDocument.evaluate("//script[contains(.,'window.__INITIAL_STATE__ = JSON.parse(') and contains(.,'window.__URL_CONFIG__ = JSON.parse(')]",
                                       aDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    if (scriptnode === null) return null;
    // create and run the script code as a function body.
    // pass in a fake "window" object as a parameter to redirect from the originally intended global scope window object to our own Object.
    const windowHolder = {};
    Function("window", "'use strict'; " + scriptnode.textContent)(windowHolder);
    if (Object.hasOwn(windowHolder, "__INITIAL_STATE__") && Object.hasOwn(windowHolder, "__INITIAL_I18N__")) {
      return windowHolder;
    } else {
      throw new Error (logPrefix + "getInitials - Unable to extract __INITIAL_STATE__ or __INITIAL_I18N__ JSON objects", { cause: scriptnode });
    }
  }


  function getSizeText(sizeInBytes) {
    if (sizeInBytes == null) {
      return "";
    }
    if (sizeInBytes >= 1048576) {
      return (sizeInBytes / 1048576).toFixed(1) + " MB";
    }
    else if (sizeInBytes >= 1024) {
      return (sizeInBytes / 1024).toFixed(0) + " KB";
    }
    else {
      return sizeInBytes.toFixed(0) + " B";
    }
  }



  // Determines the best resampled image (mediatype with "c" field).
  // Non-resampled media (original, video, etc.) are ignored (items without "c" field")
  // IMPORTANT: The WIX image resampling converts animated images (GIF, etc) into static JPEGs :-(
  // NOTE: this function "pre-selects" the media type "preview" over other types with equal height/resolution
  //       The reason is to derive a nicer proposed filename: "...-preview.jpg" instead of "...-300w-2x.jpg"
  //   aMediaTokens can be undefined (e.g.: video deviations use no tokens at all, not even for WIX transformations)
  // returns an object struct with members:
  //   keyString   - a string that is unique to this media, allowing to identify it when analyzing HTML snippet (used for MultiImage deviations)
  //   url         - the download url
  //   filename    - proposed filename (or empty string) - to be used with "download" attrib of anchor ("a" ) element
  //   description - proposed description for display as hover pop with "title" attrib
  //   original    - always FALSE for this function - boolean indicating if this is the original (non-resampled) media file
  function findBestResampledImageInfo(aMediaTypes, aMediaBaseUri, aPrettyName, aMediaTokens) {
    if (!aMediaBaseUri || !aPrettyName || !aMediaTypes) {
      throw new Error(logPrefix + "findBestResampledImageInfo - some required parameters are not set");
    }
    if (!(aMediaTypes.length > 0)) {
      throw new Error(logPrefix + "findBestResampledImageInfo - aMediaTypes is empty or wrong type");
    }
    var bestHeight = -1;
    var bestWidth = -1;
    var bestC = null; // WIX image transformation/resampling URI path (like "/v1/fill/w_1063,h_752,q_70,strp/<prettyName>-pre.jpg")
    var bestT = null; // name/type of media ("fullview", "preview", etc.)
    var bestR = -1; // URL protection token

    // Media type with "c" field uses a WIX image transformation URL -> and the proposed filename pattern is part of the URL.
    // This pattern is extracted - as browser-based download won't honor non-same-origin "download" attribute
    // Instead, we need to process the pattern (replace "<prettyName>" placeholder with actual name).
    // File size information in "f" attrib is *INCORRECT* (it's the size of the original file, not the transformed one).
    // ADDITIONALLY:
    //   There can be a larger transformation image nested inside this type (typically "-2x", within "ss" array).
    //   However, this is not always the case: (a) nested "ss" array might not be there, or (b) nested inside might be exact same size.
    //   these "sub-types" use the same access token as the "outer" media type (token index in attrib "r")
    for (var i = 0; (i < aMediaTypes.length); i++) {
      const mediatype = aMediaTypes[i];
      var r = mediatype.r; // URL protection token
      if (!(r >=0)) continue; // skip if not protected by a token (like static placeholder or error image)
      const t = mediatype.t; // name/type of media ("fullview", "preview", etc.)
      var c = mediatype.c; // WIX image transformation/resampling URI path (like "/v1/fill/w_1063,h_752,q_70,strp/<prettyName>-pre.jpg")
      if (c === undefined) continue; // skip if this is not a resampled media
      if (c.includes("/v1/crop/") || c.includes("/v1/fit/")) continue; // skip types that crop image or reduce in size
      var h = mediatype.h; // height
      if ((h > bestHeight) || ((h === bestHeight) && ((t == "preview") || (t == "fullview")))) {
        bestHeight = h;
        bestWidth = mediatype.w;
        bestC = c;
        bestT = t;
        bestR = r;
      }
      var ss = mediatype.ss; //check for additional media variants (like "/v1/fill/w_1063,h_752,q_70,strp/<prettyName>-pre-2x.jpg")
      for (var j = 0; (j < ss?.length); j++) {
        const mediatypess = ss[j];
        h = mediatypess.h;
        if (h > bestHeight) {
          bestHeight = h;
          bestWidth = mediatypess.w;
          bestC = mediatypess.c;
          bestT = t;
          bestR = r;
        }
      }
    }

    // Note: image size info for resampled media is either wrong (it's the size of the original file) or missing entirely
    if (bestHeight > 0) {
      return {
        keyString: aMediaBaseUri,
        filename: bestC.substring(bestC.indexOf("<prettyName>")).replaceAll("<prettyName>", aPrettyName),
        description: "" + bestWidth + "x" + bestHeight + // width and height
        " - " + bestC.substring(bestC.lastIndexOf(".")+1).toUpperCase() + // file type extension
        " - " + bestT + // sample t: "fullview"
        " - download resampled media",
        url: aMediaBaseUri + bestC.replaceAll("<prettyName>", aPrettyName) + ((bestR >= 0) ? "?token=" + aMediaTokens[bestR] : ""),
        original: false
      };
    } else {
      return { keyString: null, filename: "", description: "no resampled media found", url: "", original: false };
    }
  }



  // Determines the best media file that is NOT a resampled image (no WIX image transformation)
  // This should only be one of the following: Either an original image, or a video/document.
  //   aMediaTokens can be undefined (e.g.: video deviations use no tokens at all, not even for WIX transformations)
  //   aOrigHeight can be undefined if it is not known
  // returns an object struct with members:
  //   keyString   - a string that is unique to this media, allowing to identify it when analyzing HTML snippet (used for MultiImage deviations)
  //   url         - the download url
  //   filename    - proposed filename (or empty string) - to be used with "download" attrib of anchor ("a" ) element
  //   description - proposed description for display as hover pop with "title" attrib
  //   original    - boolean indicating if this is the original (non-resampled) media file (videos need aOrigHeight to determine)
  function findBestOtherInfo(aMediaTypes, aMediaBaseUri, aPrettyName, aIsVideo, aIsDoc, aFileType, aMediaTokens, aOrigHeight) {
    if (!aMediaBaseUri || !aPrettyName || !aMediaTypes) {
      throw new Error(logPrefix + "findBestOtherInfo - some required parameters are not set");
    }
    if (!(aMediaTypes.length > 0)) {
      throw new Error(logPrefix + "findBestOtherInfo - aMediaTypes is empty or wrong type");
    }
    var bestHeight = -1;
    var bestWidth = -1;
    var bestB = null; // video URL (if exist)
    var bestS = null; // document URL (if exist)
    var bestT = null; // name/type of media ("fullview", "preview", etc.)
    var bestR = -1; // URL protection token
    var bestF = -1; // file size

    for (var i = 0; (i < aMediaTypes.length); i++) {
      const mediatype = aMediaTypes[i];
      if (mediatype.c) continue; // ignore WIX image transformations
      if (!aIsDoc && mediatype.s) continue; // ignore static images (like error images)
      if (aIsVideo && (mediatype.t != "video")) continue; // only use "video" types
      if (aIsDoc && (mediatype.t != aFileType)) continue; // only use document types
      const h = mediatype.h; // height
      if (h > bestHeight) {
        bestHeight = h;
        bestWidth = mediatype.w;
        bestB = mediatype.b;
        bestS = mediatype.s;
        bestT = mediatype.t;
        bestR = mediatype.r;
        bestF = mediatype.f;
      }
    }

    if (bestHeight >= 0) {
      if (bestB) {
        // this media type uses a URL independent of the baseUri but specific to the deviation - this is used for videos
        const filetype = bestB.substring(bestB.lastIndexOf(".")+1);
        // !! ASSUMPTION !!
        //     => If height here is same a height of orig file, then we have original video here, as well.
        // !! ASSUMPTION !!
        const orig = (aOrigHeight > 0) ? ((bestHeight == aOrigHeight) ? "original " : "resampled ") : "";
        return {
          keyString: aMediaBaseUri, // not using bestB - there will always be a video thumbnail using aMediaBaseUri - but likely NOT always the URI of the largest video variant
          filename: aPrettyName + "_" + bestHeight + "p." + filetype,
          description: "" + bestWidth + "x" + bestHeight +
          ((bestF > 0) ? " (" + getSizeText(Number.parseInt(bestF)) + ") - " : " - ") +
          filetype.toUpperCase() + " - " +
          bestT + " - download " + orig + "media file",
          url: bestB + ((bestR >= 0) ? "?token=" + aMediaTokens[bestR] : ""),
          original: (bestHeight == aOrigHeight)
        };
      }
      else if (aIsDoc && bestS) {
        // this media type uses a static URL independent of the baseUri - this is used for documents
        return {
          keyString: aMediaBaseUri, // not using bestS - there will always be a document thumbnail using aMediaBaseUri
          filename: aPrettyName + "." + aFileType,
          description: "" + ((bestF > 0) ? " (" + getSizeText(Number.parseInt(bestF)) + ") - " : " - ") +
          aFileType.toUpperCase() + " - " +
          bestT + " - download original file",
          url: bestS,
          original: true
        };
      }
      else
      {
        // this media type does not use WIX image transformations, and it is not a static URL (like used for "social_preview" media type)
        // this means it is the original full size media, without any transformation - and with a URL that does NOT time-out quickly :-)
        // but the URL will be with a cryptic GUID-like filename - so we must suggest better filename
        const filetype = aMediaBaseUri.substring(aMediaBaseUri.lastIndexOf(".")+1);
        return {
          keyString: aMediaBaseUri,
          filename: aPrettyName + "." + filetype,
          description: "" + bestWidth + "x" + bestHeight +
          ((bestF > 0) ? " (" + getSizeText(Number.parseInt(bestF)) + ") - " : " - ") +
          filetype.toUpperCase() + " - " +
          bestT + " - download original media file",
          url: aMediaBaseUri + ((bestR >= 0) ? "?token=" + aMediaTokens[bestR] : ""),
          original: true
        };
      }
    }
    else
    {
      return { keyString: null, filename: "", description: "no non-resampled media found", url: "", original: false };
    }
  }



  // Parses the JSON object "initials.__INITIAL_STATE__" from the "initials" parameter as created by "getInitials()".
  // Nearly all page information that this script needs, comes from __INITIAL_STATE__ -> thus this function is rather large...
  function parseInitials(initials) {
    'use strict'; // make double sure that this function uses strict mode (strict mode on should be inherited, anyway).

    try {

      const entities = initials.__INITIAL_STATE__["@@entities"];
console.info("TEST-entities: ", entities);

      var tmpVal;

      // extra info about deviation author
      tmpVal = Object.keys(entities.user).at(0); // has only one child, with node name being the authors ID
      deviationAuthorJson = entities.user[tmpVal];
      const authorId = deviationAuthorJson.userId;
      console.assert(authorId == tmpVal, logPrefix + "entities.user JSON key ("+tmpVal+") and userId ("+authorId+") should be identical", entities);
      const authorName = deviationAuthorJson.username;

      // extract info about deviation basics
      tmpVal = Object.keys(entities.deviation).at(0); // has only one child, with node name being the authors ID
      deviationJson = entities.deviation[tmpVal];
      deviationId = deviationJson.deviationId;
      console.assert(deviationId == tmpVal, logPrefix + "entities.deviation JSON key ("+tmpVal+") and deviationId ("+deviationId+") should be identical", entities);
      tmpVal = deviationJson.author;
      console.assert(authorId == tmpVal, logPrefix + "user.userId ("+authorId+") and deviation.author ("+tmpVal+") should be identical", entities);

      // extract extended info (info about original file)
      tmpVal = Object.keys(entities.deviationExtended).at(0); // has only one child, with node name being the authors ID
      deviationExtendedJson = entities.deviationExtended[tmpVal];
      console.assert(deviationId == tmpVal, logPrefix + "entities.deviationExtended JSON key ("+tmpVal+") and deviationId ("+deviationId+") should be identical", entities);

      const isDownloadable = deviationJson.isDownloadable;
      const isAiGenerated = deviationJson.isAiGenerated;
      // isLocked indicates that the deviation is inaccessible - all available images will be blurred
      const isLocked = (deviationJson.tierAccess == "locked") ||
                       (deviationJson.premiumFolderData?.hasAccess == false);
      const isVideo = deviationJson.isVideo;
      const fileType = deviationJson.type;
      const isDoc = !isVideo && ((fileType == "pdf") || (deviationJson.canBeMultiImage == false));

      const isMultiImage = deviationJson.isMultiImage;
      const deviationAdditionalMedia = (isMultiImage) ? deviationExtendedJson.additionalMedia : null;
      console.assert((!isMultiImage) || (deviationAdditionalMedia?.length > 0), logPrefix + "MultiImage deviations should have additionalMedia array within deviationExtended JSON", entities);

      // prep global variables
      numDeviations = (deviationAdditionalMedia?.length > 0) ? deviationAdditionalMedia.length + 1 : 1;
      deviationOriginalInfos = new Array(numDeviations);
      deviationResampledInfos = new Array(numDeviations);

      // *****
      // ***** parse the FIRST/MAIN deviation media - if this is multi-image, additional media must be parsed, as well
      // *****
      // extract info about file name and media types
      const media = deviationJson.media;
      const mediaPrettyName = media.prettyName; // deviation basic filename without extension
      // modify filename to my preference:
      //  -> prefix author name and additional "AI" prefix if "AI generated" flag is set
      const myPrettyName = authorName.toLowerCase() + ((isAiGenerated) ? "_AI_" : "_") + mediaPrettyName;
      // extract info about full size and/or preview media file
      const mediaBaseUri = media.baseUri; // sample: "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/i/<guid>/<otherguid>.jpg"
      const mediaTokens = media.token; // optional tokens to access media types - if present, baseUri alone will result in access denied error

      // extract info about original file
      // results in deviationOriginalInfos[0] either set or null, never undefined
      const download = deviationExtendedJson.download;
      if (download) {
        console.assert(isDownloadable, logPrefix + "download info is available but isDownloadable is false. Ignoring.", entities);
        const downloadType = download.url.match(/\.([^.?]+)\?token/i)[1]; // extract file extension from url (not the same as download.type)
        deviationOriginalInfos[0] = {
          keyString: mediaBaseUri,
          filename: myPrettyName + "." + downloadType,
          description: "" + download.width + "x" + download.height + " (" + getSizeText(Number.parseInt(download.filesize)) + ") - " +
                       downloadType.toUpperCase() + " - download original media file",
          url: download.url,
          original: true,
          timeout: Number.parseInt(download.url.substring(download.url.indexOf("&ts=")+4))
        };
      } else {
        console.assert(!isDownloadable, logPrefix + "isDownloadable is true but no download info is available. Ignoring.", entities);
        deviationOriginalInfos[0] = null;
      }
      const origFile = deviationExtendedJson.originalFile;
      const origFileSize = origFile?.filesize;
      const origWidth = origFile?.width;
      const origHeight = origFile?.height;

      // decide which of the available downloads / media types to use for Original (if any) and for Resampled
      if (!isLocked) {
        if (!isVideo && !isDoc) {

          deviationResampledInfos[0] = findBestResampledImageInfo(media.types, mediaBaseUri, myPrettyName, mediaTokens);
          if (!deviationOriginalInfos[0]) { // no download url (the one that times out) -> try to find original in mediaTypes
            const bestOther = findBestOtherInfo(media.types, mediaBaseUri, myPrettyName, isVideo, isDoc, fileType, mediaTokens, origHeight);
            deviationOriginalInfos[0] = (bestOther?.original) ? bestOther : null;
          }
        }
        else // isVideo == true
        {
          const bestOther = findBestOtherInfo(media.types, mediaBaseUri, myPrettyName, isVideo, isDoc, fileType, mediaTokens, origHeight);
          if (bestOther?.original) {
            deviationOriginalInfos[0] = bestOther;
            deviationResampledInfos[0] = null;
          } else {
            deviationOriginalInfos[0] = null;
            deviationResampledInfos[0] = bestOther;
          }
        }
      }
      else {
        // nothing we can do if the deviation is locked
        deviationResampledInfos[0] = null;
      }

      // *****
      // ***** If MultiImage -> parse additional media
      // *****
      for (var mediaindex = 0; (mediaindex < (numDeviations-1)); mediaindex++) {
        const additionalMedia = deviationAdditionalMedia[mediaindex];
        const origFileSize = additionalMedia.filesize;
        const origWidth = additionalMedia.width;
        const origHeight = additionalMedia.height;
        const media = additionalMedia.media;
        const mediaPrettyName = media.prettyName; // deviation basic filename without extension
        // modify filename of MultiImage items to my preference:
        //  -> MultiImage deviations often seem to include the file extension as part of the pretty name - try to detect and remove
        //  -> prefix the ENTIRE pretty name of the first/main deviation (this will also "inherit" the author and potential AI prefixes)
        const myMultiPrettyName = myPrettyName + "_" + mediaPrettyName.replace(/\.[a-zA-Z0-9]+$/, "");
        // extract info about full size and/or preview media file
        const mediaBaseUri = media.baseUri; // sample: "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/i/<guid>/<otherguid>.jpg"
        const mediaTokens = media.token; // optional tokens to access media types - if present, baseUri alone will result in access denied error
        if (!isLocked) {
          if (!isVideo && !isDoc) {
            deviationResampledInfos[mediaindex+1] = findBestResampledImageInfo(media.types, mediaBaseUri, myMultiPrettyName, mediaTokens);
            const bestOther = findBestOtherInfo(media.types, mediaBaseUri, myMultiPrettyName, isVideo, isDoc, fileType, mediaTokens, origHeight);
            deviationOriginalInfos[mediaindex+1] = (bestOther?.original) ? bestOther : null;
          }
          else // there should be no videos in MuiltiImage deviations - but who knows... better safe than sorry...
          {
            const bestOther = findBestOtherInfo(media.types, mediaBaseUri, myMultiPrettyName, isVideo, isDoc, fileType, mediaTokens, origHeight);
            if (bestOther?.original) {
              deviationOriginalInfos[mediaindex+1] = bestOther;
              deviationResampledInfos[mediaindex+1] = null;
            } else {
              deviationOriginalInfos[mediaindex+1] = null;
              deviationResampledInfos[mediaindex+1] = bestOther;
            }
          }
        }
        else {
          // nothing we can do if the deviation is locked
          deviationResampledInfos[mediaindex+1] = null;
        }

      }

    }
    catch (e) {
      throw new Error(logPrefix + "parseInitials - Failure parsing __INITIAL_STATE__", { cause: e });
    }
  }


  function determineDeviationIndex(aDocument) {
    var newDeviationsShown = [];
    if (!multinode || !(numDeviations > 0)) {
      return newDeviationsShown;
    }
    if (numDeviations == 1) {
      newDeviationsShown = [0];
      return newDeviationsShown;
    }

    for (var i=0; (i < numDeviations); i++) {
      // if the below "keys" occur in the target DOM, then this identifies which deviation is currently being shown
      const keyResampled = deviationResampledInfos[i]?.keyString;
      const keyOriginal = deviationOriginalInfos[i]?.keyString;
      var matchR = null;
      if (keyResampled?.length > 0) {
        matchR = aDocument.evaluate(".//figure//img[not(ancestor::button) and (contains(@src,'" + keyResampled + "') or contains(@srcset,'" + keyResampled + "'))]" +
                                    " | " +
                                    ".//div[contains(@style, '" + keyResampled + "')]", // for video thumbnail overlay: style='... background-image: url("https:/ ...'
                                    multinode, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
      }
      var matchO = null;
      if (keyOriginal?.length > 0) {
        matchO = aDocument.evaluate(".//figure//img[not(ancestor::button) and (contains(@src,'" + keyOriginal + "') or contains(@srcset,'" + keyOriginal + "'))]" +
                                    " | " +
                                    ".//div[contains(@style, '" + keyOriginal + "')]", // for video thumbnail overlay: style='... background-image: url("https:/ ...'
                                    multinode, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
      }
      if (matchR || matchO) newDeviationsShown.push(i);
    }
    return newDeviationsShown;
  }


  // Inserts up to two extra download buttons, requires that __INITIAL_STATE__ has been parsed and processed
  //  - button "original" will be present if media download in original, non-resampled quality is available
  //  - button "resampled" will be present if a resampled media download is available
  // Also checks if previously inserted buttons need to be updated (needed for MultiImage deviations)
  function insertUpdateExtraButtons(aDocument) {

    var newDeviationsShown = determineDeviationIndex(aDocument);

    // only continue if no buttons inserted or if devinationsShown has changed
    // CAREFUL: likely endless loop otherwise, as observer includes this DOM area
    if (linkResampledDownload || linkOriginalDownload) {
      if (deviationsShown &&
          newDeviationsShown.length === deviationsShown?.length &&
          newDeviationsShown.every((e, i) => e === deviationsShown[i])) {
        return;
      }
    }
    deviationsShown = newDeviationsShown;

    const resampledInfos = [];
    const originalInfos = [];
    for (var i=0; (i<deviationsShown?.length); i++) {
      const oi = deviationOriginalInfos[deviationsShown[i]];
      const ri = deviationResampledInfos[deviationsShown[i]];
      if (oi?.url?.length > 0) {
        originalInfos.push(oi);
      }
      if (ri?.url?.length > 0) {
        resampledInfos.push(ri);
      }
    }

    if (linkResampledDownload) {
      linkResampledDownload.remove();
      linkResampledDownload = null;
    }
    if (resampledInfos.length > 0) {
      linkResampledDownload = insertExtraDownloadButton(
        aDocument, ariaPrivateColl, idExtraResampledDownload,
        ariaExtraResampledDownload, extraDownload2Svg,
        resampledInfos);
    }

    if (linkOriginalDownload) {
      linkOriginalDownload.remove();
      linkOriginalDownload = null;
    }
    if (originalInfos.length > 0) {
      linkOriginalDownload = insertExtraDownloadButton(
        aDocument, ariaPrivateColl, idExtraOriginalDownload,
        ariaExtraOriginalDownload, extraDownload1Svg,
        originalInfos);
    }
  }



  function installMultiObserver(aDocument) {
    // this observer is to deal multi image deviations -  it must be re-initialized after full-screen zoom (observer node gets removed on full-screen zoom)
    observerMultiImage = new MutationObserver(function(mutations) {
      // don't need to iterate over added or removed nodes!
      // instead, the below function checks globally by ID (fast! simple!) if extra buttons already exist or not
      insertUpdateExtraButtons(aDocument);
    });
    // Configuration of the observer
    // look at everything below a certain div that encapsulates both the deviation and the button panel below (where the extra buttons are)
    // It is removed from DOM when switching to full-screen.
    // It's descendants structure is different for image deviations and video deviations
    // It's descendants structure and/or descendant attributes change for MultiImage deviation when navigating images and/or when switching between single image and all image display modes
    const multiconfig = { attributes: true, childList: true, characterData: false, subtree: true };
    multinode = aDocument.evaluate("(./main/div[1])[preceding-sibling::header]/div[child::section]",
                                         aDocument.body, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    if (multinode) {
      observerMultiImage.observe(multinode, multiconfig);
    } else {
      observerMultiImage = null;
    }
  }


  function installObservers(aDocument) {
    // create an observer instances to check if extra buttons need to be re-added or modified (override React-based re-rendering of DOM)
    // this observer is to deal with zoom/full-screen display modes - it only looks at direct children of <main>
    observerMain = new MutationObserver(function(mutations) {
      if (!(multinode?.connected == true)) {
        if (observerMultiImage) {
          observerMultiImage.disconnect();
        }
        installMultiObserver(aDocument);
      }
      // don't need to iterate over added or removed nodes!
      // instead, the below function checks globally by ID (fast! simple!) if extra buttons already exist or not
      insertUpdateExtraButtons(aDocument);
    });
    // Configuration of the observer
    // "main" node is the start of React managed hierarchy.
    // It changes in major way when switching to full-screen.
    // Fortunately, it is sufficient to just observe direct children - all sub-hierarchy elements get added/removed right along with them
    const mainconfig = { attributes: false, childList: true, characterData: false, subtree: false };
    const mainnode = aDocument.evaluate("./main", aDocument.body, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    observerMain.observe(mainnode, mainconfig);
  }



  //
  // ***** START OF USER SCRIPT *****
  //

  // extract all info needed for extra download button fROM __INITIAL_STATE__ object ("initials" itself is already initialized)
  parseInitials(initials);

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

  // mutation observer to re-create extra download buttons if React re-renders DOM (e.g. expanding to and exiting from full-screen view).
  installMultiObserver(document);
  installObservers(document);

})();