Greasy Fork is available in English.
Adds metacritic score to GOG game's page
/* Metacritic score for GOG - Adds metacritic score to GOG game's page. Copyright (C) 2019 T1mL3arn This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. */ // ==UserScript== // @name Metacritic score for GOG // @description Adds metacritic score to GOG game's page // @version 1.2.2 // @author T1mL3arn // @namespace https://github.com/T1mL3arn // @icon https://upload.wikimedia.org/wikipedia/commons/thumb/2/20/Metacritic.svg/88px-Metacritic.svg.png // @match https://gog.com/game/* // @match https://gog.com/*/game/* // @match https://www.gog.com/game/* // @match https://www.gog.com/*/game/* // @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 // @license GPLv3 // @homepageURL https://github.com/t1ml3arn-userscript-js/Metacritic-score-for-GOG // @supportURL https://github.com/t1ml3arn-userscript-js/Metacritic-score-for-GOG/issues // @run-at document-start // ==/UserScript== (function () { // ============================================================= // // Greasemonkey polyfill // // ============================================================= // 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; } .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; }` })() 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}/r###lts?search_type=advanced&plats[3]=1` } /** * Returns an array of search r###lts from given html code * @param {String} html Raw html from which search r###lts will be parsed * @returns {Array} array of objects */ function parseSearchR###lts(html) { const doc = new DOMParser().parseFromString(html, 'text/html') const yearReg = /\d{4}/ const r###lts = $(doc).find('ul.search_r###lts .r###lt_wrap') return r###lts.map((ind, elt) => { const r###lt = $(elt) let year = yearReg.exec(r###lt.find('.main_stats p').text()) year = year == null ? 0 : parseInt(year[0]) return { title: r###lt.find('.product_title').text().trim(), pageurl: 'https://www.metacritic.com' + r###lt.find('.product_title > a').attr('href'), platform: r###lt.find('.platform').text().trim(), year, metascore: parseInt(r###lt.find('.metascore_w').text()), criticReviewsCount: 0, userscore: 0.0, userReviewsCount: 0, description: r###lt.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 r###lts objects. * R###lt 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 r###lts = parseSearchR###lts(responseText) if (r###lts.length == 0) { throw `Can't find game "${context.query}" on www.metacritic.com` } // I have to find the game in r###lts 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 r###lts with the same // title as in search query const ind = r###lts.findIndex(r###lt => r###lt.title.toLowerCase()===context.query.toLowerCase()) if (ind != -1) return r###lts[ind] else { console.error('Metacritic r###lts:', r###lts) throw `There are r###lts, 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 MetacriticLogo(props) { let { reviewsUrl } = props return ` <div class="mcg-logo"> <div class="mcg-logo__img" title="metacritic logo"></div> <p> metacritic <a href=${ reviewsUrl || "#" } target="_blank" rel="noopener noreferer"> Read reviews <img src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22%3E %3Cpath fill=%22%23fff%22 stroke=%22%2336c%22 d=%22M1.5 4.518h5.982V10.5H1.5z%22/%3E %3Cpath fill=%22%2336c%22 d=%22M5.765 1H11v5.39L9.427 7.937l-1.31-1.31L5.393 9.35l-2.69-2.688 2.81-2.808L4.2 2.544z%22/%3E %3Cpath fill=%22%23fff%22 d=%22M9.995 2.004l.022 4.885L8.2 5.07 5.32 7.95 4.09 6.723l2.882-2.88-1.85-1.852z%22/%3E %3C/svg%3E" /> </a> </p> </div> ` } 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 Scor###mmary(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'> ${ Scor###mmary({ score: formatUserScore(userscore), scoreLabel: 'User score', scoreTypeClass: 'mcg-score-summary__score--circle', scoreColorClass: getScoreColor(userscore * 10), }) } ${ Scor###mmary({ score: formatMetaScore(metascore), scoreLabel: 'Meta score', scoreTypeClass: '', scoreColorClass: getScoreColor(metascore), }) } ${ MetacriticLogo({ reviewsUrl: pageurl }) } </div> ` } function showMetacriticScoreElt(gameData) { const metascore = MetacriticScore(gameData) $('div[content-summary-section-id="productDetails"] > .details') .append('<hr class="details__separator"/>') .append(metascore) .append('<hr class="details__separator"/>') } // ============================================================= // // Code section // // ============================================================= const documentReady = new Promise((resolve, rej) => $(document).ready(resolve)) documentReady.then(() => GM_addStyle(css)) // get game name from page's url let gameNameFromUrl = window.location.pathname .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)) } 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)) })();