您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add Rotten Tomatoes ratings to IMDb movie and TV show pages
当前为
// ==UserScript== // @name IMDb Tomatoes // @description Add Rotten Tomatoes ratings to IMDb movie and TV show pages // @author chocolateboy // @copyright chocolateboy // @version 4.7.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/bury.js // @require https://unpkg.com/[email protected]/dice.js // @require https://unpkg.com/[email protected]/dist/index.umd.min.js // @resource api https://pastebin.com/raw/hcN4ysZD // @resource overrides https://pastebin.com/raw/SUP1rYtX // @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="tampermonkey" /> /// <reference types="jquery" /> /// <reference types="node" /> 'use strict'; /* begin */ { const API_LIMIT = 100 const DATA_VERSION = 1.1 const INACTIVE_MONTHS = 3 const MAX_YEAR_DIFF = 3 const METADATA_VERSION = { stats: 1 } 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 RT_INVALID_DATE = new Set(['', 'TODO_MISSING']) const RT_INVALID_NAME = new Set(['', 'NA']) const SCRIPT_NAME = GM_info.script.name const TITLE_MATCH_THRESHOLD = 0.6 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 CONNECTION_ERROR = { status: 420, statusText: 'Connection Error', } const RT_TYPE = { TVSeries: 'tvSeries', Movie: 'movie', } const STATS = { request: 0, hit: 0, miss: 0, preload: { hit: 0, miss: 0, }, } 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 /** * deep-clone a JSON-serializable value * * @type {<T>(value: T) => T} */ const clone = value => JSON.parse(JSON.stringify(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: '.' }) /** * 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 */ $.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 timestamp (ISO-8601 string) of the last time an RT movie page * was updated, e.g. the date of the most-recently published review * * @param {RTDoc} $rt * @return {string | undefined} */ lastModified ({ meta }) { return lastModified(meta, 'dateCreated', 'dateModified') }, /** * return a movie record ({ url: string }) from the API results which * matches the supplied IMDb data * * @param {any} rtResults * @param {any} imdb * @return {RTMatch | void} */ match (rtResults, imdb) { const sorted = rtResults.movies .flatMap((rt, index) => { const { castItems, name: title, url } = rt if (!(title && url && castItems)) { return [] } if (url === '/m/null') { return [] } const rtCast = pluck(castItems, 'name').map(stripRtName) let castMatch = -1, verify = true if (rtCast.length) { const { got, want } = shared(rtCast, imdb.fullCast) if (got >= want) { verify = false castMatch = got } else { return [] } } const yearDiff = (imdb.year && rt.year) ? { value: Math.abs(imdb.year - rt.year) } : null if (yearDiff && yearDiff.value > MAX_YEAR_DIFF) { return [] } const titleMatch = titleSimilarity({ imdb, rt: { title } }) const result = { title, url, rating: rt.meterScore, popularity: (rt.meterScore == null ? 0 : 1), cast: rtCast, year: rt.year, index, titleMatch, castMatch, yearDiff, 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) } 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) 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 */ verify (imdb, $rt) { // 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 metaDirectors = pluck($rt.meta.director, 'name') .flatMap(name => RT_INVALID_NAME.has(name) ? [] : [stripRtName(name)]) const pageDirectors = $rt.find('[data-qa="movie-info-director"]') .get() .map(el => el.textContent?.trim()) debug(`movie directors: meta: ${metaDirectors.length}, page: ${pageDirectors.length}`) /** @type {string[]} */ // @ts-ignore const rtDirectors = metaDirectors.length > pageDirectors.length ? metaDirectors : pageDirectors return verifyShared({ imdb: imdb.directors, rt: rtDirectors, name: 'directors', }) }, } const TVMatcher = { /** * return the timestamp (ISO-8601 string) of the last time an RT TV show * page was updated, e.g. the date of the most-recently published review * * @param {RTDoc} $rt * @return {string | undefined} */ lastModified ({ meta }) { return lastModified(meta, 'datePublished') }, /** * return a TV show record ({ url: string }) from the API results which * matches the supplied IMDb data * * @param {any} rtResults * @param {any} imdb * @return {RTMatch | void} */ match (rtResults, imdb) { const sorted = rtResults.tvSeries .flatMap((rt, index) => { const { title, startYear, endYear, url } = rt if (!(title && (startYear || endYear) && url)) { return [] } let suffix, $url const match = url.match(/^(\/tv\/[^/]+)(?:\/(.+))?$/) if (match) { if (match[1] === '/tv/null') { return [] } suffix = match[2] $url = suffix ? url : `${url}/s01` } else { warn("can't parse RT URL:", url) return [] } const titleMatch = titleSimilarity({ imdb, rt }) if (titleMatch < TITLE_MATCH_THRESHOLD) { return [] } const dateDiffs = {} for (const dateProp of ['startYear', 'endYear']) { if (imdb[dateProp] && rt[dateProp]) { const diff = Math.abs(imdb[dateProp] - rt[dateProp]) if (diff > MAX_YEAR_DIFF) { return [] } else { dateDiffs[dateProp] = { value: diff } } } } 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, verify: true, } 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) 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 metadata * * @param {any} imdb * @param {RTDoc} $rt */ verify (imdb, $rt) { const metaCast = pluck($rt.meta.actor, 'name') .flatMap(name => RT_INVALID_NAME.has(name) ? [] : [stripRtName(name)]) const pageCast = $rt.find('[data-qa="cast-item-name"] > span[title]') .get() .flatMap(el => { const name = el.getAttribute('title') || '' return RT_INVALID_NAME.has(name) ? [] : [name] }) debug(`TV cast: meta: ${metaCast.length}, page: ${pageCast.length}`) /** @type {string[]} */ // @ts-ignore const rtCast = metaCast.length > pageCast.length ? metaCast : pageCast // compare the RT cast with the partial IMDb cast and the full IMDb cast // and select the one which produces the best match const partialShared = shared(rtCast, imdb.cast) const fullShared = shared(rtCast, imdb.fullCast) const partialDiff = partialShared.got - partialShared.want const fullDiff = fullShared.got - fullShared.want const imdbCast = partialDiff > fullDiff ? imdb.cast : imdb.fullCast return verifyShared({ imdb: imdbCast, rt: rtCast }) } } 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 (match or fallback) */ class RTClient { 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} 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 = $rt.jsonLd(id) return Object.assign($rt, { meta, document: dom }) } /** * load the RT URL (match or fallback) and return the corresponding XHR * response */ async loadPage () { const { match, preload, state } = this let requestType = match.fallback ? 'fallback' : 'match' let res log(`loading ${requestType} URL:`, state.url) // match URL (API result) and fallback (guessed) URL are the same if (match.url === preload.url) { res = await preload.promise // join the in-flight request if (!res) { log(`error loading ${state.url} (${preload.error.status} ${preload.error.statusText})`) return } } else { // separate match URL and fallback URL try { res = await asyncGet(state.url) // load the (absolute) match URL state.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 return } else { // use (and verify) the fallback URL requestType = 'fallback' state.url = preload.fullUrl state.verify = true log(`loading ${requestType} URL:`, state.url) res = await preload.promise if (!res) { log(`error loading ${state.url} (${preload.error.status} ${preload.error.statusText})`) return } } } } log(`${requestType} response: ${res.status} ${res.statusText}`) return this._parseResponse(res, state.url) } /** * confirm the metadata of the RT page (match or fallback) matches the IMDb * metadata * * @return {Promise<boolean>} */ async verify (imdb) { const { match, matcher, preload, state } = this let verified = matcher.verify(imdb, state.rtPage) if (!verified) { if (match.force) { log('force:', true) verified = true } else if (state.fallbackUnused) { state.url = preload.fullUrl log(`loading fallback URL:`, state.url) const res = await preload.promise if (res) { log(`fallback response: ${res.status} ${res.statusText}`) state.rtPage = this._parseResponse(res, preload.url) verified = matcher.verify(imdb, state.rtPage) } else { log(`error loading ${state.url} (${preload.error.status} ${preload.error.statusText})`) } } } return verified } } /* * 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 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) update the link's label and URL $rtRating .find('a[role="button"]') .attr({ 'aria-label': 'View RT Rating', href: url }) // 8) attach the tooltip to the widget const balloonOptions = Object.assign({}, BALLOON_OPTIONS, { contents: consensus }) $rtRating.balloon(balloonOptions) // 9) prepend the widget 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) { 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 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 (which happens no more than once - the ratings bar is frozen * (i.e. synchronisation is halted) once the page has loaded) * * @param {JQuery} $target * @param {JQuery} $rtRating */ function attachWidget ($target, $rtRating) { const init = { childList: true } const target = $target.get(0) const rtRating = $rtRating.get(0) if (!target) { throw new ReferenceError("can't find ratings bar") } if (!rtRating) { throw new ReferenceError("can't find RT widget") } // 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 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 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 } /** * return the consensus from a movie/TV page as a HTML string * * @param {JQuery<Document>} $rt */ function getConsensus ($rt) { return $rt.find('[data-qa="score-panel-critics-consensus"], [data-qa="critics-consensus"]') .first() .html() } /** * 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.pageProps.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 {keyof Matcher} rtType */ async function getRTData (imdb, rtType) { log(`querying API for ${JSON.stringify(imdb.title)}`) /** @type {AsyncGetOptions} */ const apiRequest = { params: { t: rtType, q: imdb.title, limit: API_LIMIT }, request: { responseType: 'json' }, title: 'API', } 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/s01 // // this guess produces the correct URL most (~75%) 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) loads but fails the verification check // const preload = (function () { const path = matcher.rtPath(imdb.title) const url = RT_BASE + path debug('preloading fallback URL:', url) /** @type {Promise<Tampermonkey.Response>} */ const promise = 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, promise, url: path, } })() const api = GM_getResourceText('api') /** @type {Tampermonkey.Response} */ let res = await asyncGet(api, apiRequest) log(`API response: ${res.status} ${res.statusText}`) let results try { results = JSON.parse(res.responseText) } catch (e) { throw new Error(`can't parse response: ${e}`) } if (!results) { throw new Error('invalid JSON type') } debug('results:', results) const matched = matcher.match(results, imdb) const match = checkOverrides(matched, imdb.id) || { url: preload.url, verify: true, fallback: true, } debug('match:', match) log('matched:', !match.fallback) // values that can be modified by the RT client /** @type {{ fallbackUnused: boolean, rtPage?: RTDoc, url: string, verify: boolean }} */ // @ts-ignore const state = { fallbackUnused: false, rtPage: undefined, url: RT_BASE + match.url, verify: match.verify, } const rtClient = new RTClient({ match, matcher, preload, state }) state.rtPage = await rtClient.loadPage() if (!state.rtPage) { throw abort() } if (state.verify) { const verified = await rtClient.verify(imdb) if (!verified) { throw abort() } } const $rt = state.rtPage const $rating = $rt.meta.aggregateRating const rating = Number(($rating?.name === 'Tomatometer' ? $rating.ratingValue : null) ?? -1) const consensus = getConsensus($rt)?.trim()?.replace(/--/g, '—') || NO_CONSENSUS const updated = matcher.lastModified($rt) return { data: { consensus, rating, url: state.url }, preloadUrl: preload.fullUrl, updated, } } /** * return the last time a movie/TV page was updated based on its JSON-LD * metadata * * @param {any} rtMeta * @param {string} reviewProp * @param {string=} pageProp * @returns {string | undefined} */ function lastModified (rtMeta, reviewProp, pageProp) { 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 && pageProp && !RT_INVALID_DATE.has(rtMeta[pageProp])) { updated = rtMeta[pageProp] 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 */ 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] if (metadataVersion) { // persistent (until the next METADATA_VERSION[key] change) if (value.version !== metadataVersion) { log(`purging invalid metadata (obsolete version: ${value.version}): ${key}`) GM_deleteValue(key) } } else if (value.version !== DATA_VERSION) { log(`purging invalid data (obsolete 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) } } } /** * 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, '_') } /** * 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] */ 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 * @return {number} */ function similarity (a, b, map = normalize) { return a === b ? 2 : exports.dice(map(a), map(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 */ 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, originalTitle: string }} options.imdb * @param {{ title: string }} options.rt */ function titleSimilarity ({ imdb, rt }) { const rtTitle = rt.title .trim() .replace(/\s+/, ' ') // remove extraneous spaces, e.g. tt2521668 .replace(/\s+\((?:US|UK|(?:(?:19|20)\d\d))\)$/, '') 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 log('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) } /** @type {{ version: number, data: typeof STATS }} */ const stats = JSON.parse(GM_getValue('stats', 'null')) || { version: METADATA_VERSION.stats, data: clone(STATS), } /** @type {(path: string) => void} */ const bump = path => { exports.bury(stats.data, path, get(stats.data, path, 0) + 1) } bump('request') try { const { data, updated: $updated, preloadUrl } = await getRTData(imdb, rtType) log('RT data:', data) bump('hit') bump(data.url === preloadUrl ? 'preload.hit' : 'preload.miss') let active = false if ($updated) { dayjs.extend(dayjs_plugin_relativeTime) const updated = dayjs($updated) 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('YYYY-MM-DD')} (${ago})`) } if (active) { 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) { bump('miss') bump('preload.miss') 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) } } finally { debug('stats:', stats.data) GM_setValue('stats', JSON.stringify(stats)) } } // register these first so data can be cleared even if there's an error GM_registerMenuCommand(`${SCRIPT_NAME}: clear cache`, () => { purgeCached(-1) }) GM_registerMenuCommand(`${SCRIPT_NAME}: clear stats`, () => { if (confirm('Clear stats?')) { log('clearing stats') GM_deleteValue('stats') } }) // 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 userscript // sees. on my system, this can occur up to 4 seconds before DOMContentLoaded $(document).one('readystatechange', run) /* end */ }
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址