Greasy Fork 支持简体中文。

Metacritic score for GOG

Adds metacritic score to GOG game's page

  1. /*
  2. Metacritic score for GOG - Adds metacritic score to GOG game's page.
  3. Copyright (C) 2019 T1mL3arn
  4. This program is free software: you can redistribute it and/or modify
  5. it under the terms of the GNU General Public License as published by
  6. the Free Software Foundation, either version 3 of the License, or
  7. (at your option) any later version.
  8. This program is distributed in the hope that it will be useful,
  9. but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. GNU General Public License for more details.
  12. You should have received a copy of the GNU General Public License
  13. along with this program. If not, see <https://www.gnu.org/licenses/>.
  14. */
  15.  
  16. // ==UserScript==
  17. // @name Metacritic score for GOG
  18. // @description Adds metacritic score to GOG game's page
  19. // @version 1.2.2
  20. // @author T1mL3arn
  21. // @namespace https://github.com/T1mL3arn
  22. // @icon https://upload.wikimedia.org/wikipedia/commons/thumb/2/20/Metacritic.svg/88px-Metacritic.svg.png
  23. // @match https://gog.com/game/*
  24. // @match https://gog.com/*/game/*
  25. // @match https://www.gog.com/game/*
  26. // @match https://www.gog.com/*/game/*
  27. // @require https://code.jquery.com/jquery-3.3.1.min.js
  28. // @grant GM_xmlhttpRequest
  29. // @grant GM.xmlhttpRequest
  30. // @grant GM_xmlHttpRequest
  31. // @grant GM.xmlHttpRequest
  32. // @grant GM_addStyle
  33. // @grant GM.addStyle
  34. // @license GPLv3
  35. // @homepageURL https://github.com/t1ml3arn-userscript-js/Metacritic-score-for-GOG
  36. // @supportURL https://github.com/t1ml3arn-userscript-js/Metacritic-score-for-GOG/issues
  37. // @run-at document-start
  38. // ==/UserScript==
  39.  
  40. (function () {
  41. // =============================================================
  42. //
  43. // Greasemonkey polyfill
  44. //
  45. // =============================================================
  46. // probably it is Greasemonkey
  47. if (typeof GM !== 'undefined') {
  48. if (typeof GM.info !== 'undefined')
  49. window.GM_info = GM.info;
  50.  
  51. // VM has GM_xmlhttpRequest but GM has GM_xmlHttpRequest
  52. // Mad mad world !
  53. if (typeof GM.xmlHttpRequest !== 'undefined') {
  54. window.GM_xmlhttpRequest = GM.xmlHttpRequest
  55. }
  56. // addStyle
  57. window.GM_addStyle = function(css) {
  58. return new Promise((resolve, reject) => {
  59. try {
  60. let style = document.head.appendChild(document.createElement('style'))
  61. style.type = 'text/css'
  62. style.textContent = css;
  63. resolve(style)
  64. } catch(e) {
  65. console.error(`It is not possible to add style with GM_addStyle()`)
  66. reject(e)
  67. }
  68. })
  69. }
  70. }
  71.  
  72. //console.log(`[${GM_info.scriptHandler}][${GM_info.script.name} v${GM_info.script.version}] inited`)
  73.  
  74. // =============================================================
  75. //
  76. // API section
  77. //
  78. // =============================================================
  79.  
  80. const css = (() => {
  81. return `
  82. .mcg-wrap {
  83. /* Base size for all icons */
  84. --size: 80px;
  85.  
  86. display: flex;
  87. flex-flow: row;
  88. flex-wrap: wrap;
  89. align-items: center;
  90. justify-content: center;
  91.  
  92. width: auto;
  93. padding: 4px;
  94. box-sizing: border-box;
  95. }
  96.  
  97. .mcg-wrap * {
  98. all: unset;
  99. box-sizing: border-box;
  100. }
  101.  
  102. .mcg-score-summary {
  103. display: flex;
  104. flex-flow: row nowrap;
  105. justify-content: flex-start;
  106. align-items: center;
  107.  
  108. margin: 0 2px 0 2px;
  109. }
  110.  
  111. .mcg-score-summary__score {
  112. display: flex;
  113. flex-flow: row;
  114. justify-content: center;
  115. align-items: center;
  116.  
  117. min-width: calc(var(--size) * 0.5);
  118. min-height: calc(var(--size) * 0.5);
  119. width: calc(var(--size) * 0.5);
  120. height: calc(var(--size) * 0.5);
  121. margin: 0 4px 0 4px;
  122.  
  123. background-color: #0f0;
  124. background-color: #c0c0c0;
  125. border-radius: 6px;
  126. font-family: sans-serif;
  127. font-size: 1.2em;
  128. font-weight: bold;
  129. color: white;
  130. }
  131.  
  132. .mcg-score--bad {
  133. background-color: #f00;
  134. color: white;
  135. }
  136.  
  137. .mcg-score--mixed {
  138. background-color: #fc3;
  139. color: #111;
  140. }
  141.  
  142. .mcg-score--good {
  143. background-color: #6c3;
  144. color: white;
  145. }
  146.  
  147. .mcg-score-summary__score--circle {
  148. border-radius: 50%;
  149. }
  150.  
  151. .mcg-score-summary .mcg-score-summary__label {
  152. align-self: flex-start;
  153. align-self: center;
  154. max-width: 50px;
  155.  
  156. font-size: 0.9em;
  157. font-size: 14px;
  158. font-weight: bold;
  159. text-align: left;
  160. text-align: center;
  161. }
  162.  
  163. .mcg-logo {
  164. display: flex;
  165. flex-flow: row;
  166. align-items: center;
  167. }
  168.  
  169. .mcg-logo__img {
  170. background-image: url(https://upload.wikimedia.org/wikipedia/commons/thumb/2/20/Metacritic.svg/200px-Metacritic.svg.png);
  171. background-position: center center;
  172. background-size: cover;
  173. min-width: calc(var(--size) * 0.5);
  174. min-height: calc(var(--size) * 0.5);
  175. width: calc(var(--size) * 0.5);
  176. height: calc(var(--size) * 0.5);
  177. }
  178.  
  179. .mcg-logo p {
  180. display: flex;
  181. flex-flow: column;
  182. align-items: center;
  183. justify-content: center;
  184.  
  185. margin: 4px 6px;
  186.  
  187. font-family: sans-serif;
  188. font-size: 22px;
  189. text-align: center;
  190. font-weight: bold;
  191. }
  192.  
  193. .mcg-logo p > a {
  194. cursor: pointer;
  195. text-decoration: underline;
  196. font-size: 0.65em;
  197. font-size: 14px;
  198. font-weight: normal;
  199. color: #36c;
  200. }`
  201. })()
  202.  
  203. const defaultHeaders = {
  204. "Origin": null,
  205. "Referer": null,
  206. "Cache-Control": "max-age=3600",
  207. }
  208.  
  209. /**
  210. * Sends xmlHttpRequest via GM api (this allows crossdomain reqeusts).
  211. * NOTE Different userscript engines support different
  212. * details object format.
  213. *
  214. * Violentmonkey @see https://violentmonkey.github.io/api/gm/#gm_xmlhttprequest
  215. *
  216. * Greasemonkey @see https://wiki.greasespot.net/GM.xmlHttpRequest
  217. * @param {Object} details @see ...
  218. * @returns {Promise}
  219. */
  220. function ajax(details) {
  221. return new Promise((resolve, reject) => {
  222. details.onload = resolve
  223. details.onerror = reject
  224. GM_xmlhttpRequest(details)
  225. })
  226. }
  227.  
  228. /**
  229. * Returns an URL to make a search request
  230. * for given game.
  231. * @param {String} game Game name
  232. * @param {String} platform Target platform (PC is default)
  233. */
  234. function getSearhURL(game, platform) {
  235. // searches GAME only in "game" for PC platform (plats[3]=1)
  236. ///TODO sanitize game name (trim, remove extra spacebars etc) ?
  237. return `https://www.metacritic.com/search/game/${game}/results?search_type=advanced&plats[3]=1`
  238. }
  239.  
  240. /**
  241. * Returns an array of search results from given html code
  242. * @param {String} html Raw html from which search results will be parsed
  243. * @returns {Array} array of objects
  244. */
  245. function parseSearchResults(html) {
  246. const doc = new DOMParser().parseFromString(html, 'text/html')
  247. const yearReg = /\d{4}/
  248. const results = $(doc).find('ul.search_results .result_wrap')
  249.  
  250. return results.map((ind, elt) => {
  251. const result = $(elt)
  252. let year = yearReg.exec(result.find('.main_stats p').text())
  253. year = year == null ? 0 : parseInt(year[0])
  254. return {
  255. title: result.find('.product_title').text().trim(),
  256. pageurl: 'https://www.metacritic.com' + result.find('.product_title > a').attr('href'),
  257. platform: result.find('.platform').text().trim(),
  258. year,
  259. metascore: parseInt(result.find('.metascore_w').text()),
  260. criticReviewsCount: 0,
  261. userscore: 0.0,
  262. userReviewsCount: 0,
  263. description: result.find('.deck').text().trim()
  264. }
  265. })
  266. .get()
  267. }
  268.  
  269. function swap(arr, ind1, ind2) {
  270. const tmp = arr[ind1]
  271. arr[ind1] = arr[ind2]
  272. arr[ind2] = tmp
  273. }
  274.  
  275. /**
  276. * Returns integer which represents total user reviews
  277. * @param {Object} doc jQuery document object
  278. * @returns {Number}
  279. */
  280. function getUserReviesCount(doc) {
  281. const reg = /\d+/
  282. let count = doc.find('.feature_userscore .count a').text()
  283. count = reg.exec(count)
  284. count = count == null ? 0 : parseInt(count[0])
  285. return count;
  286. }
  287.  
  288. /**
  289. * Returns float which represents user score
  290. * @param {Object} doc jQuery document object
  291. * @returns {Number}
  292. */
  293. function getUserScore(doc) {
  294. return parseFloat(doc.find('.feature_userscore .metascore_w.user').eq(0).text())
  295. }
  296. function getMetascore(doc) {
  297. return parseInt(doc.find('.metascore_summary .metascore_w span').text())
  298. }
  299.  
  300. /**
  301. * Returns a number of crititc reviews
  302. * @param {Object} doc jQuery document object
  303. * @returns {Number}
  304. */
  305. function getCriticReviewsCount(doc) {
  306. return parseInt(doc.find('.score_summary.metascore_summary a>span').text())
  307. }
  308.  
  309. function parseDataFromGamePage(html) {
  310. const doc = $(new DOMParser().parseFromString(html, 'text/html'))
  311. const yearReg = /\d{4}/
  312. let year = yearReg.exec(doc.find('.release_data .data').text())
  313. year = year == null ? 0 : year[0]
  314.  
  315. return {
  316. title: doc.find('.product_title h1').text(),
  317. platform: doc.find('.platform a').text(),
  318. year,
  319. metascore: getMetascore(doc),
  320. criticReviewsCount: getCriticReviewsCount(doc),
  321. userscore: getUserScore(doc),
  322. userReviewsCount: getUserReviesCount(doc),
  323. }
  324. }
  325.  
  326. /**
  327. * Converts given object to string
  328. * like `foo=bar&bizz=bazz`
  329. * @param {Object} obj
  330. */
  331. function objectToUrlArgs(obj) {
  332. return Object.entries(obj)
  333. .map(kv => `${kv[0]}=${kv[1]}`)
  334. .join('&')
  335. }
  336.  
  337. /**
  338. * Query metacritic autosearch api.
  339. * Returns Promise with an array with results objects.
  340. * Result object properties:
  341. * - url: link to page
  342. * - name: game name
  343. * - itemDate: release date (string ?)
  344. * - imagePath: url to cover image
  345. * - metaScore: critic score (int)
  346. * - scoreWord: like mixed, good, bad etc
  347. * - refType: item type, e.g "PC Game"
  348. * - refTypeId: type id, (string)
  349. * @param {String} query term for search
  350. * @returns {Promise}
  351. */
  352. function autoSearch(query) {
  353. return ajax({
  354. url: 'https://www.metacritic.com/autosearch',
  355. method: 'post',
  356. data: objectToUrlArgs({ image_size: 98, search_term: query }),
  357. responseType: 'json',
  358. // Strictly recomended to watch Network log
  359. // and get Request Headers from it
  360. headers: {
  361. "Origin": null,
  362. "Referer": "https://www.metacritic.com",
  363. "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
  364. "X-Requested-With": "XMLHttpRequest"
  365. }
  366. })
  367. .then(response => JSON.parse(response.responseText).autoComplete)
  368. }
  369.  
  370. /**
  371. * Queries metacritic search page with given query.
  372. * Returns Promise with `response` object.
  373. * Html code of the page can be read from `response.responseText`
  374. * @param {String} query term for search
  375. * @returns {Promise}
  376. */
  377. function fullSearch(query) {
  378. return ajax({
  379. url: getSearhURL(query),
  380. method: "GET",
  381. headers: defaultHeaders,
  382. context: { query }
  383. })
  384. }
  385.  
  386. /**
  387. * Returns a resolved Promise with game data object on success
  388. * and rejected Promise on failure.
  389. * Game data object has these fields:
  390. * - title: Game title
  391. * - pageurl: Game page url
  392. * - platform: Game platform (pc, ps3 etc)
  393. * - year: Release year (int)
  394. * - metascore: Critic score (int)
  395. * - criticReviewsCount: The number of critic reviews
  396. * - userscore: User score (float)
  397. * - userReviewsCount: The number of user reviews
  398. * - description: Description of the game
  399. * - queryString: Original query string
  400. * @param {String} gameName Game name
  401. */
  402. function getMetacriticGameDetails(gameName) {
  403. return fullSearch(gameName)
  404. .then(response => {
  405. const { context, responseText } = response
  406. const results = parseSearchResults(responseText)
  407.  
  408. if (results.length == 0) {
  409. throw `Can't find game "${context.query}" on www.metacritic.com`
  410. }
  411.  
  412. // I have to find the game in results and this is not so easy,
  413. // metacritic gives stupid order, e.g
  414. // most relevant game for "mass effect" is ME: Andromeda,
  415. // not the first Mass Effect game from 2007.
  416. // lets assume that GOG has correct game titles
  417. // (which is not always true)
  418. // then we can get game from results with the same
  419. // title as in search query
  420. const ind = results.findIndex(result => result.title.toLowerCase()===context.query.toLowerCase())
  421. if (ind != -1)
  422. return results[ind]
  423. else {
  424. console.error('Metacritic results:', results)
  425. throw `There are results, but can't find game "${context.query}" on www.metacritic.com`
  426. }
  427. } /* Network error */
  428. )
  429. .then(gameData => {
  430.  
  431. // request to the game page to get
  432. // user score and reviews count
  433. return ajax({
  434. url: gameData.pageurl,
  435. method: 'GET',
  436. headers: defaultHeaders,
  437. context: { gameData },
  438. })
  439. } /* catch error, if there is no such game */
  440. ).then(response => {
  441. const { context, responseText } = response
  442. const { gameData } = context
  443. const doc = $(new DOMParser().parseFromString(responseText, 'text/html'))
  444.  
  445. gameData.userReviewsCount = getUserReviesCount(doc)
  446. gameData.userscore = getUserScore(doc)
  447. gameData.criticReviewsCount = getCriticReviewsCount(doc)
  448. return { ...gameData, queryString: gameName }
  449. } /* catches error when fetching game page */
  450. );
  451. }
  452.  
  453. /**
  454. * Get gog product details via REST api
  455. * @see https://gogapidocs.readthedocs.io/en/latest/galaxy.html#get--products-(int-product_id)
  456. * @param {String} productId
  457. * @param {String} locale
  458. * @returns {Promise} fullfiled with json object
  459. */
  460. function getGOGProductDetails(productId, locale) {
  461. return ajax({
  462. url: `https://api.gog.com/products/${productId}?locale=${locale}`,
  463. method: 'get',
  464. defaultHeaders: { 'Cache-Control': 'max-age=3600' },
  465. responseType: 'json',
  466. }).then(response => JSON.parse(response.responseText))
  467. }
  468.  
  469. function MetacriticLogo(props) {
  470. let { reviewsUrl } = props
  471.  
  472. return `
  473. <div class="mcg-logo">
  474. <div class="mcg-logo__img" title="metacritic logo"></div>
  475. <p>
  476. metacritic
  477. <a href=${ reviewsUrl || "#" } target="_blank" rel="noopener noreferer">
  478. Read reviews
  479. <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" />
  480. </a>
  481. </p>
  482. </div>
  483. `
  484. }
  485.  
  486. function getScoreColor(score) {
  487. // tbd - gray
  488. // 0-49 - red
  489. // 50-74 - yellow
  490. // 75 - 100 - green
  491. if (score === 'tbd' || score !== score)
  492. // default bg color is already present in css
  493. return ''
  494. else {
  495. if (score < 50) return 'mcg-score--bad'
  496. else if (score < 75) return 'mcg-score--mixed'
  497. else return 'mcg-score--good'
  498. }
  499. }
  500.  
  501. /**
  502. * Converts user score value to its string representation.
  503. * @param {Number} score user score
  504. * @returns {String} a string in format like "7.0" or "8.8",
  505. * or "tbd" if a given score is NaN
  506. */
  507. function formatUserScore(score) {
  508. return score !== score ? 'tbd' : score.toFixed(1)
  509. }
  510.  
  511. /**
  512. * Converts critic score to its string representation.
  513. * @param {Number} score critic score
  514. * @returns {String} a string like "98" or "100",
  515. * or "tbd" if a given score is NaN
  516. */
  517. function formatMetaScore(score) {
  518. return score !== score ? 'tbd' : score
  519. }
  520.  
  521. function ScoreSummary(props) {
  522. const { score, scoreLabel, scoreTypeClass, scoreColorClass } = props
  523. const scoreEltClass = `"mcg-score-summary__score ${scoreTypeClass} ${scoreColorClass}"`
  524. return `
  525. <div class="mcg-score-summary">
  526. <span class=${ scoreEltClass }>${ score }</span>
  527. <span class="mcg-score-summary__label">${ scoreLabel }</span>
  528. </div>
  529. `
  530. }
  531.  
  532. function MetacriticScore(props) {
  533. const { metascore, userscore, pageurl } = props;
  534.  
  535. return `
  536. <div class='mcg-wrap'>
  537. ${ ScoreSummary({
  538. score: formatUserScore(userscore),
  539. scoreLabel: 'User score',
  540. scoreTypeClass: 'mcg-score-summary__score--circle',
  541. scoreColorClass: getScoreColor(userscore * 10),
  542. })
  543. }
  544. ${ ScoreSummary({
  545. score: formatMetaScore(metascore),
  546. scoreLabel: 'Meta score',
  547. scoreTypeClass: '',
  548. scoreColorClass: getScoreColor(metascore),
  549. })
  550. }
  551. ${ MetacriticLogo({ reviewsUrl: pageurl }) }
  552. </div>
  553. `
  554. }
  555.  
  556. function showMetacriticScoreElt(gameData) {
  557. const metascore = MetacriticScore(gameData)
  558. $('div[content-summary-section-id="productDetails"] > .details')
  559. .append('<hr class="details__separator"/>')
  560. .append(metascore)
  561. .append('<hr class="details__separator"/>')
  562. }
  563.  
  564. // =============================================================
  565. //
  566. // Code section
  567. //
  568. // =============================================================
  569.  
  570. const documentReady = new Promise((resolve, rej) => $(document).ready(resolve))
  571.  
  572. documentReady.then(() => GM_addStyle(css))
  573. // get game name from page's url
  574. let gameNameFromUrl = window.location.pathname
  575. .replace('/game/', '')
  576. .replace(/_/g, '-')
  577. // first trying to get the same game page from metacritic
  578. ajax({
  579. url: `https://www.metacritic.com/game/pc/${gameNameFromUrl}`,
  580. method: "GET",
  581. headers: defaultHeaders,
  582. }).then(response => {
  583. const { responseText, finalUrl, status } = response
  584. if (status === 200) {
  585. const gameData = {
  586. ...parseDataFromGamePage(responseText),
  587. pageurl: finalUrl
  588. }
  589.  
  590. documentReady.then(() => showMetacriticScoreElt(gameData))
  591. }
  592. else if (status === 404) {
  593.  
  594. documentReady.then(() => {
  595. const productId = $(document).find('div[card-product]').attr('card-product')
  596. // get product details from gog api
  597. getGOGProductDetails(productId, 'en')
  598. .then(details => details.title)
  599. .then(getMetacriticGameDetails)
  600. .then(showMetacriticScoreElt)
  601. })
  602.  
  603. }
  604. }, e => console.error('Error', e))
  605. })();