🏠 返回首頁 

Greasy Fork is available in English.

Metacritic score for GOG

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))
})();