IMDb Tomatoes

Add Rotten Tomatoes ratings to IMDb movie pages

目前為 2021-02-16 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name IMDb Tomatoes
  3. // @description Add Rotten Tomatoes ratings to IMDb movie pages
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 3.0.0
  7. // @namespace https://github.com/chocolateboy/userscripts
  8. // @license GPL: https://www.gnu.org/copyleft/gpl.html
  9. // @include http://*.imdb.tld/title/tt*
  10. // @include http://*.imdb.tld/*/title/tt*
  11. // @include https://*.imdb.tld/title/tt*
  12. // @include https://*.imdb.tld/*/title/tt*
  13. // @require https://code.jquery.com/jquery-3.5.1.min.js
  14. // @require https://cdn.jsdelivr.net/gh/urin/jquery.balloon.js@8b79aab63b9ae34770bfa81c9bfe30019d9a13b0/jquery.balloon.js
  15. // @require https://unpkg.com/@chocolateboy/uncommonjs@3.1.2/dist/polyfill.iife.min.js
  16. // @require https://unpkg.com/get-wild@1.4.0/dist/index.umd.min.js
  17. // @resource query https://pastebin.com/raw/EdgTfhij
  18. // @resource fallback https://cdn.jsdelivr.net/gh/chocolateboy/corrigenda@0.2.2/data/omdb-tomatoes.json
  19. // @grant GM_addStyle
  20. // @grant GM_deleteValue
  21. // @grant GM_getResourceText
  22. // @grant GM_getValue
  23. // @grant GM_listValues
  24. // @grant GM_registerMenuCommand
  25. // @grant GM_setValue
  26. // @grant GM_xmlhttpRequest
  27. // @noframes
  28. // ==/UserScript==
  29.  
  30. /*
  31. * OK:
  32. *
  33. * - https://www.imdb.com/title/tt0309698/ - 4 widgets
  34. * - https://www.imdb.com/title/tt0086312/ - 3 widgets
  35. * - https://www.imdb.com/title/tt0037638/ - 2 widgets
  36. *
  37. * Fixed:
  38. *
  39. * layout:
  40. *
  41. * - https://www.imdb.com/title/tt0162346/ - 4 widgets
  42. * - https://www.imdb.com/title/tt0159097/ - 4 widgets
  43. * - https://www.imdb.com/title/tt0129387/ - 2 .plot_summary_wrapper DIVs
  44. *
  45. * RT/OMDb alias [1]:
  46. *
  47. * - https://www.imdb.com/title/tt0120755/ - Mission: Impossible II
  48. */
  49.  
  50. // [1] unaliased and incorrectly aliased titles are common:
  51. // http://web.archive.org/web/20151105080717/http://developer.rottentomatoes.com/forum/read/110751/2
  52.  
  53. // XXX metadata mismatch: Zéro de conduite [2] is a "Movie" in the old UI, but a
  54. // "Short" in the new one
  55. //
  56. // [2] https://www.imdb.com/title/tt0024803/
  57.  
  58. 'use strict';
  59.  
  60. const NO_CONSENSUS = 'No consensus yet.'
  61. const NOW = Date.now()
  62. const ONE_DAY = 1000 * 60 * 60 * 24
  63. const ONE_WEEK = ONE_DAY * 7
  64. const SCRIPT_NAME = GM_info.script.name
  65. const SCRIPT_VERSION = GM_info.script.version
  66. const STATUS_TO_STYLE = { 'N/A': 'tbd', Fresh: 'favorable', Rotten: 'unfavorable' }
  67. const THIS_YEAR = new Date().getFullYear()
  68.  
  69. const COLOR = {
  70. tbd: '#d9d9d9',
  71. favorable: '#66cc33',
  72. unfavorable: '#ff0000',
  73. }
  74.  
  75. const COMPACT_LAYOUT = [
  76. '.plot_summary_wrapper .minPlotHeightWithPoster', // XXX probably obsolete
  77. '.plot_summary_wrapper .minPlotHeightWithPosterAndWatchlistButton', // XXX probably obsolete
  78. '.minPosterWithPlotSummaryHeight .plot_summary_wrapper',
  79. ].join(', ')
  80.  
  81. // the version of each cached record is a combination of the schema version and
  82. // the <major>.<minor> parts of the script's (SemVer) version, e.g. 3 (schema
  83. // version) + 1.7.0 (script version) gives a version of "3/1.7"
  84. //
  85. // this means cached records are invalidated either a) when the schema changes
  86. // or b) when the major or minor version (i.e. not the patch version) of the
  87. // script changes
  88. const SCHEMA_VERSION = 4
  89. const DATA_VERSION = SCHEMA_VERSION + '/' + SCRIPT_VERSION.replace(/\.\d+$/, '') // e.g. 3/1.7
  90.  
  91. const BALLOON_OPTIONS = {
  92. classname: 'rt-consensus-balloon',
  93. css: {
  94. maxWidth: '31rem',
  95. fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
  96. fontSize: '0.9rem',
  97. padding: '0.75rem',
  98. },
  99. html: true,
  100. position: 'bottom',
  101. }
  102.  
  103. // a custom version of get-wild's `get` function which uses a simpler/faster
  104. // path parser since we don't use the extended syntax
  105. const pluck = exports.getter({ split: '.' })
  106.  
  107. // log a debug message to the console
  108. const debug = console.debug
  109.  
  110. // URL-encode the supplied query parameter and replace encoded spaces ("%20")
  111. // with plus signs ("+")
  112. function encodeParam (param) {
  113. return encodeURIComponent(param).replace(/%20/g, '+')
  114. }
  115.  
  116. // encode a dictionary of params as a query parameter string. this is similar to
  117. // jQuery.params, but we additionally replace spaces ("%20") with plus signs
  118. // ("+")
  119. function encodeParams (params) {
  120. const pairs = []
  121.  
  122. for (const [key, value] of Object.entries(params)) {
  123. pairs.push(`${encodeParam(key)}=${encodeParam(value)}`)
  124. }
  125.  
  126. return pairs.join('&')
  127. }
  128.  
  129. // promisified cross-origin HTTP requests
  130. function httpGet (url, options = {}) {
  131. if (options.params) {
  132. url = url + '?' + encodeParams(options.params)
  133. }
  134.  
  135. const request = Object.assign({ method: 'GET', url }, options.request || {})
  136.  
  137. return new Promise((resolve, reject) => {
  138. request.onload = resolve
  139.  
  140. // XXX the onerror response object doesn't contain any useful info
  141. request.onerror = _res => {
  142. reject(new Error(`error fetching ${options.title || url}`))
  143. }
  144.  
  145. GM_xmlhttpRequest(request)
  146. })
  147. }
  148.  
  149. // purge expired entries
  150. function purgeCached (date) {
  151. for (const key of GM_listValues()) {
  152. const json = GM_getValue(key)
  153. const value = JSON.parse(json)
  154.  
  155. if (value.expires === -1) { // persistent storage (currently unused)
  156. if (value.version !== SCHEMA_VERSION) {
  157. debug(`purging invalid value (obsolete schema version): ${key}`)
  158. GM_deleteValue(key)
  159. }
  160. } else if (value.version !== DATA_VERSION) {
  161. debug(`purging invalid value (obsolete data version): ${key}`)
  162. GM_deleteValue(key)
  163. } else if (date === -1 || (typeof value.expires !== 'number') || (date > value.expires)) {
  164. debug(`purging expired value: ${key}`)
  165. GM_deleteValue(key)
  166. }
  167. }
  168. }
  169.  
  170. // add an RT widget to the review bar
  171. function addWidget ($target, data) {
  172. const { consensus, rating, url } = data
  173.  
  174. let status
  175.  
  176. if (rating === -1) {
  177. status = 'N/A'
  178. } else if (rating < 60) {
  179. status = 'Rotten'
  180. } else {
  181. status = 'Fresh'
  182. }
  183.  
  184. const style = STATUS_TO_STYLE[status]
  185. const score = rating === -1 ? 'N/A' : rating
  186.  
  187. if ($target.hasClass('titleReviewBar')) {
  188. // reduce the amount of space taken up by the Metacritic widget
  189. // and make it consistent with our style (i.e. site name rather
  190. // than domain name)
  191. $target.find('a[href="http://www.metacritic.com"]').text('Metacritic')
  192.  
  193. // 4 review widgets is too many for the "compact" layout (i.e.
  194. // a poster but no trailer). it's designed for a maximum of 3.
  195. // to work around this, we hoist the review bar out of the
  196. // movie-info block (.plot_summary_wrapper) and float it left
  197. // beneath the poster, e.g.:
  198. //
  199. // before:
  200. //
  201. // [ [ ] [ ] ]
  202. // [ [ ] [ ] ]
  203. // [ [ Poster ] [ Info ] ]
  204. // [ [ ] [ ] ]
  205. // [ [ ] [ [MC] [IMDb] [etc.] ] ]
  206. //
  207. // after:
  208. //
  209. // [ [ ] [ ] ]
  210. // [ [ ] [ ] ]
  211. // [ [ Poster ] [ Info ] ]
  212. // [ [ ] [ ] ]
  213. // [ [ ] [ ] ]
  214. // [ ]
  215. // [ [RT] [MC] [IMDb] [etc.] ]
  216.  
  217. if ($(COMPACT_LAYOUT).length && $target.find('.titleReviewBarItem').length > 2) {
  218. const $clear = $('<div class="clear">&nbsp;</div>')
  219.  
  220. // sometimes there are two Info (.plot_summary_wrapper) DIVs (e.g.
  221. // [1]). the first is (currently) empty and the second contains the
  222. // actual markup. this may be a transient error in the markup, or
  223. // may be used somehow (e.g. for mobile). if targeted, the first one
  224. // is displayed above the visible Plot/Info row, whereas the second
  225. // one is to the right of the poster, as expected, so we target that
  226. //
  227. // [1] https://www.imdb.com/title/tt0129387/
  228. $('.plot_summary_wrapper').last().after($target.remove())
  229.  
  230. $target.before($clear).after($clear).css({
  231. 'float': 'left',
  232. 'padding-top': '11px',
  233. 'padding-bottom': '0px',
  234. })
  235. }
  236.  
  237. const html = `
  238. <div class="titleReviewBarItem">
  239. <a href="${url}"><div
  240. class="rt-consensus metacriticScore score_${style} titleReviewBarSubItem"><span>${score}</span></div></a>
  241. <div class="titleReviewBarSubItem">
  242. <div>
  243. <a href="${url}">Tomatometer</a>
  244. </div>
  245. <div>
  246. <span class="subText">
  247. From <a href="https://www.rottentomatoes.com" target="_blank">Rotten Tomatoes</a>
  248. </span>
  249. </div>
  250. </div>
  251. </div>
  252. <div class="divider"></div>
  253. `
  254. $target.prepend(html)
  255. } else { // new UI
  256. // clone the IMDb rating widget: https://git.io/JtXpQ
  257. const $rtRating = $target.children().first().clone()
  258.  
  259. // 1) set the star (SVG) to the right color
  260. $rtRating.find('svg').css('color', COLOR[style])
  261.  
  262. // 2) remove the review count and its preceding spacer element
  263. const $reviewCount = $rtRating.find('[class^="AggregateRatingButton__TotalRatingAmount-"]')
  264.  
  265. $reviewCount.add($reviewCount.prev()).remove()
  266.  
  267. // 3) replace "IMDb Rating" with "RT Rating"
  268. $rtRating.find('[class^="TitleBlockButtonBase__Header-"]').text('RT RATING')
  269.  
  270. // 4) remove the "/ 10" suffix
  271. const $score = $rtRating.find('[data-testid="hero-title-block__aggregate-rating__score"]')
  272. .children()
  273.  
  274. $score.last().remove()
  275.  
  276. // 5) replace the IMDb rating with the RT score
  277. $score.first().text(score)
  278.  
  279. // 6) add the tooltip class to the link and update its label and URL
  280. $rtRating.find('a[role="button"]')
  281. .addClass('rt-consensus')
  282. .attr('aria-label', 'View RT Rating')
  283. .attr('href', url)
  284.  
  285. // 7) prepend the element to the review bar
  286. $target.prepend($rtRating)
  287. }
  288.  
  289. const balloonOptions = Object.assign({}, BALLOON_OPTIONS, { contents: consensus })
  290.  
  291. $target.find('.rt-consensus').balloon(balloonOptions)
  292. }
  293.  
  294. // take a record (object) from the OMDb fallback data (object) and convert it
  295. // into the parsed format we expect to get back from the API, e.g.:
  296. //
  297. // before:
  298. //
  299. // {
  300. // Title: "Example",
  301. // Ratings: [
  302. // {
  303. // Source: "Rotten Tomatoes",
  304. // Value: "42%"
  305. // }
  306. // ],
  307. // tomatoURL: "https://www.rottentomatoes.com/m/example"
  308. // }
  309. //
  310. // after:
  311. //
  312. // {
  313. // CriticRating: 42,
  314. // RTConsensus: undefined,
  315. // RTUrl: "https://www.rottentomatoes.com/m/example",
  316. // }
  317.  
  318. function adaptOmdbData (data) {
  319. const ratings = data.Ratings || []
  320. const rating = ratings.find(it => it.Source === 'Rotten Tomatoes') || {}
  321. const score = rating.Value && parseInt(rating.Value)
  322.  
  323. return {
  324. CriticRating: (Number.isInteger(score) ? score : null),
  325. RTConsensus: rating.tomatoConsensus,
  326. RTUrl: data.tomatoURL,
  327. }
  328. }
  329.  
  330. // parse the API's response and extract the RT rating and consensus.
  331. //
  332. // if there's no consensus, default to "No consensus yet."
  333. // if there's no rating, default to -1
  334. async function getRTData ({ response, imdbId, title, fallback }) {
  335. function fail (msg) {
  336. throw new Error(`error querying data for ${imdbId}: ${msg}`)
  337. }
  338.  
  339. let results
  340.  
  341. try {
  342. results = JSON.parse(JSON.parse(response)) // ಠ_ಠ
  343. } catch (e) {
  344. fail(`can't parse response: ${e}`)
  345. }
  346.  
  347. if (!results) {
  348. fail('no response')
  349. }
  350.  
  351. if (!Array.isArray(results)) {
  352. const type = {}.toString.call(results)
  353. fail(`invalid response: ${type}`)
  354. }
  355.  
  356. let movie = results.find(it => it.imdbID === imdbId)
  357.  
  358. if (!movie) {
  359. if (fallback) {
  360. debug(`no results for ${imdbId} - using fallback data`)
  361. movie = adaptOmdbData(fallback)
  362. } else {
  363. fail('no results found')
  364. }
  365. }
  366.  
  367. let { RTConsensus: consensus, CriticRating: rating, RTUrl: url } = movie
  368. let updated = false
  369.  
  370. if (url) {
  371. // the new way: the RT URL is provided: scrape the consensus from
  372. // that page
  373.  
  374. debug(`loading RT URL for ${imdbId}: ${url}`)
  375. const res = await httpGet(url)
  376. debug(`response for ${url}: ${res.status} ${res.statusText}`)
  377.  
  378. const parser = new DOMParser()
  379. const dom = parser.parseFromString(res.responseText, 'text/html')
  380. const $rt = $(dom)
  381. const $consensus = $rt.find('.what-to-know__section-body > span')
  382.  
  383. if ($consensus.length) {
  384. consensus = $consensus.html().trim()
  385. }
  386.  
  387. // update the rating
  388. // @ts-ignore
  389. const meta = $rt.jsonLd(url)
  390. const newRating = meta.aggregateRating.ratingValue
  391.  
  392. if (newRating !== rating) {
  393. debug(`updating rating for ${url}: ${rating} -> ${newRating}`)
  394. rating = newRating
  395. updated = true
  396. }
  397. } else {
  398. // the old way: a rating but no RT URL (or consensus).
  399. // may still be used for some old and new releases
  400. debug(`no Rotten Tomatoes URL for ${imdbId}`)
  401. url = `https://www.rottentomatoes.com/search/?search=${encodeURIComponent(title)}`
  402. }
  403.  
  404. if (rating == null) {
  405. rating = -1
  406. }
  407.  
  408. consensus = consensus ? consensus.replace(/--/g, '&#8212;') : NO_CONSENSUS
  409.  
  410. return { data: { consensus, rating, url }, updated }
  411. }
  412.  
  413. // extract a property from a META element, or return null if the property is
  414. // not defined
  415. function prop (name) {
  416. const $meta = $(`meta[property="${name}"]`)
  417. return $meta.length ? $meta.attr('content') : null
  418. }
  419.  
  420. // extract metadata from the JSON+LD data embedded in the page and from metadata
  421. // elements
  422. function jsonLdMetadata (imdbId) {
  423. // @ts-ignore
  424. const meta = $(document).jsonLd(imdbId)
  425.  
  426. // override the original title (e.g. "Le fabuleux destin d'Amélie Poulain")
  427. // with the English language (US) title (e.g. "Amélie") if available
  428. // (the API only supports English-language titles)
  429. const title = $('#star-rating-widget').data('title') || meta.name
  430.  
  431. return {
  432. pageType: prop('pageType'),
  433. title,
  434. type: meta['@type']
  435. }
  436. }
  437.  
  438. // extract metadata from the GraphQL data embedded in the page
  439. function graphQlMetadata (imdbId) {
  440. const meta = JSON.parse($('#__NEXT_DATA__').text())
  441. const pageType = pluck(meta, 'props.requestContext.pageType')
  442.  
  443. // there are multiple matching subtrees (with different but partially
  444. // overlapping keys). select the first one with the required properties
  445. let title, type
  446.  
  447. const found = pluck(meta, 'props.urqlState.*.data', [])
  448. .find(it => {
  449. return pluck(it, 'title.id') === imdbId
  450. && (title = pluck(it, 'title.titleText.text'))
  451. && (type = pluck(it, 'title.titleType.text'))
  452. })
  453.  
  454. if (!found) {
  455. if (!title) {
  456. throw new TypeError("Can't find title in metadata")
  457. }
  458.  
  459. if (!type) {
  460. throw new TypeError("Can't find type in metadata")
  461. }
  462. }
  463.  
  464. return { pageType, title, type }
  465. }
  466.  
  467. async function main () {
  468. // @ts-ignore
  469. const imdbId = location.pathname.match(/\/title\/(tt\d+)/)[1]
  470.  
  471. const $classicReviewBar = $('.titleReviewBar')
  472. const $darkReviewBar = $('[class^="TitleBlock__ButtonContainer-"]')
  473. const $target = ($classicReviewBar.length && $classicReviewBar)
  474. || ($darkReviewBar.length && $darkReviewBar)
  475.  
  476. if (!$target) {
  477. console.warn(`Can't find target for ${location.href}`)
  478. return
  479. }
  480.  
  481. let meta
  482.  
  483. if ($target === $classicReviewBar) {
  484. meta = jsonLdMetadata(imdbId)
  485.  
  486. /* make the background color more legible (darker) if the rating is N/A */
  487. GM_addStyle(`.score_tbd { background-color: ${COLOR.tbd} }`)
  488. } else if ($target === $darkReviewBar) {
  489. meta = graphQlMetadata(imdbId)
  490. } else {
  491. console.warn(`can't find metadata for ${imdbId}`)
  492. return
  493. }
  494.  
  495. debug('metadata:', meta)
  496.  
  497. const { pageType, title, type } = meta
  498.  
  499. if (type !== 'Movie') {
  500. debug(`invalid type for ${imdbId}: ${type}`)
  501. return
  502. }
  503.  
  504. if (pageType !== 'title') {
  505. console.warn(`invalid page type for ${imdbId}: ${pageType}`)
  506. return
  507. }
  508.  
  509. purgeCached(NOW)
  510.  
  511. const cached = JSON.parse(GM_getValue(imdbId, 'null'))
  512.  
  513. if (cached) {
  514. const expires = new Date(cached.expires).toLocaleString()
  515.  
  516. if (cached.error) {
  517. debug(`cached error (expires: ${expires}): ${imdbId}`)
  518.  
  519. // couldn't retrieve any RT data so there's nothing
  520. // more we can do
  521. console.warn(cached.error)
  522. } else {
  523. debug(`cached result (expires: ${expires}): ${imdbId}`)
  524. addWidget($target, cached.data)
  525. }
  526.  
  527. return
  528. } else {
  529. debug(`not cached: ${imdbId}`)
  530. }
  531.  
  532. // add an { expires, version, data|error } entry to the cache
  533. function store (dataOrError, ttl) {
  534. const cached = Object.assign({
  535. expires: NOW + ttl,
  536. version: DATA_VERSION
  537. }, dataOrError)
  538.  
  539. const json = JSON.stringify(cached)
  540.  
  541. GM_setValue(imdbId, json)
  542. }
  543.  
  544. const query = JSON.parse(GM_getResourceText('query'))
  545.  
  546. Object.assign(query.params, { searchTerm: title, yearMax: THIS_YEAR })
  547.  
  548. try {
  549. debug(`querying API for ${imdbId} (${JSON.stringify(title)})`)
  550. const requestOptions = Object.assign({}, query, { title: `data for ${imdbId}` })
  551. const response = await httpGet(query.api, requestOptions)
  552. const fallback = JSON.parse(GM_getResourceText('fallback'))
  553.  
  554. debug(`response for ${imdbId}: ${response.status} ${response.statusText}`)
  555.  
  556. const { data, updated } = await getRTData({
  557. response: response.responseText,
  558. imdbId,
  559. title,
  560. fallback: fallback[imdbId],
  561. })
  562.  
  563. if (updated) {
  564. debug(`caching ${imdbId} result for one day`)
  565. store({ data }, ONE_DAY)
  566. } else {
  567. debug(`caching ${imdbId} result for one week`)
  568. store({ data }, ONE_WEEK)
  569. }
  570.  
  571. addWidget($target, data)
  572. } catch (error) {
  573. const message = error.message || String(error) // stringify
  574. debug(`caching ${imdbId} error for one day`)
  575. store({ error: message }, ONE_DAY)
  576. console.error(message)
  577. }
  578. }
  579.  
  580. // register a jQuery plugin which extracts and returns JSON-LD data for
  581. // the specified document
  582. // @ts-ignore
  583. $.fn.jsonLd = function jsonLd (id) {
  584. const $script = this.find('script[type="application/ld+json"]')
  585.  
  586. let data
  587.  
  588. if ($script.length) {
  589. try {
  590. data = JSON.parse($script.first().text().trim())
  591. } catch (e) {
  592. throw new Error(`Can't parse JSON-LD data for ${id}: ${e}`)
  593. }
  594. } else {
  595. throw new Error(`Can't find JSON-LD data for ${id}`)
  596. }
  597.  
  598. return data
  599. }
  600.  
  601. // register this first so data can be cleared even if there's an error
  602. GM_registerMenuCommand(SCRIPT_NAME + ': clear cache', () => { purgeCached(-1) })
  603.  
  604. $(window).on('pageshow', main)

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址