IMDb Tomatoes

Add Rotten Tomatoes ratings to IMDb movie and TV show pages

  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 7.2.3
  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.7.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.13/dayjs.min.js
  13. // @require https://unpkg.com/dayjs@1.11.13/plugin/relativeTime.js
  14. // @require https://unpkg.com/@chocolateboy/uncommonjs@3.2.1/dist/polyfill.iife.min.js
  15. // @require https://unpkg.com/@chocolatey/enumerator@1.1.1/dist/index.umd.min.js
  16. // @require https://unpkg.com/@chocolatey/when@1.2.0/dist/index.umd.min.js
  17. // @require https://unpkg.com/dset@3.1.4/dist/index.min.js
  18. // @require https://unpkg.com/fast-dice-coefficient@1.0.3/dice.js
  19. // @require https://unpkg.com/get-wild@3.0.2/dist/index.umd.min.js
  20. // @require https://unpkg.com/little-emitter@0.3.5/dist/emitter.js
  21. // @resource api https://pastebin.com/raw/absEYaJ8
  22. // @resource overrides https://pastebin.com/raw/sRQpz471
  23. // @grant GM_addStyle
  24. // @grant GM_deleteValue
  25. // @grant GM_getResourceText
  26. // @grant GM_getValue
  27. // @grant GM_listValues
  28. // @grant GM_registerMenuCommand
  29. // @grant GM_setValue
  30. // @grant GM_xmlhttpRequest
  31. // @grant GM_unregisterMenuCommand
  32. // @connect algolia.net
  33. // @connect www.rottentomatoes.com
  34. // @run-at document-start
  35. // @noframes
  36. // ==/UserScript==
  37.  
  38. /// <reference types="greasemonkey" />
  39. /// <reference types="tampermonkey" />
  40. /// <reference types="jquery" />
  41. /// <reference types="node" />
  42. /// <reference path="../types/imdb-tomatoes.user.d.ts" />
  43.  
  44. 'use strict';
  45.  
  46. /* begin */ {
  47.  
  48. const API_LIMIT = 100
  49. const CHANGE_TARGET = 'target:change'
  50. const DATA_VERSION = 1.3
  51. const DATE_FORMAT = 'YYYY-MM-DD'
  52. const DEBUG_KEY = 'debug'
  53. const DISABLE_CACHE = false
  54. const INACTIVE_MONTHS = 3
  55. const LD_JSON = 'script[type="application/ld+json"]'
  56. const MAX_YEAR_DIFF = 3
  57. const NO_CONSENSUS = 'No consensus yet.'
  58. const NO_MATCH = 'no matching results'
  59. const ONE_DAY = 1000 * 60 * 60 * 24
  60. const ONE_WEEK = ONE_DAY * 7
  61. const RT_BALLOON_CLASS = 'rt-consensus-balloon'
  62. const RT_BASE = 'https://www.rottentomatoes.com'
  63. const RT_WIDGET_CLASS = 'rt-rating'
  64. const STATS_KEY = 'stats'
  65. const TARGET_KEY = 'target'
  66.  
  67. /** @type {Record<string, number>} */
  68. const METADATA_VERSION = {
  69. [STATS_KEY]: 3,
  70. [TARGET_KEY]: 1,
  71. [DEBUG_KEY]: 1,
  72. }
  73.  
  74. const BALLOON_OPTIONS = {
  75. classname: RT_BALLOON_CLASS,
  76. css: {
  77. fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
  78. fontSize: '16px',
  79. lineHeight: '24px',
  80. maxWidth: '24rem',
  81. padding: '10px',
  82. },
  83. html: true,
  84. position: 'bottom',
  85. }
  86.  
  87. const COLOR = {
  88. tbd: '#d9d9d9',
  89. fresh: '#67ad4b',
  90. rotten: '#fb3c3c',
  91. }
  92.  
  93. const CONNECTION_ERROR = {
  94. status: 420,
  95. statusText: 'Connection Error',
  96. }
  97.  
  98. const ENABLE_DEBUGGING = JSON.stringify({
  99. data: true,
  100. version: METADATA_VERSION[DEBUG_KEY],
  101. })
  102.  
  103. const NEW_WINDOW = JSON.stringify({
  104. data: '_blank',
  105. version: METADATA_VERSION[TARGET_KEY],
  106. })
  107.  
  108. const RT_TYPE = /** @type {const} */ ({
  109. TVSeries: 'tvSeries',
  110. Movie: 'movie',
  111. })
  112.  
  113. const RT_TYPE_ID = /** @type {const} */ ({
  114. movie: 1,
  115. tvSeries: 2,
  116. })
  117.  
  118. const STATS = {
  119. requests: 0,
  120. hit: 0,
  121. miss: 0,
  122. preload: {
  123. hit: 0,
  124. miss: 0,
  125. },
  126. }
  127.  
  128. const UNSHARED = Object.freeze({
  129. got: -1,
  130. want: 1,
  131. max: 0,
  132. })
  133.  
  134. /**
  135. * an Event Emitter instance used to publish changes to the target for RT links
  136. * ("_blank" or "_self")
  137. *
  138. * @type {import("little-emitter")}
  139. */
  140. const EMITTER = new exports.Emitter()
  141.  
  142. /*
  143. * per-page performance metrics, only displayed when debugging is enabled
  144. */
  145. const PAGE_STATS = { titleComparisons: 0 }
  146.  
  147. /*
  148. * enable verbose logging
  149. */
  150. let DEBUG = JSON.parse(GM_getValue(DEBUG_KEY, 'false'))?.data || false
  151.  
  152. /*
  153. * log a message to the console
  154. */
  155. const { debug, log, warn } = console
  156.  
  157. /** @type {(...args: any[]) => void} */
  158. const trace = (...args) => {
  159. if (DEBUG) {
  160. if (args.length === 1 && typeof args[0] === 'function') {
  161. args = [].concat(args[0]())
  162. }
  163.  
  164. debug(...args)
  165. }
  166. }
  167.  
  168. /**
  169. * return the Cartesian product of items from a collection of arrays
  170. *
  171. * @type {(arrays: string[][]) => [string, string][]}
  172. */
  173. const cartesianProduct = exports.enumerator
  174.  
  175. /**
  176. * deep-clone a JSON-serializable value
  177. *
  178. * @type {<T>(value: T) => T}
  179. */
  180. const clone = value => JSON.parse(JSON.stringify(value))
  181.  
  182. /**
  183. * decode HTML entities, e.g.:
  184. *
  185. * from: "Bill &amp; Ted&apos;s Excellent Adventure"
  186. * to: "Bill & Ted's Excellent Adventure"
  187. *
  188. * @type {(html: string | undefined) => string}
  189. */
  190. const htmlDecode = (html) => {
  191. if (!html) {
  192. return ''
  193. }
  194.  
  195. const el = document.createElement('textarea')
  196. el.innerHTML = html
  197. return el.value
  198. }
  199.  
  200. /*
  201. * a custom version of get-wild's `get` function which uses a simpler/faster
  202. * path parser since we don't use the extended syntax
  203. */
  204. const get = exports.getter({ split: '.' })
  205.  
  206. /**
  207. * retrieve the target for RT links from GM storage, either "_self" (default)
  208. * or "_blank" (new window)
  209. *
  210. * @type {() => LinkTarget}
  211. */
  212. const getRTLinkTarget = () => JSON.parse(GM_getValue(TARGET_KEY, 'null'))?.data || '_self'
  213.  
  214. /**
  215. * extract JSON-LD data for the loaded document
  216. *
  217. * used to extract metadata on IMDb and Rotten Tomatoes
  218. *
  219. * @param {Document | HTMLScriptElement} el
  220. * @param {string} id
  221. */
  222. function jsonLd (el, id) {
  223. const script = el instanceof HTMLScriptElement
  224. ? el
  225. : el.querySelector(LD_JSON)
  226.  
  227. let data
  228.  
  229. if (script) {
  230. try {
  231. const json = /** @type {string} */ (script.textContent)
  232. data = JSON.parse(json.trim())
  233. } catch (e) {
  234. throw new Error(`Can't parse JSON-LD data for ${id}: ${e}`)
  235. }
  236. } else {
  237. throw new Error(`Can't find JSON-LD data for ${id}`)
  238. }
  239.  
  240. return data
  241. }
  242.  
  243. const BaseMatcher = {
  244. /**
  245. * return the consensus from an RT page as a HTML string
  246. *
  247. * @param {RTDoc} $rt
  248. * @return {string}
  249. */
  250. consensus ($rt) {
  251. return $rt.find('#critics-consensus p').html()
  252. },
  253.  
  254. /**
  255. * return the last time an RT page was updated based on its most recently
  256. * published review
  257. *
  258. * @param {RTDoc} $rt
  259. * @return {DayJs | undefined}
  260. */
  261. lastModified ($rt) {
  262. return $rt.find('.critics-reviews rt-text[slot="createDate"] span')
  263. .get()
  264. .map(el => dayjs($(el).text().trim()))
  265. .sort((a, b) => b.unix() - a.unix())
  266. .shift()
  267. },
  268.  
  269. rating ($rt) {
  270. const rating = parseInt($rt.meta?.aggregateRating?.ratingValue)
  271. return rating >= 0 ? rating : -1
  272. },
  273. }
  274.  
  275. const MovieMatcher = {
  276. /**
  277. * return a movie record ({ url: string }) from the API results which
  278. * matches the supplied IMDb data
  279. *
  280. * @param {any} imdb
  281. * @param {RTMovieResult[]} rtResults
  282. */
  283. match (imdb, rtResults) {
  284. const sharedWithImdb = shared(imdb.cast)
  285.  
  286. const sorted = rtResults
  287. .flatMap((rt, index) => {
  288. // XXX the order of these tests matters: do fast, efficient
  289. // checks first to reduce the number of results for the more
  290. // expensive checks to process
  291.  
  292. const { title, vanity: slug } = rt
  293.  
  294. if (!(title && slug)) {
  295. warn('invalid result:', rt)
  296. return []
  297. }
  298.  
  299. const rtYear = rt.releaseYear ? Number(rt.releaseYear) : null
  300. const yearDiff = (imdb.year && rtYear)
  301. ? { value: Math.abs(imdb.year - rtYear) }
  302. : null
  303.  
  304. if (yearDiff && yearDiff.value > MAX_YEAR_DIFF) {
  305. return []
  306. }
  307.  
  308. /** @type {Shared} */
  309. let castMatch = UNSHARED
  310. let verify = true
  311.  
  312. const rtCast = pluck(rt.cast, 'name')
  313.  
  314. if (rtCast.length) {
  315. const fullShared = sharedWithImdb(rtCast)
  316.  
  317. if (fullShared.got >= fullShared.want) {
  318. verify = false
  319. castMatch = fullShared
  320. } else if (fullShared.got) {
  321. // fall back to matching IMDb's main cast (e.g. 2/3) if
  322. // the full-cast match fails (e.g. 8/18)
  323. const mainShared = shared(imdb.mainCast, rtCast)
  324.  
  325. if (mainShared.got >= mainShared.want) {
  326. verify = false
  327. castMatch = mainShared
  328. castMatch.full = fullShared
  329. } else {
  330. return []
  331. }
  332. } else {
  333. return []
  334. }
  335. }
  336.  
  337. const rtRating = rt.rottenTomatoes?.criticsScore
  338. const url = `/m/${slug}`
  339.  
  340. // XXX the title is in the AKA array, but a) we don't want to
  341. // assume that and b) it's not usually first
  342. const rtTitles = rt.aka ? [...new Set([title, ...rt.aka])] : [title]
  343.  
  344. // XXX only called after the other checks have filtered out
  345. // non-matches, so the number of comparisons remains small
  346. // (usually 1 or 2, and seldom more than 3, even with 100 results)
  347. const titleMatch = titleSimilarity(imdb.titles, rtTitles)
  348.  
  349. const result = {
  350. title,
  351. url,
  352. year: rtYear,
  353. cast: rtCast,
  354. titleMatch,
  355. castMatch,
  356. yearDiff,
  357. rating: rtRating,
  358. titles: rtTitles,
  359. popularity: rt.pageViews_popularity ?? 0,
  360. updated: rt.updateDate,
  361. index,
  362. verify,
  363. }
  364.  
  365. return [result]
  366. })
  367. .sort((a, b) => {
  368. // combine the title and the year into a single score
  369. //
  370. // being a year or two out shouldn't be a dealbreaker, and it's
  371. // not uncommon for an RT title to differ from the IMDb title
  372. // (e.g. an AKA), so we don't want one of these to pre-empt the
  373. // other (yet)
  374. const score = new Score()
  375.  
  376. score.add(b.titleMatch - a.titleMatch)
  377.  
  378. if (a.yearDiff && b.yearDiff) {
  379. score.add(a.yearDiff.value - b.yearDiff.value)
  380. }
  381.  
  382. const popularity = (a.popularity && b.popularity)
  383. ? b.popularity - a.popularity
  384. : 0
  385.  
  386. return (b.castMatch.got - a.castMatch.got)
  387. || (score.b - score.a)
  388. || (b.titleMatch - a.titleMatch) // prioritise the title if we're still deadlocked
  389. || popularity // last resort
  390. })
  391.  
  392. debug('matches:', sorted)
  393.  
  394. return sorted[0]
  395. },
  396.  
  397. /**
  398. * return the likely RT path for an IMDb movie title, e.g.:
  399. *
  400. * title: "Bolt"
  401. * path: "/m/bolt"
  402. *
  403. * @param {string} title
  404. */
  405. rtPath (title) {
  406. return `/m/${rtName(title)}`
  407. },
  408.  
  409. /**
  410. * confirm the supplied RT page data matches the IMDb metadata
  411. *
  412. * @param {any} imdb
  413. * @param {RTDoc} $rt
  414. * @return {boolean}
  415. */
  416. verify (imdb, $rt) {
  417. log('verifying movie')
  418.  
  419. // match the director(s)
  420. const rtDirectors = pluck($rt.meta.director, 'name')
  421.  
  422. return verifyShared({
  423. name: 'directors',
  424. imdb: imdb.directors,
  425. rt: rtDirectors,
  426. })
  427. },
  428. }
  429.  
  430. const TVMatcher = {
  431. /**
  432. * return a TV show record ({ url: string }) from the API results which
  433. * matches the supplied IMDb data
  434. *
  435. * @param {any} imdb
  436. * @param {RTTVResult[]} rtResults
  437. */
  438. match (imdb, rtResults) {
  439. const sharedWithImdb = shared(imdb.cast)
  440.  
  441. const sorted = rtResults
  442. .flatMap((rt, index) => {
  443. // XXX the order of these tests matters: do fast, efficient
  444. // checks first to reduce the number of results for the more
  445. // expensive checks to process
  446.  
  447. const { title, vanity: slug } = rt
  448.  
  449. if (!(title && slug)) {
  450. warn('invalid result:', rt)
  451. return []
  452. }
  453.  
  454. const startYear = rt.releaseYear ? Number(rt.releaseYear) : null
  455. const startYearDiff = (imdb.startYear && startYear)
  456. ? { value: Math.abs(imdb.startYear - startYear) }
  457. : null
  458.  
  459. if (startYearDiff && startYearDiff.value > MAX_YEAR_DIFF) {
  460. return []
  461. }
  462.  
  463. const endYear = rt.seriesFinale ? dayjs(rt.seriesFinale).year() : null
  464. const endYearDiff = (imdb.endYear && endYear)
  465. ? { value: Math.abs(imdb.endYear - endYear) }
  466. : null
  467.  
  468. if (endYearDiff && endYearDiff.value > MAX_YEAR_DIFF) {
  469. return []
  470. }
  471.  
  472. const seasons = rt.seasons || []
  473. const seasonsDiff = (imdb.seasons && seasons.length)
  474. ? { value: Math.abs(imdb.seasons - seasons.length) }
  475. : null
  476.  
  477. /** @type {Shared} */
  478. let castMatch = UNSHARED
  479. let verify = true
  480.  
  481. const rtCast = pluck(rt.cast, 'name')
  482.  
  483. if (rtCast.length) {
  484. const fullShared = sharedWithImdb(rtCast)
  485.  
  486. if (fullShared.got >= fullShared.want) {
  487. verify = false
  488. castMatch = fullShared
  489. } else if (fullShared.got) {
  490. // fall back to matching IMDb's main cast (e.g. 2/3) if
  491. // the full-cast match fails (e.g. 8/18)
  492. const mainShared = shared(imdb.mainCast, rtCast)
  493.  
  494. if (mainShared.got >= mainShared.want) {
  495. verify = false
  496. castMatch = mainShared
  497. castMatch.full = fullShared
  498. } else {
  499. return []
  500. }
  501. } else {
  502. return []
  503. }
  504. }
  505.  
  506. const rtRating = rt.rottenTomatoes?.criticsScore
  507. const url = `/tv/${slug}/s01`
  508.  
  509. // XXX the title is in the AKA array, but a) we don't want to
  510. // assume that and b) it's not usually first
  511. const rtTitles = rt.aka ? [...new Set([title, ...rt.aka])] : [title]
  512.  
  513. // XXX only called after the other checks have filtered out
  514. // non-matches, so the number of comparisons remains small
  515. // (usually 1 or 2, and seldom more than 3, even with 100 results)
  516. const titleMatch = titleSimilarity(imdb.titles, rtTitles)
  517.  
  518. const result = {
  519. title,
  520. url,
  521. startYear,
  522. endYear,
  523. seasons: seasons.length,
  524. cast: rtCast,
  525. titleMatch,
  526. castMatch,
  527. startYearDiff,
  528. endYearDiff,
  529. seasonsDiff,
  530. rating: rtRating,
  531. titles: rtTitles,
  532. popularity: rt.pageViews_popularity ?? 0,
  533. index,
  534. updated: rt.updateDate,
  535. verify,
  536. }
  537.  
  538. return [result]
  539. })
  540. .sort((a, b) => {
  541. const score = new Score()
  542.  
  543. score.add(b.titleMatch - a.titleMatch)
  544.  
  545. if (a.startYearDiff && b.startYearDiff) {
  546. score.add(a.startYearDiff.value - b.startYearDiff.value)
  547. }
  548.  
  549. if (a.endYearDiff && b.endYearDiff) {
  550. score.add(a.endYearDiff.value - b.endYearDiff.value)
  551. }
  552.  
  553. if (a.seasonsDiff && b.seasonsDiff) {
  554. score.add(a.seasonsDiff.value - b.seasonsDiff.value)
  555. }
  556.  
  557. const popularity = (a.popularity && b.popularity)
  558. ? b.popularity - a.popularity
  559. : 0
  560.  
  561. return (b.castMatch.got - a.castMatch.got)
  562. || (score.b - score.a)
  563. || (b.titleMatch - a.titleMatch) // prioritise the title if we're still deadlocked
  564. || popularity // last resort
  565. })
  566.  
  567. debug('matches:', sorted)
  568.  
  569. return sorted[0] // may be undefined
  570. },
  571.  
  572. /**
  573. * return the likely RT path for an IMDb TV show title, e.g.:
  574. *
  575. * title: "Sesame Street"
  576. * path: "/tv/sesame_street/s01"
  577. *
  578. * @param {string} title
  579. */
  580. rtPath (title) {
  581. return `/tv/${rtName(title)}/s01`
  582. },
  583.  
  584. /**
  585. * confirm the supplied RT page data matches the IMDb data
  586. *
  587. * @param {any} imdb
  588. * @param {RTDoc} $rt
  589. * @return {boolean}
  590. */
  591. verify (imdb, $rt) {
  592. log('verifying TV show')
  593.  
  594. // match the genre(s) AND release date
  595. if (!(imdb.genres.length && imdb.releaseDate)) {
  596. return false
  597. }
  598.  
  599. const rtGenres = ($rt.meta.genre || [])
  600. .flatMap(it => it === 'Mystery & Thriller' ? it.split(' & ') : [it])
  601.  
  602. if (!rtGenres.length) {
  603. return false
  604. }
  605.  
  606. const matchedGenres = verifyShared({
  607. name: 'genres',
  608. imdb: imdb.genres,
  609. rt: rtGenres,
  610. })
  611.  
  612. if (!matchedGenres) {
  613. return false
  614. }
  615.  
  616. debug('verifying release date')
  617.  
  618. const startDate = get($rt.meta, 'partOfSeries.startDate')
  619.  
  620. if (!startDate) {
  621. return false
  622. }
  623.  
  624. const rtReleaseDate = dayjs(startDate).format(DATE_FORMAT)
  625.  
  626. debug('imdb release date:', imdb.releaseDate)
  627. debug('rt release date:', rtReleaseDate)
  628.  
  629. return rtReleaseDate === imdb.releaseDate
  630. }
  631. }
  632.  
  633. const Matcher = {
  634. tvSeries: TVMatcher,
  635. movie: MovieMatcher,
  636. }
  637.  
  638. /*
  639. * a helper class used to load and verify data from RT pages which transparently
  640. * handles the selection of the most suitable URL, either from the API (match)
  641. * or guessed from the title (fallback)
  642. */
  643. class RTClient {
  644. /**
  645. * @param {Object} options
  646. * @param {any} options.match
  647. * @param {Matcher[keyof Matcher]} options.matcher
  648. * @param {any} options.preload
  649. * @param {RTState} options.state
  650. */
  651. constructor ({ match, matcher, preload, state }) {
  652. this.match = match
  653. this.matcher = matcher
  654. this.preload = preload
  655. this.state = state
  656. }
  657.  
  658. /**
  659. * transform an XHR response into a JQuery document wrapper with a +meta+
  660. * property containing the page's parsed JSON metadata
  661. *
  662. * @param {Tampermonkey.Response<any>} res
  663. * @param {string} id
  664. * @return {RTDoc}
  665. */
  666. _parseResponse (res, id) {
  667. const parser = new DOMParser()
  668. const dom = parser.parseFromString(res.responseText, 'text/html')
  669. const $rt = $(dom)
  670. const meta = jsonLd(dom, id)
  671. return Object.assign($rt, { meta, document: dom })
  672. }
  673.  
  674. /**
  675. * confirm the metadata of the RT page (match or fallback) matches the IMDb
  676. * data
  677. *
  678. * @param {any} imdb
  679. * @param {RTDoc} rtPage
  680. * @param {boolean} fallbackUnused
  681. * @return {Promise<{ verified: boolean, rtPage: RTDoc }>}
  682. */
  683. async _verify (imdb, rtPage, fallbackUnused) {
  684. const { match, matcher, preload, state } = this
  685.  
  686. let verified = matcher.verify(imdb, rtPage)
  687.  
  688. if (!verified) {
  689. if (match.force) {
  690. log('forced:', true)
  691. verified = true
  692. } else if (fallbackUnused) {
  693. state.url = preload.fullUrl
  694. log('loading fallback URL:', preload.fullUrl)
  695.  
  696. const res = await preload.request
  697.  
  698. if (res) {
  699. log(`fallback response: ${res.status} ${res.statusText}`)
  700. rtPage = this._parseResponse(res, preload.url)
  701. verified = matcher.verify(imdb, rtPage)
  702. } else {
  703. log(`error loading ${preload.fullUrl} (${preload.error.status} ${preload.error.statusText})`)
  704. }
  705. }
  706. }
  707.  
  708. log('verified:', verified)
  709.  
  710. return { verified, rtPage }
  711. }
  712.  
  713. /**
  714. * load the RT URL (match or fallback) and return the resulting RT page
  715. *
  716. * @param {any} imdb
  717. * @return {Promise<RTDoc | void>}
  718. */
  719. async loadPage (imdb) {
  720. const { match, preload, state } = this
  721. let requestType = match.fallback ? 'fallback' : 'match'
  722. let verify = match.verify
  723. let fallbackUnused = false
  724. let res
  725.  
  726. log(`loading ${requestType} URL:`, state.url)
  727.  
  728. // match URL (API result) and fallback URL (guessed) are the same
  729. if (match.url === preload.url) {
  730. res = await preload.request // join the in-flight request
  731. } else { // different match URL and fallback URL
  732. try {
  733. res = await asyncGet(state.url) // load the (absolute) match URL
  734. fallbackUnused = true // only set if the request succeeds
  735. } catch (error) { // bogus URL in API result (or transient server error)
  736. log(`error loading ${state.url} (${error.status} ${error.statusText})`)
  737.  
  738. if (match.force) { // URL locked in checkOverrides, so nothing to fall back to
  739. return
  740. } else { // use (and verify) the fallback URL
  741. requestType = 'fallback'
  742. state.url = preload.fullUrl
  743. verify = true
  744.  
  745. log(`loading ${requestType} URL:`, state.url)
  746.  
  747. res = await preload.request
  748. }
  749. }
  750. }
  751.  
  752. if (!res) {
  753. log(`error loading ${state.url} (${preload.error.status} ${preload.error.statusText})`)
  754. return
  755. }
  756.  
  757. log(`${requestType} response: ${res.status} ${res.statusText}`)
  758.  
  759. let rtPage = this._parseResponse(res, state.url)
  760.  
  761. if (verify) {
  762. const { verified, rtPage: newRtPage } = await this._verify(
  763. imdb,
  764. rtPage,
  765. fallbackUnused
  766. )
  767.  
  768. if (!verified) {
  769. return
  770. }
  771.  
  772. rtPage = newRtPage
  773. }
  774.  
  775. return rtPage
  776. }
  777. }
  778.  
  779. /*
  780. * a helper class which keeps a running total of scores for two values (a and
  781. * b). used to rank values in a sort function
  782. */
  783. class Score {
  784. constructor () {
  785. this.a = 0
  786. this.b = 0
  787. }
  788.  
  789. /**
  790. * add a score to the total
  791. *
  792. * @param {number} order
  793. * @param {number=} points
  794. */
  795. add (order, points = 1) {
  796. if (order < 0) {
  797. this.a += points
  798. } else if (order > 0) {
  799. this.b += points
  800. }
  801. }
  802. }
  803.  
  804. /******************************************************************************/
  805.  
  806. /**
  807. * raise a non-error exception indicating no matching result has been found
  808. *
  809. * @param {string} message
  810. */
  811.  
  812. // XXX return an error object rather than throwing it to work around a
  813. // TypeScript bug: https://github.com/microsoft/TypeScript/issues/31329
  814. function abort (message = NO_MATCH) {
  815. return Object.assign(new Error(message), { abort: true })
  816. }
  817.  
  818. /**
  819. * add Rotten Tomatoes widgets to the desktop/mobile ratings bars
  820. *
  821. * @param {Object} data
  822. * @param {string} data.url
  823. * @param {string} data.consensus
  824. * @param {number} data.rating
  825. */
  826. async function addWidgets ({ consensus, rating, url }) {
  827. trace('adding RT widgets')
  828. const imdbRatings = await waitFor('IMDb widgets', () => {
  829. /** @type {NodeListOf<HTMLElement>} */
  830. const ratings = document.querySelectorAll('[data-testid="hero-rating-bar__aggregate-rating"]')
  831. return ratings.length > 1 ? ratings : null
  832. })
  833. trace('found IMDb widgets')
  834.  
  835. const balloonOptions = Object.assign({}, BALLOON_OPTIONS, { contents: consensus })
  836. const score = rating === -1 ? 'N/A' : `${rating}%`
  837. const rtLinkTarget = getRTLinkTarget()
  838.  
  839. /** @type {"tbd" | "rotten" | "fresh"} */
  840. let style
  841.  
  842. if (rating === -1) {
  843. style = 'tbd'
  844. } else if (rating < 60) {
  845. style = 'rotten'
  846. } else {
  847. style = 'fresh'
  848. }
  849.  
  850. // add a custom stylesheet which:
  851. //
  852. // - sets the star (SVG) to the right color
  853. // - reorders the appended widget (see attachWidget)
  854. // - restores support for italics in the consensus text
  855. GM_addStyle(`
  856. .${RT_WIDGET_CLASS} svg { color: ${COLOR[style]}; }
  857. .${RT_WIDGET_CLASS} { order: -1; }
  858. .${RT_BALLOON_CLASS} em { font-style: italic; }
  859. `)
  860.  
  861. // the markup for the small (e.g. mobile) and large (e.g. desktop) IMDb
  862. // ratings widgets is exactly the same - they only differ in the way they're
  863. // (externally) styled
  864. for (let i = 0; i < imdbRatings.length; ++i) {
  865. const imdbRating = imdbRatings.item(i)
  866. const $imdbRating = $(imdbRating)
  867. const $ratings = $imdbRating.parent()
  868.  
  869. // clone the IMDb rating widget
  870. const $rtRating = $imdbRating.clone()
  871.  
  872. // 1) assign a unique class for styling
  873. $rtRating.addClass(RT_WIDGET_CLASS)
  874.  
  875. // 2) replace "IMDb Rating" with "RT Rating"
  876. $rtRating.children().first().text('RT RATING')
  877.  
  878. // 3) remove the review count and its preceding spacer element
  879. const $score = $rtRating.find('[data-testid="hero-rating-bar__aggregate-rating__score"]')
  880. $score.nextAll().remove()
  881.  
  882. // 4) replace the IMDb rating with the RT score and remove the "/ 10" suffix
  883. $score.children().first().text(score).nextAll().remove()
  884.  
  885. // 5) rename the testids, e.g.:
  886. // hero-rating-bar__aggregate-rating -> hero-rating-bar__rt-rating
  887. $rtRating.find('[data-testid]').addBack().each((_index, el) => {
  888. $(el).attr('data-testid', (_index, id) => id.replace('aggregate', 'rt'))
  889. })
  890.  
  891. // 6) update the link's label and URL
  892. const $link = $rtRating.find('a[href]')
  893. $link.attr({ 'aria-label': 'View RT Rating', href: url, target: rtLinkTarget })
  894.  
  895. // 7) observe changes to the link's target
  896. EMITTER.on(CHANGE_TARGET, (/** @type {LinkTarget} */ target) => $link.prop('target', target))
  897.  
  898. // 8) attach the tooltip to the widget
  899. $rtRating.balloon(balloonOptions)
  900.  
  901. // 9) prepend the widget to the ratings bar
  902. attachWidget($ratings.get(0), $rtRating.get(0), i)
  903. }
  904.  
  905. trace('added RT widgets')
  906. }
  907.  
  908. /**
  909. * promisified cross-origin HTTP requests
  910. *
  911. * @param {string} url
  912. * @param {AsyncGetOptions} [options]
  913. */
  914. function asyncGet (url, options = {}) {
  915. if (options.params) {
  916. url = url + '?' + $.param(options.params)
  917. }
  918.  
  919. const id = options.title || url
  920. const request = Object.assign({ method: 'GET', url }, options.request || {})
  921.  
  922. return new Promise((resolve, reject) => {
  923. request.onload = res => {
  924. if (res.status >= 400) {
  925. const error = Object.assign(
  926. new Error(`error fetching ${id} (${res.status} ${res.statusText})`),
  927. { status: res.status, statusText: res.statusText }
  928. )
  929.  
  930. reject(error)
  931. } else {
  932. resolve(res)
  933. }
  934. }
  935.  
  936. // XXX apart from +finalUrl+, the +onerror+ response object doesn't
  937. // contain any useful info
  938. request.onerror = _res => {
  939. const { status, statusText } = CONNECTION_ERROR
  940. const error = Object.assign(
  941. new Error(`error fetching ${id} (${status} ${statusText})`),
  942. { status, statusText },
  943. )
  944.  
  945. reject(error)
  946. }
  947.  
  948. GM_xmlhttpRequest(request)
  949. })
  950. }
  951.  
  952. /**
  953. * attach an RT ratings widget to a ratings bar
  954. *
  955. * although the widget appears to be prepended to the bar, we need to append it
  956. * (and reorder it via CSS) to work around React reconciliation (updating the
  957. * DOM to match the (virtual DOM representation of the) underlying model) after
  958. * we've added the RT widget
  959. *
  960. * when this synchronisation occurs, React will try to restore nodes
  961. * (attributes, text, elements) within each widget to match the widget's props,
  962. * so the first widget will be updated in place to match the data for the IMDb
  963. * rating etc. this changes some, but not all nodes within an element, and most
  964. * attributes added to/changed in a prepended RT widget remain when it's
  965. * reverted back to an IMDb widget, including its class (rt-rating), which
  966. * controls the color of the rating star. as a result, we end up with a restored
  967. * IMDb widget but with an RT-colored star (and with the RT widget removed since
  968. * it's not in the ratings-bar model)
  969. *
  970. * if we *append* the RT widget, none of the other widgets will need to be
  971. * changed/updated if the DOM is re-synced, so we won't end up with a mangled
  972. * IMDb widget; however, our RT widget will still be removed since it's not in
  973. * the model. to rectify this, we use a mutation observer to detect and revert
  974. * its removal (which happens no more than once - the ratings bar is frozen
  975. * (i.e. synchronisation is halted) once the page has loaded)
  976. *
  977. * @param {HTMLElement | undefined} target
  978. * @param {HTMLElement | undefined} rtRating
  979. * @param {number} index
  980. */
  981. function attachWidget (target, rtRating, index) {
  982. if (!target) {
  983. throw new ReferenceError("can't find ratings bar")
  984. }
  985.  
  986. if (!rtRating) {
  987. throw new ReferenceError("can't find RT widget")
  988. }
  989.  
  990. const ids = ['rt-rating-large', 'rt-rating-small']
  991. const id = ids[index]
  992. const init = { childList: true, subtree: true }
  993. const $ = document.body
  994.  
  995. rtRating.id = id
  996.  
  997. // restore the RT widget if it's removed
  998. //
  999. // work around the fact that the target element (the ratings bar) can be
  1000. // completely blown away and rebuilt (so we can't scope our observer to it)
  1001. //
  1002. // even with this caveat, I haven't seen the widgets removed more than twice
  1003. // (or more than once if the result isn't cached), so we could turn off the
  1004. // observer after the second restoration
  1005. const callback = () => {
  1006. observer.disconnect()
  1007.  
  1008. const imdbWidgets = $.querySelectorAll('[data-testid="hero-rating-bar__aggregate-rating"]')
  1009. const imdbWidget = imdbWidgets.item(index)
  1010. const ratingsBar = imdbWidget.parentElement
  1011. const rtWidget = ratingsBar.querySelector(`:scope #${id}`)
  1012.  
  1013. if (!rtWidget) {
  1014. ratingsBar.appendChild(rtRating)
  1015. }
  1016.  
  1017. observer.observe($, init)
  1018. }
  1019.  
  1020. const observer = new MutationObserver(callback)
  1021. callback()
  1022. }
  1023.  
  1024. /**
  1025. * check the override data in case of a failed match, but only use it as a last
  1026. * resort, i.e. try the verifier first in case the page data has been
  1027. * fixed/updated
  1028. *
  1029. * @param {any} match
  1030. * @param {string} imdbId
  1031. */
  1032. function checkOverrides (match, imdbId) {
  1033. const overrides = JSON.parse(GM_getResourceText('overrides'))
  1034. const url = overrides[imdbId]
  1035.  
  1036. if (url) {
  1037. const $url = JSON.stringify(url)
  1038.  
  1039. if (!match) { // missing result
  1040. debug('fallback:', $url)
  1041. match = { url }
  1042. } else if (match.url !== url) { // wrong result
  1043. const $overridden = JSON.stringify(match.url)
  1044. debug(`override: ${$overridden} -> ${$url}`)
  1045. match.url = url
  1046. }
  1047.  
  1048. Object.assign(match, { verify: true, force: true })
  1049. }
  1050.  
  1051. return match
  1052. }
  1053.  
  1054. /**
  1055. * extract IMDb metadata from the props embedded in the page
  1056. *
  1057. * @param {string} imdbId
  1058. * @param {string} rtType
  1059. */
  1060. async function getIMDbMetadata (imdbId, rtType) {
  1061. trace('waiting for props')
  1062. const json = await waitFor('props', () => {
  1063. return document.getElementById('__NEXT_DATA__')?.textContent?.trim()
  1064. })
  1065. trace('got props:', json.length)
  1066.  
  1067. const data = JSON.parse(json)
  1068. const main = get(data, 'props.pageProps.mainColumnData')
  1069. const extra = get(data, 'props.pageProps.aboveTheFoldData')
  1070. const cast = get(main, 'cast.edges.*.node.name.nameText.text', [])
  1071. const mainCast = get(extra, 'castPageTitle.edges.*.node.name.nameText.text', [])
  1072. const type = get(main, 'titleType.id', '')
  1073. const title = get(main, 'titleText.text', '')
  1074. const originalTitle = get(main, 'originalTitleText.text', title)
  1075. const titles = title === originalTitle ? [title] : [title, originalTitle]
  1076. const genres = get(extra, 'genres.genres.*.text', [])
  1077. const year = get(extra, 'releaseYear.year') || 0
  1078. const $releaseDate = get(extra, 'releaseDate')
  1079.  
  1080. let releaseDate = null
  1081.  
  1082. if ($releaseDate) {
  1083. const date = new Date(
  1084. $releaseDate.year,
  1085. $releaseDate.month - 1,
  1086. $releaseDate.day
  1087. )
  1088.  
  1089. releaseDate = dayjs(date).format(DATE_FORMAT)
  1090. }
  1091.  
  1092. /** @type {Record<string, any>} */
  1093. const meta = {
  1094. id: imdbId,
  1095. type,
  1096. title,
  1097. originalTitle,
  1098. titles,
  1099. cast,
  1100. mainCast,
  1101. genres,
  1102. releaseDate,
  1103. }
  1104.  
  1105. if (rtType === 'tvSeries') {
  1106. meta.startYear = year
  1107. meta.endYear = get(extra, 'releaseYear.endYear') || 0
  1108. meta.seasons = get(main, 'episodes.seasons.length') || 0
  1109. meta.creators = get(main, 'creators.*.credits.*.name.nameText.text', [])
  1110. } else if (rtType === 'movie') {
  1111. meta.directors = get(main, 'directors.*.credits.*.name.nameText.text', [])
  1112. meta.writers = get(main, 'writers.*.credits.*.name.nameText.text', [])
  1113. meta.year = year
  1114. }
  1115.  
  1116. return meta
  1117. }
  1118.  
  1119. /**
  1120. * query the API, parse its response and extract the RT rating and consensus.
  1121. *
  1122. * if there's no consensus, default to "No consensus yet."
  1123. * if there's no rating, default to -1
  1124. *
  1125. * @param {string} imdbId
  1126. * @param {string} title
  1127. * @param {keyof Matcher} rtType
  1128. */
  1129. async function getRTData (imdbId, title, rtType) {
  1130. const matcher = Matcher[rtType]
  1131.  
  1132. // we preload the anticipated RT page URL at the same time as the API request.
  1133. // the URL is the obvious path-formatted version of the IMDb title, e.g.:
  1134. //
  1135. // movie: "Bolt"
  1136. // preload URL: https://www.rottentomatoes.com/m/bolt
  1137. //
  1138. // tvSeries: "Sesame Street"
  1139. // preload URL: https://www.rottentomatoes.com/tv/sesame_street
  1140. //
  1141. // this guess produces the correct URL most (~70%) of the time
  1142. //
  1143. // preloading this page serves two purposes:
  1144. //
  1145. // 1) it reduces the time spent waiting for the RT widget to be displayed.
  1146. // rather than querying the API and *then* loading the page, the requests
  1147. // run concurrently, effectively halving the waiting time in most cases
  1148. //
  1149. // 2) it serves as a fallback if the API URL:
  1150. //
  1151. // a) is missing
  1152. // b) is invalid/fails to load
  1153. // c) is wrong (fails the verification check)
  1154. //
  1155. const preload = (function () {
  1156. const path = matcher.rtPath(title)
  1157. const url = RT_BASE + path
  1158.  
  1159. debug('preloading fallback URL:', url)
  1160.  
  1161. /** @type {Promise<Tampermonkey.Response<any>>} */
  1162. const request = asyncGet(url)
  1163. .then(res => {
  1164. debug(`preload response: ${res.status} ${res.statusText}`)
  1165. return res
  1166. })
  1167. .catch(e => {
  1168. debug(`error preloading ${url} (${e.status} ${e.statusText})`)
  1169. preload.error = e
  1170. })
  1171.  
  1172. return {
  1173. error: null,
  1174. fullUrl: url,
  1175. request,
  1176. url: path,
  1177. }
  1178. })()
  1179.  
  1180. const typeId = RT_TYPE_ID[rtType]
  1181. const template = GM_getResourceText('api')
  1182. const json = template
  1183. .replace('{{apiLimit}}', String(API_LIMIT))
  1184. .replace('{{typeId}}', String(typeId))
  1185.  
  1186. const { api, params, search, data } = JSON.parse(json)
  1187.  
  1188. const unquoted = title
  1189. .replace(/"/g, ' ')
  1190. .replace(/\s+/g, ' ')
  1191. .trim()
  1192.  
  1193. const query = JSON.stringify(unquoted)
  1194.  
  1195. for (const [key, value] of Object.entries(search)) {
  1196. if (value && typeof value === 'object') {
  1197. search[key] = JSON.stringify(value)
  1198. }
  1199. }
  1200.  
  1201. Object.assign(data.requests[0], {
  1202. query,
  1203. params: $.param(search),
  1204. })
  1205.  
  1206. /** @type {AsyncGetOptions} */
  1207. const request = {
  1208. title: 'API',
  1209. params,
  1210. request: {
  1211. method: 'POST',
  1212. responseType: 'json',
  1213. data: JSON.stringify(data),
  1214. },
  1215. }
  1216.  
  1217. log(`querying API for ${query}`)
  1218.  
  1219. /** @type {Tampermonkey.Response<any>} */
  1220. const res = await asyncGet(api, request)
  1221.  
  1222. log(`API response: ${res.status} ${res.statusText}`)
  1223.  
  1224. let results
  1225.  
  1226. try {
  1227. results = JSON.parse(res.responseText).results[0].hits
  1228. } catch (e) {
  1229. throw new Error(`can't parse response: ${e}`)
  1230. }
  1231.  
  1232. if (!Array.isArray(results)) {
  1233. throw new TypeError('invalid response type')
  1234. }
  1235.  
  1236. // reorder the fields so the main fields are visible in the console without
  1237. // needing to expand each result
  1238. for (let i = 0; i < results.length; ++i) {
  1239. const result = results[i]
  1240.  
  1241. results[i] = {
  1242. title: result.title,
  1243. releaseYear: result.releaseYear,
  1244. vanity: result.vanity,
  1245. ...result
  1246. }
  1247. }
  1248.  
  1249. debug('results:', results)
  1250.  
  1251. const imdb = await getIMDbMetadata(imdbId, rtType)
  1252.  
  1253. // do a basic sanity check to make sure it's valid
  1254. if (!imdb?.type) {
  1255. throw new Error(`can't find metadata for ${imdbId}`)
  1256. }
  1257.  
  1258. log('metadata:', imdb)
  1259. const matched = matcher.match(imdb, results)
  1260. const match = checkOverrides(matched, imdbId) || {
  1261. url: preload.url,
  1262. verify: true,
  1263. fallback: true,
  1264. }
  1265.  
  1266. debug('match:', match)
  1267. log('matched:', !match.fallback)
  1268.  
  1269. // values that can be modified by the RT client
  1270. /** @type {RTState} */
  1271. const state = {
  1272. url: RT_BASE + match.url
  1273. }
  1274.  
  1275. const rtClient = new RTClient({ match, matcher, preload, state })
  1276. const $rt = await rtClient.loadPage(imdb)
  1277.  
  1278. if (!$rt) {
  1279. throw abort()
  1280. }
  1281.  
  1282. const rating = BaseMatcher.rating($rt)
  1283. const $consensus = BaseMatcher.consensus($rt)
  1284. const consensus = $consensus?.trim()?.replace(/--/g, '&#8212;') || NO_CONSENSUS
  1285. const updated = BaseMatcher.lastModified($rt)
  1286. const preloaded = state.url === preload.fullUrl
  1287.  
  1288. return {
  1289. data: { consensus, rating, url: state.url },
  1290. preloaded,
  1291. updated,
  1292. }
  1293. }
  1294.  
  1295. /**
  1296. * normalize names so matches don't fail due to minor differences in casing or
  1297. * punctuation
  1298. *
  1299. * @param {string} name
  1300. */
  1301. function normalize (name) {
  1302. return name
  1303. .normalize('NFKD')
  1304. .replace(/[\u0300-\u036F]/g, '')
  1305. .toLowerCase()
  1306. .replace(/[^a-z0-9]/g, ' ')
  1307. .replace(/\s+/g, ' ')
  1308. .trim()
  1309. }
  1310.  
  1311. /**
  1312. * extract the value of a property (dotted path) from each member of an array
  1313. *
  1314. * @param {any[] | undefined} array
  1315. * @param {string} path
  1316. */
  1317. function pluck (array, path) {
  1318. return (array || []).map(it => get(it, path))
  1319. }
  1320.  
  1321. /**
  1322. * remove expired cache entries older than the supplied date (milliseconds since
  1323. * the epoch). if the date is -1, remove all entries
  1324. *
  1325. * @param {number} date
  1326. */
  1327. function purgeCached (date) {
  1328. for (const key of GM_listValues()) {
  1329. const json = GM_getValue(key, '{}')
  1330. const value = JSON.parse(json)
  1331. const metadataVersion = METADATA_VERSION[key]
  1332.  
  1333. let $delete = false
  1334.  
  1335. if (metadataVersion) { // persistent (until the next METADATA_VERSION[key] change)
  1336. if (value.version !== metadataVersion) {
  1337. $delete = true
  1338. log(`purging invalid metadata (obsolete version: ${value.version}): ${key}`)
  1339. }
  1340. } else if (value.version !== DATA_VERSION) {
  1341. $delete = true
  1342. log(`purging invalid data (obsolete version: ${value.version}): ${key}`)
  1343. } else if (date === -1 || (typeof value.expires !== 'number') || date > value.expires) {
  1344. $delete = true
  1345. log(`purging expired value: ${key}`)
  1346. }
  1347.  
  1348. if ($delete) {
  1349. GM_deleteValue(key)
  1350. }
  1351. }
  1352. }
  1353.  
  1354. /**
  1355. * register a menu command which toggles verbose logging
  1356. */
  1357. function registerDebugMenuCommand () {
  1358. /** @type {ReturnType<typeof GM_registerMenuCommand> | null} */
  1359. let id = null
  1360.  
  1361. const onClick = () => {
  1362. if (id) {
  1363. DEBUG = !DEBUG
  1364.  
  1365. if (DEBUG) {
  1366. GM_setValue(DEBUG_KEY, ENABLE_DEBUGGING)
  1367. } else {
  1368. GM_deleteValue(DEBUG_KEY)
  1369. }
  1370.  
  1371. GM_unregisterMenuCommand(id)
  1372. }
  1373.  
  1374. const name = `Enable debug logging${DEBUG ? ' ✔' : ''}`
  1375.  
  1376. id = GM_registerMenuCommand(name, onClick)
  1377. }
  1378.  
  1379. onClick()
  1380. }
  1381.  
  1382. /**
  1383. * register a menu command which toggles the RT link target between the current
  1384. * tab/window and a new tab/window
  1385. */
  1386. function registerLinkTargetMenuCommand () {
  1387. const toggle = /** @type {const} */ ({ _self: '_blank', _blank: '_self' })
  1388.  
  1389. /** @type {(target: LinkTarget) => string} */
  1390. const name = target => `Open links in a new window${target === '_self' ? ' ✔' : ''}`
  1391.  
  1392. /** @type {ReturnType<typeof GM_registerMenuCommand> | null} */
  1393. let id = null
  1394.  
  1395. let target = getRTLinkTarget()
  1396.  
  1397. const onClick = () => {
  1398. if (id) {
  1399. target = toggle[target]
  1400.  
  1401. if (target === '_self') {
  1402. GM_deleteValue(TARGET_KEY)
  1403. } else {
  1404. GM_setValue(TARGET_KEY, NEW_WINDOW)
  1405. }
  1406.  
  1407. GM_unregisterMenuCommand(id)
  1408. EMITTER.emit(CHANGE_TARGET, target)
  1409. }
  1410.  
  1411. id = GM_registerMenuCommand(name(toggle[target]), onClick)
  1412. }
  1413.  
  1414. onClick()
  1415. }
  1416.  
  1417. /**
  1418. * convert an IMDb title into the most likely basename (final part of the URL)
  1419. * for that title on Rotten Tomatoes, e.g.:
  1420. *
  1421. * "A Stitch in Time" -> "a_stitch_in_time"
  1422. * "Lilo & Stitch" -> "lilo_and_stitch"
  1423. * "Peter's Friends" -> "peters_friends"
  1424. *
  1425. * @param {string} title
  1426. */
  1427. function rtName (title) {
  1428. const name = title
  1429. .replace(/\s+&\s+/g, ' and ')
  1430. .replace(/'/g, '')
  1431.  
  1432. return normalize(name).replace(/\s+/g, '_')
  1433. }
  1434.  
  1435. /**
  1436. * take two iterable collections of strings and return an object containing:
  1437. *
  1438. * - got: the number of shared strings (strings common to both)
  1439. * - want: the required number of shared strings (minimum: 1)
  1440. * - max: the maximum possible number of shared strings
  1441. *
  1442. * if either collection is empty, the number of strings they have in common is -1
  1443. *
  1444. * @typedef Shared
  1445. * @prop {number} got
  1446. * @prop {number} want
  1447. * @prop {number} max
  1448. * @prop {Shared=} full
  1449. *
  1450. * @param {Iterable<string>} a
  1451. * @param {Iterable<string>} b
  1452. * @return Shared
  1453. */
  1454. function _shared (a, b) {
  1455. /** @type {Set<string>} */
  1456. const $a = (a instanceof Set) ? a : new Set(Array.from(a, normalize))
  1457.  
  1458. if ($a.size === 0) {
  1459. return UNSHARED
  1460. }
  1461.  
  1462. /** @type {Set<string>} */
  1463. const $b = (b instanceof Set) ? b : new Set(Array.from(b, normalize))
  1464.  
  1465. if ($b.size === 0) {
  1466. return UNSHARED
  1467. }
  1468.  
  1469. const [smallest, largest] = $a.size < $b.size ? [$a, $b] : [$b, $a]
  1470.  
  1471. // the minimum number of elements shared between two Sets for them to be
  1472. // deemed similar
  1473. const minimumShared = Math.round(smallest.size / 2)
  1474.  
  1475. // we always want at least 1 even if the max is 0
  1476. const want = Math.max(minimumShared, 1)
  1477.  
  1478. let count = 0
  1479.  
  1480. for (const value of smallest) {
  1481. if (largest.has(value)) {
  1482. ++count
  1483. }
  1484. }
  1485.  
  1486. return { got: count, want, max: smallest.size }
  1487. }
  1488.  
  1489. /**
  1490. * a curried wrapper for +_shared+ which takes two iterable collections of
  1491. * strings and returns an object containing:
  1492. *
  1493. * - got: the number of shared strings (strings common to both)
  1494. * - want: the required number of shared strings (minimum: 1)
  1495. * - max: the maximum possible number of shared strings
  1496. *
  1497. * if either collection is empty, the number of strings they have in common is -1
  1498. *
  1499. * @overload
  1500. * @param {Iterable<string>} a
  1501. * @return {(b: Iterable<string>) => Shared}
  1502. *
  1503. * @overload
  1504. * @param {Iterable<string>} a
  1505. * @param {Iterable<string>} b
  1506. * @return {Shared}
  1507. *
  1508. * @type {(...args: [Iterable<string>] | [Iterable<string>, Iterable<string>]) => unknown}
  1509. */
  1510. function shared (...args) {
  1511. if (args.length === 2) {
  1512. return _shared(...args)
  1513. } else {
  1514. const a = new Set(Array.from(args[0], normalize))
  1515. return (/** @type {Iterable<string>} */ b) => _shared(a, b)
  1516. }
  1517. }
  1518.  
  1519. /**
  1520. * return the similarity between two strings, ranging from 0 (no similarity) to
  1521. * 2 (identical)
  1522. *
  1523. * similarity("John Woo", "John Woo") // 2
  1524. * similarity("Matthew Macfadyen", "Matthew MacFadyen") // 1
  1525. * similarity("Alan Arkin", "Zazie Beetz") // 0
  1526. *
  1527. * @param {string} a
  1528. * @param {string} b
  1529. * @return {number}
  1530. */
  1531. function similarity (a, b, transform = normalize) {
  1532. // XXX work around a bug in fast-dice-coefficient which returns 0
  1533. // if either string's length is < 2
  1534.  
  1535. if (a === b) {
  1536. return 2
  1537. } else {
  1538. const $a = transform(a)
  1539. const $b = transform(b)
  1540.  
  1541. return ($a === $b ? 1 : exports.dice($a, $b))
  1542. }
  1543. }
  1544.  
  1545. /**
  1546. * measure the similarity of an IMDb title and an RT title returned by the API
  1547. *
  1548. * return the best match between the IMDb titles (display and original) and RT
  1549. * titles (display and AKAs)
  1550. *
  1551. * similarity("La haine", "Hate") // 0.2
  1552. * titleSimilarity(["La haine"], ["Hate", "La Haine"]) // 1
  1553. *
  1554. * @param {string[]} aTitles
  1555. * @param {string[]} bTitles
  1556. */
  1557. function titleSimilarity (aTitles, bTitles) {
  1558. let max = 0
  1559.  
  1560. for (const [aTitle, bTitle] of cartesianProduct([aTitles, bTitles])) {
  1561. ++PAGE_STATS.titleComparisons
  1562.  
  1563. const score = similarity(aTitle, bTitle)
  1564.  
  1565. if (score === 2) {
  1566. return score
  1567. } else if (score > max) {
  1568. max = score
  1569. }
  1570. }
  1571.  
  1572. return max
  1573. }
  1574.  
  1575. /**
  1576. * return true if the supplied arrays are similar (sufficiently overlap), false
  1577. * otherwise
  1578. *
  1579. * @param {Object} options
  1580. * @param {string} options.name
  1581. * @param {string[]} options.imdb
  1582. * @param {string[]} options.rt
  1583. */
  1584. function verifyShared ({ name, imdb, rt }) {
  1585. debug(`verifying ${name}`)
  1586. debug(`imdb ${name}:`, imdb)
  1587. debug(`rt ${name}:`, rt)
  1588. const $shared = shared(rt, imdb)
  1589. debug(`shared ${name}:`, $shared)
  1590. return $shared.got >= $shared.want
  1591. }
  1592.  
  1593. /*
  1594. * poll for a truthy value, returning a promise which resolves the value or
  1595. * which is rejected if the probe times out
  1596. */
  1597. const { waitFor, TimeoutError } = (function () {
  1598. class TimeoutError extends Error {}
  1599.  
  1600. // "pin" the window.load event
  1601. //
  1602. // we only wait for DOM elements, so if they don't exist by the time the
  1603. // last DOM lifecycle event fires, they never will
  1604. const onLoad = exports.when(/** @type {(done: () => boolean) => void} */ done => {
  1605. window.addEventListener('load', done, { once: true })
  1606. })
  1607.  
  1608. // don't keep polling if we still haven't found anything after the page has
  1609. // finished loading
  1610. /** @type {WaitFor.Callback} */
  1611. const defaultCallback = onLoad
  1612.  
  1613. let ID = 0
  1614.  
  1615. /**
  1616. * @type {WaitFor.WaitFor}
  1617. * @param {any[]} args
  1618. */
  1619. const waitFor = (...args) => {
  1620. /** @type {WaitFor.Checker<unknown>} */
  1621. const checker = args.pop()
  1622.  
  1623. /** @type {WaitFor.Callback} */
  1624. const callback = (args.length && (typeof args.at(-1) === 'function'))
  1625. ? args.pop()
  1626. : defaultCallback
  1627.  
  1628. const id = String(args.length ? args.pop() : ++ID)
  1629.  
  1630. let count = -1
  1631. let retry = true
  1632. let found = false
  1633.  
  1634. const done = () => {
  1635. trace(() => `inside timeout handler for ${id}: ${found ? 'found' : 'not found'}`)
  1636. retry = false
  1637. return found
  1638. }
  1639.  
  1640. callback(done, id)
  1641.  
  1642. return new Promise((resolve, reject) => {
  1643. /** @type {FrameRequestCallback} */
  1644. const check = time => {
  1645. ++count
  1646.  
  1647. let result
  1648.  
  1649. try {
  1650. result = checker({ tick: count, time, id })
  1651. } catch (e) {
  1652. return reject(/** @type {Error} */ e)
  1653. }
  1654.  
  1655. if (result) {
  1656. found = true
  1657. resolve(/** @type {any} */ (result))
  1658. } else if (retry) {
  1659. requestAnimationFrame(check)
  1660. } else {
  1661. const ticks = 'tick' + (count === 1 ? '' : 's')
  1662. const error = new TimeoutError(`polling timed out after ${count} ${ticks} (${id})`)
  1663. reject(error)
  1664. }
  1665. }
  1666.  
  1667. const now = document.timeline.currentTime ?? -1
  1668. check(now)
  1669. })
  1670. }
  1671.  
  1672. return { waitFor, TimeoutError }
  1673. })()
  1674.  
  1675. /******************************************************************************/
  1676.  
  1677. /**
  1678. * @param {string} imdbId
  1679. */
  1680. async function run (imdbId) {
  1681. const now = Date.now()
  1682.  
  1683. // purgeCached(-1) // disable the cache
  1684. purgeCached(now)
  1685.  
  1686. // get the cached result for this page
  1687. const cached = JSON.parse(GM_getValue(imdbId, 'null'))
  1688.  
  1689. if (cached) {
  1690. const expires = new Date(cached.expires).toLocaleString()
  1691.  
  1692. if (cached.error) {
  1693. log(`cached error (expires: ${expires}):`, cached.error)
  1694. return
  1695. } else {
  1696. log(`cached result (expires: ${expires}):`, cached.data)
  1697. return addWidgets(cached.data)
  1698. }
  1699. } else {
  1700. log('not cached')
  1701. }
  1702.  
  1703. trace('waiting for json-ld')
  1704. const script = await waitFor('json-ld', () => {
  1705. return /** @type {HTMLScriptElement} */ (document.querySelector(LD_JSON))
  1706. })
  1707. trace('got json-ld: ', script.textContent?.length)
  1708.  
  1709. const ld = jsonLd(script, location.href)
  1710. const imdbType = /** @type {keyof RT_TYPE} */ (ld['@type'])
  1711. const rtType = RT_TYPE[imdbType]
  1712.  
  1713. if (!rtType) {
  1714. log(`invalid type for ${imdbId}: ${imdbType}`)
  1715. return
  1716. }
  1717.  
  1718. const name = htmlDecode(ld.name)
  1719. const alternateName = htmlDecode(ld.alternateName)
  1720. trace('ld.name:', JSON.stringify(name))
  1721. trace('ld.alternateName:', JSON.stringify(alternateName))
  1722. const title = alternateName || name
  1723.  
  1724. /**
  1725. * add a { version, expires, data|error } entry to the cache
  1726. *
  1727. * @param {any} dataOrError
  1728. * @param {number} ttl
  1729. */
  1730. const store = (dataOrError, ttl) => {
  1731. if (DISABLE_CACHE) {
  1732. return
  1733. }
  1734.  
  1735. const expires = now + ttl
  1736. const cached = { version: DATA_VERSION, expires, ...dataOrError }
  1737. const json = JSON.stringify(cached)
  1738.  
  1739. GM_setValue(imdbId, json)
  1740. }
  1741.  
  1742. /** @type {{ version: number, data: typeof STATS }} */
  1743. const stats = JSON.parse(GM_getValue(STATS_KEY, 'null')) || {
  1744. version: METADATA_VERSION.stats,
  1745. data: clone(STATS),
  1746. }
  1747.  
  1748. /** @type {(path: string) => void} */
  1749. const bump = path => {
  1750. exports.dset(stats.data, path, get(stats.data, path, 0) + 1)
  1751. }
  1752.  
  1753. try {
  1754. const { data, preloaded, updated } = await getRTData(imdbId, title, rtType)
  1755.  
  1756. log('RT data:', data)
  1757. bump('hit')
  1758. bump(preloaded ? 'preload.hit' : 'preload.miss')
  1759.  
  1760. let active = false
  1761.  
  1762. if (updated) {
  1763. dayjs.extend(dayjs_plugin_relativeTime)
  1764.  
  1765. const date = dayjs()
  1766. const ago = date.to(updated)
  1767. const delta = date.diff(updated, 'month', /* float */ true)
  1768.  
  1769. active = delta <= INACTIVE_MONTHS
  1770.  
  1771. log(`last update: ${updated.format(DATE_FORMAT)} (${ago})`)
  1772. }
  1773.  
  1774. if (active) {
  1775. log('caching result for: one day')
  1776. store({ data }, ONE_DAY)
  1777. } else {
  1778. log('caching result for: one week')
  1779. store({ data }, ONE_WEEK)
  1780. }
  1781.  
  1782. await addWidgets(data)
  1783. } catch (error) {
  1784. bump('miss')
  1785.  
  1786. const message = error.message || String(error) // stringify
  1787.  
  1788. log(`caching error for one day: ${message}`)
  1789. store({ error: message }, ONE_DAY)
  1790.  
  1791. if (!error.abort) {
  1792. throw error
  1793. }
  1794. } finally {
  1795. bump('requests')
  1796. GM_setValue(STATS_KEY, JSON.stringify(stats))
  1797. debug('stats:', stats.data)
  1798. trace('page stats:', PAGE_STATS)
  1799. }
  1800. }
  1801.  
  1802. {
  1803. const start = Date.now()
  1804. const imdbId = location.pathname.split('/')[2]
  1805.  
  1806. log('id:', imdbId)
  1807.  
  1808. run(imdbId)
  1809. .then(() => {
  1810. const time = (Date.now() - start) / 1000
  1811. debug(`completed in ${time}s`)
  1812. })
  1813. .catch(e => {
  1814. if (e instanceof TimeoutError) {
  1815. warn(e.message)
  1816. } else {
  1817. console.error(e)
  1818. }
  1819. })
  1820. }
  1821.  
  1822. registerLinkTargetMenuCommand()
  1823.  
  1824. GM_registerMenuCommand('Clear cache', () => {
  1825. purgeCached(-1)
  1826. })
  1827.  
  1828. GM_registerMenuCommand('Clear stats', () => {
  1829. if (confirm('Clear stats?')) {
  1830. log('clearing stats')
  1831. GM_deleteValue(STATS_KEY)
  1832. }
  1833. })
  1834.  
  1835. registerDebugMenuCommand()
  1836.  
  1837. /* end */ }

QingJ © 2025

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