Last active
December 29, 2025 12:45
-
-
Save tejasvi/e95e0e361df7eb9ec2c0385d99dc8f9a to your computer and use it in GitHub Desktop.
Show Rotten Tomatoes rating on IMDB pages with link to Wikipedia's Critical Reception section
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name IMDb Rotten Tomatoes & Metacritic Scores | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.2 | |
| // @description Show Rotten Tomatoes, Metacritic & Wiki links on IMDb pages (Lists + Main Page) | |
| // @author You | |
| // @match https://www.imdb.com/* | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_setValue | |
| // @grant GM_getValue | |
| // @grant GM_listValues | |
| // @grant GM_deleteValue | |
| // @grant GM_registerMenuCommand | |
| // @connect whatson-api.onrender.com | |
| // @connect omdbapi.com | |
| // @connect query.wikidata.org | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // --- Configuration --- | |
| const API_BASE_URL = 'https://whatson-api.onrender.com'; | |
| const OMDB_BASE_URL = 'https://www.omdbapi.com'; | |
| const CACHE_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days | |
| const CACHE_DURATION_MISSING_MS = 24 * 60 * 60 * 1000; // 1 day for 404s | |
| // --- Prompt for OMDb Key if missing --- | |
| let storedOmdbKey = GM_getValue('omdb_key', ''); | |
| if (!storedOmdbKey) { | |
| // Only prompt if user explicitly interacts or script fails? | |
| // For now, keeping it silent unless you want to uncomment the prompt. | |
| // To set key: use the debug panel button. | |
| } | |
| // --- State --- | |
| const state = { | |
| apiCalls: 0, | |
| cacheHits: 0, | |
| errors: 0, | |
| pendingCalls: 0, | |
| processedCount: 0, | |
| processedIds: new WeakSet(), | |
| omdbKey: storedOmdbKey, | |
| omdbCalls: 0, | |
| wikiCalls: 0, | |
| debugPanelVisible: true | |
| }; | |
| // --- Cache Utils --- | |
| function getCache(key) { | |
| const cached = GM_getValue(key); | |
| if (!cached) return null; | |
| try { | |
| const data = JSON.parse(cached); | |
| const duration = data.duration || CACHE_DURATION_MS; | |
| if (Date.now() - data.timestamp > duration) return null; | |
| return data.payload; | |
| } catch (e) { return null; } | |
| } | |
| function setCache(key, payload, duration = CACHE_DURATION_MS) { | |
| GM_setValue(key, JSON.stringify({ | |
| timestamp: Date.now(), | |
| payload: payload, | |
| duration: duration | |
| })); | |
| } | |
| // --- API Client --- | |
| async function fetchScores(imdbId) { | |
| return new Promise((resolve, reject) => { | |
| state.pendingCalls++; | |
| updateDebugPanel(); | |
| const params = new URLSearchParams({ | |
| imdbId: imdbId, | |
| ratings_filters: 'rottentomatoes_critics,rottentomatoes_users,metacritic_critics,metacritic_users,letterboxd_users,tmdb_users', | |
| item_type: 'movie,tvshow' | |
| }); | |
| const url = `${API_BASE_URL}/?${params.toString()}`; | |
| GM_xmlhttpRequest({ | |
| method: "GET", | |
| url: url, | |
| timeout: 10000, | |
| onload: function (response) { | |
| state.pendingCalls--; | |
| if (response.status === 200) { | |
| try { | |
| let data = JSON.parse(response.responseText); | |
| if (data && data.results && Array.isArray(data.results)) { | |
| data = data.results.length > 0 ? data.results[0] : null; | |
| } else if (Array.isArray(data)) { | |
| data = data.length > 0 ? data[0] : null; | |
| } | |
| if (data) { | |
| state.apiCalls++; | |
| updateDebugPanel(); | |
| resolve(data); | |
| } else { | |
| resolve({ missing: true }); | |
| } | |
| } catch (e) { | |
| state.errors++; | |
| updateDebugPanel(); | |
| reject(e); | |
| } | |
| } else if (response.status === 404) { | |
| resolve({ missing: true }); | |
| } else { | |
| state.errors++; | |
| updateDebugPanel(); | |
| resolve(null); | |
| } | |
| }, | |
| onerror: (err) => { | |
| state.pendingCalls--; | |
| state.errors++; | |
| updateDebugPanel(); | |
| reject(err); | |
| } | |
| }); | |
| }); | |
| } | |
| async function fetchOMDb(imdbId) { | |
| if (!state.omdbKey) return null; | |
| state.pendingCalls++; | |
| updateDebugPanel(); | |
| return new Promise((resolve) => { | |
| GM_xmlhttpRequest({ | |
| method: "GET", | |
| url: `${OMDB_BASE_URL}/?i=${imdbId}&apikey=${state.omdbKey}`, | |
| timeout: 5000, | |
| onload: (res) => { | |
| state.pendingCalls--; | |
| try { | |
| const data = JSON.parse(res.responseText); | |
| state.omdbCalls++; | |
| updateDebugPanel(); | |
| resolve(data.Response === 'True' ? data : null); | |
| } catch (e) { resolve(null); } | |
| }, | |
| onerror: () => { state.pendingCalls--; resolve(null); } | |
| }); | |
| }); | |
| } | |
| async function fetchWikiUrl(imdbId) { | |
| const cacheKey = `wiki_${imdbId}`; | |
| const cachedUrl = getCache(cacheKey); | |
| if (cachedUrl) { | |
| state.cacheHits++; | |
| updateDebugPanel(); | |
| return cachedUrl; | |
| } | |
| state.pendingCalls++; | |
| updateDebugPanel(); | |
| const query = `SELECT ?article WHERE { ?item wdt:P345 "${imdbId}". ?article schema:about ?item ; schema:isPartOf <https://en.wikipedia.org/> . }`; | |
| const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(query)}&format=json`; | |
| return new Promise(resolve => { | |
| GM_xmlhttpRequest({ | |
| method: "GET", | |
| url: url, | |
| headers: { "User-Agent": "IMDbScript/1.0" }, | |
| onload: (res) => { | |
| state.pendingCalls--; | |
| try { | |
| const data = JSON.parse(res.responseText); | |
| if (data.results && data.results.bindings.length > 0) { | |
| const wikiUrl = data.results.bindings[0].article.value; | |
| state.wikiCalls++; | |
| setCache(cacheKey, wikiUrl, CACHE_DURATION_MS); | |
| updateDebugPanel(); | |
| resolve(wikiUrl); | |
| } else { | |
| setCache(cacheKey, null, CACHE_DURATION_MISSING_MS); | |
| resolve(null); | |
| } | |
| } catch (e) { resolve(null); } | |
| }, | |
| onerror: () => { state.pendingCalls--; resolve(null); } | |
| }); | |
| }); | |
| } | |
| // --- UI Components --- | |
| let debugPanel = null; | |
| function createDebugPanel() { | |
| if (debugPanel) return; | |
| debugPanel = document.createElement('div'); | |
| debugPanel.style.cssText = `position: fixed; bottom: 10px; right: 10px; background: rgba(0, 0, 0, 0.8); color: #fff; padding: 10px; border-radius: 5px; z-index: 9999; font-family: monospace; font-size: 12px; display: flex; align-items: center; gap: 10px;`; | |
| const textSpan = document.createElement('span'); | |
| textSpan.id = 'rt-debug-text'; | |
| debugPanel.appendChild(textSpan); | |
| const omdbBtn = document.createElement('button'); | |
| omdbBtn.textContent = state.omdbKey ? 'OMDb' : 'Set Key'; | |
| omdbBtn.style.cssText = `background: ${state.omdbKey ? '#2E7D32' : '#F57C00'}; color: white; border: none; padding: 2px 6px; border-radius: 3px; cursor: pointer;`; | |
| omdbBtn.onclick = () => { | |
| const key = prompt('Enter OMDb API Key:', state.omdbKey); | |
| if (key !== null) { GM_setValue('omdb_key', key.trim()); location.reload(); } | |
| }; | |
| debugPanel.appendChild(omdbBtn); | |
| const clearBtn = document.createElement('button'); | |
| clearBtn.textContent = 'Clr'; | |
| clearBtn.style.cssText = `background: #d32f2f; color: white; border: none; padding: 2px 6px; border-radius: 3px; cursor: pointer;`; | |
| clearBtn.onclick = () => { if(confirm('Clear Cache?')) { const k=GM_listValues(); k.forEach(x=>GM_deleteValue(x)); location.reload(); }}; | |
| debugPanel.appendChild(clearBtn); | |
| document.body.appendChild(debugPanel); | |
| updateDebugPanel(); | |
| } | |
| function updateDebugPanel() { | |
| if (!debugPanel) return; | |
| const textSpan = debugPanel.querySelector('#rt-debug-text'); | |
| if (textSpan) textSpan.textContent = `RT/MC | F:${state.processedCount} P:${state.pendingCalls} WO:${state.apiCalls} OM:${state.omdbCalls} W:${state.wikiCalls} C:${state.cacheHits} E:${state.errors}`; | |
| } | |
| // --- Rendering --- | |
| function createLoadingBadge() { | |
| const span = document.createElement('span'); | |
| span.textContent = '...'; | |
| span.style.cssText = `display: inline-block; margin-left: 8px; color: #888; font-weight: bold; font-family: monospace; animation: blink 1s infinite; align-self: center;`; | |
| if (!document.getElementById('rt-blink-style')) { | |
| const style = document.createElement('style'); | |
| style.id = 'rt-blink-style'; | |
| style.textContent = `@keyframes blink { 0% { opacity: 0.2; } 50% { opacity: 1; } 100% { opacity: 0.2; } }`; | |
| document.head.appendChild(style); | |
| } | |
| return span; | |
| } | |
| function createWikiBadge(url) { | |
| const a = document.createElement('a'); | |
| a.href = `${url}#:~:text=Critical%20reception&text=Critical%20response`; | |
| a.target = '_blank'; | |
| a.title = 'Wikipedia: Critical Reception'; | |
| a.style.cssText = `display: inline-flex; align-items: center; justify-content: center; margin-left: 8px; color: black; font-weight: bold; font-family: serif; text-decoration: none; width: 18px; height: 18px; background: #fff; border-radius: 2px; font-size: 12px; line-height: 1; flex-shrink: 0; align-self: center; border: 1px solid #ccc;`; | |
| a.textContent = 'W'; | |
| return a; | |
| } | |
| function createBadge(score, type, url, isCertified) { | |
| const container = document.createElement('a'); | |
| container.href = url || '#'; | |
| container.target = '_blank'; | |
| // align-self: center is important for the Hero section where items are tall blocks | |
| container.style.cssText = `display: inline-flex; align-items: center; margin-left: 12px; text-decoration: none; color: inherit; font-weight: bold; font-family: Arial, sans-serif; font-size: 13px; align-self: center;`; | |
| let icon = '', color = '#fff'; | |
| if (type === 'rt') { | |
| icon = score >= 60 ? '🍅' : '🦠'; | |
| color = score >= 60 ? '#FA320A' : '#547A1F'; | |
| } else if (type === 'mc') { | |
| icon = 'MC'; | |
| if (score >= 61) color = '#66CC33'; | |
| else if (score >= 40) color = '#FFCC33'; | |
| else color = '#FF0000'; | |
| } else if (type === 'lb') { | |
| icon = 'LB'; | |
| let norm = score <= 10 ? score * 20 : score; | |
| color = norm >= 60 ? '#00E054' : (norm >= 40 ? '#FF8000' : '#FF2020'); | |
| } else if (type === 'tmdb') { | |
| icon = 'TMDB'; | |
| let norm = score <= 10 ? score * 10 : score; | |
| color = norm >= 70 ? '#21d07a' : (norm >= 40 ? '#d2d531' : '#db2360'); | |
| } | |
| const iconSpan = document.createElement('span'); | |
| iconSpan.textContent = icon; | |
| if (type !== 'rt') { | |
| iconSpan.style.cssText = `background: ${color}; color: black; padding: 1px 3px; border-radius: 3px; font-size: 10px; margin-right: 4px;`; | |
| } else { | |
| iconSpan.style.marginRight = '4px'; | |
| } | |
| const scoreSpan = document.createElement('span'); | |
| scoreSpan.textContent = (type === 'lb' || type === 'tmdb' || type === 'mc') ? score : `${score}%`; | |
| if (type === 'rt') scoreSpan.style.color = color; | |
| container.appendChild(iconSpan); | |
| container.appendChild(scoreSpan); | |
| return container; | |
| } | |
| // --- Core Logic --- | |
| async function processItem(element, imdbId) { | |
| if (state.processedIds.has(element)) return; | |
| if (element.dataset.rtProcessed) return; | |
| element.dataset.rtProcessed = "true"; | |
| state.processedCount++; | |
| updateDebugPanel(); | |
| let data = getCache(imdbId); | |
| if (data) { | |
| state.cacheHits++; | |
| updateDebugPanel(); | |
| renderResults(element, data, null); | |
| } else { | |
| const loadingBadge = createLoadingBadge(); | |
| injectBadge(element, loadingBadge); | |
| data = await fetchScores(imdbId); | |
| let hasPrimary = data && (data.rotten_tomatoes?.critics_rating || data.metacritic?.critics_rating); | |
| if (!hasPrimary && state.omdbKey && data && !data.omdbChecked) { | |
| const omdbData = await fetchOMDb(imdbId); | |
| if (omdbData?.Ratings) { | |
| data.omdb = omdbData; | |
| delete data.missing; | |
| } | |
| } | |
| if (data) setCache(imdbId, data, data.missing ? CACHE_DURATION_MISSING_MS : CACHE_DURATION_MS); | |
| if (data && !data.missing) renderResults(element, data, loadingBadge); | |
| else if (loadingBadge.parentNode) loadingBadge.remove(); | |
| } | |
| // Parallel Wiki Check | |
| fetchWikiUrl(imdbId).then(wikiUrl => { | |
| if (wikiUrl) injectBadge(element, createWikiBadge(wikiUrl)); | |
| }); | |
| } | |
| function renderResults(element, data, placeholder) { | |
| let score = null, type = 'rt', url = '', isCertified = false; | |
| const { rotten_tomatoes: rt, metacritic: mc, letterboxd: lb, tmdb, omdb } = data; | |
| if (rt?.critics_rating != null) { | |
| score = rt.critics_rating; type = 'rt'; url = rt.url; isCertified = rt.critics_certified; | |
| } else if (mc?.critics_rating != null) { | |
| score = mc.critics_rating; type = 'mc'; url = mc.url; | |
| } else if (omdb?.Ratings) { | |
| const rtR = omdb.Ratings.find(r => r.Source === 'Rotten Tomatoes'); | |
| const mcR = omdb.Ratings.find(r => r.Source === 'Metacritic'); | |
| if (rtR) { score = parseInt(rtR.Value); type = 'rt'; url = `https://www.rottentomatoes.com/search?search=${encodeURIComponent(data.title || '')}`; } | |
| else if (mcR) { score = parseInt(mcR.Value); type = 'mc'; } | |
| } | |
| if (score === null && lb?.users_rating != null) { score = lb.users_rating; type = 'lb'; url = lb.url; } | |
| if (score === null && tmdb?.users_rating != null) { score = tmdb.users_rating; type = 'tmdb'; url = tmdb.url; } | |
| if (score !== null) { | |
| const badge = createBadge(score, type, url, isCertified); | |
| if (placeholder && placeholder.parentNode) placeholder.replaceWith(badge); | |
| else injectBadge(element, badge); | |
| } else if (placeholder?.parentNode) placeholder.remove(); | |
| } | |
| function injectBadge(itemElement, badge) { | |
| // 1. Specific Containers (Lists/Cards) | |
| let target = itemElement.querySelector('.dli-ratings-container') || | |
| itemElement.querySelector('[data-testid="ratingGroup--container"]') || | |
| itemElement.querySelector('.ipc-poster-card__rating-star-group') || | |
| itemElement.querySelector('.ipc-title__text')?.parentElement; | |
| // 2. Main Page Hero Section Detection | |
| // The 'itemElement' passed here for the main page is the flex container itself. | |
| // We confirm it's the hero section by looking for the unique IMDb rating child. | |
| const isHeroSection = itemElement.querySelector && itemElement.querySelector('[data-testid="hero-rating-bar__aggregate-rating"]'); | |
| if (isHeroSection) { | |
| // Direct append to the flex container for the Hero section | |
| itemElement.appendChild(badge); | |
| } else if (target) { | |
| target.style.flexWrap = 'wrap'; | |
| target.appendChild(badge); | |
| } else { | |
| // Fallback | |
| itemElement.appendChild(badge); | |
| } | |
| } | |
| function scan() { | |
| // 1. Standard Lists / Cards | |
| document.querySelectorAll('a[href*="/title/tt"]').forEach(link => { | |
| const match = link.href.match(/tt\d{7,8}/); | |
| if (match) { | |
| const card = link.closest('.ipc-metadata-list-summary-item, .ipc-poster-card, .sc-b4f120f6-0, .dli-parent'); | |
| // Filter out cards that might be inside the hero (rare, but good safety) | |
| if (card && !card.dataset.rtProcessed && !card.querySelector('[data-testid="hero-rating-bar__aggregate-rating"]')) { | |
| processItem(card, match[0]); | |
| } | |
| } | |
| }); | |
| // 2. Main Title Page Hero Section | |
| // We look for the IMDb Aggregate Rating element, which is stable via testid | |
| const heroRatingWidget = document.querySelector('[data-testid="hero-rating-bar__aggregate-rating"]'); | |
| if (heroRatingWidget) { | |
| const heroContainer = heroRatingWidget.parentElement; // The flex container holding stats | |
| const match = window.location.pathname.match(/tt\d{7,8}/); // Get ID from URL | |
| if (heroContainer && match && !heroContainer.dataset.rtProcessed) { | |
| processItem(heroContainer, match[0]); | |
| } | |
| } | |
| } | |
| function init() { | |
| createDebugPanel(); | |
| scan(); | |
| const observer = new MutationObserver((mutations) => { | |
| // Optimization: Only scan if nodes were added | |
| if (mutations.some(m => m.addedNodes.length > 0)) scan(); | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| if (document.body) init(); | |
| else window.addEventListener('DOMContentLoaded', init); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment