Metacritic score for GOG-Games

Adds Metacritic scores for GOG-Games.com listings

// ==UserScript==
// @name                Metacritic score for GOG-Games
// @description         Adds Metacritic scores for GOG-Games.com listings
// @version             1.0.0
// @author              walsh404
// @license             LGPLv3
// @namespace           https://greasyfork.org/en/users/891301-walsh404
// @match               https://gog-games.com/*
// @match               https://www.gog-games.com/*
// @require             https://code.jquery.com/jquery-3.3.1.min.js
// @grant               GM_xmlhttpRequest
// @grant               GM.xmlhttpRequest
// @grant               GM_xmlHttpRequest
// @grant               GM.xmlHttpRequest
// @grant               GM_addStyle
// @grant               GM.addStyle
// @homepageURL         https://github.com/timtwalsh/Metacritic-score-for-GOG-Games.com
// @supportURL          https://github.com/timtwalsh/Metacritic-score-for-GOG-Games.com/issues
// @run-at              document-start
// ==/UserScript==

(function () {
    // probably it is Greasemonkey
    if (typeof GM !== 'undefined') {
        if (typeof GM.info !== 'undefined')
            window.GM_info = GM.info;

        // VM has GM_xmlhttpRequest but GM has GM_xmlHttpRequest
        // Mad mad world !
        if (typeof GM.xmlHttpRequest !== 'undefined') {
            window.GM_xmlhttpRequest = GM.xmlHttpRequest
        }

        // addStyle
        window.GM_addStyle = function(css) {
            return new Promise((resolve, reject) => {
                try {
                    let style = document.head.appendChild(document.createElement('style'))
                    style.type = 'text/css'
                    style.textContent = css;
                    resolve(style)
                } catch(e) {
                    console.error(`It is not possible to add style with GM_addStyle()`)
                    reject(e)
                }
            })
        }
    }

    //console.log(`[${GM_info.scriptHandler}][${GM_info.script.name} v${GM_info.script.version}] inited`)


    // =============================================================
    //
    // API section
    //
    // =============================================================

    const css = (() => {
        return `
.mcg-wrap {
	/* Base size for all icons */
	--size: 80px;

	display: flex;
	flex-flow: row;
	flex-wrap: wrap;
	align-items: center;
	justify-content: center;

	width: auto;
	padding: 4px;
	box-sizing: border-box;
}

.mcg-wrap * {
	all: unset;
	box-sizing: border-box;
    justify-content: space-around;
}

.mcg-score-summary {
	display: flex;
	flex-flow: row nowrap;
	justify-content: flex-start;
	align-items: center;

	margin: 0 2px 0 2px;
}

.mcg-score-summary__score {
	display: flex;
	flex-flow: row;
	justify-content: center;
	align-items: center;

	min-width: calc(var(--size) * 0.5);
	min-height: calc(var(--size) * 0.5);
	width: calc(var(--size) * 0.5);
	height: calc(var(--size) * 0.5);
	margin: 0 4px 0 4px;

	background-color: #0f0;
	background-color: #c0c0c0;
	border-radius: 6px;
	font-family: sans-serif;
	
	font-size: 1.2em;
	font-weight: bold;
	color: white;
}

.mcg-score--bad {
	background-color: #f00;
	color: white;
}

.mcg-score--mixed {
	background-color: #fc3;
	color: #111;
}

.mcg-score--good {
	background-color: #6c3;
	color: white;
}

.mcg-score-summary__score--circle {
	border-radius: 50%;
}

.mcg-score-summary .mcg-score-summary__label {
	align-self: flex-start;
	align-self: center;
	
	max-width: 50px;

	font-size: 0.9em;
	font-size: 14px;
	font-weight: bold;
	text-align: left;
	text-align: center;
}

.mcg-logo {
	display: flex;
	flex-flow: row;
	align-items: center;
}

.mcg-logo__img {
	background-image: url(https://upload.wikimedia.org/wikipedia/commons/thumb/2/20/Metacritic.svg/200px-Metacritic.svg.png);
	background-position: center center;
	background-size: cover;
	
	min-width: calc(var(--size) * 0.5);
	min-height: calc(var(--size) * 0.5);
	width: calc(var(--size) * 0.5);
	height: calc(var(--size) * 0.5);
}

.mcg-logo p {
	display: flex;
	flex-flow: column;
	align-items: center;
	justify-content: center;

	margin: 4px 6px;

	font-family: sans-serif;
	font-size: 22px;
	text-align: center;
	font-weight: bold;
}

.mcg-logo p > a {
	cursor: pointer;
	
	text-decoration: underline;
	font-size: 0.65em;
	font-size: 14px;
	font-weight: normal;
	color: #36c;
}
a.block {
    height: 220px !important;
}
.info {
    height: 3em;
}
.game-blocks .block .info .title {
    overflow: overlay;
    white-space: nowrap;
    height: 3em;
}
.date {
    background: #ffffff80;
}
`
	})()

    const defaultHeaders = {
        "Origin": null,
        "Referer": null,
        "Cache-Control": "max-age=3600",
    }

    /**
	 * Sends xmlHttpRequest via GM api (this allows crossdomain reqeusts).
	 * NOTE Different userscript engines support different
	 * details object format.
	 * 
	 * Violentmonkey @see https://violentmonkey.github.io/api/gm/#gm_xmlhttprequest
	 * 
	 * Greasemonkey @see https://wiki.greasespot.net/GM.xmlHttpRequest
	 * @param {Object} details @see ...
	 * @returns {Promise}
	 */
    function ajax(details) {
        return new Promise((resolve, reject) => {
            details.onload = resolve
            details.onerror = reject
            GM_xmlhttpRequest(details)
        })
    }

    /**
	 * Returns an URL to make a search request
	 * for given game.
	 * @param {String} game Game name
	 * @param {String} platform Target platform (PC is default)
	 */
    function getSearhURL(game, platform) {
        // searches GAME only in "game" for PC platform (plats[3]=1)
        ///TODO sanitize game name (trim, remove extra spacebars etc) ?
        return `https://www.metacritic.com/search/game/${game}/results?search_type=advanced&plats[3]=1`
	}

    /**
	 * Returns an array of search results from given html code
	 * @param {String} html Raw html from which search results will be parsed
	 * @returns {Array} array of objects
	 */
    function parseSearchResults(html) {

        const doc = new DOMParser().parseFromString(html, 'text/html')
        const yearReg = /\d{4}/
        const results = $(doc).find('ul.search_results .result_wrap')

        return results.map((ind, elt) => {
            const result = $(elt)
            let year = yearReg.exec(result.find('.main_stats p').text())
            year = year == null ? 0 : parseInt(year[0])

            return {
                title: result.find('.product_title').text().trim(),
                pageurl: 'https://www.metacritic.com' + result.find('.product_title > a').attr('href'),
                platform: result.find('.platform').text().trim(),
                year,
                metascore: parseInt(result.find('.metascore_w').text()),
                criticReviewsCount: 0,
                userscore: 0.0,
                userReviewsCount: 0,
                description: result.find('.deck').text().trim()
            }
        })
            .get()
    }

    function swap(arr, ind1, ind2) {
        const tmp = arr[ind1]
        arr[ind1] = arr[ind2]
        arr[ind2] = tmp
    }

    /**
	 * Returns integer which represents total user reviews
	 * @param {Object} doc jQuery document object
	 * @returns {Number}
	 */
    function getUserReviesCount(doc) {
        const reg = /\d+/
        let count = doc.find('.feature_userscore .count a').text()
        count = reg.exec(count)
        count = count == null ? 0 : parseInt(count[0])
        return count;
    }

    /**
	 * Returns float which represents user score
	 * @param {Object} doc jQuery document object
	 * @returns {Number}
	 */
    function getUserScore(doc) {
        return parseFloat(doc.find('.feature_userscore .metascore_w.user').eq(0).text())
    }

    function getMetascore(doc) {
        return parseInt(doc.find('.metascore_summary .metascore_w span').text())
    }

    /**
	 * Returns a number of crititc reviews
	 * @param {Object} doc jQuery document object
	 * @returns {Number}
	 */
    function getCriticReviewsCount(doc) {
        return parseInt(doc.find('.score_summary.metascore_summary a>span').text())
    }

    function parseDataFromGamePage(html) {
        const doc = $(new DOMParser().parseFromString(html, 'text/html'))
        const yearReg = /\d{4}/
        let year = yearReg.exec(doc.find('.release_data .data').text())
        year = year == null ? 0 : year[0]

        return {
            title: doc.find('.product_title h1').text(),
            platform: doc.find('.platform a').text(),
            year,
            metascore: getMetascore(doc),
            criticReviewsCount: getCriticReviewsCount(doc),
            userscore: getUserScore(doc),
            userReviewsCount: getUserReviesCount(doc),
        }
    }

    /**
	 * Converts given object to string 
	 * like `foo=bar&bizz=bazz`
	 * @param {Object} obj 
	 */
    function objectToUrlArgs(obj) {
        return Object.entries(obj)
            .map(kv => `${kv[0]}=${kv[1]}`)
            .join('&')
    }

    /**
	 * Query metacritic autosearch api.
	 * Returns Promise with an array with results objects.
	 * Result object properties:
	 * - url: link to page
	 * - name: game name
	 * - itemDate: release date (string ?)
	 * - imagePath: url to cover image
	 * - metaScore: critic score (int)
	 * - scoreWord: like mixed, good, bad etc
	 * - refType: item type, e.g "PC Game"
	 * - refTypeId: type id, (string)
	 * @param {String} query term for search
	 * @returns {Promise}
	 */
    function autoSearch(query) {
        return ajax({
            url: 'https://www.metacritic.com/autosearch',
            method: 'post',
            data: objectToUrlArgs({ image_size: 98, search_term: query }),
            responseType: 'json',
            // Strictly recomended to watch Network log
            // and get Request Headers from it
            headers: {
                "Origin": null,
                "Referer": "https://www.metacritic.com",
                "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
                "X-Requested-With":	"XMLHttpRequest"
            }
        })
            .then(response => JSON.parse(response.responseText).autoComplete)
    }

    /**
	 * Queries metacritic search page with given query.
	 * Returns Promise with `response` object.
	 * Html code of the page can be read from `response.responseText`
	 * @param {String} query term for search
	 * @returns {Promise}
	 */
    function fullSearch(query) {
        return ajax({
            url: getSearhURL(query),
            method: "GET",
            headers: defaultHeaders,
            context: { query }
        })
    }

    /**
	 * Returns a resolved Promise with game data object on success
	 * and rejected Promise on failure.
	 * Game data object has these fields:
	 * - title: Game title
	 * - pageurl: Game page url
	 * - platform: Game platform (pc, ps3 etc)
	 * - year: Release year (int)
	 * - metascore: Critic score (int)
	 * - criticReviewsCount: The number of critic reviews
	 * - userscore: User score (float)
	 * - userReviewsCount: The number of user reviews
	 * - description: Description of the game
	 * - queryString: Original query string
	 * @param {String} gameName Game name
	 */
    function getMetacriticGameDetails(gameName) {
        return fullSearch(gameName)
            .then(response => {
            const { context, responseText } = response
            const results = parseSearchResults(responseText)

            if (results.length == 0) {
                throw `Can't find game "${context.query}" on www.metacritic.com`
					}

            // I have to find the game in results and this is not so easy,
            // metacritic gives stupid order, e.g
            // most relevant game for "mass effect" is ME: Andromeda,
            // not the first Mass Effect game from 2007.
            // lets assume that GOG has correct game titles
            // (which is not always true)
            // then we can get game from results with the same
            // title as in search query
            const ind = results.findIndex(result => result.title.toLowerCase()===context.query.toLowerCase())

            if (ind != -1)
                return results[ind]
            else {
                console.error('Metacritic results:', results)
                throw `There are results, but can't find game "${context.query}" on www.metacritic.com`
					}
        } /* Network error */
                 )
            .then(gameData => {

            // request to the game page to get
            // user score and reviews count
            return ajax({
                url: gameData.pageurl,
                method: 'GET',
                headers: defaultHeaders,
                context: { gameData },
            })
        } /* catch error, if there is no such game */
                 ).then(response => {
            const { context, responseText } = response
            const { gameData } = context
            const doc = $(new DOMParser().parseFromString(responseText, 'text/html'))

            gameData.userReviewsCount = getUserReviesCount(doc)
            gameData.userscore = getUserScore(doc)
            gameData.criticReviewsCount = getCriticReviewsCount(doc)

            return { ...gameData, queryString: gameName }
        } /* catches error when fetching game page */
                       );
    }

    /**
	 * Get gog product details via REST api
	 * @see https://gogapidocs.readthedocs.io/en/latest/galaxy.html#get--products-(int-product_id)
	 * @param {String} productId 
	 * @param {String} locale
	 * @returns {Promise} fullfiled with json object
	 */
    function getGOGProductDetails(productId, locale) {
        return ajax({
            url: `https://api.gog.com/products/${productId}?locale=${locale}`,
            method: 'get',
            defaultHeaders: { 'Cache-Control': 'max-age=3600' },
            responseType: 'json',
        }).then(response => JSON.parse(response.responseText))
    }

    function getScoreColor(score) {
        // tbd - gray
        // 0-49 - red
        // 50-74 - yellow
        // 75 - 100 - green

        if (score === 'tbd' || score !== score)
            // default bg color is already present in css
            return ''
        else {
            if (score < 50)			return 'mcg-score--bad'
            else if (score < 75)	return 'mcg-score--mixed'
            else					return 'mcg-score--good'
        }
    }

    /**
	 * Converts user score value to its string representation.
	 * @param {Number} score user score
	 * @returns {String} a string in format like "7.0" or "8.8",
	 * or "tbd" if a given score is NaN 
	 */
    function formatUserScore(score) {
        return score !== score ? 'tbd' : score.toFixed(1)
    }

    /**
	 * Converts critic score to its string representation.
	 * @param {Number} score critic score 
	 * @returns {String} a string like "98" or "100",
	 * or "tbd" if a given score is NaN
	 */
    function formatMetaScore(score) {
        return score !== score ? 'tbd' : score
    }

    function ScoreSummary(props) {
        const { score, scoreLabel, scoreTypeClass, scoreColorClass } = props
        const scoreEltClass = `"mcg-score-summary__score ${scoreTypeClass} ${scoreColorClass}"`
		
		return `
			<div class="mcg-score-summary">
				<span class=${ scoreEltClass }>${ score }</span>
				<span class="mcg-score-summary__label">${ scoreLabel }</span>
			</div>
		`
	}

    function MetacriticScore(props) {
        const { metascore, userscore, pageurl } = props;

        return `
		<div class='mcg-wrap'>
			${ ScoreSummary({ 
            score: formatUserScore(userscore),
            scoreLabel: 'User',
            scoreTypeClass: 'mcg-score-summary__score--circle',
            scoreColorClass: getScoreColor(userscore * 10),
        })
    }
			${ ScoreSummary({ 
            score: formatMetaScore(metascore),
            scoreLabel: 'Meta',
            scoreTypeClass: '',
            scoreColorClass: getScoreColor(metascore),
        })
    }
		</div>
		`
	}


    function showMetacriticScoreElt(gameData, index) {
        const metascore = MetacriticScore(gameData)
        const block = document.getElementsByClassName('block')[index];
        $(block)
            .append(metascore)
    }

    // =============================================================
    //
    // Code section
    //
    // =============================================================

    const documentReady = new Promise((resolve, rej) => $(document).ready(resolve))

    documentReady.then(() => GM_addStyle(css))
    // get game name from page's url
    setTimeout(() => {
    Array.from(document.getElementsByClassName('block')).forEach(function(gameBlock, i) {
        let gameNameFromUrl = gameBlock.href
        .replace('https://gog-games.com', '')
        .replace('/game/', '')
        .replace(/_/g, '-')
        // first trying to get the same game page from metacritic
        ajax({
            url: `https://www.metacritic.com/game/pc/${gameNameFromUrl}`,
            method: "GET",
            headers: defaultHeaders,
        }).then(response => {
            const { responseText, finalUrl, status } = response
            if (status === 200) {
                const gameData = {
                    ...parseDataFromGamePage(responseText),
                    pageurl: finalUrl
                }
                documentReady.then(() => showMetacriticScoreElt(gameData, i))
            }
            else if (status === 404) {
                documentReady.then(() => {
                    const productId = $(document).find('div[card-product]').attr('card-product')
                    // get product details from gog api
                    getGOGProductDetails(productId, 'en')
                        .then(details => details.title)
                        .then(getMetacriticGameDetails)
                        .then(showMetacriticScoreElt)
                })
            }
        }, e => console.error('Error', e))
    });
    }, 25);
})();