NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name IMDB Nuanced Rating // @namespace https://github.com/devjo // @version 0.2.1 // @description Normalizes the IMDB rating by supressing impact of 1 and 10 review bombing. Also indicates who the movie/series is aimed at. // @author devjo // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt // @match https://www.imdb.com/title/* // @updateURL https://openuserjs.org/meta/devjo/IMDB_Nuanced_Rating.meta.js // @downloadURL https://openuserjs.org/install/devjo/IMDB_Nuanced_Rating.user.js // @copyright 2020, devjo (https://openuserjs.org/users/devjo) // @grant none // ==/UserScript== (function () { 'use strict'; // Only trigger on the title page if (!window.location.pathname.match(/\/tt\d+\/$/)) return; // Constants const baseURL = window.location.pathname; const ratingURL = baseURL + 'ratings'; const femaleRatingURL = baseURL + 'ratings?demo=females'; const maleRatingURL = baseURL + 'ratings?demo=males'; const childRatingURL = baseURL + 'ratings?demo=aged_under_18'; // =[ Helpers ]================== const $c = (expr, root) => Array.from((root||document).querySelectorAll(expr)); const isAsync = f => f instanceof (async () => {}).constructor; function assert(value, msg) { if (value == undefined || value == null) throw msg; return value; } function log(...args) { console.debug('NuancedRating', ...args); } // TTL cache of function return values. Provided function can be either sync or async. // Always returns a promise that needs to be resolved to get the function return value. async function cache(key, f) { const TTL = 7200 * 1000; // Cache for 2 hours return new Promise(async resolve => { // If fresh in cache, return cached value if (key in localStorage) { const item = JSON.parse(localStorage[key]); const since = new Date().getTime() - item.stored; const expired = since > TTL; if (!expired) { resolve(item.value); return; } } // Either not in cache or expired, so try to refresh let value; if (isAsync(f)) { value = await f(); } else { value = f(); } if (value != undefined && value != null) { localStorage[key] = JSON.stringify({ stored: new Date().getTime(), value: value }); } resolve(value); }); } async function fetchDoc(url) { return fetch(url) .then(req => req.text()) .then(html => new DOMParser().parseFromString(html, 'text/html')); } // =[ END Helpers ]============== // Extract the voting statistics from the ratings page function extractVoteStats(doc) { const rows = $c('.article.listo table[cellpadding] tr', doc).slice(1); if (rows.length != 10) throw 'BUG: Failed to find the score rows on the ratings page'; return rows.map(row => { return { score: +$c('.rightAligned', row)[0].textContent, votes: +$c('.leftAligned', row)[0].textContent.replace(/[,.]/g, ''), }; }); } // Penalize 10 and 1 votes when computing a more reasonable title rating. // Algo from ChoFlojT: https://openuserjs.org/scripts/choflojt/Imdb_Smart_Score function computeScore(stats) { let totalVotes = 0; let totalScore = 0; let numberOneVotes = 0; let numberTenVotes = 0; // Aggregate voting stats stats.forEach(stat => { const {score, votes} = stat; if (score == 1) { numberOneVotes = votes; } else if (score == 10) { numberTenVotes = votes; } else { totalVotes += votes; totalScore += votes * score; } }); var factor = numberTenVotes / numberOneVotes; numberTenVotes -= numberOneVotes; if (numberTenVotes > 0) { numberTenVotes = parseInt(numberTenVotes * (1 - 1 / factor)); totalVotes += numberTenVotes; totalScore += numberTenVotes * 10; } else if (numberTenVotes < 0) { numberOneVotes = -parseInt(numberTenVotes * (1 - factor)); totalVotes += numberOneVotes; totalScore += numberOneVotes; } let roundedScore = +(Math.round((totalScore / totalVotes) * 10) / 10).toFixed(1); return {score: roundedScore, votes: totalVotes}; } function estimateTargetAudience(scores) { /** * Account for gender skew at IMDB, as men are many times more likely to post reviews there. * MPAA baseline: https://web.archive.org/web/20201120002958/https://womenandhollywood.com/mpaa-report-2018-women-represent-51-of-moviegoers-47-of-ticket-buyers/ * Incredibles 2 IMDB stats (50/50% gender neutral of moviegoers): https://www.imdb.com/title/tt3606756/ratings?ref_=tt_ov_rt * Estimated INDB reviewer gender distribution: Men 81%, Women 19% * Compensation: Number of IMDB female reviewers need to be boosted by 4.27 to be comparable to the IMDB stats for men when assessing whether or not a title is leaning towards an M/F audience. */ const femaleBoostFactor = 4.27; const femaleVotes = scores.female.votes * femaleBoostFactor; const maleVotes = scores.male.votes; const childScore = scores.child.score; const totalScore = scores.total.score; return { male: maleVotes / (maleVotes + femaleVotes), female: femaleVotes / (maleVotes + femaleVotes), forChildren: (childScore / totalScore) > 1.05 // If children/teens like a title 5% more than adults, assume it's aimed at them. }; } function updateScoreOnPage(newScore) { const el = assert($c('span[itemprop="ratingValue"]')[0], 'Failed to find original score on title page'); // log(newScore, el); el.innerHTML = ''+newScore; el.classList.add('recomputed'); } function addTargetAudienceToPage(audience) { const parent = $c('.recomputed')[0].closest('div'); const container = document.createElement('div'); container.id = 'target-audience'; function addSymbol(symbol, size, desc) { const el = document.createElement('span'); el.innerHTML = symbol; el.setAttribute('style', `font-size: ${Math.round(size)}px;`); el.setAttribute('title', desc); container.appendChild(el); } // font-size: 28px; const maxSize = 30; // px; const minSize = 16; // px; const meanSize = (maxSize + minSize) / 2; const mfRatio = audience.male / audience.female; const m = audience.male / Math.min(audience.male, audience.female); const f = audience.female / Math.min(audience.male, audience.female); let desc; if (mfRatio > 3) { desc = 'squarely for men'; } else if (mfRatio > 2) { desc = 'mostly for men'; } else if (mfRatio > 1.2) { desc = 'slightly angled toward a male audience'; } else if (1/mfRatio > 3) { desc = 'squarely for women'; } else if (1/mfRatio > 2) { desc = 'mostly for women'; } else if (1/mfRatio > 1.2) { desc = 'slightly angled toward a female audience'; } else { desc = 'for both men and women alike'; } // Scale symbols in relation to gender skew addSymbol('♂', Math.min(maxSize, Math.max(minSize, audience.male * minSize + minSize)), desc); addSymbol('♀', Math.min(maxSize, Math.max(minSize, audience.female * minSize + minSize)), desc); // Only show kid symbol when there is a childish tendency for the title if (audience.forChildren) addSymbol('🧒', meanSize, 'for kids, tweens or teens'); parent.appendChild(container); } function addStyling() { document.head.appendChild(document.createElement('style')).innerHTML = ` .recomputed { color: #ffde5c; } #target-audience { position: absolute; margin-top: 18px; cursor: help; margin-left: 71px; } `; } async function main() { addStyling(); log('Recomputing title score'); // Fetch all rating pages in parallel const scores = await cache('normscore|' + ratingURL, async () => { const [total, female, male, child] = await Promise.all( [ratingURL, femaleRatingURL, maleRatingURL, childRatingURL] .map(url => fetchDoc(url).then(extractVoteStats).then(computeScore))); return {total, female, male, child}; }); console.log(scores); updateScoreOnPage(scores.total.score); addTargetAudienceToPage(estimateTargetAudience(scores)); } main(); })();