Metacritic score for GOG

Adds metacritic score to GOG game's page

目前为 2019-04-12 提交的版本。查看 最新版本

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