// ==UserScript==
// @name IMDb Tomatoes
// @description Add Rotten Tomatoes ratings to IMDb movie and TV show pages
// @author chocolateboy
// @copyright chocolateboy
// @version 4.3.0
// @namespace https://github.com/chocolateboy/userscripts
// @license GPL
// @include /^https://www\.imdb\.com/title/tt[0-9]+/([#?].*)?$/
// @require https://code.jquery.com/jquery-3.6.0.min.js
// @require https://cdn.jsdelivr.net/gh/urin/jquery.balloon.js@8b79aab63b9ae34770bfa81c9bfe30019d9a13b0/jquery.balloon.js
// @require https://unpkg.com/[email protected]/dayjs.min.js
// @require https://unpkg.com/[email protected]/plugin/relativeTime.js
// @require https://unpkg.com/@chocolateboy/[email protected]/dist/polyfill.iife.min.js
// @require https://unpkg.com/[email protected]/dist/index.umd.min.js
// @require https://unpkg.com/[email protected]/dice.js
// @resource api https://pastebin.com/raw/hcN4ysZD
// @resource fallback https://pastebin.com/raw/st41GA15
// @grant GM_addStyle
// @grant GM_deleteValue
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect www.rottentomatoes.com
// @run-at document-start
// @noframes
// ==/UserScript==
/// <reference types="greasemonkey" />
/// <reference types="jquery" />
/// <reference types="tampermonkey" />
/**
* @typedef {Object} AsyncGetOptions
*
* @prop {Record<string, string | number | boolean>} [params]
* @prop {string} [title]
* @prop {Partial<Tampermonkey.Request>} [request]
*/
'use strict';
/* begin */ {
const INACTIVE_MONTHS = 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_BASE = 'https://www.rottentomatoes.com'
const SCRIPT_NAME = GM_info.script.name
const SCRIPT_VERSION = GM_info.script.version
// the version of each cached record is a combination of the schema version and
// the major part of the script's (SemVer) version, and an additional number
// (the cache-generation) which can be incremented to force the cache to be
// cleared. the generation is reset when the schema or major versions change
//
// e.g. 4 (schema) + 3 (major) + 0 (generation) gives a version of "4/3.0"
//
// this means cached records are invalidated either a) when the schema changes,
// b) when the major version of the script changes, or c) when the generation is
// bumped
const SCHEMA_VERSION = 4
const SCRIPT_MAJOR = SCRIPT_VERSION.split('.')[0]
const CACHE_GENERATION = 0
const DATA_VERSION = `${SCHEMA_VERSION}/${SCRIPT_MAJOR}.${CACHE_GENERATION}`
const BALLOON_OPTIONS = {
classname: 'rt-consensus-balloon',
css: {
fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
fontSize: '0.9rem',
lineHeight: '1.26rem',
maxWidth: '31rem',
padding: '0.75rem',
},
html: true,
position: 'bottom',
}
const COLOR = {
tbd: '#d9d9d9',
fresh: '#67ad4b',
rotten: '#fb3c3c',
}
const RT_TYPE = {
TVSeries: 'tvSeries',
Movie: 'movie',
}
const UNSHARED = Object.freeze({
got: -1,
want: 1,
max: 0,
})
/*
* the minimum number of elements shared between two Sets for them to be
* deemed similar
*
* @type {<T>(smallest: Set<T>, largest: Set<T>) => number}
*/
const MINIMUM_SHARED = smallest => Math.round(smallest.size / 2)
// log a message to the console
const { debug, info, log, warn } = console
// 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: '.' })
/**
* register a jQuery plugin which extracts and returns JSON-LD data for the
* loaded document
*
* used to extract metadata on IMDb and Rotten Tomatoes
*
* @param {string} id
* @return {any}
*/
$.fn.jsonLd = function jsonLd (id) {
const $script = this.find('script[type="application/ld+json"]')
let data
if ($script.length) {
try {
data = JSON.parse($script.first().text().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 MovieMatcher = {
/**
* return the consensus from a movie page as a HTML string
*
* @param {JQuery<Document>} $rt
* @return {string | undefined}
*/
getConsensus ($rt) {
return $rt.find('[data-qa="score-panel-critics-consensus"], [data-qa="critics-consensus"]')
.first()
.html()
},
/**
* return a movie record ({ url: string }) from the API results which
* matches the supplied IMDb data
*
* @param {any} rtResults
* @param {any} imdb
* @return {{ match: { url: string }, verify?: ($rt: JQuery & { meta: any }) => boolean } | void}
*/
match (rtResults, imdb) {
const sorted = rtResults.movies
.flatMap((rt, index) => {
if (!(rt.name && rt.url && rt.castItems)) {
return []
}
const { name: title } = rt
const rtCast = pluck(rt.castItems, 'name').map(stripRtName)
let castMatch
if (rtCast.length) {
const { got, want } = shared(rtCast, imdb.fullCast)
if (got < want) {
return []
} else {
castMatch = got
}
} else {
castMatch = -1
}
const yearDiff = (imdb.year && rt.year)
? { value: Math.abs(imdb.year - rt.year) }
: null
if (yearDiff && yearDiff.value > 3) {
return []
}
const titleMatch = titleSimilarity({ imdb, rt: { title } })
const result = {
title,
url: rt.url,
rating: rt.meterScore,
popularity: (rt.meterScore == null ? 0 : 1),
cast: rtCast,
year: rt.year,
index,
titleMatch,
castMatch,
yearDiff,
}
return [result]
})
.sort((a, b) => {
// combine the title and the year
//
// 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)
}
return (b.castMatch - a.castMatch)
|| (score.b - score.a)
|| (b.titleMatch - a.titleMatch) // prioritise the title if we're still deadlocked
|| (b.popularity - a.popularity) // last resort
})
debug('matches:', sorted)
if (sorted.length) {
const [match] = sorted
if (match.cast.length) { // already verified
return { match }
} else {
// in theory, we can verify the cast when the page is loaded. in
// practice, it doesn't work: if the cast is missing from the
// API results, it's also missing from the page's metadata
//
// when the cast is missing from the metadata, the directors are
// often missing from the metadata as well, so we try to scrape
// them instead
const verify = $rt => {
const rtDirectors = $rt.meta.director?.length
? pluck($rt.meta.director, 'name').map(stripRtName)
: $rt.find('[data-qa="movie-info-director"]').get().map(it => it.textContent?.trim())
return verifyShared({ imdb: imdb.directors, rt: rtDirectors, name: 'directors' })
}
return { match, verify }
}
}
},
}
const TVMatcher = {
/**
* return the consensus from a TV page as a HTML string
*
* @param {JQuery<Document>} $rt
* @return {string | undefined}
*/
getConsensus ($rt) {
return $rt.find('season-list-item[consensus]')
.last()
.attr('consensus')
},
/**
* return a TV show record ({ url: string }) from the API results which
* matches the supplied IMDb data
*
* returns an additional validation function to run against the RT page's
* metadata to confirm the match
*
* @param {any} rtResults
* @param {any} imdb
* @return {{ match: { url: string }, verify?: ($rt: JQuery & { meta: any }) => boolean } | void}
*/
match (rtResults, imdb) {
const verify = $rt => {
const rtCast = $rt.meta.actor?.length
? pluck($rt.meta.actor, 'name').map(stripRtName)
: $rt.find('[data-qa="cast-member"]').get().map(it => it.textContent?.trim())
const diff1 = Math.abs(rtCast.length - imdb.fullCast.length)
const diff2 = Math.abs(rtCast.length - imdb.cast.length)
const imdbCast = diff1 > diff2 ? imdb.fullCast : imdb.cast
return verifyShared({ imdb: imdbCast, rt: rtCast })
}
const sorted = rtResults.tvSeries
.flatMap((rt, index) => {
const { title, startYear, endYear, url } = rt
if (!(title && (startYear || endYear) && url)) {
return []
}
const titleMatch = titleSimilarity({ imdb, rt })
if (titleMatch < 0.6) {
return []
}
const dateDiffs = {}
for (const dateProp of ['startYear', 'endYear']) {
if (imdb[dateProp] && rt[dateProp]) {
const diff = Math.abs(imdb[dateProp] - rt[dateProp])
if (diff > 3) {
return []
} else {
dateDiffs[dateProp] = { value: diff }
}
}
}
let suffix, $url
const match = url.match(/^(\/tv\/[^/]+)(?:\/(.+))?$/)
if (match) {
suffix = match[2]
$url = match[1]
} else {
warn("can't parse RT URL:", url)
return []
}
const seasonsDiff = (suffix === 's01' && imdb.seasons)
? { value: imdb.seasons - 1 }
: null
const result = {
title,
url: $url,
rating: rt.meterScore,
popularity: (rt.meterScore == null ? 0 : 1),
startYear,
endYear,
index,
titleMatch,
startYearDiff: dateDiffs.startYear,
endYearDiff: dateDiffs.endYear,
seasonsDiff,
}
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)
}
return (score.b - score.a)
|| (b.titleMatch - a.titleMatch) // prioritise the title if we're still deadlocked
|| (b.popularity - a.popularity) // last resort
})
debug('matches:', sorted)
const [match] = sorted
if (match) {
return { match, verify }
}
},
}
/**
* a helper class which keeps a running total of scores for two values (a and
* b). used to rank values in a sort function
*
* @param {number} order
* @return {void}
*/
class Score {
constructor () {
this.a = 0
this.b = 0
}
/**
* add a score to the total
*
* @param {number} order
* @param {number=} points
* @return {void}
*/
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
* @throws {Error}
*/
function abort (message) {
throw Object.assign(new Error(message), { abort: true })
}
/**
* add a Rotten Tomatoes widget to the ratings bar
*
* @param {JQuery} $ratings
* @param {JQuery} $imdbRating
* @param {Object} data
* @param {string} data.url
* @param {string} data.consensus
* @param {number} data.rating
*/
function addWidget ($ratings, $imdbRating, { consensus, rating, url }) {
let style
if (rating === -1) {
style = 'tbd'
} else if (rating < 60) {
style = 'rotten'
} else {
style = 'fresh'
}
// clone the IMDb rating widget
const $rtRating = $imdbRating.clone()
// 1) assign a unique ID
$rtRating.attr('id', 'rt-rating')
// 2) add a custom stylesheet which:
//
// - sets the star (SVG) to the right color
// - restores support for italics in the consensus text
// - reorders the appended widget (see attachWidget)
GM_addStyle(`
#rt-rating svg { color: ${COLOR[style]}; }
#rt-rating { order: -1; }
.rt-consensus-balloon em { font-style: italic; }
`)
// 3) remove the review count and its preceding spacer element
$rtRating
.find('[class^="AggregateRatingButton__TotalRatingAmount-"]')
.prev()
.addBack()
.remove()
// 4) replace "IMDb Rating" with "RT Rating"
$rtRating.find('[class^="RatingBarButtonBase__Header-"]')
.text('RT RATING')
// 5) replace the IMDb rating with the RT score and remove the "/ 10" suffix
const score = rating === -1 ? 'N/A' : `${rating}%`
$rtRating
.find('[class^="AggregateRatingButton__RatingScore-"]')
.text(score)
.next()
.remove()
// 6) rename the testids, e.g.:
// hero-rating-bar__aggregate-rating -> hero-rating-bar__rt-rating
$rtRating.find('[data-testid]').addBack().each(function () {
$(this).attr('data-testid', (_, id) => id.replace('aggregate', 'rt'))
})
// 7) add the tooltip class to the link and update its label and URL
const balloonOptions = Object.assign({}, BALLOON_OPTIONS, { contents: consensus })
$rtRating.find('a[role="button"]')
.addClass('rt-consensus')
.balloon(balloonOptions)
.attr('aria-label', 'View RT Rating')
.attr('href', url)
// 8) prepend the element to the ratings bar
attachWidget($ratings, $rtRating)
}
/**
* 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) {
reject(new Error(`error fetching ${id} (${res.status} ${res.statusText})`))
} else {
resolve(res)
}
}
// XXX the +onerror+ response object doesn't contain any useful info
request.onerror = _res => {
reject(new Error(`error fetching ${id}`))
}
GM_xmlhttpRequest(request)
})
}
/**
* attach the RT ratings widget to the 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 the RT widget remain in the updated IMDb
* widget, including its ID attribute (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
*
* @param {JQuery} $target
* @param {JQuery} $rtRating
*/
function attachWidget ($target, $rtRating) {
const init = { childList: true }
const target = $target.get(0)
const rtRating = $rtRating.get(0)
const callback = () => {
if (target.lastElementChild !== rtRating) {
observer.disconnect()
target.appendChild(rtRating)
observer.observe(target, init)
}
}
const observer = new MutationObserver(callback)
target.appendChild(rtRating)
observer.observe(target, init)
}
/**
* check the fallback data in case of a failed match, but only use it as a last
* resort, i.e. try the verifier first, if defined, in case the data has
* been fixed/updated
*
* @param {any} matched
* @param {string} imdbId
*/
function checkFallback (matched, imdbId) {
const fallback = JSON.parse(GM_getResourceText('fallback'))
const url = fallback[imdbId]
if (url) {
const $url = JSON.stringify(url)
const { verify } = matched
if (!matched.match) { // missing result
debug('fallback:', $url)
matched.match = { url }
} else if (matched.match.url !== url) { // wrong result
const $overridden = JSON.stringify(matched.match.url)
debug(`override: ${$overridden} -> ${$url}`)
matched.match.url = url
}
if (verify) {
matched.verify = $rt => {
if (!verify($rt)) { // missing/incompatible RT data
debug('force:', true)
}
return true
}
}
}
return matched
}
/**
* extract IMDb metadata from the GraphQL data embedded in the page
*
* @param {string} imdbId
* @param {string} rtType
*/
function getIMDbMetadata (imdbId, rtType) {
const data = JSON.parse($('#__NEXT_DATA__').text())
const decorate = data => {
return { data, size: Object.keys(data).length }
}
// there are multiple matching subtrees (with different but partially
// overlapping keys). order them in descending order of size (number of keys)
const titles = get(data, 'props.urqlState.*.data.title', [])
.filter(title => title.id === imdbId)
.map(decorate)
.sort((a, b) => b.size - a.size)
.map(it => it.data)
const [main, extra] = titles
const mainCast = get(main, 'principalCast.*.credits.*.name.nameText.text', [])
const extraCast = get(main, 'cast.edges.*.node.name.nameText.text', [])
const fullCast = Array.from(new Set([...mainCast, ...extraCast]))
const title = get(main, 'titleText.text', '')
const originalTitle = get(main, 'originalTitleText.text', '')
const year = get(main, 'releaseYear.year') || 0
const type = get(main, 'titleType.id', '')
const meta = {
id: imdbId,
cast: mainCast,
fullCast,
title,
originalTitle,
type,
}
if (rtType === 'tvSeries') {
meta.startYear = year
meta.endYear = get(extra, 'releaseYear.endYear') || 0
meta.seasons = get(main, 'episodes.seasons.length') || 0
} else if (rtType === 'movie') {
meta.directors = get(main, 'directors.*.credits.*.name.nameText.text', [])
meta.year = year
}
return meta
}
/**
* parse the API's 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 {any} imdb
* @param {"tvSeries" | "movie"} rtType
* @return {Promise<{data: { consensus: string, rating: number, url: string }, updated: string}>}
*/
async function getRTData (imdb, rtType) {
log(`querying API for ${JSON.stringify(imdb.title)}`)
/** @type {AsyncGetOptions} */
const request = {
params: { t: rtType, q: imdb.title, limit: 100 },
request: { responseType: 'json' },
title: 'API',
}
const api = GM_getResourceText('api')
const response = await asyncGet(api, request)
log(`response: ${response.status} ${response.statusText}`)
let results
try {
results = JSON.parse(response.responseText)
} catch (e) {
throw new Error(`can't parse response: ${e}`)
}
if (!results) {
throw new Error('invalid JSON type')
}
debug('results:', results)
const matcher = rtType === 'movie' ? MovieMatcher : TVMatcher
const matched = matcher.match(results, imdb) || {}
const { match, verify } = checkFallback(matched, imdb.id)
if (!match) {
abort(NO_MATCH)
}
debug('match:', match)
// @ts-ignore
const url = RT_BASE + match.url
log(`loading RT URL: ${url}`)
const res = await asyncGet(url)
log(`response: ${res.status} ${res.statusText}`)
const parser = new DOMParser()
const dom = parser.parseFromString(res.responseText, 'text/html')
const $rt = $(dom)
const consensus = matcher.getConsensus($rt)?.trim()?.replace(/--/g, '—') || NO_CONSENSUS
const meta = $rt.jsonLd(url)
if (verify) {
Object.assign($rt, { meta })
if (!verify($rt)) {
abort(NO_MATCH)
}
}
const updated = getUpdated(meta, rtType)
const $rating = meta.aggregateRating
const rating = Number(($rating.name === 'Tomatometer' ? $rating.ratingValue : null) ?? -1)
return { data: { consensus, rating, url }, updated }
}
/**
* get the timestamp (ISO-8601 string) of the last time the RT page was updated,
* e.g. the date of the most-recently published review
*
* @param {any} rtMeta
* @param {'movie' | 'tvSeries'} rtType
* @return {string}
*/
function getUpdated (rtMeta, rtType) {
const [reviewProp, rootProp] = rtType === 'movie'
? ['dateCreated', 'dateModified']
: ['datePublished', 'datePublished']
let updated
if (rtMeta.review?.length) {
debug('reviews:', rtMeta.review.length)
const [latest] = rtMeta.review
.flatMap(review => {
return review[reviewProp]
? [{ review, mtime: dayjs(review[reviewProp]).unix() }]
: []
})
.sort((a, b) => b.mtime - a.mtime)
if (latest) {
updated = latest.review[reviewProp]
debug('updated (most recent review):', updated)
}
}
if (!updated && (updated = rtMeta[rootProp])) {
debug('updated (page modified):', updated)
}
return updated
}
/**
* normalize names so matches don't fail due to minor differences in casing or
* punctuation
*
* @param {string} name
* @returns {string}
*/
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)
if (value.version !== DATA_VERSION) {
log(`purging invalid value (obsolete data version (${value.version})): ${key}`)
GM_deleteValue(key)
} else if (date === -1 || (typeof value.expires !== 'number') || (date > value.expires)) {
log(`purging expired value: ${key}`)
GM_deleteValue(key)
}
}
}
/**
* given two arrays of strings, 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 array is empty, the number of strings they have in common is -1
*
* @param {Iterable<string>} a
* @param {Iterable<string>} b
* @param {Object} [options]
* @param {(smallest: Set<string>, largest: Set<string>) => number} [options.min]
* @param {(value: string) => string} [options.map]
* @return {{ got: number, want: number, max: number }}
*/
function shared (a, b, { min = MINIMUM_SHARED, map: transform = normalize } = {}) {
const $a = new Set(Array.from(a, transform))
if ($a.size === 0) {
return UNSHARED
}
const $b = new Set(Array.from(b, transform))
if ($b.size === 0) {
return UNSHARED
}
const [smallest, largest] = $a.size < $b.size ? [$a, $b] : [$b, $a]
// we always want at least 1 even if the maximum is 0
const want = Math.max(min(smallest, largest), 1)
let count = 0
for (const value of smallest) {
if (largest.has(value)) {
++count
}
}
return { got: count, want, max: smallest.size }
}
/**
* 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
*/
function similarity (a, b) {
return a === b ? 2 : exports.dice(normalize(a), normalize(b))
}
/**
* strip trailing sequence numbers in names in RT metadata, e.g.
*
* - "Meng Li (IX)" -> "Meng Li"
* - "Michael Dwyer (X) " -> "Michael Dwyer"
*
* @param {string} name
* @return string
*/
function stripRtName (name) {
return name.replace(/\s+\([IVXLCDM]+\)\s*$/, '')
}
/*
* measure the similarity of an IMDb title and an RT title returned by the API
*
* RT titles for foreign-language films/shows sometimes contain the original
* title at the end in brackets, so we take that into account
*
* note, we only use this if the original IMDb title differs from the main
* IMDb title
*
* similarity("The Swarm", "The Swarm (La Nuée)") // 0.66
* titleSimilarity({ imdb: "The Swarm", rt: "The Swarm (La Nuée)" }) // 2
*
* @param {Object} options
* @param {{ title: string }} options.imdb
* @param {{ title: string }} options.rt
* @return {number}
*/
function titleSimilarity ({ imdb, rt }) {
const rtTitle = rt.title
.trim()
.replace(/\s+/, ' ') // remove extraneous spaces, e.g. tt2521668
.replace(/\s+\((?:US|UK)\)$/, '')
if (imdb.originalTitle && imdb.title !== imdb.originalTitle) {
const match = rtTitle.match(/^(.+?)\s+\(([^)]+)\)$/)
if (match) {
const s1 = similarity(imdb.title, match[1])
const s2 = similarity(imdb.title, match[2])
const s3 = similarity(imdb.title, rtTitle)
return Math.max(s1, s2, s3)
} else {
const s1 = similarity(imdb.title, rtTitle)
const s2 = similarity(imdb.originalTitle, rtTitle)
return Math.max(s1, s2)
}
}
return similarity(imdb.title, rtTitle)
}
/**
* return true if the supplied arrays are similar (sufficiently overlap), false
* otherwise
*
* @param {Object} options
* @param {string[]} options.imdb
* @param {string=} options.name
* @param {string[]} options.rt
*/
function verifyShared ({ imdb, rt, name = 'cast' }) {
debug(`verifying ${name}`)
debug(`imdb ${name}:`, imdb)
debug(`rt ${name}:`, rt)
const $shared = shared(rt, imdb)
debug(`shared ${name}:`, $shared)
const verified = $shared.got >= $shared.want
debug('verified:', verified)
return verified
}
/******************************************************************************/
async function run () {
const now = Date.now()
// purgeCached(-1) // disable the cache
purgeCached(now)
const imdbId = $(`meta[property="imdb:pageConst"]`).attr('content')
if (!imdbId) {
// XXX shouldn't get here
console.error("can't find IMDb ID:", location.href)
return
}
log('id:', imdbId)
// we clone the IMDb widget, so make sure it exists before navigating up to
// its container
const $imdbRating = $('[data-testid="hero-rating-bar__aggregate-rating"]').first()
if (!$imdbRating.length) {
info(`can't find IMDb rating for ${imdbId}`)
return
}
const $ratings = $imdbRating.parent()
// 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)
} else {
log(`cached result (expires: ${expires}):`, cached.data)
addWidget($ratings, $imdbRating, cached.data)
}
return
} else {
log('not cached')
}
const imdbType = $(document).jsonLd(location.href)?.['@type']
const rtType = RT_TYPE[imdbType]
if (!rtType) {
info(`invalid type for ${imdbId}: ${imdbType}`)
return
}
const imdb = getIMDbMetadata(imdbId, rtType)
// do a basic sanity check to make sure it's valid
if (!imdb?.type) {
console.error(`can't find metadata for ${imdbId}`)
return
}
log('metadata:', imdb)
// add a { version, expires, data|error } entry to the cache
const store = (dataOrError, ttl) => {
const expires = now + ttl
const cached = { version: DATA_VERSION, expires, ...dataOrError }
const json = JSON.stringify(cached)
GM_setValue(imdbId, json)
}
try {
const { data, updated: $updated } = await getRTData(imdb, rtType)
log('RT data:', data)
dayjs.extend(dayjs_plugin_relativeTime)
const updated = dayjs($updated)
const date = dayjs()
const delta = date.diff(updated, 'month', /* float */ true)
const ago = date.to(updated)
log(`last update: ${updated.format('YYYY-MM-DD')} (${ago})`)
if (delta <= INACTIVE_MONTHS) {
log('caching result for: one day')
store({ data }, ONE_DAY)
} else {
log('caching result for: one week')
store({ data }, ONE_WEEK)
}
addWidget($ratings, $imdbRating, data)
} catch (error) {
const message = error.message || String(error) // stringify
log(`caching error for one day: ${message}`)
store({ error: message }, ONE_DAY)
if (!error.abort) {
console.error(error)
}
}
}
// register this first so data can be cleared even if there's an error
GM_registerMenuCommand(`${SCRIPT_NAME}: clear cache`, () => {
purgeCached(-1)
});
// DOMContentLoaded typically fires several seconds after the IMDb ratings
// widget is displayed, which leads to an unacceptable delay if the result is
// already cached, so we hook into the earliest event which fires after the
// widget is loaded.
//
// this occurs when document.readyState transitions from "loading" to
// "interactive", which should be the first readystatechange event a script
// sees. on my system, this can occur up to 4 seconds before DOMContentLoaded
$(document).one('readystatechange', run)
/* end */ }