// ==UserScript==
// @name IMDb Tomatoes
// @description Add Rotten Tomatoes ratings to IMDb movie and TV show pages
// @author chocolateboy
// @copyright chocolateboy
// @version 7.1.1
// @namespace https://github.com/chocolateboy/userscripts
// @license GPL
// @include /^https://www\.imdb\.com/title/tt[0-9]+/([#?].*)?$/
// @require https://code.jquery.com/jquery-3.7.1.min.js
// @require https://cdn.jsdelivr.net/gh/urin/jquery.balloon.js@8b79aab63b9ae34770bfa81c9bfe30019d9a13b0/jquery.balloon.js
// @require https://unpkg.com/dayjs@1.11.13/dayjs.min.js
// @require https://unpkg.com/dayjs@1.11.13/plugin/relativeTime.js
// @require https://unpkg.com/@chocolateboy/uncommonjs@3.2.1/dist/polyfill.iife.min.js
// @require https://unpkg.com/@chocolatey/enumerator@1.1.1/dist/index.umd.min.js
// @require https://unpkg.com/@chocolatey/when@1.2.0/dist/index.umd.min.js
// @require https://unpkg.com/dset@3.1.4/dist/index.min.js
// @require https://unpkg.com/fast-dice-coefficient@1.0.3/dice.js
// @require https://unpkg.com/get-wild@3.0.2/dist/index.umd.min.js
// @require https://unpkg.com/little-emitter@0.3.5/dist/emitter.js
// @resource api https://pastebin.com/raw/absEYaJ8
// @resource overrides https://pastebin.com/raw/sRQpz471
// @grant GM_addStyle
// @grant GM_deleteValue
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_unregisterMenuCommand
// @connect algolia.net
// @connect www.rottentomatoes.com
// @run-at document-start
// @noframes
// ==/UserScript==
/// <reference types="greasemonkey" />
/// <reference types="tampermonkey" />
/// <reference types="jquery" />
/// <reference types="node" />
/// <reference path="../types/imdb-tomatoes.user.d.ts" />
'use strict';
/* begin */ {
const API_LIMIT = 100
const CHANGE_TARGET = 'target:change'
const DATA_VERSION = 1.3
const DATE_FORMAT = 'YYYY-MM-DD'
const DEBUG_KEY = 'debug'
const DISABLE_CACHE = false
const INACTIVE_MONTHS = 3
const LD_JSON = 'script[type="application/ld+json"]'
const MAX_YEAR_DIFF = 3
const NO_CONSENSUS = 'No consensus yet.'
const NO_MATCH = 'no matching results'
const ONE_DAY = 1000 * 60 * 60 * 24
const ONE_WEEK = ONE_DAY * 7
const RT_BALLOON_CLASS = 'rt-consensus-balloon'
const RT_BASE = 'https://www.rottentomatoes.com'
const RT_WIDGET_CLASS = 'rt-rating'
const STATS_KEY = 'stats'
const TARGET_KEY = 'target'
/** @type {Record<string, number>} */
const METADATA_VERSION = {
[STATS_KEY]: 3,
[TARGET_KEY]: 1,
[DEBUG_KEY]: 1,
}
const BALLOON_OPTIONS = {
classname: RT_BALLOON_CLASS,
css: {
fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
fontSize: '16px',
lineHeight: '24px',
maxWidth: '24rem',
padding: '10px',
},
html: true,
position: 'bottom',
}
const COLOR = {
tbd: '#d9d9d9',
fresh: '#67ad4b',
rotten: '#fb3c3c',
}
const CONNECTION_ERROR = {
status: 420,
statusText: 'Connection Error',
}
const ENABLE_DEBUGGING = JSON.stringify({
data: true,
version: METADATA_VERSION[DEBUG_KEY],
})
const NEW_WINDOW = JSON.stringify({
data: '_blank',
version: METADATA_VERSION[TARGET_KEY],
})
const RT_TYPE = /** @type {const} */ ({
TVSeries: 'tvSeries',
Movie: 'movie',
})
const RT_TYPE_ID = /** @type {const} */ ({
movie: 1,
tvSeries: 2,
})
const STATS = {
requests: 0,
hit: 0,
miss: 0,
preload: {
hit: 0,
miss: 0,
},
}
const UNSHARED = Object.freeze({
got: -1,
want: 1,
max: 0,
})
/**
* an Event Emitter instance used to publish changes to the target for RT links
* ("_blank" or "_self")
*
* @type {import("little-emitter")}
*/
const EMITTER = new exports.Emitter()
/*
* per-page performance metrics, only displayed when debugging is enabled
*/
const PAGE_STATS = { titleComparisons: 0 }
/*
* enable verbose logging
*/
let DEBUG = JSON.parse(GM_getValue(DEBUG_KEY, 'false'))?.data || false
/*
* log a message to the console
*/
const { debug, log, warn } = console
/** @type {(...args: any[]) => void} */
const trace = (...args) => {
if (DEBUG) {
if (args.length === 1 && typeof args[0] === 'function') {
args = [].concat(args[0]())
}
debug(...args)
}
}
/**
* return the Cartesian product of items from a collection of arrays
*
* @type {(arrays: string[][]) => [string, string][]}
*/
const cartesianProduct = exports.enumerator
/**
* deep-clone a JSON-serializable value
*
* @type {<T>(value: T) => T}
*/
const clone = value => JSON.parse(JSON.stringify(value))
/**
* decode HTML entities, e.g.:
*
* from: "Bill & Ted's Excellent Adventure"
* to: "Bill & Ted's Excellent Adventure"
*
* @type {(html: string | undefined) => string}
*/
const htmlDecode = (html) => {
if (!html) {
return ''
}
const el = document.createElement('textarea')
el.innerHTML = html
return el.value
}
/*
* a custom version of get-wild's `get` function which uses a simpler/faster
* path parser since we don't use the extended syntax
*/
const get = exports.getter({ split: '.' })
/**
* retrieve the target for RT links from GM storage, either "_self" (default)
* or "_blank" (new window)
*
* @type {() => LinkTarget}
*/
const getRTLinkTarget = () => JSON.parse(GM_getValue(TARGET_KEY, 'null'))?.data || '_self'
/**
* extract JSON-LD data for the loaded document
*
* used to extract metadata on IMDb and Rotten Tomatoes
*
* @param {Document | HTMLScriptElement} el
* @param {string} id
*/
function jsonLd (el, id) {
const script = el instanceof HTMLScriptElement
? el
: el.querySelector(LD_JSON)
let data
if (script) {
try {
const json = /** @type {string} */ (script.textContent)
data = JSON.parse(json.trim())
} catch (e) {
throw new Error(`Can't parse JSON-LD data for ${id}: ${e}`)
}
} else {
throw new Error(`Can't find JSON-LD data for ${id}`)
}
return data
}
const BaseMatcher = {
/**
* return the consensus from an RT page as a HTML string
*
* @param {RTDoc} $rt
* @return {string}
*/
consensus ($rt) {
return $rt.find('#critics-consensus p').html()
},
/**
* return the last time an RT page was updated based on its most recently
* published review
*
* @param {RTDoc} $rt
* @return {DayJs | undefined}
*/
lastModified ($rt) {
return pluck($rt.meta.review, 'dateCreated')
.map(dayjs)
.sort((a, b) => b.unix() - a.unix())
.shift()
},
rating ($rt) {
const $rating = $rt.find('rt-button[slot="criticsScore"]')
const rating = parseInt($rating.text().trim())
return rating >= 0 ? rating : -1
},
}
const MovieMatcher = {
/**
* return a movie record ({ url: string }) from the API results which
* matches the supplied IMDb data
*
* @param {any} imdb
* @param {RTMovieResult[]} rtResults
*/
match (imdb, rtResults) {
const sharedWithImdb = shared(imdb.cast)
const sorted = rtResults
.flatMap((rt, index) => {
// XXX the order of these tests matters: do fast, efficient
// checks first to reduce the number of results for the more
// expensive checks to process
const { title, vanity: slug } = rt
if (!(title && slug)) {
warn('invalid result:', rt)
return []
}
const rtYear = rt.releaseYear ? Number(rt.releaseYear) : null
const yearDiff = (imdb.year && rtYear)
? { value: Math.abs(imdb.year - rtYear) }
: null
if (yearDiff && yearDiff.value > MAX_YEAR_DIFF) {
return []
}
/** @type {Shared} */
let castMatch = UNSHARED
let verify = true
const rtCast = pluck(rt.cast, 'name')
if (rtCast.length) {
const fullShared = sharedWithImdb(rtCast)
if (fullShared.got >= fullShared.want) {
verify = false
castMatch = fullShared
} else if (fullShared.got) {
// fall back to matching IMDb's main cast (e.g. 2/3) if
// the full-cast match fails (e.g. 8/18)
const mainShared = shared(imdb.mainCast, rtCast)
if (mainShared.got >= mainShared.want) {
verify = false
castMatch = mainShared
castMatch.full = fullShared
} else {
return []
}
} else {
return []
}
}
const rtRating = rt.rottenTomatoes?.criticsScore
const url = `/m/${slug}`
// XXX the title is in the AKA array, but a) we don't want to
// assume that and b) it's not usually first
const rtTitles = rt.aka ? [...new Set([title, ...rt.aka])] : [title]
// XXX only called after the other checks have filtered out
// non-matches, so the number of comparisons remains small
// (usually 1 or 2, and seldom more than 3, even with 100 results)
const titleMatch = titleSimilarity(imdb.titles, rtTitles)
const result = {
title,
url,
year: rtYear,
cast: rtCast,
titleMatch,
castMatch,
yearDiff,
rating: rtRating,
titles: rtTitles,
popularity: rt.pageViews_popularity ?? 0,
updated: rt.updateDate,
index,
verify,
}
return [result]
})
.sort((a, b) => {
// combine the title and the year into a single score
//
// being a year or two out shouldn't be a dealbreaker, and it's
// not uncommon for an RT title to differ from the IMDb title
// (e.g. an AKA), so we don't want one of these to pre-empt the
// other (yet)
const score = new Score()
score.add(b.titleMatch - a.titleMatch)
if (a.yearDiff && b.yearDiff) {
score.add(a.yearDiff.value - b.yearDiff.value)
}
const popularity = (a.popularity && b.popularity)
? b.popularity - a.popularity
: 0
return (b.castMatch.got - a.castMatch.got)
|| (score.b - score.a)
|| (b.titleMatch - a.titleMatch) // prioritise the title if we're still deadlocked
|| popularity // last resort
})
debug('matches:', sorted)
return sorted[0]
},
/**
* return the likely RT path for an IMDb movie title, e.g.:
*
* title: "Bolt"
* path: "/m/bolt"
*
* @param {string} title
*/
rtPath (title) {
return `/m/${rtName(title)}`
},
/**
* confirm the supplied RT page data matches the IMDb metadata
*
* @param {any} imdb
* @param {RTDoc} $rt
* @return {boolean}
*/
verify (imdb, $rt) {
log('verifying movie')
// match the director(s)
const rtDirectors = pluck($rt.meta.director, 'name')
return verifyShared({
name: 'directors',
imdb: imdb.directors,
rt: rtDirectors,
})
},
}
const TVMatcher = {
/**
* return a TV show record ({ url: string }) from the API results which
* matches the supplied IMDb data
*
* @param {any} imdb
* @param {RTTVResult[]} rtResults
*/
match (imdb, rtResults) {
const sharedWithImdb = shared(imdb.cast)
const sorted = rtResults
.flatMap((rt, index) => {
// XXX the order of these tests matters: do fast, efficient
// checks first to reduce the number of results for the more
// expensive checks to process
const { title, vanity: slug } = rt
if (!(title && slug)) {
warn('invalid result:', rt)
return []
}
const startYear = rt.releaseYear ? Number(rt.releaseYear) : null
const startYearDiff = (imdb.startYear && startYear)
? { value: Math.abs(imdb.startYear - startYear) }
: null
if (startYearDiff && startYearDiff.value > MAX_YEAR_DIFF) {
return []
}
const endYear = rt.seriesFinale ? dayjs(rt.seriesFinale).year() : null
const endYearDiff = (imdb.endYear && endYear)
? { value: Math.abs(imdb.endYear - endYear) }
: null
if (endYearDiff && endYearDiff.value > MAX_YEAR_DIFF) {
return []
}
const seasons = rt.seasons || []
const seasonsDiff = (imdb.seasons && seasons.length)
? { value: Math.abs(imdb.seasons - seasons.length) }
: null
/** @type {Shared} */
let castMatch = UNSHARED
let verify = true
const rtCast = pluck(rt.cast, 'name')
if (rtCast.length) {
const fullShared = sharedWithImdb(rtCast)
if (fullShared.got >= fullShared.want) {
verify = false
castMatch = fullShared
} else if (fullShared.got) {
// fall back to matching IMDb's main cast (e.g. 2/3) if
// the full-cast match fails (e.g. 8/18)
const mainShared = shared(imdb.mainCast, rtCast)
if (mainShared.got >= mainShared.want) {
verify = false
castMatch = mainShared
castMatch.full = fullShared
} else {
return []
}
} else {
return []
}
}
const rtRating = rt.rottenTomatoes?.criticsScore
const url = `/tv/${slug}/s01`
// XXX the title is in the AKA array, but a) we don't want to
// assume that and b) it's not usually first
const rtTitles = rt.aka ? [...new Set([title, ...rt.aka])] : [title]
// XXX only called after the other checks have filtered out
// non-matches, so the number of comparisons remains small
// (usually 1 or 2, and seldom more than 3, even with 100 results)
const titleMatch = titleSimilarity(imdb.titles, rtTitles)
const result = {
title,
url,
startYear,
endYear,
seasons: seasons.length,
cast: rtCast,
titleMatch,
castMatch,
startYearDiff,
endYearDiff,
seasonsDiff,
rating: rtRating,
titles: rtTitles,
popularity: rt.pageViews_popularity ?? 0,
index,
updated: rt.updateDate,
verify,
}
return [result]
})
.sort((a, b) => {
const score = new Score()
score.add(b.titleMatch - a.titleMatch)
if (a.startYearDiff && b.startYearDiff) {
score.add(a.startYearDiff.value - b.startYearDiff.value)
}
if (a.endYearDiff && b.endYearDiff) {
score.add(a.endYearDiff.value - b.endYearDiff.value)
}
if (a.seasonsDiff && b.seasonsDiff) {
score.add(a.seasonsDiff.value - b.seasonsDiff.value)
}
const popularity = (a.popularity && b.popularity)
? b.popularity - a.popularity
: 0
return (b.castMatch.got - a.castMatch.got)
|| (score.b - score.a)
|| (b.titleMatch - a.titleMatch) // prioritise the title if we're still deadlocked
|| popularity // last resort
})
debug('matches:', sorted)
return sorted[0] // may be undefined
},
/**
* return the likely RT path for an IMDb TV show title, e.g.:
*
* title: "Sesame Street"
* path: "/tv/sesame_street/s01"
*
* @param {string} title
*/
rtPath (title) {
return `/tv/${rtName(title)}/s01`
},
/**
* confirm the supplied RT page data matches the IMDb data
*
* @param {any} imdb
* @param {RTDoc} $rt
* @return {boolean}
*/
verify (imdb, $rt) {
log('verifying TV show')
// match the genre(s) AND release date
if (!(imdb.genres.length && imdb.releaseDate)) {
return false
}
const rtGenres = ($rt.meta.genre || [])
.flatMap(it => it === 'Mystery & Thriller' ? it.split(' & ') : [it])
if (!rtGenres.length) {
return false
}
const matchedGenres = verifyShared({
name: 'genres',
imdb: imdb.genres,
rt: rtGenres,
})
if (!matchedGenres) {
return false
}
debug('verifying release date')
const startDate = get($rt.meta, 'partOfSeries.startDate')
if (!startDate) {
return false
}
const rtReleaseDate = dayjs(startDate).format(DATE_FORMAT)
debug('imdb release date:', imdb.releaseDate)
debug('rt release date:', rtReleaseDate)
return rtReleaseDate === imdb.releaseDate
}
}
const Matcher = {
tvSeries: TVMatcher,
movie: MovieMatcher,
}
/*
* a helper class used to load and verify data from RT pages which transparently
* handles the selection of the most suitable URL, either from the API (match)
* or guessed from the title (fallback)
*/
class RTClient {
/**
* @param {Object} options
* @param {any} options.match
* @param {Matcher[keyof Matcher]} options.matcher
* @param {any} options.preload
* @param {RTState} options.state
*/
constructor ({ match, matcher, preload, state }) {
this.match = match
this.matcher = matcher
this.preload = preload
this.state = state
}
/**
* transform an XHR response into a JQuery document wrapper with a +meta+
* property containing the page's parsed JSON-LD data
*
* @param {Tampermonkey.Response<any>} res
* @param {string} id
* @return {RTDoc}
*/
_parseResponse (res, id) {
const parser = new DOMParser()
const dom = parser.parseFromString(res.responseText, 'text/html')
const $rt = $(dom)
const meta = jsonLd(dom, id)
return Object.assign($rt, { meta, document: dom })
}
/**
* confirm the metadata of the RT page (match or fallback) matches the IMDb
* data
*
* @param {any} imdb
* @param {RTDoc} rtPage
* @param {boolean} fallbackUnused
* @return {Promise<{ verified: boolean, rtPage: RTDoc }>}
*/
async _verify (imdb, rtPage, fallbackUnused) {
const { match, matcher, preload, state } = this
let verified = matcher.verify(imdb, rtPage)
if (!verified) {
if (match.force) {
log('forced:', true)
verified = true
} else if (fallbackUnused) {
state.url = preload.fullUrl
log('loading fallback URL:', preload.fullUrl)
const res = await preload.request
if (res) {
log(`fallback response: ${res.status} ${res.statusText}`)
rtPage = this._parseResponse(res, preload.url)
verified = matcher.verify(imdb, rtPage)
} else {
log(`error loading ${preload.fullUrl} (${preload.error.status} ${preload.error.statusText})`)
}
}
}
log('verified:', verified)
return { verified, rtPage }
}
/**
* load the RT URL (match or fallback) and return the resulting RT page
*
* @param {any} imdb
* @return {Promise<RTDoc | void>}
*/
async loadPage (imdb) {
const { match, preload, state } = this
let requestType = match.fallback ? 'fallback' : 'match'
let verify = match.verify
let fallbackUnused = false
let res
log(`loading ${requestType} URL:`, state.url)
// match URL (API result) and fallback URL (guessed) are the same
if (match.url === preload.url) {
res = await preload.request // join the in-flight request
} else { // different match URL and fallback URL
try {
res = await asyncGet(state.url) // load the (absolute) match URL
fallbackUnused = true // only set if the request succeeds
} catch (error) { // bogus URL in API result (or transient server error)
log(`error loading ${state.url} (${error.status} ${error.statusText})`)
if (match.force) { // URL locked in checkOverrides, so nothing to fall back to
return
} else { // use (and verify) the fallback URL
requestType = 'fallback'
state.url = preload.fullUrl
verify = true
log(`loading ${requestType} URL:`, state.url)
res = await preload.request
}
}
}
if (!res) {
log(`error loading ${state.url} (${preload.error.status} ${preload.error.statusText})`)
return
}
log(`${requestType} response: ${res.status} ${res.statusText}`)
let rtPage = this._parseResponse(res, state.url)
if (verify) {
const { verified, rtPage: newRtPage } = await this._verify(
imdb,
rtPage,
fallbackUnused
)
if (!verified) {
return
}
rtPage = newRtPage
}
return rtPage
}
}
/*
* a helper class which keeps a running total of scores for two values (a and
* b). used to rank values in a sort function
*/
class Score {
constructor () {
this.a = 0
this.b = 0
}
/**
* add a score to the total
*
* @param {number} order
* @param {number=} points
*/
add (order, points = 1) {
if (order < 0) {
this.a += points
} else if (order > 0) {
this.b += points
}
}
}
/******************************************************************************/
/**
* raise a non-error exception indicating no matching result has been found
*
* @param {string} message
*/
// XXX return an error object rather than throwing it to work around a
// TypeScript bug: https://github.com/microsoft/TypeScript/issues/31329
function abort (message = NO_MATCH) {
return Object.assign(new Error(message), { abort: true })
}
/**
* add Rotten Tomatoes widgets to the desktop/mobile ratings bars
*
* @param {Object} data
* @param {string} data.url
* @param {string} data.consensus
* @param {number} data.rating
*/
async function addWidgets ({ consensus, rating, url }) {
trace('adding RT widgets')
const imdbRatings = await waitFor('IMDb widgets', () => {
/** @type {NodeListOf<HTMLElement>} */
const ratings = document.querySelectorAll('[data-testid="hero-rating-bar__aggregate-rating"]')
return ratings.length > 1 ? ratings : null
})
trace('found IMDb widgets')
const balloonOptions = Object.assign({}, BALLOON_OPTIONS, { contents: consensus })
const score = rating === -1 ? 'N/A' : `${rating}%`
const rtLinkTarget = getRTLinkTarget()
/** @type {"tbd" | "rotten" | "fresh"} */
let style
if (rating === -1) {
style = 'tbd'
} else if (rating < 60) {
style = 'rotten'
} else {
style = 'fresh'
}
// add a custom stylesheet which:
//
// - sets the star (SVG) to the right color
// - reorders the appended widget (see attachWidget)
// - restores support for italics in the consensus text
GM_addStyle(`
.${RT_WIDGET_CLASS} svg { color: ${COLOR[style]}; }
.${RT_WIDGET_CLASS} { order: -1; }
.${RT_BALLOON_CLASS} em { font-style: italic; }
`)
// the markup for the small (e.g. mobile) and large (e.g. desktop) IMDb
// ratings widgets is exactly the same - they only differ in the way they're
// (externally) styled
for (const imdbRating of imdbRatings) {
const $imdbRating = $(imdbRating)
const $ratings = $imdbRating.parent()
// clone the IMDb rating widget
const $rtRating = $imdbRating.clone()
// 1) assign a unique class for styling
$rtRating.addClass(RT_WIDGET_CLASS)
// 2) replace "IMDb Rating" with "RT Rating"
$rtRating.children().first().text('RT RATING')
// 3) remove the review count and its preceding spacer element
const $score = $rtRating.find('[data-testid="hero-rating-bar__aggregate-rating__score"]')
$score.nextAll().remove()
// 4) replace the IMDb rating with the RT score and remove the "/ 10" suffix
$score.children().first().text(score).nextAll().remove()
// 5) rename the testids, e.g.:
// hero-rating-bar__aggregate-rating -> hero-rating-bar__rt-rating
$rtRating.find('[data-testid]').addBack().each((_index, el) => {
$(el).attr('data-testid', (_index, id) => id.replace('aggregate', 'rt'))
})
// 6) update the link's label and URL
const $link = $rtRating.find('a[href]')
$link.attr({ 'aria-label': 'View RT Rating', href: url, target: rtLinkTarget })
// 7) observe changes to the link's target
EMITTER.on(CHANGE_TARGET, (/** @type {LinkTarget} */ target) => $link.prop('target', target))
// 8) attach the tooltip to the widget
$rtRating.balloon(balloonOptions)
// 9) prepend the widget to the ratings bar
attachWidget($ratings.get(0), $rtRating.get(0))
}
trace('added RT widgets')
}
/**
* promisified cross-origin HTTP requests
*
* @param {string} url
* @param {AsyncGetOptions} [options]
*/
function asyncGet (url, options = {}) {
if (options.params) {
url = url + '?' + $.param(options.params)
}
const id = options.title || url
const request = Object.assign({ method: 'GET', url }, options.request || {})
return new Promise((resolve, reject) => {
request.onload = res => {
if (res.status >= 400) {
const error = Object.assign(
new Error(`error fetching ${id} (${res.status} ${res.statusText})`),
{ status: res.status, statusText: res.statusText }
)
reject(error)
} else {
resolve(res)
}
}
// XXX apart from +finalUrl+, the +onerror+ response object doesn't
// contain any useful info
request.onerror = _res => {
const { status, statusText } = CONNECTION_ERROR
const error = Object.assign(
new Error(`error fetching ${id} (${status} ${statusText})`),
{ status, statusText },
)
reject(error)
}
GM_xmlhttpRequest(request)
})
}
/**
* attach an RT ratings widget to a ratings bar
*
* although the widget appears to be prepended to the bar, we need to append it
* (and reorder it via CSS) to work around React reconciliation (updating the
* DOM to match the (virtual DOM representation of the) underlying model) after
* we've added the RT widget
*
* when this synchronisation occurs, React will try to restore nodes
* (attributes, text, elements) within each widget to match the widget's props,
* so the first widget will be updated in place to match the data for the IMDb
* rating etc. this changes some, but not all nodes within an element, and most
* attributes added to/changed in a prepended RT widget remain when it's
* reverted back to an IMDb widget, including its class (rt-rating), which
* controls the color of the rating star. as a result, we end up with a restored
* IMDb widget but with an RT-colored star (and with the RT widget removed since
* it's not in the ratings-bar model)
*
* if we *append* the RT widget, none of the other widgets will need to be
* changed/updated if the DOM is re-synced, so we won't end up with a mangled
* IMDb widget; however, our RT widget will still be removed since it's not in
* the model. to rectify this, we use a mutation observer to detect and revert
* its removal (which happens no more than once - the ratings bar is frozen
* (i.e. synchronisation is halted) once the page has loaded)
*
* @param {HTMLElement | undefined} target
* @param {HTMLElement | undefined} rtRating
*/
function attachWidget (target, rtRating) {
if (!target) {
throw new ReferenceError("can't find ratings bar")
}
if (!rtRating) {
throw new ReferenceError("can't find RT widget")
}
const init = { childList: true }
// restore the RT widget if it is removed. only called (once) if the widget
// is added "quickly" (i.e. while the ratings bar is still being finalized),
// e.g. when the result is cached
const checkRatingsWidget = () => {
if (rtRating.parentElement !== target) {
observer.disconnect()
target.appendChild(rtRating)
observer.observe(target, init)
}
}
const callback = () => {
// XXX if we restore the ratings widget here immediately, it (now)
// conflicts with/confuses the reconciliation algorithm in some way and
// rather than just removing the widget, the *whole ratings bar* gets
// removed and rebuilt (!)
//
// this "delay" appears to prevent that (+queueMicrotask+ doesn't work).
setTimeout(checkRatingsWidget, 0)
}
const observer = new MutationObserver(callback)
callback()
}
/**
* check the override data in case of a failed match, but only use it as a last
* resort, i.e. try the verifier first in case the page data has been
* fixed/updated
*
* @param {any} match
* @param {string} imdbId
*/
function checkOverrides (match, imdbId) {
const overrides = JSON.parse(GM_getResourceText('overrides'))
const url = overrides[imdbId]
if (url) {
const $url = JSON.stringify(url)
if (!match) { // missing result
debug('fallback:', $url)
match = { url }
} else if (match.url !== url) { // wrong result
const $overridden = JSON.stringify(match.url)
debug(`override: ${$overridden} -> ${$url}`)
match.url = url
}
Object.assign(match, { verify: true, force: true })
}
return match
}
/**
* extract IMDb metadata from the props embedded in the page
*
* @param {string} imdbId
* @param {string} rtType
*/
async function getIMDbMetadata (imdbId, rtType) {
trace('waiting for props')
const json = await waitFor('props', () => {
return document.getElementById('__NEXT_DATA__')?.textContent?.trim()
})
trace('got props:', json.length)
const data = JSON.parse(json)
const main = get(data, 'props.pageProps.mainColumnData')
const extra = get(data, 'props.pageProps.aboveTheFoldData')
const cast = get(main, 'cast.edges.*.node.name.nameText.text', [])
const mainCast = get(extra, 'castPageTitle.edges.*.node.name.nameText.text', [])
const type = get(main, 'titleType.id', '')
const title = get(main, 'titleText.text', '')
const originalTitle = get(main, 'originalTitleText.text', title)
const titles = title === originalTitle ? [title] : [title, originalTitle]
const genres = get(extra, 'genres.genres.*.text', [])
const year = get(extra, 'releaseYear.year') || 0
const $releaseDate = get(extra, 'releaseDate')
let releaseDate = null
if ($releaseDate) {
const date = new Date(
$releaseDate.year,
$releaseDate.month - 1,
$releaseDate.day
)
releaseDate = dayjs(date).format(DATE_FORMAT)
}
/** @type {Record<string, any>} */
const meta = {
id: imdbId,
type,
title,
originalTitle,
titles,
cast,
mainCast,
genres,
releaseDate,
}
if (rtType === 'tvSeries') {
meta.startYear = year
meta.endYear = get(extra, 'releaseYear.endYear') || 0
meta.seasons = get(main, 'episodes.seasons.length') || 0
meta.creators = get(main, 'creators.*.credits.*.name.nameText.text', [])
} else if (rtType === 'movie') {
meta.directors = get(main, 'directors.*.credits.*.name.nameText.text', [])
meta.writers = get(main, 'writers.*.credits.*.name.nameText.text', [])
meta.year = year
}
return meta
}
/**
* query the API, parse its response and extract the RT rating and consensus.
*
* if there's no consensus, default to "No consensus yet."
* if there's no rating, default to -1
*
* @param {string} imdbId
* @param {string} title
* @param {keyof Matcher} rtType
*/
async function getRTData (imdbId, title, rtType) {
const matcher = Matcher[rtType]
// we preload the anticipated RT page URL at the same time as the API request.
// the URL is the obvious path-formatted version of the IMDb title, e.g.:
//
// movie: "Bolt"
// preload URL: https://www.rottentomatoes.com/m/bolt
//
// tvSeries: "Sesame Street"
// preload URL: https://www.rottentomatoes.com/tv/sesame_street
//
// this guess produces the correct URL most (~70%) of the time
//
// preloading this page serves two purposes:
//
// 1) it reduces the time spent waiting for the RT widget to be displayed.
// rather than querying the API and *then* loading the page, the requests
// run concurrently, effectively halving the waiting time in most cases
//
// 2) it serves as a fallback if the API URL:
//
// a) is missing
// b) is invalid/fails to load
// c) is wrong (fails the verification check)
//
const preload = (function () {
const path = matcher.rtPath(title)
const url = RT_BASE + path
debug('preloading fallback URL:', url)
/** @type {Promise<Tampermonkey.Response<any>>} */
const request = asyncGet(url)
.then(res => {
debug(`preload response: ${res.status} ${res.statusText}`)
return res
})
.catch(e => {
debug(`error preloading ${url} (${e.status} ${e.statusText})`)
preload.error = e
})
return {
error: null,
fullUrl: url,
request,
url: path,
}
})()
const typeId = RT_TYPE_ID[rtType]
const template = GM_getResourceText('api')
const json = template
.replace('{{apiLimit}}', String(API_LIMIT))
.replace('{{typeId}}', String(typeId))
const { api, params, search, data } = JSON.parse(json)
const unquoted = title
.replace(/"/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const query = JSON.stringify(unquoted)
for (const [key, value] of Object.entries(search)) {
if (value && typeof value === 'object') {
search[key] = JSON.stringify(value)
}
}
Object.assign(data.requests[0], {
query,
params: $.param(search),
})
/** @type {AsyncGetOptions} */
const request = {
title: 'API',
params,
request: {
method: 'POST',
responseType: 'json',
data: JSON.stringify(data),
},
}
log(`querying API for ${query}`)
/** @type {Tampermonkey.Response<any>} */
const res = await asyncGet(api, request)
log(`API response: ${res.status} ${res.statusText}`)
let results
try {
results = JSON.parse(res.responseText).results[0].hits
} catch (e) {
throw new Error(`can't parse response: ${e}`)
}
if (!Array.isArray(results)) {
throw new Error('invalid response type')
}
// reorder the fields so the main fields are visible in the console without
// needing to expand each result
for (let i = 0; i < results.length; ++i) {
const result = results[i]
results[i] = {
title: result.title,
releaseYear: result.releaseYear,
vanity: result.vanity,
...result
}
}
debug('results:', results)
const imdb = await getIMDbMetadata(imdbId, rtType)
// do a basic sanity check to make sure it's valid
if (!imdb?.type) {
throw new Error(`can't find metadata for ${imdbId}`)
}
log('metadata:', imdb)
const matched = matcher.match(imdb, results)
const match = checkOverrides(matched, imdbId) || {
url: preload.url,
verify: true,
fallback: true,
}
debug('match:', match)
log('matched:', !match.fallback)
// values that can be modified by the RT client
/** @type {RTState} */
const state = {
url: RT_BASE + match.url
}
const rtClient = new RTClient({ match, matcher, preload, state })
const $rt = await rtClient.loadPage(imdb)
if (!$rt) {
throw abort()
}
const rating = BaseMatcher.rating($rt)
const $consensus = BaseMatcher.consensus($rt)
const consensus = $consensus?.trim()?.replace(/--/g, '—') || NO_CONSENSUS
const updated = BaseMatcher.lastModified($rt)
const preloaded = state.url === preload.fullUrl
return {
data: { consensus, rating, url: state.url },
preloaded,
updated,
}
}
/**
* normalize names so matches don't fail due to minor differences in casing or
* punctuation
*
* @param {string} name
*/
function normalize (name) {
return name
.normalize('NFKD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
/**
* extract the value of a property (dotted path) from each member of an array
*
* @param {any[] | undefined} array
* @param {string} path
*/
function pluck (array, path) {
return (array || []).map(it => get(it, path))
}
/**
* purge expired entries from the cache older than the supplied date
* (milliseconds since the epoch). if the date is -1, purge all entries
*
* @param {number} date
*/
function purgeCached (date) {
for (const key of GM_listValues()) {
const json = GM_getValue(key, '{}')
const value = JSON.parse(json)
const metadataVersion = METADATA_VERSION[key]
let $delete = false
if (metadataVersion) { // persistent (until the next METADATA_VERSION[key] change)
if (value.version !== metadataVersion) {
$delete = true
log(`purging invalid metadata (obsolete version: ${value.version}): ${key}`)
}
} else if (value.version !== DATA_VERSION) {
$delete = true
log(`purging invalid data (obsolete version: ${value.version}): ${key}`)
} else if (date === -1 || (typeof value.expires !== 'number') || date > value.expires) {
$delete = true
log(`purging expired value: ${key}`)
}
if ($delete) {
GM_deleteValue(key)
}
}
}
/**
* register a menu command which toggles verbose logging
*/
function registerDebugMenuCommand () {
/** @type {ReturnType<typeof GM_registerMenuCommand> | null} */
let id = null
const onClick = () => {
if (id) {
DEBUG = !DEBUG
if (DEBUG) {
GM_setValue(DEBUG_KEY, ENABLE_DEBUGGING)
} else {
GM_deleteValue(DEBUG_KEY)
}
GM_unregisterMenuCommand(id)
}
const name = `Enable debug logging${DEBUG ? ' ✔' : ''}`
id = GM_registerMenuCommand(name, onClick)
}
onClick()
}
/**
* register a menu command which toggles the RT link target between the current
* tab/window and a new tab/window
*/
function registerLinkTargetMenuCommand () {
const toggle = /** @type {const} */ ({ _self: '_blank', _blank: '_self' })
/** @type {(target: LinkTarget) => string} */
const name = target => `Open links in a new window${target === '_self' ? ' ✔' : ''}`
/** @type {ReturnType<typeof GM_registerMenuCommand> | null} */
let id = null
let target = getRTLinkTarget()
const onClick = () => {
if (id) {
target = toggle[target]
if (target === '_self') {
GM_deleteValue(TARGET_KEY)
} else {
GM_setValue(TARGET_KEY, NEW_WINDOW)
}
GM_unregisterMenuCommand(id)
EMITTER.emit(CHANGE_TARGET, target)
}
id = GM_registerMenuCommand(name(toggle[target]), onClick)
}
onClick()
}
/**
* convert an IMDb title into the most likely basename (final part of the URL)
* for that title on Rotten Tomatoes, e.g.:
*
* "A Stitch in Time" -> "a_stitch_in_time"
* "Lilo & Stitch" -> "lilo_and_stitch"
* "Peter's Friends" -> "peters_friends"
*
* @param {string} title
*/
function rtName (title) {
const name = title
.replace(/\s+&\s+/g, ' and ')
.replace(/'/g, '')
return normalize(name).replace(/\s+/g, '_')
}
/**
* take two iterable collections of strings and return an object containing:
*
* - got: the number of shared strings (strings common to both)
* - want: the required number of shared strings (minimum: 1)
* - max: the maximum possible number of shared strings
*
* if either collection is empty, the number of strings they have in common is -1
*
* @typedef Shared
* @prop {number} got
* @prop {number} want
* @prop {number} max
* @prop {Shared=} full
*
* @param {Iterable<string>} a
* @param {Iterable<string>} b
* @return Shared
*/
function _shared (a, b) {
/** @type {Set<string>} */
const $a = (a instanceof Set) ? a : new Set(Array.from(a, normalize))
if ($a.size === 0) {
return UNSHARED
}
/** @type {Set<string>} */
const $b = (b instanceof Set) ? b : new Set(Array.from(b, normalize))
if ($b.size === 0) {
return UNSHARED
}
const [smallest, largest] = $a.size < $b.size ? [$a, $b] : [$b, $a]
// the minimum number of elements shared between two Sets for them to be
// deemed similar
const minimumShared = Math.round(smallest.size / 2)
// we always want at least 1 even if the max is 0
const want = Math.max(minimumShared, 1)
let count = 0
for (const value of smallest) {
if (largest.has(value)) {
++count
}
}
return { got: count, want, max: smallest.size }
}
/**
* a curried wrapper for +_shared+ which takes two iterable collections of
* strings and returns an object containing:
*
* - got: the number of shared strings (strings common to both)
* - want: the required number of shared strings (minimum: 1)
* - max: the maximum possible number of shared strings
*
* if either collection is empty, the number of strings they have in common is -1
*
* @overload
* @param {Iterable<string>} a
* @return {(b: Iterable<string>) => Shared}
*
* @overload
* @param {Iterable<string>} a
* @param {Iterable<string>} b
* @return {Shared}
*
* @type {(...args: [Iterable<string>] | [Iterable<string>, Iterable<string>]) => unknown}
*/
function shared (...args) {
if (args.length === 2) {
return _shared(...args)
} else {
const a = new Set(Array.from(args[0], normalize))
return (/** @type {Iterable<string>} */ b) => _shared(a, b)
}
}
/**
* return the similarity between two strings, ranging from 0 (no similarity) to
* 2 (identical)
*
* similarity("John Woo", "John Woo") // 2
* similarity("Matthew Macfadyen", "Matthew MacFadyen") // 1
* similarity("Alan Arkin", "Zazie Beetz") // 0
*
* @param {string} a
* @param {string} b
* @return {number}
*/
function similarity (a, b, transform = normalize) {
// XXX work around a bug in fast-dice-coefficient which returns 0
// if either string's length is < 2
if (a === b) {
return 2
} else {
const $a = transform(a)
const $b = transform(b)
return ($a === $b ? 1 : exports.dice($a, $b))
}
}
/**
* measure the similarity of an IMDb title and an RT title returned by the API
*
* return the best match between the IMDb titles (display and original) and RT
* titles (display and AKAs)
*
* similarity("La haine", "Hate") // 0.2
* titleSimilarity(["La haine"], ["Hate", "La Haine"]) // 1
*
* @param {string[]} aTitles
* @param {string[]} bTitles
*/
function titleSimilarity (aTitles, bTitles) {
let max = 0
for (const [aTitle, bTitle] of cartesianProduct([aTitles, bTitles])) {
++PAGE_STATS.titleComparisons
const score = similarity(aTitle, bTitle)
if (score === 2) {
return score
} else if (score > max) {
max = score
}
}
return max
}
/**
* return true if the supplied arrays are similar (sufficiently overlap), false
* otherwise
*
* @param {Object} options
* @param {string} options.name
* @param {string[]} options.imdb
* @param {string[]} options.rt
*/
function verifyShared ({ name, imdb, rt }) {
debug(`verifying ${name}`)
debug(`imdb ${name}:`, imdb)
debug(`rt ${name}:`, rt)
const $shared = shared(rt, imdb)
debug(`shared ${name}:`, $shared)
return $shared.got >= $shared.want
}
/*
* poll for a truthy value, returning a promise which resolves the value or
* which is rejected if the probe times out
*/
const { waitFor, TimeoutError } = (function () {
class TimeoutError extends Error {}
// "pin" the window.load event
//
// we only wait for DOM elements, so if they don't exist by the time the
// last DOM lifecycle event fires, then they never will
const onLoad = exports.when(/** @type {(done: () => boolean) => void} */ done => {
window.addEventListener('load', done, { once: true })
})
// don't keep polling if we still haven't found anything after the page has
// finished loading
/** @type {WaitFor.Callback} */
const defaultCallback = onLoad
let ID = 0
/**
* @type {WaitFor.WaitFor}
* @param {any[]} args
*/
const waitFor = (...args) => {
/** @type {WaitFor.Checker<unknown>} */
const checker = args.pop()
/** @type {WaitFor.Callback} */
const callback = (args.length && (typeof args.at(-1) === 'function'))
? args.pop()
: defaultCallback
const id = String(args.length ? args.pop() : ++ID)
let count = -1
let retry = true
let found = false
const done = () => {
trace(() => `inside timeout handler for ${id}: ${found ? 'found' : 'not found'}`)
retry = false
return found
}
callback(done, id)
return new Promise((resolve, reject) => {
/** @type {FrameRequestCallback} */
const check = time => {
++count
let result
try {
result = checker({ tick: count, time, id })
} catch (e) {
return reject(/** @type {Error} */ e)
}
if (result) {
found = true
resolve(/** @type {any} */ (result))
} else if (retry) {
requestAnimationFrame(check)
} else {
const ticks = 'tick' + (count === 1 ? '' : 's')
const error = new TimeoutError(`polling timed out after ${count} ${ticks} (${id})`)
reject(error)
}
}
const now = document.timeline.currentTime ?? -1
check(now)
})
}
return { waitFor, TimeoutError }
})()
/******************************************************************************/
/**
* @param {string} imdbId
*/
async function run (imdbId) {
const now = Date.now()
// purgeCached(-1) // disable the cache
purgeCached(now)
// get the cached result for this page
const cached = JSON.parse(GM_getValue(imdbId, 'null'))
if (cached) {
const expires = new Date(cached.expires).toLocaleString()
if (cached.error) {
log(`cached error (expires: ${expires}):`, cached.error)
return
} else {
log(`cached result (expires: ${expires}):`, cached.data)
return addWidgets(cached.data)
}
} else {
log('not cached')
}
trace('waiting for json-ld')
const script = await waitFor('json-ld', () => {
return /** @type {HTMLScriptElement} */ (document.querySelector(LD_JSON))
})
trace('got json-ld: ', script.textContent?.length)
const ld = jsonLd(script, location.href)
const imdbType = /** @type {keyof RT_TYPE} */ (ld['@type'])
const rtType = RT_TYPE[imdbType]
if (!rtType) {
log(`invalid type for ${imdbId}: ${imdbType}`)
return
}
const name = htmlDecode(ld.name)
const alternateName = htmlDecode(ld.alternateName)
trace('ld.name:', JSON.stringify(name))
trace('ld.alternateName:', JSON.stringify(alternateName))
const title = alternateName || name
/**
* add a { version, expires, data|error } entry to the cache
*
* @param {any} dataOrError
* @param {number} ttl
*/
const store = (dataOrError, ttl) => {
if (DISABLE_CACHE) {
return
}
const expires = now + ttl
const cached = { version: DATA_VERSION, expires, ...dataOrError }
const json = JSON.stringify(cached)
GM_setValue(imdbId, json)
}
/** @type {{ version: number, data: typeof STATS }} */
const stats = JSON.parse(GM_getValue(STATS_KEY, 'null')) || {
version: METADATA_VERSION.stats,
data: clone(STATS),
}
/** @type {(path: string) => void} */
const bump = path => {
exports.dset(stats.data, path, get(stats.data, path, 0) + 1)
}
try {
const { data, preloaded, updated } = await getRTData(imdbId, title, rtType)
log('RT data:', data)
bump('hit')
bump(preloaded ? 'preload.hit' : 'preload.miss')
let active = false
if (updated) {
dayjs.extend(dayjs_plugin_relativeTime)
const date = dayjs()
const ago = date.to(updated)
const delta = date.diff(updated, 'month', /* float */ true)
active = delta <= INACTIVE_MONTHS
log(`last update: ${updated.format(DATE_FORMAT)} (${ago})`)
}
if (active) {
log('caching result for: one day')
store({ data }, ONE_DAY)
} else {
log('caching result for: one week')
store({ data }, ONE_WEEK)
}
await addWidgets(data)
} catch (error) {
bump('miss')
const message = error.message || String(error) // stringify
log(`caching error for one day: ${message}`)
store({ error: message }, ONE_DAY)
if (!error.abort) {
throw error
}
} finally {
bump('requests')
debug('stats:', stats.data)
trace('page stats:', PAGE_STATS)
GM_setValue(STATS_KEY, JSON.stringify(stats))
}
}
{
const start = Date.now()
const imdbId = location.pathname.split('/')[2]
log('id:', imdbId)
run(imdbId)
.then(() => {
const time = (Date.now() - start) / 1000
debug(`completed in ${time}s`)
})
.catch(e => {
if (e instanceof TimeoutError) {
warn(e.message)
} else {
console.error(e)
}
})
}
registerLinkTargetMenuCommand()
GM_registerMenuCommand('Clear cache', () => {
purgeCached(-1)
})
GM_registerMenuCommand('Clear stats', () => {
if (confirm('Clear stats?')) {
log('clearing stats')
GM_deleteValue(STATS_KEY)
}
})
registerDebugMenuCommand()
/* end */ }