Greasy Fork 支持 简体中文。

Metacritic score for GOG

Adds metacritic score to GOG game's page

目前為 2019-04-15 提交的版本,檢視 最新版本

  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.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. // @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 search results will be parsed
  241. * @returns {Array} array of objects
  242. */
  243. function parseSearchResults(html) {
  244. const doc = new DOMParser().parseFromString(html, 'text/html')
  245. const yearReg = /\d{4}/
  246. const results = $(doc).find('ul.search_results .result_wrap')
  247.  
  248. return results.map((ind, elt) => {
  249. const result = $(elt)
  250. let year = yearReg.exec(result.find('.main_stats p').text())
  251. year = year == null ? 0 : parseInt(year[0])
  252. return {
  253. title: result.find('.product_title').text().trim(),
  254. pageurl: 'https://www.metacritic.com' + result.find('.product_title > a').attr('href'),
  255. platform: result.find('.platform').text().trim(),
  256. year,
  257. metascore: parseInt(result.find('.metascore_w').text()),
  258. criticReviewsCount: 0,
  259. userscore: 0.0,
  260. userReviewsCount: 0,
  261. description: result.find('.deck').text().trim()
  262. }
  263. })
  264. .get()
  265. }
  266.  
  267. function swap(arr, ind1, ind2) {
  268. const tmp = arr[ind1]
  269. arr[ind1] = arr[ind2]
  270. arr[ind2] = tmp
  271. }
  272.  
  273. /**
  274. * Returns integer which represents total user reviews
  275. * @param {Object} doc jQuery document object
  276. * @returns {Number}
  277. */
  278. function getUserReviesCount(doc) {
  279. const reg = /\d+/
  280. let count = doc.find('.feature_userscore .count a').text()
  281. count = reg.exec(count)
  282. count = count == null ? 0 : parseInt(count[0])
  283. return count;
  284. }
  285.  
  286. /**
  287. * Returns float which represents user score
  288. * @param {Object} doc jQuery document object
  289. * @returns {Number}
  290. */
  291. function getUserScore(doc) {
  292. return parseFloat(doc.find('.feature_userscore .metascore_w.user').eq(0).text())
  293. }
  294. function getMetascore(doc) {
  295. return parseInt(doc.find('.metascore_summary .metascore_w span').text())
  296. }
  297.  
  298. /**
  299. * Returns a number of crititc reviews
  300. * @param {Object} doc jQuery document object
  301. * @returns {Number}
  302. */
  303. function getCriticReviewsCount(doc) {
  304. return parseInt(doc.find('.score_summary.metascore_summary a>span').text())
  305. }
  306.  
  307. function parseDataFromGamePage(html) {
  308. const doc = $(new DOMParser().parseFromString(html, 'text/html'))
  309. const yearReg = /\d{4}/
  310. let year = yearReg.exec(doc.find('.release_data .data').text())
  311. year = year == null ? 0 : year[0]
  312.  
  313. return {
  314. title: doc.find('.product_title h1').text(),
  315. platform: doc.find('.platform a').text(),
  316. year,
  317. metascore: getMetascore(doc),
  318. criticReviewsCount: getCriticReviewsCount(doc),
  319. userscore: getUserScore(doc),
  320. userReviewsCount: getUserReviesCount(doc),
  321. }
  322. }
  323.  
  324. /**
  325. * Converts given object to string
  326. * like `foo=bar&bizz=bazz`
  327. * @param {Object} obj
  328. */
  329. function objectToUrlArgs(obj) {
  330. return Object.entries(obj)
  331. .map(kv => `${kv[0]}=${kv[1]}`)
  332. .join('&')
  333. }
  334.  
  335. /**
  336. * Query metacritic autosearch api.
  337. * Returns Promise with an array with results objects.
  338. * Result object properties:
  339. * - url: link to page
  340. * - name: game name
  341. * - itemDate: release date (string ?)
  342. * - imagePath: url to cover image
  343. * - metaScore: critic score (int)
  344. * - scoreWord: like mixed, good, bad etc
  345. * - refType: item type, e.g "PC Game"
  346. * - refTypeId: type id, (string)
  347. * @param {String} query term for search
  348. * @returns {Promise}
  349. */
  350. function autoSearch(query) {
  351. return ajax({
  352. url: 'https://www.metacritic.com/autosearch',
  353. method: 'post',
  354. data: objectToUrlArgs({ image_size: 98, search_term: query }),
  355. responseType: 'json',
  356. // Strictly recomended to watch Network log
  357. // and get Request Headers from it
  358. headers: {
  359. "Origin": null,
  360. "Referer": "https://www.metacritic.com",
  361. "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
  362. "X-Requested-With": "XMLHttpRequest"
  363. }
  364. })
  365. .then(response => JSON.parse(response.responseText).autoComplete)
  366. }
  367.  
  368. /**
  369. * Queries metacritic search page with given query.
  370. * Returns Promise with `response` object.
  371. * Html code of the page can be read from `response.responseText`
  372. * @param {String} query term for search
  373. * @returns {Promise}
  374. */
  375. function fullSearch(query) {
  376. return ajax({
  377. url: getSearhURL(query),
  378. method: "GET",
  379. headers: defaultHeaders,
  380. context: { query }
  381. })
  382. }
  383.  
  384. /**
  385. * Returns a resolved Promise with game data object on success
  386. * and rejected Promise on failure.
  387. * Game data object has these fields:
  388. * - title: Game title
  389. * - pageurl: Game page url
  390. * - platform: Game platform (pc, ps3 etc)
  391. * - year: Release year (int)
  392. * - metascore: Critic score (int)
  393. * - criticReviewsCount: The number of critic reviews
  394. * - userscore: User score (float)
  395. * - userReviewsCount: The number of user reviews
  396. * - description: Description of the game
  397. * - queryString: Original query string
  398. * @param {String} gameName Game name
  399. */
  400. function getMetacriticGameDetails(gameName) {
  401. return fullSearch(gameName)
  402. .then(response => {
  403. const { context, responseText } = response
  404. const results = parseSearchResults(responseText)
  405.  
  406. if (results.length == 0) {
  407. throw `Can't find game "${context.query}" on www.metacritic.com`
  408. }
  409.  
  410. // I have to find the game in results and this is not so easy,
  411. // metacritic gives stupid order, e.g
  412. // most relevant game for "mass effect" is ME: Andromeda,
  413. // not the first Mass Effect game from 2007.
  414. // lets assume that GOG has correct game titles
  415. // (which is not always true)
  416. // then we can get game from results with the same
  417. // title as in search query
  418. const ind = results.findIndex(result => result.title.toLowerCase()===context.query.toLowerCase())
  419. if (ind != -1)
  420. return results[ind]
  421. else {
  422. console.error('Metacritic results:', results)
  423. throw `There are results, but can't find game "${context.query}" on www.metacritic.com`
  424. }
  425. } /* Network error */
  426. )
  427. .then(gameData => {
  428.  
  429. // request to the game page to get
  430. // user score and reviews count
  431. return ajax({
  432. url: gameData.pageurl,
  433. method: 'GET',
  434. headers: defaultHeaders,
  435. context: { gameData },
  436. })
  437. } /* catch error, if there is no such game */
  438. ).then(response => {
  439. const { context, responseText } = response
  440. const { gameData } = context
  441. const doc = $(new DOMParser().parseFromString(responseText, 'text/html'))
  442.  
  443. gameData.userReviewsCount = getUserReviesCount(doc)
  444. gameData.userscore = getUserScore(doc)
  445. gameData.criticReviewsCount = getCriticReviewsCount(doc)
  446. return { ...gameData, queryString: gameName }
  447. } /* catches error when fetching game page */
  448. );
  449. }
  450.  
  451. /**
  452. * Get gog product details via REST api
  453. * @see https://gogapidocs.readthedocs.io/en/latest/galaxy.html#get--products-(int-product_id)
  454. * @param {String} productId
  455. * @param {String} locale
  456. * @returns {Promise} fullfiled with json object
  457. */
  458. function getGOGProductDetails(productId, locale) {
  459. return ajax({
  460. url: `https://api.gog.com/products/${productId}?locale=${locale}`,
  461. method: 'get',
  462. defaultHeaders: { 'Cache-Control': 'max-age=3600' },
  463. responseType: 'json',
  464. }).then(response => JSON.parse(response.responseText))
  465. }
  466.  
  467. function MetacriticLogo(props) {
  468. let { reviewsUrl } = props
  469.  
  470. return `
  471. <div class="mcg-logo">
  472. <div class="mcg-logo__img" title="metacritic logo"></div>
  473. <p>
  474. metacritic
  475. <a href=${ reviewsUrl || "#" } target="_blank" rel="noopener noreferer">
  476. Read reviews
  477. <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" />
  478. </a>
  479. </p>
  480. </div>
  481. `
  482. }
  483.  
  484. function getScoreColor(score) {
  485. // tbd - gray
  486. // 0-49 - red
  487. // 50-74 - yellow
  488. // 75 - 100 - green
  489. if (score === 'tbd' || score !== score)
  490. // default bg color is already present in css
  491. return ''
  492. else {
  493. if (score < 50) return 'mcg-score--bad'
  494. else if (score < 75) return 'mcg-score--mixed'
  495. else return 'mcg-score--good'
  496. }
  497. }
  498.  
  499. /**
  500. * Converts user score value to its string representation.
  501. * @param {Number} score user score
  502. * @returns {String} a string in format like "7.0" or "8.8",
  503. * or "tbd" if a given score is NaN
  504. */
  505. function formatUserScore(score) {
  506. return score !== score ? 'tbd' : score.toFixed(1)
  507. }
  508.  
  509. /**
  510. * Converts critic score to its string representation.
  511. * @param {Number} score critic score
  512. * @returns {String} a string like "98" or "100",
  513. * or "tbd" if a given score is NaN
  514. */
  515. function formatMetaScore(score) {
  516. return score !== score ? 'tbd' : score
  517. }
  518.  
  519. function ScoreSummary(props) {
  520. const { score, scoreLabel, scoreTypeClass, scoreColorClass } = props
  521. const scoreEltClass = `"mcg-score-summary__score ${scoreTypeClass} ${scoreColorClass}"`
  522. return `
  523. <div class="mcg-score-summary">
  524. <span class=${ scoreEltClass }>${ score }</span>
  525. <span class="mcg-score-summary__label">${ scoreLabel }</span>
  526. </div>
  527. `
  528. }
  529.  
  530. function MetacriticScore(props) {
  531. const { metascore, userscore, pageurl } = props;
  532.  
  533. return `
  534. <div class='mcg-wrap'>
  535. ${ ScoreSummary({
  536. score: formatUserScore(userscore),
  537. scoreLabel: 'User score',
  538. scoreTypeClass: 'mcg-score-summary__score--circle',
  539. scoreColorClass: getScoreColor(userscore * 10),
  540. })
  541. }
  542. ${ ScoreSummary({
  543. score: formatMetaScore(metascore),
  544. scoreLabel: 'Meta score',
  545. scoreTypeClass: '',
  546. scoreColorClass: getScoreColor(metascore),
  547. })
  548. }
  549. ${ MetacriticLogo({ reviewsUrl: pageurl }) }
  550. </div>
  551. `
  552. }
  553.  
  554. function showMetacriticScoreElt(gameData) {
  555. const metascore = MetacriticScore(gameData)
  556. $('div[content-summary-section-id="productDetails"] > .details')
  557. .append('<hr class="details__separator"/>')
  558. .append(metascore)
  559. .append('<hr class="details__separator"/>')
  560. }
  561.  
  562. // =============================================================
  563. //
  564. // Code section
  565. //
  566. // =============================================================
  567.  
  568. const documentReady = new Promise((resolve, rej) => $(document).ready(resolve))
  569.  
  570. documentReady.then(() => GM_addStyle(css))
  571. // get game name from page's url
  572. let gameNameFromUrl = window.location.pathname
  573. .replace('/game/', '')
  574. .replace(/_/g, '-')
  575. // first trying to get the same game page from metacritic
  576. ajax({
  577. url: `https://www.metacritic.com/game/pc/${gameNameFromUrl}`,
  578. method: "GET",
  579. headers: defaultHeaders,
  580. }).then(response => {
  581. const { responseText, finalUrl, status } = response
  582. if (status === 200) {
  583. const gameData = {
  584. ...parseDataFromGamePage(responseText),
  585. pageurl: finalUrl
  586. }
  587.  
  588. documentReady.then(() => showMetacriticScoreElt(gameData))
  589. }
  590. else if (status === 404) {
  591.  
  592. documentReady.then(() => {
  593. const productId = $(document).find('div[card-product]').attr('card-product')
  594. // get product details from gog api
  595. getGOGProductDetails(productId, 'en')
  596. .then(details => details.title)
  597. .then(getMetacriticGameDetails)
  598. .then(showMetacriticScoreElt)
  599. })
  600.  
  601. }
  602. }, e => console.error('Error', e))
  603. })();