IMDb Tomatoes

Add Rotten Tomatoes ratings to IMDb movie pages

目前为 2021-02-07 提交的版本。查看 最新版本

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

QingJ © 2025

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