Skip to content

Instantly share code, notes, and snippets.

@tejasvi
Last active December 29, 2025 12:45
Show Gist options
  • Select an option

  • Save tejasvi/e95e0e361df7eb9ec2c0385d99dc8f9a to your computer and use it in GitHub Desktop.

Select an option

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
// ==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