Metacritic score for GOG

Adds metacritic score to GOG game's page

目前为 2019-04-10 提交的版本,查看 最新版本

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