SB100 / GGn IGDb Game Recommendations

// ==UserScript==
// @namespace    https://openuserjs.org/users/SB100
// @name         GGn IGDb Game Recommendations
// @description  Show game recommendations from IGDb on Game pages
// @updateURL    https://openuserjs.org/meta/SB100/GGn_IGDb_Game_Recommendations.meta.js
// @version      1.0.1
// @author       SB100
// @copyright    2021, SB100 (https://openuserjs.org/users/SB100)
// @license      MIT
// @include      https://gazellegames.net/torrents.php?id=*
// @grant        GM_xmlhttpRequest
// @connect      www.igdb.com
// ==/UserScript==

// ==OpenUserJS==
// @author SB100
// ==/OpenUserJS==

/* jshint esversion: 6 */

const URL_IGDB_BASE = 'https://www.igdb.com';
const URL_IGDB_SEARCH = 'https://www.igdb.com/search?type=1&q=';
const URL_GGN_SEARCH = 'https://gazellegames.net/torrents.php?order_by=relevance&order_way=desc&artistname=Games&searchstr=';

/**
 * Turn a HTML string into a HTML element so that we can run querySelector calls against it
 */
function htmlToElement(html) {
  var template = document.createElement('template');
  html = html.trim();
  template.innerHTML = html;
  return template.content;
}

function query(url) {
  let resolver;
  let rejecter;
  const p = new Promise((resolveFn, rejectFn) => {
    resolver = resolveFn;
    rejecter = rejectFn;
  });

  GM_xmlhttpRequest({
    method: 'get',
    url: url,
    timeout: 5000,
    onloadstart: function () {},
    onload: result => resolver(result),
    onerror: result => rejecter(result),
    ontimeout: result => rejecter(result),
  });

  return p;
}

function searchIGDb(gameName, year) {
  const url = `${URL_IGDB_SEARCH}${encodeURIComponent(gameName)}`;
  return query(url).then(result => {
    if (result.status !== 200) {
      return;
    }

    return htmlToElement(result.response);
  }).then(doc => {
    const jsonElem = doc.querySelector('#results_json');
    const json = jsonElem && JSON.parse(jsonElem.dataset.json);
    if (!json || json.length === 0) {
      return [];
    }

    return json.filter(item => item.type === 'game').map(item => ({
      name: item.data.name,
      nameFormatted: item.data.name.toLowerCase().replace(/[^\w\s]/g, ''),
      url: item.data.url,
      year: item.data.release_year,
    }));
  });
}

function findRecommendationsFromIGDbPage(bestMatch) {
  const url = `${URL_IGDB_BASE}${bestMatch.url}`;

  return query(url).then(result => {
    if (result.status !== 200) {
      return;
    }

    return htmlToElement(result.response);
  }).then(doc => {
    const recElems = Array.from(doc.querySelectorAll('.col-md-9 > .game-list-inline .game-list-inline-item a'));
    return recElems.map(a => ({
      image: a.querySelector('img').src,
      name: a.querySelector('span').innerText
    }));
  });
}

function getGameNameAndYearFromGGn() {
  const elem = document.getElementById('display_name').cloneNode(true);
  const groupPlatform = elem.querySelector('#groupplatform');
  elem.removeChild(groupPlatform);

  const gameMatch = elem.innerText.match(/\-\s([^\(]+)/);
  const gameName = gameMatch && gameMatch[1].trim().toLowerCase().replace(/[^\w\s]/g, '');

  const yearMatch = elem.innerText.match(/\(([\d]+)\)/);
  const year = yearMatch && yearMatch[1].length === 4 && parseInt(yearMatch[1], 10);

  return {
    gameName,
    year
  }
}

function findBestMatch(json, gameName, year) {
  // match both name and year
  for (let i = 0, len = json.length; i < len; i += 1) {
    if (json[i].nameFormatted === gameName && json[i].year === year) {
      return json[i];
    }
  }

  // just match name
  for (let i = 0, len = json.length; i < len; i += 1) {
    if (json[i].nameFormatted === gameName) {
      return json[i];
    }
  }

  return null;
}

function createRecommendationsBox(recommendations) {
  const builtRecs = recommendations.map(rec => {
    return `<li class='recommendation__item' title='${rec.name}'>
  <a href='${URL_GGN_SEARCH}${encodeURIComponent(rec.name)}'>
    <img src='${rec.image}' alt='${rec.name} Cover' />
    <div class='recommendation__name'>${rec.name}</div>
  </a>
</li>`
  }).join('')

  const div = document.createElement('div');
  div.classList.add('box');
  div.innerHTML = `<div class='head'>Recommendations from IGDb</div>
<div class='body similar'>
  <ul class='nobullet recommendation__cont'>
    ${builtRecs}
  </ul>
</div>
`;

  const allBoxes = Array.from(document.querySelectorAll('.main_column > .box'));
  const lastBox = allBoxes[allBoxes.length - 1];
  // append after
  lastBox.parentNode.insertBefore(div, lastBox.nextSibling);
}

function createStyleTag() {
  const css = `.recommendation__cont {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
}

.recommendation__cont::after {
  content: "";
  flex: auto;
  flex-basis: 130px;
  flex-grow: 0
}

.recommendation__item a {
  display: inline-block;
  position: relative;
}

.recommendation__name {
  display: none;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 7px;
  background: rgba(0,0,0,0.8);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.recommendation__item:hover .recommendation__name {
  display: block;
}`;

  const style = document.createElement('style');
  style.type = 'text/css';
  style.appendChild(document.createTextNode(css));

  document.head.appendChild(style);
}

(async function () {
  'use strict';

  const {
    gameName,
    year
  } = getGameNameAndYearFromGGn();
  const results = await searchIGDb(gameName);
  if (!results || results.length === 0) {
    console.log('[GGn IGDb Game Recommendations] No IGDb results');
    return;
  }

  const bestMatch = findBestMatch(results, gameName, year);
  if (!bestMatch) {
    console.log('[GGn IGDb Game Recommendations] No game match found');
    return;
  }

  const recommendations = await findRecommendationsFromIGDbPage(bestMatch);
  if (!recommendations) {
    console.log('[GGn IGDb Game Recommendations] No recommendations found');
    return;
  }

  createStyleTag();
  createRecommendationsBox(recommendations);
})();