Metacritic score for GOG

Adds metacritic score to GOG game's page

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

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