// ==UserScript== // @name Show Rottentomatoes meter // @description Show Rotten Tomatoes score on imdb.com, metacritic.com, letterboxd.com, BoxOfficeMojo, serienjunkies.de, Amazon, tv.com, Google Play, allmovie.com, Wikipedia, themoviedb.org, movies.com, tvmaze.com, tvguide.com, followshows.com, thetvdb.com, tvnfo.com // @namespace cuzi // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @grant GM.xmlHttpRequest // @grant GM.setValue // @grant GM.getValue // @require http://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js // @license GPL-3.0-or-later; http://www.gnu.org/licenses/gpl-3.0.txt // @version 28 // @connect www.rottentomatoes.com // @connect algolia.net // @connect www.flixster.com // @include https://www.rottentomatoes.com/ // @include https://play.google.com/store/movies/details/* // @include http://www.amazon.com/* // @include https://www.amazon.com/* // @include http://www.amazon.co.uk/* // @include https://www.amazon.co.uk/* // @include http://www.amazon.fr/* // @include https://www.amazon.fr/* // @include http://www.amazon.de/* // @include https://www.amazon.de/* // @include http://www.amazon.es/* // @include https://www.amazon.es/* // @include http://www.amazon.ca/* // @include https://www.amazon.ca/* // @include http://www.amazon.in/* // @include https://www.amazon.in/* // @include http://www.amazon.it/* // @include https://www.amazon.it/* // @include http://www.amazon.co.jp/* // @include https://www.amazon.co.jp/* // @include http://www.amazon.com.mx/* // @include https://www.amazon.com.mx/* // @include http://www.amazon.com.au/* // @include https://www.amazon.com.au/* // @include http://www.imdb.com/title/* // @include https://www.imdb.com/title/* // @include http://www.serienjunkies.de/* // @include https://www.serienjunkies.de/* // @include http://www.tv.com/shows/* // @include http://www.boxofficemojo.com/movies/* // @include https://www.boxofficemojo.com/movies/* // @include https://www.boxofficemojo.com/release/* // @include http://www.allmovie.com/movie/* // @include https://www.allmovie.com/movie/* // @include https://en.wikipedia.org/* // @include https://www.fandango.com/* // @include https://www.themoviedb.org/movie/* // @include https://www.themoviedb.org/tv/* // @include http://letterboxd.com/film/* // @include https://letterboxd.com/film/* // @exclude https://letterboxd.com/film/*/image* // @include http://www.tvmaze.com/shows/* // @include https://www.tvmaze.com/shows/* // @include http://www.tvguide.com/tvshows/* // @include https://www.tvguide.com/tvshows/* // @include http://followshows.com/show/* // @include https://followshows.com/show/* // @include https://thetvdb.com/series/* // @include https://thetvdb.com/movies/* // @include http://tvnfo.com/s/* // @include https://tvnfo.com/s/* // @include http://www.metacritic.com/movie/* // @include https://www.metacritic.com/movie/* // @include http://www.metacritic.com/tv/* // @include https://www.metacritic.com/tv/* // @include https://www.nme.com/reviews/movie/* // @include https://itunes.apple.com/*/movie/* // @include https://itunes.apple.com/*/tv-season/* // @include http://epguides.com/* // @include http://www.epguides.com/* // @include https://sharetv.com/shows/* // @include http://www.cc.com/* // @include https://www.tvhoard.com/* // @include https://www.amc.com/* // @include https://www.amcplus.com/* // @include https://rlsbb.ru/*/ // @include https://www.sho.com/* // @downloadURL none // ==/UserScript== /* global GM, $, unsafeWindow, RottenTomatoes */ const baseURL = 'https://www.rottentomatoes.com' const baseURLSearch = baseURL + '/api/private/v2.0/search/?limit=100&q={query}&t={type}' const baseURLOpenTab = baseURL + '/search/?search={query}' const algoliaURL = 'https://{domain}-dsn.algolia.net/1/indexes/*/queries?x-algolia-agent={agent}&x-algolia-api-key={sId}&x-algolia-application-id={aId}' const algoliaAgent = 'Algolia for JavaScript (4.12.0); Browser (lite)' const flixsterEMSURL = 'https://www.flixster.com/api/ems/v2/emsId/{emsId}' const cacheExpireAfterHours = 4 const emojiTomato = String.fromCodePoint(0x1F345) const emojiGreenApple = String.fromCodePoint(0x1F34F) const emojiStrawberry = String.fromCodePoint(0x1F353) const emojiPopcorn = '\uD83C\uDF7F' const emojiGreenSalad = '\uD83E\uDD57' const emojiNauseated = '\uD83E\uDD22' function minutesSince (time) { const seconds = ((new Date()).getTime() - time.getTime()) / 1000 return seconds > 60 ? parseInt(seconds / 60) + ' min ago' : 'now' } function intersection (setA, setB) { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set const _intersection = new Set() for (const elem of setB) { if (setA.has(elem)) { _intersection.add(elem) } } return _intersection } const parseLDJSONCache = {} function parseLDJSON (keys, condition) { if (document.querySelector('script[type="application/ld+json"]')) { const data = [] const scripts = document.querySelectorAll('script[type="application/ld+json"]') for (let i = 0; i < scripts.length; i++) { let jsonld if (scripts[i].innerText in parseLDJSONCache) { jsonld = parseLDJSONCache[scripts[i].innerText] } else { try { jsonld = JSON.parse(scripts[i].innerText) parseLDJSONCache[scripts[i].innerText] = jsonld } catch (e) { parseLDJSONCache[scripts[i].innerText] = null continue } } if (jsonld) { if (Array.isArray(jsonld)) { data.push(...jsonld) } else { data.push(jsonld) } } } for (let i = 0; i < data.length; i++) { try { if (data[i] && data[i] && (typeof condition !== 'function' || condition(data[i]))) { if (Array.isArray(keys)) { const r = [] for (let j = 0; j < keys.length; j++) { r.push(data[i][keys[j]]) } return r } else if (keys) { return data[i][keys] } else if (typeof condition === 'function') { return data[i] // Return whole object } } } catch (e) { continue } } return data } return null } function askFlixsterEMS (emsId) { return new Promise(function flixsterEMSRequest (resolve) { GM.getValue('flixsterEmsCache', '{}').then(function (s) { const flixsterEmsCache = JSON.parse(s) // Delete algoliaCached values, that are expired for (const prop in flixsterEmsCache) { if ((new Date()).getTime() - (new Date(flixsterEmsCache[prop].time)).getTime() > cacheExpireAfterHours * 60 * 60 * 1000) { delete flixsterEmsCache[prop] } } // Check cache or request new content if (emsId in flixsterEmsCache) { return resolve(flixsterEmsCache[emsId]) } const url = flixsterEMSURL.replace('{emsId}', encodeURIComponent(emsId)) GM.xmlHttpRequest({ method: 'GET', url: url, onload: function (response) { let data = null try { data = JSON.parse(response.responseText) } catch (e) { console.error('Rottentomatoes flixster ems JSON Error\nURL: ' + url) console.error(e) data = {} } // Save to flixsterEmsCache data.time = (new Date()).toJSON() flixsterEmsCache[emsId] = data GM.setValue('flixsterEmsCache', JSON.stringify(flixsterEmsCache)) resolve(data) }, onerror: function (response) { console.error('Rottentomatoes flixster ems GM.xmlHttpRequest Error: ' + response.status + '\nURL: ' + url + '\nResponse:\n' + response.responseText) resolve(null) } }) }) }) } async function addFlixsterEMS (orgData) { const flixsterData = await askFlixsterEMS(orgData.emsId) if (!flixsterData || !('tomatometer' in flixsterData)) { return orgData } if ('certifiedFresh' in flixsterData.tomatometer && flixsterData.tomatometer.certifiedFresh) { orgData.meterClass = 'certified_fresh' } if ('numReviews' in flixsterData.tomatometer && flixsterData.tomatometer.numReviews) { orgData.numReviews = flixsterData.tomatometer.numReviews } if ('consensus' in flixsterData.tomatometer && flixsterData.tomatometer.consensus) { orgData.consensus = flixsterData.tomatometer.consensus } if ('userRatingSummary' in flixsterData) { if ('scoresCount' in flixsterData.userRatingSummary && flixsterData.userRatingSummary.scoresCount) { orgData.audienceCount = flixsterData.userRatingSummary.scoresCount } else if ('dtlScoreCount' in flixsterData.userRatingSummary && flixsterData.userRatingSummary.dtlScoreCount) { orgData.audienceCount = flixsterData.userRatingSummary.dtlScoreCount } if ('wtsCount' in flixsterData.userRatingSummary && flixsterData.userRatingSummary.wtsCount) { orgData.audienceWantToSee = flixsterData.userRatingSummary.wtsCount } else if ('dtlWtsCount' in flixsterData.userRatingSummary && flixsterData.userRatingSummary.dtlWtsCount) { orgData.audienceWantToSee = flixsterData.userRatingSummary.dtlWtsCount } if ('reviewCount' in flixsterData.userRatingSummary && flixsterData.userRatingSummary.reviewCount) { orgData.audienceReviewCount = flixsterData.userRatingSummary.reviewCount } if ('avgScore' in flixsterData.userRatingSummary && flixsterData.userRatingSummary.avgScore) { orgData.audienceAvgScore = flixsterData.userRatingSummary.avgScore } } return orgData } function updateAlgolia () { // Get algolia data from https://www.rottentomatoes.com/ const algoliaSearch = { aId: null, sId: null } if (RottenTomatoes && 'thirdParty' in RottenTomatoes && 'algoliaSearch' in RottenTomatoes.thirdParty) { if (typeof (RottenTomatoes.thirdParty.algoliaSearch.aId) === 'string' && typeof (RottenTomatoes.thirdParty.algoliaSearch.sId) === 'string') { algoliaSearch.aId = RottenTomatoes.thirdParty.algoliaSearch.aId // x-algolia-application-id algoliaSearch.sId = RottenTomatoes.thirdParty.algoliaSearch.sId // x-algolia-api-key } } // Always store even if null to hide the "You need to visit www.rottentomatoes.com at least once to enable audience score" warning GM.setValue('algoliaSearch', JSON.stringify(algoliaSearch)).then(function () { console.debug('Updated algoliaSearch: ' + JSON.stringify(algoliaSearch)) }) } function meterBar (data) { // Create the "progress" bar with the meter score let barColor = 'grey' let bgColor = '#ECE4B5' let color = 'black' let width = 0 let textInside = '' let textAfter = '' if (data.meterClass === 'certified_fresh') { barColor = '#C91B22' color = 'yellow' textInside = emojiStrawberry + ' ' + data.meterScore.toLocaleString() + '%' width = data.meterScore || 0 } else if (data.meterClass === 'fresh') { barColor = '#C91B22' color = 'white' textInside = emojiTomato + ' ' + data.meterScore.toLocaleString() + '%' width = data.meterScore || 0 } else if (data.meterClass === 'rotten') { color = 'gray' barColor = '#94B13C' if (data.meterScore && data.meterScore > 30) { textAfter = data.meterScore.toLocaleString() + '% ' textInside = '' + emojiGreenApple + '' } else { textAfter = data.meterScore.toLocaleString() + '% ' + emojiGreenApple + '' } width = data.meterScore || 0 } else { bgColor = barColor = '#787878' color = 'silver' textInside = 'N/A' width = 100 } let title = 'Critics ' + (typeof data.meterScore === 'number' ? data.meterScore.toLocaleString() : 'N/A') + '% ' + data.meterClass if ('numReviews' in data && typeof data.numReviews === 'number') { title += ' ' + data.numReviews.toLocaleString() + ' reviews' } if ('consensus' in data) { const node = document.createElement('span') node.innerHTML = data.consensus title += '\n' + node.textContent } return '
' } function audienceBar (data) { // Create the "progress" bar with the audience score if (!('audienceScore' in data) || data.audienceScore === null) { return '' } let barColor = 'grey' let bgColor = '#ECE4B5' let color = 'black' let width = 0 let textInside = '' let textAfter = '' if (data.audienceClass === 'red_popcorn') { barColor = '#C91B22' color = data.audienceScore > 94 ? 'yellow' : 'white' textInside = emojiPopcorn + ' ' + data.audienceScore.toLocaleString() + '%' width = data.audienceScore } else if (data.audienceClass === 'green_popcorn') { color = 'gray' barColor = '#94B13C' if (data.audienceScore > 30) { textAfter = data.audienceScore.toLocaleString() + '% ' textInside = '' + emojiGreenSalad + '' } else { textAfter = data.audienceScore.toLocaleString() + '% ' + emojiNauseated + '' } width = data.audienceScore } else { bgColor = barColor = '#787878' color = 'silver' textInside = 'N/A' width = 100 } let title = 'Audience ' + (typeof data.audienceScore === 'number' ? data.audienceScore.toLocaleString() : 'N/A') + '% ' + data.audienceClass const titleLine2 = [] if ('audienceCount' in data && typeof data.audienceCount === 'number') { titleLine2.push(data.audienceCount.toLocaleString() + ' Votes') } if ('audienceReviewCount' in data) { titleLine2.push(data.audienceReviewCount.toLocaleString() + ' Reviews') } if ('audienceAvgScore' in data && typeof data.audienceAvgScore === 'number') { titleLine2.push('Average score: ' + data.audienceAvgScore.toLocaleString() + ' / 5 stars') } if ('audienceWantToSee' in data && typeof data.audienceWantToSee === 'number') { titleLine2.push(data.audienceWantToSee.toLocaleString() + ' want to see') } title = title + (titleLine2 ? ('\n' + titleLine2.join('\n')) : '') return ' ' } const current = { type: null, query: null, year: null } async function loadMeter (query, type, year) { // Load data from rotten tomatoes search API or from cache current.type = type current.query = query current.year = year const rottenType = type === 'movie' ? 'movie' : 'tvSeries' const url = baseURLSearch.replace('{query}', encodeURIComponent(query)).replace('{type}', encodeURIComponent(rottenType)) const cache = JSON.parse(await GM.getValue('cache', '{}')) // Delete cached values, that are expired for (const prop in cache) { if ((new Date()).getTime() - (new Date(cache[prop].time)).getTime() > cacheExpireAfterHours * 60 * 60 * 1000) { delete cache[prop] } } const algoliaCache = JSON.parse(await GM.getValue('algoliaCache', '{}')) // Delete algoliaCached values, that are expired for (const prop in algoliaCache) { if ((new Date()).getTime() - (new Date(algoliaCache[prop].time)).getTime() > cacheExpireAfterHours * 60 * 60 * 1000) { delete algoliaCache[prop] } } const algoliaSearch = JSON.parse(await GM.getValue('algoliaSearch', '{}')) // Check cache or request new content if (query in algoliaCache) { // Use cached response console.debug('Use cached algolia response') handleAlgoliaResponse(algoliaCache[query]) } else if ('aId' in algoliaSearch && 'sId' in algoliaSearch) { // Use algolia.net API const url = algoliaURL.replace('{domain}', algoliaSearch.aId.toLowerCase()).replace('{aId}', encodeURIComponent(algoliaSearch.aId)).replace('{sId}', encodeURIComponent(algoliaSearch.sId)).replace('{agent}', encodeURIComponent(algoliaAgent)) GM.xmlHttpRequest({ method: 'POST', url: url, data: '{"requests":[{"indexName":"content_rt","query":"' + query.replace('"', '') + '","params":"filters=rtId%20%3E%200%20AND%20isEmsSearchable%20%3D%201&hitsPerPage=20"}]}', onload: function (response) { // Save to algoliaCache response.time = (new Date()).toJSON() // Chrome fix: Otherwise JSON.stringify(cache) omits responseText const newobj = {} for (const key in response) { newobj[key] = response[key] } newobj.responseText = response.responseText algoliaCache[query] = newobj GM.setValue('algoliaCache', JSON.stringify(algoliaCache)) handleAlgoliaResponse(response) }, onerror: function (response) { console.error('Rottentomatoes algoliaSearch GM.xmlHttpRequest Error: ' + response.status + '\nURL: ' + url + '\nResponse:\n' + response.responseText) } }) } else if (url in cache) { // Use cached legacy response console.debug('Use cached legacy response') handleResponse(cache[url]) } else { console.debug('algoliaSearch not configured, falling back to legacy API: ' + url) GM.xmlHttpRequest({ method: 'GET', url: url, onload: function (response) { // Save to cache response.time = (new Date()).toJSON() // Chrome fix: Otherwise JSON.stringify(cache) omits responseText const newobj = {} for (const key in response) { newobj[key] = response[key] } newobj.responseText = response.responseText cache[url] = newobj GM.setValue('cache', JSON.stringify(cache)) handleResponse(response) }, onerror: function (response) { console.error('Rottentomatoes legacy API GM.xmlHttpRequest Error: ' + response.status + '\nURL: ' + url + '\nResponse:\n' + response.responseText) } }) } } function matchQuality (title, year, currentSet) { if (title === current.query && year === current.year) { return 104 + year } if (title.toLowerCase() === current.query.toLowerCase() && year === current.year) { return 103 + year } if (title === current.query && current.year) { return 102 - Math.abs(year - current.year) } if (title.toLowerCase() === current.query.toLowerCase() && current.year) { return 101 - Math.abs(year - current.year) } if (title.replace(/\(.+\)/, '').trim() === current.query && current.year) { return 100 - Math.abs(year - current.year) } if (title === current.query) { return 8 } if (title.replace(/\(.+\)/, '').trim() === current.query) { return 7 } if (title.startsWith(current.query)) { return 6 } if (current.query.indexOf(title) !== -1) { return 5 } if (title.indexOf(current.query) !== -1) { return 4 } if (current.query.toLowerCase().indexOf(title.toLowerCase()) !== -1) { return 3 } if (title.toLowerCase().indexOf(current.query.toLowerCase()) !== -1) { return 2 } const titleSet = new Set(title.replace(/[^a-z ]/gi, ' ').split(' ')) const score = intersection(titleSet, currentSet).size - 20 if (year === current.year) { return score + 1 } return score } function handleResponse (response) { // Handle GM.xmlHttpRequest response from legacy API https://www.rottentomatoes.com/api/private/v2.0/search/?limit=100&q={query}&t={type} const data = JSON.parse(response.responseText) // Adapt type name from original metacritic type to rotten tomatoes type let prop if (current.type === 'movie') { prop = 'movies' } else { prop = 'tvSeries' // Align series info with movie info for (let i = 0; i < data[prop].length; i++) { data[prop][i].name = data[prop][i].title data[prop][i].year = data[prop][i].startYear } } if (data[prop] && data[prop].length) { // Sort results by closest match const currentSet = new Set(current.query.replace(/[^a-z ]/gi, ' ').split(' ')) data[prop].sort(function (a, b) { if (!Object.prototype.hasOwnProperty.call(a, 'matchQuality')) { a.matchQuality = matchQuality(a.name, a.year, currentSet) } if (!Object.prototype.hasOwnProperty.call(b, 'matchQuality')) { b.matchQuality = matchQuality(b.name, b.year, currentSet) } return b.matchQuality - a.matchQuality }) data[prop][0].legacy = 1 showMeter(data[prop], new Date(response.time)) } else { console.debug('Rottentomatoes: No results for ' + current.query) } } async function handleAlgoliaResponse (response) { // Handle GM.xmlHttpRequest response const rawData = JSON.parse(response.responseText) // Filter according to type const hits = rawData.results[0].hits.filter(hit => hit.type === current.type) // Change to same data structure as legacy API const arr = [] hits.forEach(function (hit) { const result = { name: hit.title, year: parseInt(hit.releaseYear), url: '/' + (current.type === 'tv' ? 'tv' : 'm') + '/' + hit.vanity, meterClass: null, meterScore: null, audienceClass: null, audienceScore: null, emsId: hit.emsId } if ('rottenTomatoes' in hit) { if ('criticsIconUrl' in hit.rottenTomatoes) { result.meterClass = hit.rottenTomatoes.criticsIconUrl.match(/\/(\w+)\.png/)[1] } if ('criticsScore' in hit.rottenTomatoes) { result.meterScore = hit.rottenTomatoes.criticsScore } if ('audienceIconUrl' in hit.rottenTomatoes) { result.audienceClass = hit.rottenTomatoes.audienceIconUrl.match(/\/(\w+)\.png/)[1] } if ('audienceScore' in hit.rottenTomatoes) { result.audienceScore = hit.rottenTomatoes.audienceScore } if ('certifiedFresh' in hit.rottenTomatoes && hit.rottenTomatoes.certifiedFresh) { result.meterClass = 'certified_fresh' } } arr.push(result) }) // Sort results by closest match const currentSet = new Set(current.query.replace(/[^a-z ]/gi, ' ').split(' ')) arr.sort(function (a, b) { if (!Object.prototype.hasOwnProperty.call(a, 'matchQuality')) { a.matchQuality = matchQuality(a.name, a.year, currentSet) } if (!Object.prototype.hasOwnProperty.call(b, 'matchQuality')) { b.matchQuality = matchQuality(b.name, b.year, currentSet) } return b.matchQuality - a.matchQuality }) if (arr.length > 0 && arr[0].meterScore && arr[0].meterScore >= 70 && arr[0].meterClass !== 'certified_fresh') { // Get more details for first result arr[0] = await addFlixsterEMS(arr[0]) } if (arr) { showMeter(arr, new Date(response.time)) } else { console.debug('Rottentomatoes: No results for ' + current.query) } } function showMeter (arr, time) { // Show a small box in the right lower corner $('#mcdiv321rotten').remove() let main, div div = main = $('').appendTo(document.body) div.css({ position: 'fixed', bottom: 0, right: 0, minWidth: 100, maxWidth: 400, maxHeight: '95%', overflow: 'auto', backgroundColor: '#fff', border: '2px solid #bbb', borderRadius: ' 6px', boxShadow: '0 0 3px 3px rgba(100, 100, 100, 0.2)', color: '#000', padding: ' 3px', zIndex: '5010001', fontFamily: 'Helvetica,Arial,sans-serif' }) // First result $('