Velitar / GOG Checksum Lookup

// ==UserScript==
// @name        GOG Checksum Lookup
// @description Use the GOG API to show MD5 checksums of owned games.
// @icon        https://www.gog.com/apple-touch-icon.png
// @homepageURL https://gogapidocs.readthedocs.io/en/latest/
// @match       https://www.gog.com/account
// @match       https://www.gog.com/*/account
// @version     22w04b
// @license     MIT
// @author      xmanacollectorx
// @grant       GM_addStyle
// @grant       GM.xmlHttpRequest
// ==/UserScript==

// ------------------------------------------------------------------
// VARIABLES
// ------------------------------------------------------------------

// Strings for UserScript communication.
var s_us_enter_lookup = 'Starting lookup for game ID ';
var s_us_error_generic = 'Generic file handler not found.';
var s_us_error_chunk_url = 'Could not extract chunklist URL.';
var s_us_got_chunk_url = ', received chunklist URL: ';
var s_us_got_data = ', received final data: ';
var s_us_got_generic = ', extracted generic file handler: ';
var s_us_got_token = 'Successfully retrieved GOG access token.';
var s_us_id_disappeared = 'Product ID disappeared.';
var s_us_id_detected = 'Detected product ID: ';
var s_us_invalid_task = 'Script could not understand its task.';
var s_us_iteration = 'Iterating download link ';
var s_us_no_checksum = 'Unable to extract md5 checksum from XML.';
var s_us_no_dl_rows = 'No downloadable content was found.';
var s_us_no_token = 'GOG access token not found.';
var s_us_row = 'Row ';
var s_us_startup = 'GOG Checksum Lookup loaded.';
var s_us_text_button_0 = 'Checksums ✓';
var s_us_text_button_1 = 'Checksums 🗘';
var s_us_text_hash = 'md5';
var s_us_update_button = 'Added lookup button to product popup.';

// HTML target search strings.
var s_html_dl_rows = 'a.game-link.row[ng-href^="/downloads/"]';
var s_html_dl_version = 'span.game-link__info';
var s_html_product_head = 'header.game-details__header';
var s_html_product_id = 'gog-account-product';
var s_html_product_popup = 'section.game-details';

// GOG API snippets.
var s_api_url_bonus_d = 'product_bonus/';
var s_api_url_downlink_b = 'https://api.gog.com/products/';
var s_api_url_downlink_d = '/downlink/';
var s_api_url_chunklist = '';
var s_api_url_redirect = '';

// Other global variables used in the script.
var arr_dl_rows = '';
var bool_button_added = false;
var bool_button_enabled = true;
var num_product_id = '';
var s_accessToken = '';
var s_chunklist = '';
var s_dl_generic = '';
var s_fetch_urls = '';
var s_nextTask = 'waitProduct';

// ------------------------------------------------------------------
// FUNCTIONS
// ------------------------------------------------------------------

// Detect product selection in the GOG game library.
function detectProductChange() {
  var oTarget = document.querySelector(s_html_product_popup);
  var oConfig = {
    attributes: true,
    attributeFilter: [s_html_product_id]
  };
  var oCallback = function (mutationsList, observer) {
    for (const mutation of mutationsList) {
      num_product_id = document.querySelector(s_html_product_popup).getAttribute(s_html_product_id);
      if (num_product_id == '' || isNaN(num_product_id)) {
        console.log(s_us_id_disappeared);
      }
      else {
        console.log(s_us_id_detected + num_product_id);
        enableLookup();
        enableButton();
      }
    }
  };
  var observer = new MutationObserver(oCallback);
  observer.observe(oTarget, oConfig);
}

// Filter product popup for product header.
function detectProductHeader() {
  return document.querySelector(s_html_product_head);
}

// Filter product popup for download links.
function detectDownloadLinks() {
  return document.querySelectorAll(s_html_dl_rows);
}

// Display button for checksum lookup.
function enableLookup() {
  // Only add button if is doesn't exist yet.
  if (bool_button_added != true) {
    // Build the actual HTML element.
    var header = detectProductHeader();
    var button = document.createElement('div');
    button.setAttribute('class', 'game-details__section__dropdown module-header2__element');
    button.innerHTML = `
            <span class="module-header2__dropdown" id="lookupButton" style="cursor:pointer">
                ` + s_us_text_button_1 + `
            </span>
        `;
    header.appendChild(button);
    document.getElementById('lookupButton').addEventListener('click', retrieveHashes, false);
    bool_button_added = true;
    console.log(s_us_update_button);
  }
}

// Routine for looking up hashes, duh.
function retrieveHashes() {
  console.log('User wants to see hashes.');
  disableButton();
  lookupRows();
}

// Wait for a download link table to appear.
function lookupRows() {
  var intv = setInterval(
    function () {
      // Select elements with specific properties.
      arr_dl_rows = detectDownloadLinks();
      // When no element is found, start all over again.
      if (arr_dl_rows.length < 1) {
        return false;
      }
      // When an element is found, clear the interval.
      clearInterval(intv);
      // Iterate through the discovered elements.
      for (let i = 0; i < arr_dl_rows.length; i++) {
        fetchInstallerURLs(i);
      }
    },
    100);
}

// Have a closer look at a table row and extract a generic URL.
function extractGenericURL(i) {
  var name = arr_dl_rows[i].getAttribute('href').split('/').pop();
  if (name != null) {
    console.log(s_us_row + i + s_us_got_generic + name);
    if (name.replace(/\d+/, '') != '') {
      var step1 = name.replace(/^[a-zA-Z]+\d+/, '');
      var step2 = step1.replace(/\d+/, '');
      var generic = step2 + '/' + name;
      return generic;
    }
    else {
      var generic = s_api_url_bonus_d + name;
      return generic;
    }
  }
  else {
    throw new Error(s_us_error_generic);
  }
}

// Retrieve the GOG user access token from the browsers local storage.
function getAccessToken() {
  var json = localStorage.getItem('dataClient_menuData');
  var obj = JSON.parse(json);
  if (obj != null) {
    return obj.accessToken;
  }
  else {
    throw new Error(s_us_no_token);
  }
}

// Call the GOG API to hand over installer download links, bypass CORS.
function fetchInstallerURLs(i) {
  GM.xmlHttpRequest({
    method: 'GET',
    url: s_api_url_downlink_b + num_product_id + s_api_url_downlink_d + extractGenericURL(i),
    headers: {
      'Authorization': 'Bearer ' + getAccessToken()
    },
    onload: function (response) {
      extractChunklistURL(i, response.responseText.substring(0, 2000));
    }
  });
}

// Extract URL for installer metadata from the first API response.
function extractChunklistURL(i, raw) {
  var json = JSON.parse(raw);
  var url = json.checksum;
  if (url != null) {
    console.log(s_us_row + i + s_us_got_chunk_url + url);
    fetchChunklist(i, url);
  }
  else {
    throw new Error(s_us_error_chunk_url);
  }
}

// Call the GOG API to send installer metadata, bypass CORS.
function fetchChunklist(i, url) {
  GM.xmlHttpRequest({
    method: 'GET',
    url: url,
    headers: {
      'Authorization': 'Bearer ' + getAccessToken()
    },
    onload: function (response) {
      extractInstallerChecksum(i, response.responseText);
    }
  });
}

// Extract the installer checksum from the second API response.
function extractInstallerChecksum(i, chunklist) {
  var parser = new DOMParser();
  var xmlDoc = parser.parseFromString(chunklist, 'text/xml');
  var checksum = xmlDoc.getElementsByTagName("file")[0].getAttribute('md5');
  var date = xmlDoc.getElementsByTagName("file")[0].getAttribute('timestamp').replace(/\s.*/, '');
  if (checksum.length < 1) {
    throw new Error(s_us_no_checksum);
  }
  else {
    console.log(s_us_row + i + s_us_got_data + checksum + ', ' + date);
    updateRows(i, checksum, date);
  }
}

// Display the received checksums.
function updateRows(i, checksum, date) {
  var hash = document.createElement('a');
  hash.setAttribute('class', 'game-link row');
  hash.setAttribute('ng-repeat', 'item in details.main.currentDownloads');
  hash.innerHTML = `
    <span ng-bind="item.name" class="game-link__right row__column">
        ` + checksum + `
    </span>
    <span class="game-link__right game-link__info row__column">
        ` + date + `
    </span>
    <span class="game-link__right game-link__size row__column">
        ` + s_us_text_hash + `
    </span>
    `;
  insertAfter(arr_dl_rows[i], hash);
}

// Extract the installer version from a row entry.
function extractInstallerVersion(i) {
  var version = arr_dl_rows[i].querySelector(s_html_dl_version).innerText;
  if (version != null) {
    return version;
  }
  else {
    return '?';
  }
}

// UserScript injection routine, duh.
function insertAfter(referenceNode, newNode) {
  referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}

// Disable the lookup button to prevent spam.
function disableButton() {
  var button = document.getElementById('lookupButton');
  button.removeEventListener('click', retrieveHashes, false);
  button.setAttribute('class', 'module-header2__element-label');
  button.removeAttribute('style');
  button.innerHTML = s_us_text_button_0;
}

// Enable the lookup button to allow API interaction.
function enableButton() {
  var button = document.getElementById('lookupButton');
  button.addEventListener('click', retrieveHashes, false);
  button.setAttribute('class', 'module-header2__dropdown');
  button.setAttribute('style', 'cursor:pointer');
  button.innerHTML = s_us_text_button_1;
}

// ------------------------------------------------------------------
// MAIN
// ------------------------------------------------------------------

// Begin when the page has loaded.
window.addEventListener('load', function () {
  console.log(s_us_startup);
  detectProductChange();
})