IMDb Tomatoes

Add Rotten Tomatoes ratings to IMDb movie and TV show pages

目前為 2022-09-19 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name IMDb Tomatoes
  3. // @description Add Rotten Tomatoes ratings to IMDb movie and TV show pages
  4. // @author chocolateboy
  5. // @copyright chocolateboy
  6. // @version 4.16.1
  7. // @namespace https://github.com/chocolateboy/userscripts
  8. // @license GPL
  9. // @include /^https://www\.imdb\.com/title/tt[0-9]+/([#?].*)?$/
  10. // @require https://code.jquery.com/jquery-3.6.1.min.js
  11. // @require https://cdn.jsdelivr.net/gh/urin/jquery.balloon.js@8b79aab63b9ae34770bfa81c9bfe30019d9a13b0/jquery.balloon.js
  12. // @require https://unpkg.com/dayjs@1.11.5/dayjs.min.js
  13. // @require https://unpkg.com/dayjs@1.11.5/plugin/relativeTime.js
  14. // @require https://unpkg.com/@chocolateboy/uncommonjs@3.2.1/dist/polyfill.iife.min.js
  15. // @require https://unpkg.com/dset@3.1.2/dist/index.min.js
  16. // @require https://unpkg.com/fast-dice-coefficient@1.0.3/dice.js
  17. // @require https://unpkg.com/get-wild@3.0.2/dist/index.umd.min.js
  18. // @resource api https://pastebin.com/raw/hcN4ysZD
  19. // @resource overrides https://pastebin.com/raw/nTw33T55
  20. // @grant GM_addStyle
  21. // @grant GM_deleteValue
  22. // @grant GM_getResourceText
  23. // @grant GM_getValue
  24. // @grant GM_listValues
  25. // @grant GM_registerMenuCommand
  26. // @grant GM_setValue
  27. // @grant GM_xmlhttpRequest
  28. // @connect www.rottentomatoes.com
  29. // @run-at document-start
  30. // @noframes
  31. // ==/UserScript==
  32.  
  33. /// <reference types="greasemonkey" />
  34. /// <reference types="tampermonkey" />
  35. /// <reference types="jquery" />
  36. /// <reference types="node" />
  37. /// <reference path="../types/imdb-tomatoes.user.d.ts" />
  38.  
  39. 'use strict';
  40.  
  41. /* begin */ {
  42.  
  43. const API_LIMIT = 100
  44. const DATA_VERSION = 1.2
  45. const DATE_FORMAT = 'YYYY-MM-DD'
  46. const DEBUG = false
  47. const INACTIVE_MONTHS = 3
  48. const MAX_YEAR_DIFF = 3
  49. const NO_CONSENSUS = 'No consensus yet.'
  50. const NO_MATCH = 'no matching results'
  51. const ONE_DAY = 1000 * 60 * 60 * 24
  52. const ONE_WEEK = ONE_DAY * 7
  53. const RT_BASE = 'https://www.rottentomatoes.com'
  54. const SCRIPT_NAME = GM_info.script.name
  55. const TITLE_MATCH_THRESHOLD = 0.6
  56.  
  57. /** @type {Record<string, number>} */
  58. const METADATA_VERSION = { stats: 2 }
  59.  
  60. const BALLOON_OPTIONS = {
  61. classname: 'rt-consensus-balloon',
  62. css: {
  63. fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
  64. fontSize: '16px',
  65. lineHeight: '24px',
  66. maxWidth: '24rem',
  67. padding: '10px',
  68. },
  69. html: true,
  70. position: 'bottom',
  71. }
  72.  
  73. const COLOR = {
  74. tbd: '#d9d9d9',
  75. fresh: '#67ad4b',
  76. rotten: '#fb3c3c',
  77. }
  78.  
  79. const CONNECTION_ERROR = {
  80. status: 420,
  81. statusText: 'Connection Error',
  82. }
  83.  
  84. const RT_TYPE = /** @type {const} */ ({
  85. TVSeries: 'tvSeries',
  86. Movie: 'movie',
  87. })
  88.  
  89. const STATS = {
  90. requests: 0,
  91. hit: 0,
  92. miss: 0,
  93. preload: {
  94. hit: 0,
  95. miss: 0,
  96. },
  97. }
  98.  
  99. const UNSHARED = Object.freeze({
  100. got: -1,
  101. want: 1,
  102. max: 0,
  103. })
  104.  
  105. /**
  106. * the minimum number of elements shared between two Sets for them to be
  107. * deemed similar
  108. *
  109. * @type {<T>(smallest: Set<T>, largest: Set<T>) => number}
  110. */
  111. const MINIMUM_SHARED = smallest => Math.round(smallest.size / 2)
  112.  
  113. /*
  114. * log a message to the console
  115. */
  116. const { debug, info, log, warn } = console
  117.  
  118. /**
  119. * deep-clone a JSON-serializable value
  120. *
  121. * @type {<T>(value: T) => T}
  122. */
  123. const clone = value => JSON.parse(JSON.stringify(value))
  124.  
  125. /*
  126. * a custom version of get-wild's `get` function which uses a simpler/faster
  127. * path parser since we don't use the extended syntax
  128. */
  129. const get = exports.getter({ split: '.' })
  130.  
  131. /**
  132. * scan an RT document for properties defined in the text of metadata elements
  133. * of the specified type
  134. *
  135. * @param {RTDoc} $rt
  136. * @param {string} type
  137. * @return {string[]}
  138. */
  139. const rtProps = ($rt, type) => {
  140. return $rt.find(`[data-qa="${type}"]`).get().flatMap(el => {
  141. const name = $(el).text().trim()
  142. return name ? [name] : []
  143. })
  144. }
  145.  
  146. /**
  147. * register a jQuery plugin which extracts and returns JSON-LD data for the
  148. * loaded document
  149. *
  150. * used to extract metadata on IMDb and Rotten Tomatoes
  151. *
  152. * @param {string} id
  153. */
  154. $.fn.jsonLd = function jsonLd (id) {
  155. const $script = this.find('script[type="application/ld+json"]')
  156.  
  157. let data
  158.  
  159. if ($script.length) {
  160. try {
  161. data = JSON.parse($script.first().text().trim())
  162. } catch (e) {
  163. throw new Error(`Can't parse JSON-LD data for ${id}: ${e}`)
  164. }
  165. } else {
  166. throw new Error(`Can't find JSON-LD data for ${id}`)
  167. }
  168.  
  169. return data
  170. }
  171.  
  172. const MovieMatcher = {
  173. /**
  174. * return the consensus from a movie page as a HTML string
  175. *
  176. * @param {RTDoc} $rt
  177. * @return {[string]}
  178. */
  179. getConsensus ($rt) {
  180. const $consensus = $rt.find('[data-qa="score-panel-critics-consensus"], [data-qa="critics-consensus"]')
  181. .first()
  182. return [$consensus.html()]
  183. },
  184.  
  185. /**
  186. * return the last time a movie page was updated based on its most
  187. * recently-published review
  188. *
  189. * @param {RTDoc} $rt
  190. * @return {DayJs | undefined}
  191. */
  192. lastModified ($rt) {
  193. return $rt
  194. .find('critic-review-bubble[createdate]:not([createdate=""])').get()
  195. .map(review => dayjs($(review).attr('createdate')))
  196. .sort((a, b) => b.unix() - a.unix())
  197. .shift()
  198. },
  199.  
  200. /**
  201. * return a movie record ({ url: string }) from the API results which
  202. * matches the supplied IMDb data
  203. *
  204. * @param {{ movies: RTMovieResult[] }} rtResults
  205. * @param {any} imdb
  206. */
  207. match (rtResults, imdb) {
  208. const sorted = rtResults.movies
  209. .flatMap((rt, index) => {
  210. const { castItems, name: title, url } = rt
  211.  
  212. if (!(title && url && castItems)) {
  213. return []
  214. }
  215.  
  216. if (url === '/m/null') {
  217. return []
  218. }
  219.  
  220. const rtCast = pluck(castItems, 'name').flatMap(name => {
  221. return name ? [stripRtName(name)] : []
  222. })
  223.  
  224. let castMatch = -1, verify = true
  225.  
  226. if (rtCast.length) {
  227. const { got, want } = shared(rtCast, imdb.fullCast)
  228.  
  229. if (got >= want) {
  230. verify = false
  231. castMatch = got
  232. } else {
  233. return []
  234. }
  235. }
  236.  
  237. const yearDiff = (imdb.year && rt.year)
  238. ? { value: Math.abs(imdb.year - rt.year) }
  239. : null
  240.  
  241. if (yearDiff && yearDiff.value > MAX_YEAR_DIFF) {
  242. return []
  243. }
  244.  
  245. const titleMatch = titleSimilarity({ imdb, rt: { title } })
  246.  
  247. const result = {
  248. title,
  249. url,
  250. rating: rt.meterScore,
  251. popularity: (rt.meterScore == null ? 0 : 1),
  252. cast: rtCast,
  253. year: rt.year,
  254. index,
  255. titleMatch,
  256. castMatch,
  257. yearDiff,
  258. verify,
  259. }
  260.  
  261. return [result]
  262. })
  263. .sort((a, b) => {
  264. // combine the title and the year into a single score
  265. //
  266. // being a year or two out shouldn't be a dealbreaker, and it's
  267. // not uncommon for an RT title to differ from the IMDb title
  268. // (e.g. an AKA), so we don't want one of these to pre-empt the
  269. // other (yet)
  270. const score = new Score()
  271.  
  272. score.add(b.titleMatch - a.titleMatch)
  273.  
  274. if (a.yearDiff && b.yearDiff) {
  275. score.add(a.yearDiff.value - b.yearDiff.value)
  276. }
  277.  
  278. return (b.castMatch - a.castMatch)
  279. || (score.b - score.a)
  280. || (b.titleMatch - a.titleMatch) // prioritise the title if we're still deadlocked
  281. || (b.popularity - a.popularity) // last resort
  282. })
  283.  
  284. debug('matches:', sorted)
  285.  
  286. return sorted[0]
  287. },
  288.  
  289. /**
  290. * return the likely RT path for an IMDb movie title, e.g.:
  291. *
  292. * title: "Bolt"
  293. * path: "/m/bolt"
  294. *
  295. * @param {string} title
  296. */
  297. rtPath (title) {
  298. return `/m/${rtName(title)}`
  299. },
  300.  
  301. /**
  302. * confirm the supplied RT page data matches the IMDb metadata
  303. *
  304. * @param {RTDoc} $rt
  305. * @param {any} imdb
  306. * @return {boolean}
  307. */
  308. verify ($rt, imdb) {
  309. log('verifying movie')
  310.  
  311. // match the director(s)
  312. const rtDirectors = rtProps($rt, 'movie-info-director')
  313.  
  314. return verifyShared({
  315. name: 'directors',
  316. imdb: imdb.directors,
  317. rt: rtDirectors,
  318. })
  319. },
  320. }
  321.  
  322. const TVMatcher = {
  323. /**
  324. * return the consensus (HTML string) and rating (number) from a TV page
  325. *
  326. * @param {RTDoc} $rt
  327. * @param {number} showRating
  328. * @return {[string | undefined, number]}
  329. */
  330. getConsensus ($rt, showRating) {
  331. const $consensus = $rt.find('season-list-item[consensus]:not([consensus=""])').last()
  332. const $rating = $rt.find('season-list-item[tomatometerscore]:not([tomatometerscore=""])').last()
  333.  
  334. /** @type {string | undefined} */
  335. let consensus
  336.  
  337. /** @type {string | undefined} */
  338. let score
  339.  
  340. if ($consensus.length) {
  341. consensus = $consensus.attr('consensus')
  342. score = $consensus.attr('tomatometerscore')
  343. } else if ($rating.length) {
  344. score = $rating.attr('tomatometerscore')
  345. }
  346.  
  347. let seasonRating = showRating
  348.  
  349. if (score) {
  350. const rating = parseInt(score)
  351.  
  352. if (Number.isSafeInteger(rating)) {
  353. seasonRating = rating
  354. }
  355. }
  356.  
  357. return [consensus, seasonRating]
  358. },
  359.  
  360. /**
  361. * return the last time a TV page was updated based on its most
  362. * recently-published review
  363. *
  364. * @param {RTDoc} _$rt
  365. * @return {DayJs | undefined}
  366. */
  367. lastModified (_$rt) {
  368. // XXX there's no way to determine this from the main page of a TV show
  369. return undefined
  370. },
  371.  
  372. /**
  373. * return a TV show record ({ url: string }) from the API results which
  374. * matches the supplied IMDb data
  375. *
  376. * @param {{ tvSeries: RTTVResult[] }} rtResults
  377. * @param {any} imdb
  378. */
  379. match (rtResults, imdb) {
  380. const sorted = rtResults.tvSeries
  381. .flatMap((rt, index) => {
  382. const { title, startYear, endYear, url } = rt
  383.  
  384. if (!(title && (startYear || endYear) && url)) {
  385. return []
  386. }
  387.  
  388. let suffix, path
  389.  
  390. const match = url.match(/^(\/tv\/[^/]+)(?:\/(.+))?$/)
  391.  
  392. if (match) {
  393. if (match[1] === '/tv/null') {
  394. return []
  395. }
  396.  
  397. path = match[1] // strip the season
  398. suffix = match[2]
  399. } else {
  400. warn("can't parse RT URL:", url)
  401. return []
  402. }
  403.  
  404. const titleMatch = titleSimilarity({ imdb, rt })
  405.  
  406. if (titleMatch < TITLE_MATCH_THRESHOLD) {
  407. return []
  408. }
  409.  
  410. /** @type {Record<string, { value: number } | null>} */
  411. const dateDiffs = {}
  412.  
  413. for (const dateProp of /** @type {const} */ (['startYear', 'endYear'])) {
  414. if (imdb[dateProp] && rt[dateProp]) {
  415. const diff = Math.abs(imdb[dateProp] - rt[dateProp])
  416.  
  417. if (diff > MAX_YEAR_DIFF) {
  418. return []
  419. } else {
  420. dateDiffs[dateProp] = { value: diff }
  421. }
  422. }
  423. }
  424.  
  425. const seasonsDiff = (suffix === 's01' && imdb.seasons)
  426. ? { value: imdb.seasons - 1 }
  427. : null
  428.  
  429. const result = {
  430. title,
  431. url: path,
  432. rating: rt.meterScore,
  433. popularity: (rt.meterScore == null ? 0 : 1),
  434. startYear,
  435. endYear,
  436. index,
  437. titleMatch,
  438. startYearDiff: dateDiffs.startYear,
  439. endYearDiff: dateDiffs.endYear,
  440. seasonsDiff,
  441. verify: true,
  442. }
  443.  
  444. return [result]
  445. })
  446. .sort((a, b) => {
  447. const score = new Score()
  448.  
  449. score.add(b.titleMatch - a.titleMatch)
  450.  
  451. if (a.startYearDiff && b.startYearDiff) {
  452. score.add(a.startYearDiff.value - b.startYearDiff.value)
  453. }
  454.  
  455. if (a.endYearDiff && b.endYearDiff) {
  456. score.add(a.endYearDiff.value - b.endYearDiff.value)
  457. }
  458.  
  459. if (a.seasonsDiff && b.seasonsDiff) {
  460. score.add(a.seasonsDiff.value - b.seasonsDiff.value)
  461. }
  462.  
  463. return (score.b - score.a)
  464. || (b.titleMatch - a.titleMatch) // prioritise the title if we're still deadlocked
  465. || (b.popularity - a.popularity) // last resort
  466. })
  467.  
  468. debug('matches:', sorted)
  469.  
  470. return sorted[0] // may be undefined
  471. },
  472.  
  473. /**
  474. * return the likely RT path for an IMDb TV show title, e.g.:
  475. *
  476. * title: "Sesame Street"
  477. * path: "/tv/sesame_street"
  478. *
  479. * @param {string} title
  480. */
  481. rtPath (title) {
  482. return `/tv/${rtName(title)}`
  483. },
  484.  
  485. /**
  486. * confirm the supplied RT page data matches the IMDb metadata
  487. *
  488. * @param {RTDoc} $rt
  489. * @param {any} imdb
  490. * @return {boolean | string}
  491. */
  492. verify ($rt, imdb) {
  493. log('verifying TV show')
  494.  
  495. // match the cast or, if empty, the creator(s). if neither are
  496. // available, match the RT executive producers against the IMDb
  497. // creators, or, failing that, if all other data is unavailable (e.g.
  498. // for TV documentaries), match the genres AND release date.
  499.  
  500. let verified = false
  501.  
  502. match: {
  503. if (imdb.fullCast.length) {
  504. const rtCast = rtProps($rt, 'cast-member')
  505.  
  506. if (rtCast.length) {
  507. verified = verifyShared({
  508. name: 'cast',
  509. imdb: imdb.fullCast,
  510. rt: rtCast,
  511. })
  512.  
  513. break match
  514. }
  515. }
  516.  
  517. if (imdb.creators.length) {
  518. const rtCreators = rtProps($rt, 'creator')
  519.  
  520. if (rtCreators.length) {
  521. verified = verifyShared({
  522. name: 'creators',
  523. imdb: imdb.creators,
  524. rt: rtCreators,
  525. })
  526.  
  527. break match
  528. }
  529.  
  530. const rtProducers = rtProps($rt, 'series-details-producer')
  531.  
  532. if (rtProducers.length) {
  533. verified = verifyShared({
  534. name: 'producers',
  535. imdb: imdb.creators,
  536. rt: rtProducers,
  537. })
  538.  
  539. break match
  540. }
  541. }
  542.  
  543. // last resort: match the genre(s) and release date
  544. if (imdb.genres.length && imdb.releaseDate) {
  545. const rtGenres = rtProps($rt, 'series-details-genre')
  546.  
  547. if (!rtGenres.length) {
  548. break match
  549. }
  550.  
  551. const matchedGenres = verifyShared({
  552. name: 'genres',
  553. imdb: imdb.genres,
  554. rt: rtGenres,
  555. })
  556.  
  557. if (!matchedGenres) {
  558. break match
  559. }
  560.  
  561. debug('verifying release date')
  562.  
  563. const [rtReleaseDate] = rtProps($rt, 'series-details-premiere-date')
  564. .map(date => dayjs(date).format(DATE_FORMAT))
  565.  
  566. if (!rtReleaseDate) {
  567. break match
  568. }
  569.  
  570. debug('imdb release date:', imdb.releaseDate)
  571. debug('rt release date:', rtReleaseDate)
  572. verified = rtReleaseDate === imdb.releaseDate
  573. }
  574. }
  575.  
  576. // change the target URL from "/tv/name" to "/tv/name/s01" if there's
  577. // only one season
  578. if (verified) {
  579. /** @type {{ url: string }[] | undefined} */
  580. const seasons = $rt.meta.containsSeason
  581.  
  582. if (seasons?.length === 1) {
  583. const url = get(seasons, [-1, 'url'])
  584.  
  585. if (url) {
  586. return url
  587. }
  588. }
  589. }
  590.  
  591. return verified
  592. }
  593. }
  594.  
  595. const Matcher = {
  596. tvSeries: TVMatcher,
  597. movie: MovieMatcher,
  598. }
  599.  
  600. /*
  601. * a helper class used to load and verify data from RT pages which transparently
  602. * handles the selection of the most suitable URL, either from the API (match)
  603. * or guessed from the title (fallback)
  604. */
  605. class RTClient {
  606. /**
  607. * @param {Object} options
  608. * @param {any} options.match
  609. * @param {Matcher[keyof Matcher]} options.matcher
  610. * @param {any} options.preload
  611. * @param {RTState} options.state
  612. */
  613. constructor ({ match, matcher, preload, state }) {
  614. this.match = match
  615. this.matcher = matcher
  616. this.preload = preload
  617. this.state = state
  618. }
  619.  
  620. /**
  621. * transform an XHR response into a JQuery document wrapper with a +meta+
  622. * property containing the page's parsed JSON-LD data
  623. *
  624. * @param {Tampermonkey.Response<any>} res
  625. * @param {string} id
  626. * @return {RTDoc}
  627. */
  628. _parseResponse (res, id) {
  629. const parser = new DOMParser()
  630. const dom = parser.parseFromString(res.responseText, 'text/html')
  631. const $rt = $(dom)
  632. const meta = $rt.jsonLd(id)
  633. return Object.assign($rt, { meta, document: dom })
  634. }
  635.  
  636. /**
  637. * load the RT URL (match or fallback) and return the corresponding XHR
  638. * response
  639. */
  640. async loadPage () {
  641. const { match, preload, state } = this
  642. let requestType = match.fallback ? 'fallback' : 'match'
  643. let res
  644.  
  645. log(`loading ${requestType} URL:`, state.url)
  646.  
  647. // match URL (API result) and fallback (guessed) URL are the same
  648. if (match.url === preload.url) {
  649. res = await preload.promise // join the in-flight request
  650.  
  651. if (!res) {
  652. log(`error loading ${state.url} (${preload.error.status} ${preload.error.statusText})`)
  653. return
  654. }
  655. } else { // separate match URL and fallback URL
  656. try {
  657. res = await asyncGet(state.url) // load the (absolute) match URL
  658. state.fallbackUnused = true // only set if the request succeeds
  659. } catch (error) { // bogus URL in API result (or transient server error)
  660. log(`error loading ${state.url} (${error.status} ${error.statusText})`)
  661.  
  662. if (match.force) { // URL locked in checkOverrides
  663. return
  664. } else { // use (and verify) the fallback URL
  665. requestType = 'fallback'
  666. state.url = preload.fullUrl
  667. state.verify = true
  668.  
  669. log(`loading ${requestType} URL:`, state.url)
  670.  
  671. res = await preload.promise
  672.  
  673. if (!res) {
  674. log(`error loading ${state.url} (${preload.error.status} ${preload.error.statusText})`)
  675. return
  676. }
  677. }
  678. }
  679. }
  680.  
  681. log(`${requestType} response: ${res.status} ${res.statusText}`)
  682.  
  683. return this._parseResponse(res, state.url)
  684. }
  685.  
  686. /**
  687. * confirm the metadata of the RT page (match or fallback) matches the IMDb
  688. * metadata
  689. *
  690. * @param {any} imdb
  691. * @return {Promise<boolean>}
  692. */
  693. async verify (imdb) {
  694. const { match, matcher, preload, state } = this
  695.  
  696. let $rt = /** @type {RTDoc} */ (state.rtPage)
  697. let verified = matcher.verify($rt, imdb)
  698.  
  699. if (!verified) {
  700. if (match.force) {
  701. log('forced:', true)
  702. verified = true
  703. } else if (state.fallbackUnused) {
  704. state.url = preload.fullUrl
  705. log(`loading fallback URL:`, state.url)
  706.  
  707. const res = await preload.promise
  708.  
  709. if (res) {
  710. log(`fallback response: ${res.status} ${res.statusText}`)
  711. $rt = state.rtPage = this._parseResponse(res, preload.url)
  712. verified = matcher.verify($rt, imdb)
  713. } else {
  714. log(`error loading ${state.url} (${preload.error.status} ${preload.error.statusText})`)
  715. }
  716. }
  717. }
  718.  
  719. if (typeof verified === 'string') {
  720. state.targetUrl = verified
  721. verified = true
  722. }
  723.  
  724. log('verified:', verified)
  725.  
  726. return verified
  727. }
  728. }
  729.  
  730. /*
  731. * a helper class which keeps a running total of scores for two values (a and
  732. * b). used to rank values in a sort function
  733. */
  734. class Score {
  735. constructor () {
  736. this.a = 0
  737. this.b = 0
  738. }
  739.  
  740. /**
  741. * add a score to the total
  742. *
  743. * @param {number} order
  744. * @param {number=} points
  745. */
  746. add (order, points = 1) {
  747. if (order < 0) {
  748. this.a += points
  749. } else if (order > 0) {
  750. this.b += points
  751. }
  752. }
  753. }
  754.  
  755. /******************************************************************************/
  756.  
  757. /**
  758. * raise a non-error exception indicating no matching result has been found
  759. *
  760. * @param {string} message
  761. */
  762.  
  763. // XXX return an error object rather than throwing it to work around a
  764. // TypeScript bug: https://github.com/microsoft/TypeScript/issues/31329
  765. function abort (message = NO_MATCH) {
  766. return Object.assign(new Error(message), { abort: true })
  767. }
  768.  
  769. /**
  770. * add a Rotten Tomatoes widget to the ratings bar
  771. *
  772. * @param {JQuery} $ratings
  773. * @param {JQuery} $imdbRating
  774. * @param {Object} data
  775. * @param {string} data.url
  776. * @param {string} data.consensus
  777. * @param {number} data.rating
  778. */
  779. function addWidget ($ratings, $imdbRating, { consensus, rating, url }) {
  780. /** @type {"tbd" | "rotten" | "fresh"} */
  781. let style
  782.  
  783. if (rating === -1) {
  784. style = 'tbd'
  785. } else if (rating < 60) {
  786. style = 'rotten'
  787. } else {
  788. style = 'fresh'
  789. }
  790.  
  791. // clone the IMDb rating widget
  792. const $rtRating = $imdbRating.clone()
  793.  
  794. // 1) assign a unique ID
  795. $rtRating.attr('id', 'rt-rating')
  796.  
  797. // 2) add a custom stylesheet which:
  798. //
  799. // - sets the star (SVG) to the right color
  800. // - restores support for italics in the consensus text
  801. // - reorders the appended widget (see attachWidget)
  802. GM_addStyle(`
  803. #rt-rating svg { color: ${COLOR[style]}; }
  804. #rt-rating { order: -1; }
  805. .rt-consensus-balloon em { font-style: italic; }
  806. `)
  807.  
  808. // 3) replace "IMDb Rating" with "RT Rating"
  809. $rtRating.children().first().text('RT RATING')
  810.  
  811. // 4) remove the review count and its preceding spacer element
  812. const $score = $rtRating.find('[data-testid="hero-rating-bar__aggregate-rating__score"]')
  813. $score.nextAll().remove()
  814.  
  815. // 5) replace the IMDb rating with the RT score and remove the "/ 10" suffix
  816. const score = rating === -1 ? 'N/A' : `${rating}%`
  817. $score.children().first().text(score).nextAll().remove()
  818.  
  819. // 6) rename the testids, e.g.:
  820. // hero-rating-bar__aggregate-rating -> hero-rating-bar__rt-rating
  821. $rtRating.find('[data-testid]').addBack().each(function () {
  822. $(this).attr('data-testid', (_, id) => id.replace('aggregate', 'rt'))
  823. })
  824.  
  825. // 7) update the link's label and URL
  826. $rtRating
  827. .find('a[role="button"]')
  828. .attr({ 'aria-label': 'View RT Rating', href: url })
  829.  
  830. // 8) attach the tooltip to the widget
  831. const balloonOptions = Object.assign({}, BALLOON_OPTIONS, { contents: consensus })
  832. $rtRating.balloon(balloonOptions)
  833.  
  834. // 9) prepend the widget to the ratings bar
  835. attachWidget($ratings, $rtRating)
  836. }
  837.  
  838. /**
  839. * promisified cross-origin HTTP requests
  840. *
  841. * @param {string} url
  842. * @param {AsyncGetOptions} [options]
  843. */
  844. function asyncGet (url, options = {}) {
  845. if (options.params) {
  846. url = url + '?' + $.param(options.params)
  847. }
  848.  
  849. const id = options.title || url
  850. const request = Object.assign({ method: 'GET', url }, options.request || {})
  851.  
  852. return new Promise((resolve, reject) => {
  853. request.onload = res => {
  854. if (res.status >= 400) {
  855. const error = Object.assign(
  856. new Error(`error fetching ${id} (${res.status} ${res.statusText})`),
  857. { status: res.status, statusText: res.statusText }
  858. )
  859.  
  860. reject(error)
  861. } else {
  862. resolve(res)
  863. }
  864. }
  865.  
  866. // XXX apart from +finalUrl+, the +onerror+ response object doesn't
  867. // contain any useful info
  868. request.onerror = _res => {
  869. const { status, statusText } = CONNECTION_ERROR
  870. const error = Object.assign(
  871. new Error(`error fetching ${id} (${status} ${statusText})`),
  872. { status, statusText },
  873. )
  874.  
  875. reject(error)
  876. }
  877.  
  878. GM_xmlhttpRequest(request)
  879. })
  880. }
  881.  
  882. /**
  883. * attach the RT ratings widget to the ratings bar
  884. *
  885. * although the widget appears to be prepended to the bar, we need to append it
  886. * (and reorder it via CSS) to work around React reconciliation (updating the
  887. * DOM to match the (virtual DOM representation of the) underlying model) after
  888. * we've added the RT widget
  889. *
  890. * when this synchronisation occurs, React will try to restore nodes
  891. * (attributes, text, elements) within each widget to match the widget's props,
  892. * so the first widget will be updated in place to match the data for the IMDb
  893. * rating etc. this changes some, but not all nodes within an element, and most
  894. * attributes added to/changed in a prepended RT widget remain when it's
  895. * reverted back to an IMDb widget, including its ID attribute (rt-rating),
  896. * which controls the color of the rating star. as a result, we end up with a
  897. * restored IMDb widget but with an RT-colored star (and with the RT widget
  898. * removed since it's not in the ratings-bar model)
  899. *
  900. * if we *append* the RT widget, none of the other widgets will need to be
  901. * changed/updated if the DOM is re-synced, so we won't end up with a mangled
  902. * IMDb widget; however, our RT widget will still be removed since it's not in
  903. * the model. to rectify this, we use a mutation observer to detect and revert
  904. * its removal (which happens no more than once - the ratings bar is frozen
  905. * (i.e. synchronisation is halted) once the page has loaded)
  906. *
  907. * @param {JQuery} $target
  908. * @param {JQuery} $rtRating
  909. */
  910. function attachWidget ($target, $rtRating) {
  911. const init = { childList: true }
  912. const target = $target.get(0)
  913. const rtRating = $rtRating.get(0)
  914.  
  915. if (!target) {
  916. throw new ReferenceError("can't find ratings bar")
  917. }
  918.  
  919. if (!rtRating) {
  920. throw new ReferenceError("can't find RT widget")
  921. }
  922.  
  923. // restore the RT widget if it is removed. only called (once) if the widget
  924. // is added "quickly" (i.e. while the ratings bar is still being finalized),
  925. // e.g. when the result is cached
  926. const callback = () => {
  927. if (target.lastElementChild !== rtRating) {
  928. observer.disconnect()
  929. target.appendChild(rtRating)
  930. observer.observe(target, init)
  931. }
  932. }
  933.  
  934. const observer = new MutationObserver(callback)
  935. target.appendChild(rtRating)
  936. observer.observe(target, init)
  937. }
  938.  
  939. /**
  940. * check the override data in case of a failed match, but only use it as a last
  941. * resort, i.e. try the verifier first in case the page data has been
  942. * fixed/updated
  943. *
  944. * @param {any} match
  945. * @param {string} imdbId
  946. */
  947. function checkOverrides (match, imdbId) {
  948. const overrides = JSON.parse(GM_getResourceText('overrides'))
  949. const url = overrides[imdbId]
  950.  
  951. if (url) {
  952. const $url = JSON.stringify(url)
  953.  
  954. if (!match) { // missing result
  955. debug('fallback:', $url)
  956. match = { url }
  957. } else if (match.url !== url) { // wrong result
  958. const $overridden = JSON.stringify(match.url)
  959. debug(`override: ${$overridden} -> ${$url}`)
  960. match.url = url
  961. }
  962.  
  963. Object.assign(match, { verify: true, force: true })
  964. }
  965.  
  966. return match
  967. }
  968.  
  969. /**
  970. * extract IMDb metadata from the GraphQL data embedded in the page
  971. *
  972. * @param {string} imdbId
  973. * @param {string} rtType
  974. */
  975. function getIMDbMetadata (imdbId, rtType) {
  976. const data = JSON.parse($('#__NEXT_DATA__').text())
  977. const main = get(data, 'props.pageProps.mainColumnData')
  978. const extra = get(data, 'props.pageProps.aboveTheFoldData')
  979. const mainCast = get(extra, 'castPageTitle.edges.*.node.name.nameText.text', [])
  980. const fullCast = get(main, 'cast.edges.*.node.name.nameText.text', [])
  981. const type = get(main, 'titleType.id', '')
  982. const title = get(main, 'titleText.text', '')
  983. const originalTitle = get(main, 'originalTitleText.text', '')
  984. const genres = get(extra, 'genres.genres.*.text', [])
  985. const year = get(extra, 'releaseYear.year') || 0
  986. const $releaseDate = get(extra, 'releaseDate')
  987.  
  988. let releaseDate = null
  989.  
  990. if ($releaseDate) {
  991. const date = new Date(
  992. $releaseDate.year,
  993. $releaseDate.month - 1,
  994. $releaseDate.day
  995. )
  996.  
  997. releaseDate = dayjs(date).format(DATE_FORMAT)
  998. }
  999.  
  1000. /** @type {Record<string, any>} */
  1001. const meta = {
  1002. id: imdbId,
  1003. type,
  1004. title,
  1005. originalTitle,
  1006. cast: mainCast,
  1007. fullCast,
  1008. genres,
  1009. releaseDate,
  1010. }
  1011.  
  1012. if (rtType === 'tvSeries') {
  1013. meta.startYear = year
  1014. meta.endYear = get(extra, 'releaseYear.endYear') || 0
  1015. meta.seasons = get(main, 'episodes.seasons.length') || 0
  1016. meta.creators = get(main, 'creators.*.credits.*.name.nameText.text', [])
  1017. } else if (rtType === 'movie') {
  1018. meta.directors = get(main, 'directors.*.credits.*.name.nameText.text', [])
  1019. meta.writers = get(main, 'writers.*.credits.*.name.nameText.text', [])
  1020. meta.year = year
  1021. }
  1022.  
  1023. return meta
  1024. }
  1025.  
  1026. /**
  1027. * query the API, parse its response and extract the RT rating and consensus.
  1028. *
  1029. * if there's no consensus, default to "No consensus yet."
  1030. * if there's no rating, default to -1
  1031. *
  1032. * @param {any} imdb
  1033. * @param {keyof Matcher} rtType
  1034. */
  1035. async function getRTData (imdb, rtType) {
  1036. // quoting the title behaves similarly to Google search, returning matches
  1037. // which contain the exact title (and some minor variants), rather than
  1038. // titles which are loosely similar, e.g. searching for "Quick" (tt9211804)
  1039. // yields:
  1040. //
  1041. // unquoted:
  1042. //
  1043. // - matches: 186
  1044. // - results: 79
  1045. // - found: false
  1046. // - examples: "Quick", "Quicksilver", "Quicksand", "Highlander 2: The Quickening"
  1047. // - stats: {
  1048. // "Quickie": 50,
  1049. // "Quick": 19,
  1050. // "Quicksand": 4,
  1051. // "Quicksilver": 3,
  1052. // "Quickening": 2,
  1053. // "Quicker": 1,
  1054. // }
  1055. //
  1056. // quoted:
  1057. //
  1058. // - matches: 91
  1059. // - results: 39
  1060. // - found: true
  1061. // - examples: "Quick", "Quick Change", "Kiss Me Quick", "The Quick and the Dead"
  1062. // - stats: { "Quick": 39 }
  1063.  
  1064. const unquoted = imdb.title
  1065. .replace(/"/g, ' ')
  1066. .replace(/\s+/g, ' ')
  1067. .trim()
  1068.  
  1069. const query = JSON.stringify(unquoted)
  1070.  
  1071. log(`querying API for ${query}`)
  1072.  
  1073. /** @type {AsyncGetOptions} */
  1074. const apiRequest = {
  1075. params: { t: rtType, q: query, limit: API_LIMIT },
  1076. request: { responseType: 'json' },
  1077. title: 'API',
  1078. }
  1079.  
  1080. const matcher = Matcher[rtType]
  1081.  
  1082. // we preload the anticipated RT page URL at the same time as the API request.
  1083. // the URL is the obvious path-formatted version of the IMDb title, e.g.:
  1084. //
  1085. // movie: "Bolt"
  1086. // preload URL: https://www.rottentomatoes.com/m/bolt
  1087. //
  1088. // tvSeries: "Sesame Street"
  1089. // preload URL: https://www.rottentomatoes.com/tv/sesame_street
  1090. //
  1091. // this guess produces the correct URL most (~75%) of the time
  1092. //
  1093. // preloading this page serves two purposes:
  1094. //
  1095. // 1) it reduces the time spent waiting for the RT widget to be displayed.
  1096. // rather than querying the API and *then* loading the page, the requests
  1097. // run concurrently, effectively halving the waiting time in most cases
  1098. //
  1099. // 2) it serves as a fallback if the API URL:
  1100. //
  1101. // a) is missing
  1102. // b) is invalid/fails to load
  1103. // c) is wrong (fails the verification check)
  1104. //
  1105. const preload = (function () {
  1106. const path = matcher.rtPath(imdb.title)
  1107. const url = RT_BASE + path
  1108.  
  1109. debug('preloading fallback URL:', url)
  1110.  
  1111. /** @type {Promise<Tampermonkey.Response<any>>} */
  1112. const promise = asyncGet(url)
  1113. .then(res => {
  1114. debug(`preload response: ${res.status} ${res.statusText}`)
  1115. return res
  1116. })
  1117. .catch(e => {
  1118. debug(`error preloading ${url} (${e.status} ${e.statusText})`)
  1119. preload.error = e
  1120. })
  1121.  
  1122. return {
  1123. error: null,
  1124. fullUrl: url,
  1125. promise,
  1126. url: path,
  1127. }
  1128. })()
  1129.  
  1130. const api = GM_getResourceText('api')
  1131.  
  1132. /** @type {Tampermonkey.Response<any>} */
  1133. let res = await asyncGet(api, apiRequest)
  1134.  
  1135. log(`API response: ${res.status} ${res.statusText}`)
  1136.  
  1137. let results
  1138.  
  1139. try {
  1140. results = JSON.parse(res.responseText)
  1141. } catch (e) {
  1142. throw new Error(`can't parse response: ${e}`)
  1143. }
  1144.  
  1145. if (!results) {
  1146. throw new Error('invalid JSON type')
  1147. }
  1148.  
  1149. debug('results:', results)
  1150.  
  1151. const matched = matcher.match(results, imdb)
  1152. const match = checkOverrides(matched, imdb.id) || {
  1153. url: preload.url,
  1154. verify: true,
  1155. fallback: true,
  1156. }
  1157.  
  1158. debug('match:', match)
  1159. log('matched:', !match.fallback)
  1160.  
  1161. // values that can be modified by the RT client
  1162.  
  1163. /** @type {RTState} */
  1164. const state = {
  1165. fallbackUnused: false,
  1166. rtPage: null,
  1167. targetUrl: null,
  1168. url: RT_BASE + match.url,
  1169. verify: match.verify,
  1170. }
  1171.  
  1172. const rtClient = new RTClient({ match, matcher, preload, state })
  1173. const $rt = await rtClient.loadPage()
  1174.  
  1175. if (!$rt) {
  1176. throw abort()
  1177. }
  1178.  
  1179. state.rtPage = $rt
  1180.  
  1181. if (state.verify) {
  1182. const verified = await rtClient.verify(imdb)
  1183.  
  1184. if (!verified) {
  1185. throw abort()
  1186. }
  1187. }
  1188.  
  1189. const $rating = $rt.meta.aggregateRating
  1190. const metaRating = Number(($rating?.name === 'Tomatometer' ? $rating.ratingValue : null) ?? -1)
  1191. const [$consensus, rating = metaRating] = matcher.getConsensus($rt, metaRating)
  1192. const consensus = $consensus?.trim()?.replace(/--/g, '&#8212;') || NO_CONSENSUS
  1193. const updated = matcher.lastModified($rt)
  1194. const targetUrl = state.targetUrl || state.url
  1195.  
  1196. return {
  1197. data: { consensus, rating, url: targetUrl },
  1198. matchUrl: state.url,
  1199. preloadUrl: preload.fullUrl,
  1200. updated,
  1201. }
  1202. }
  1203.  
  1204. /**
  1205. * normalize names so matches don't fail due to minor differences in casing or
  1206. * punctuation
  1207. *
  1208. * @param {string} name
  1209. */
  1210. function normalize (name) {
  1211. return name
  1212. .normalize('NFKD')
  1213. .replace(/[\u0300-\u036F]/g, '')
  1214. .toLowerCase()
  1215. .replace(/[^a-z0-9]/g, ' ')
  1216. .replace(/\s+/g, ' ')
  1217. .trim()
  1218. }
  1219.  
  1220. /**
  1221. * extract the value of a property (dotted path) from each member of an array
  1222. *
  1223. * @param {any[] | undefined} array
  1224. * @param {string} path
  1225. */
  1226. function pluck (array, path) {
  1227. return (array || []).map(it => get(it, path))
  1228. }
  1229.  
  1230. /**
  1231. * purge expired entries from the cache older than the supplied date
  1232. * (milliseconds since the epoch). if the date is -1, purge all entries
  1233. *
  1234. * @param {number} date
  1235. */
  1236. function purgeCached (date) {
  1237. for (const key of GM_listValues()) {
  1238. const json = GM_getValue(key, '{}')
  1239. const value = JSON.parse(json)
  1240. const metadataVersion = METADATA_VERSION[key]
  1241.  
  1242. if (metadataVersion) { // persistent (until the next METADATA_VERSION[key] change)
  1243. if (value.version !== metadataVersion) {
  1244. log(`purging invalid metadata (obsolete version: ${value.version}): ${key}`)
  1245. GM_deleteValue(key)
  1246. }
  1247. } else if (value.version !== DATA_VERSION) {
  1248. log(`purging invalid data (obsolete version: ${value.version}): ${key}`)
  1249. GM_deleteValue(key)
  1250. } else if (date === -1 || (typeof value.expires !== 'number') || date > value.expires) {
  1251. log(`purging expired value: ${key}`)
  1252. GM_deleteValue(key)
  1253. }
  1254. }
  1255. }
  1256.  
  1257. /**
  1258. * convert an IMDb title into the most likely basename (final part of the URL)
  1259. * for that title on Rotten Tomatoes, e.g.:
  1260. *
  1261. * "A Stitch in Time" -> "a_stitch_in_time"
  1262. * "Lilo & Stitch" -> "lilo_and_stitch"
  1263. * "Peter's Friends" -> "peters_friends"
  1264. *
  1265. * @param {string} title
  1266. */
  1267. function rtName (title) {
  1268. const name = title
  1269. .replace(/\s+&\s+/g, ' and ')
  1270. .replace(/'/g, '')
  1271.  
  1272. return normalize(name).replace(/\s+/g, '_')
  1273. }
  1274.  
  1275. /**
  1276. * given two arrays of strings, return an object containing:
  1277. *
  1278. * - got: the number of shared strings (strings common to both)
  1279. * - want: the required number of shared strings (minimum: 1)
  1280. * - max: the maximum possible number of shared strings
  1281. *
  1282. * if either array is empty, the number of strings they have in common is -1
  1283. *
  1284. * @param {Iterable<string>} a
  1285. * @param {Iterable<string>} b
  1286. * @param {Object} [options]
  1287. * @param {(smallest: Set<string>, largest: Set<string>) => number} [options.min]
  1288. * @param {(value: string) => string} [options.map]
  1289. */
  1290. function shared (a, b, { min = MINIMUM_SHARED, map: transform = normalize } = {}) {
  1291. const $a = new Set(Array.from(a, transform))
  1292.  
  1293. if ($a.size === 0) {
  1294. return UNSHARED
  1295. }
  1296.  
  1297. const $b = new Set(Array.from(b, transform))
  1298.  
  1299. if ($b.size === 0) {
  1300. return UNSHARED
  1301. }
  1302.  
  1303. const [smallest, largest] = $a.size < $b.size ? [$a, $b] : [$b, $a]
  1304.  
  1305. // we always want at least 1 even if the maximum is 0
  1306. const want = Math.max(min(smallest, largest), 1)
  1307.  
  1308. let count = 0
  1309.  
  1310. for (const value of smallest) {
  1311. if (largest.has(value)) {
  1312. ++count
  1313. }
  1314. }
  1315.  
  1316. return { got: count, want, max: smallest.size }
  1317. }
  1318.  
  1319. /**
  1320. * return the similarity between two strings, ranging from 0 (no similarity) to
  1321. * 2 (identical)
  1322. *
  1323. * similarity("John Woo", "John Woo") // 2
  1324. * similarity("Matthew Macfadyen", "Matthew MacFadyen") // 1
  1325. * similarity("Alan Arkin", "Zazie Beetz") // 0
  1326. *
  1327. * @param {string} a
  1328. * @param {string} b
  1329. * @return {number}
  1330. */
  1331. function similarity (a, b, map = normalize) {
  1332. return a === b ? 2 : exports.dice(map(a), map(b))
  1333. }
  1334.  
  1335. /**
  1336. * strip trailing sequence numbers in names in RT metadata, e.g.
  1337. *
  1338. * - "Meng Li (IX)" -> "Meng Li"
  1339. * - "Michael Dwyer (X) " -> "Michael Dwyer"
  1340. *
  1341. * @param {string} name
  1342. */
  1343. function stripRtName (name) {
  1344. return name.trim().replace(/\s+\([IVXLCDM]+\)$/, '')
  1345. }
  1346.  
  1347. /**
  1348. * measure the similarity of an IMDb title and an RT title returned by the API
  1349. *
  1350. * RT titles for foreign-language films/shows sometimes contain the original
  1351. * title at the end in brackets, so we take that into account
  1352. *
  1353. * NOTE we only use this if the original IMDb title differs from the main
  1354. * IMDb title
  1355. *
  1356. * similarity("The Swarm", "The Swarm (La Nuée)") // 0.66
  1357. * titleSimilarity({ imdb: "The Swarm", rt: "The Swarm (La Nuée)" }) // 2
  1358. *
  1359. * @param {Object} options
  1360. * @param {{ title: string, originalTitle: string }} options.imdb
  1361. * @param {{ title: string }} options.rt
  1362. */
  1363. function titleSimilarity ({ imdb, rt }) {
  1364. const rtTitle = rt.title
  1365. .trim()
  1366. .replace(/\s+/g, ' ') // remove extraneous spaces, e.g. tt2521668
  1367. .replace(/\s+\((?:US|UK|(?:(?:19|20)\d\d))\)$/, '')
  1368.  
  1369. if (imdb.originalTitle && imdb.title !== imdb.originalTitle) {
  1370. const match = rtTitle.match(/^(.+?)\s+\(([^)]+)\)$/)
  1371.  
  1372. if (match) {
  1373. const s1 = similarity(imdb.title, match[1])
  1374. const s2 = similarity(imdb.title, match[2])
  1375. const s3 = similarity(imdb.title, rtTitle)
  1376. return Math.max(s1, s2, s3)
  1377. } else {
  1378. const s1 = similarity(imdb.title, rtTitle)
  1379. const s2 = similarity(imdb.originalTitle, rtTitle)
  1380. return Math.max(s1, s2)
  1381. }
  1382. }
  1383.  
  1384. return similarity(imdb.title, rtTitle)
  1385. }
  1386.  
  1387. /**
  1388. * return true if the supplied arrays are similar (sufficiently overlap), false
  1389. * otherwise
  1390. *
  1391. * @param {Object} options
  1392. * @param {string} options.name
  1393. * @param {string[]} options.imdb
  1394. * @param {string[]} options.rt
  1395. */
  1396. function verifyShared ({ name, imdb, rt }) {
  1397. debug(`verifying ${name}`)
  1398. debug(`imdb ${name}:`, imdb)
  1399. debug(`rt ${name}:`, rt)
  1400. const $shared = shared(rt, imdb)
  1401. debug(`shared ${name}:`, $shared)
  1402. return $shared.got >= $shared.want
  1403. }
  1404.  
  1405. /******************************************************************************/
  1406.  
  1407. async function run () {
  1408. const now = Date.now()
  1409.  
  1410. // purgeCached(-1) // disable the cache
  1411. purgeCached(now)
  1412.  
  1413. const imdbId = $(`meta[property="imdb:pageConst"]`).attr('content')
  1414.  
  1415. if (!imdbId) {
  1416. // XXX shouldn't get here
  1417. console.error("can't find IMDb ID:", location.href)
  1418. return
  1419. }
  1420.  
  1421. log('id:', imdbId)
  1422.  
  1423. // we clone the IMDb widget, so make sure it exists before navigating up to
  1424. // its container
  1425. const $imdbRating = $('[data-testid="hero-rating-bar__aggregate-rating"]').first()
  1426.  
  1427. if (!$imdbRating.length) {
  1428. info(`can't find IMDb rating for ${imdbId}`)
  1429. return
  1430. }
  1431.  
  1432. const $ratings = $imdbRating.parent()
  1433.  
  1434. // get the cached result for this page
  1435. const cached = JSON.parse(GM_getValue(imdbId, 'null'))
  1436.  
  1437. if (cached) {
  1438. const expires = new Date(cached.expires).toLocaleString()
  1439.  
  1440. if (cached.error) {
  1441. log(`cached error (expires: ${expires}):`, cached.error)
  1442. } else {
  1443. log(`cached result (expires: ${expires}):`, cached.data)
  1444. addWidget($ratings, $imdbRating, cached.data)
  1445. }
  1446.  
  1447. return
  1448. } else {
  1449. log('not cached')
  1450. }
  1451.  
  1452. /** @type {keyof RT_TYPE} */
  1453. const imdbType = $(document).jsonLd(location.href)?.['@type']
  1454. const rtType = RT_TYPE[imdbType]
  1455.  
  1456. if (!rtType) {
  1457. info(`invalid type for ${imdbId}: ${imdbType}`)
  1458. return
  1459. }
  1460.  
  1461. const imdb = getIMDbMetadata(imdbId, rtType)
  1462.  
  1463. // do a basic sanity check to make sure it's valid
  1464. if (!imdb?.type) {
  1465. console.error(`can't find metadata for ${imdbId}`)
  1466. return
  1467. }
  1468.  
  1469. log('metadata:', imdb)
  1470.  
  1471. /**
  1472. * add a { version, expires, data|error } entry to the cache
  1473. *
  1474. * @param {any} dataOrError
  1475. * @param {number} ttl
  1476. */
  1477. const store = (dataOrError, ttl) => {
  1478. // don't cache results while debugging
  1479. if (DEBUG) {
  1480. return
  1481. }
  1482.  
  1483. const expires = now + ttl
  1484. const cached = { version: DATA_VERSION, expires, ...dataOrError }
  1485. const json = JSON.stringify(cached)
  1486.  
  1487. GM_setValue(imdbId, json)
  1488. }
  1489.  
  1490. /** @type {{ version: number, data: typeof STATS }} */
  1491. const stats = JSON.parse(GM_getValue('stats', 'null')) || {
  1492. version: METADATA_VERSION.stats,
  1493. data: clone(STATS),
  1494. }
  1495.  
  1496. /** @type {(path: string) => void} */
  1497. const bump = path => {
  1498. exports.dset(stats.data, path, get(stats.data, path, 0) + 1)
  1499. }
  1500.  
  1501. try {
  1502. const { data, matchUrl, preloadUrl, updated } = await getRTData(imdb, rtType)
  1503.  
  1504. log('RT data:', data)
  1505. bump('hit')
  1506. bump(matchUrl === preloadUrl ? 'preload.hit' : 'preload.miss')
  1507.  
  1508. let active = false
  1509.  
  1510. if (updated) {
  1511. dayjs.extend(dayjs_plugin_relativeTime)
  1512.  
  1513. const date = dayjs()
  1514. const ago = date.to(updated)
  1515. const delta = date.diff(updated, 'month', /* float */ true)
  1516.  
  1517. active = delta <= INACTIVE_MONTHS
  1518.  
  1519. log(`last update: ${updated.format(DATE_FORMAT)} (${ago})`)
  1520. }
  1521.  
  1522. if (active) {
  1523. log('caching result for: one day')
  1524. store({ data }, ONE_DAY)
  1525. } else {
  1526. log('caching result for: one week')
  1527. store({ data }, ONE_WEEK)
  1528. }
  1529.  
  1530. addWidget($ratings, $imdbRating, data)
  1531. } catch (error) {
  1532. bump('miss')
  1533. bump('preload.miss')
  1534.  
  1535. const message = error.message || String(error) // stringify
  1536.  
  1537. log(`caching error for one day: ${message}`)
  1538. store({ error: message }, ONE_DAY)
  1539.  
  1540. if (!error.abort) {
  1541. console.error(error)
  1542. }
  1543. } finally {
  1544. bump('requests')
  1545. debug('stats:', stats.data)
  1546. GM_setValue('stats', JSON.stringify(stats))
  1547. }
  1548. }
  1549.  
  1550. // register these first so data can be cleared even if there's an error
  1551. GM_registerMenuCommand(`${SCRIPT_NAME}: clear cache`, () => {
  1552. purgeCached(-1)
  1553. })
  1554.  
  1555. GM_registerMenuCommand(`${SCRIPT_NAME}: clear stats`, () => {
  1556. if (confirm('Clear stats?')) {
  1557. log('clearing stats')
  1558. GM_deleteValue('stats')
  1559. }
  1560. })
  1561.  
  1562. // DOMContentLoaded typically fires several seconds after the IMDb ratings
  1563. // widget is displayed, which leads to an unacceptable delay if the result is
  1564. // already cached, so we hook into the earliest event which fires after the
  1565. // widget is loaded.
  1566. //
  1567. // this occurs when document.readyState transitions from "loading" to
  1568. // "interactive", which should be the first readystatechange event a userscript
  1569. // sees. on my system, this can occur up to 4 seconds before DOMContentLoaded
  1570. $(document).one('readystatechange', run)
  1571.  
  1572. /* end */ }

QingJ © 2025

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