🏠 返回首頁 

Greasy Fork is available in English.

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