// ==UserScript==
// @name Letterboxd ratings on IMDb
// @version 1.0.4
// @namespace https://github.com/chrisjp
// @description Shows a film's Letterboxd rating on its IMDb page.
// @license MIT
// @homepageURL https://github.com/chrisjp/LetterboxdOnIMDb
// @supportURL https://github.com/chrisjp/LetterboxdOnIMDb/issues
// @match https://*.imdb.com/title/tt*
// @icon https://www.google.com/s2/favicons?sz=64&domain=www.imdb.com
// @connect letterboxd.com
// @grant GM.xmlHttpRequest
// @grant GM.addStyle
// @run-at document-end
// @noframes
// ==/UserScript==
(function() {
'use strict';
// Perform some checks...
// 1. that this film has a rating (i.e. it's been released)
// 2. User rating button. This is useful in rare cases where a film is too obscure or new to have
// enough ratings (>=5) for IMDb to display an average
// 3. Popularity meter. If neither of the above are found it's likely an upcoming film that has trailers
// or other pre-release media available such that it's getting attention.
// Obviously no rating can be displayed for such films, but we can at least link to the Letterboxd
// page (if it exists) for convenience.
let filmHasAggRating = document.querySelector('[data-testid="hero-rating-bar__aggregate-rating__score"]');
let filmHasYourRating = document.querySelector('[data-testid="hero-rating-bar__user-rating"]');
let filmHasPopularity = document.querySelector('[data-testid="hero-rating-bar__popularity"]');
// Get IMDb ID from the URL
let imdbId = document.URL.match(/\/tt([0-9]+)\//)[1];
if (imdbId > 0 && (filmHasAggRating !== null || filmHasYourRating !== null || filmHasPopularity !== null)) {
//console.log("IMDb ID: " + imdbId);
getLetterboxdRating(imdbId);
}
else {
console.log("Letterboxd on IMDb user script: No rating bar found. Film has probably not yet been released, or we're on a subpage of this film.");
}
})();
function getLetterboxdRating(imdbId)
{
// Letterboxd can redirect to a film's page if provided the IMDb ID (minus 'tt' prefix).
// Formatted as follows: https://letterboxd.com/imdb/0000000
const letterboxd = "https://letterboxd.com";
const url = letterboxd + "/imdb/" + imdbId + "/";
GM.xmlHttpRequest({
method: "GET",
timeout: 10000,
url: url,
onload: function(response) {
if (response.finalUrl != url) {
// We were redirected, so the film exists on Letterboxd. Now we can get its ID,
// or in this case the URL slug identifying it. With that we can then generate
// a URL to its rating histogram, which we can then parse to obtain ratings data.
const letterboxdUrl = response.finalUrl;
const letterboxdId = letterboxdUrl.split(letterboxd)[1];
const letterboxdHistUrl = letterboxd + "/csi" + letterboxdId + "rating-histogram/";
console.log("Letterboxd histogram URL for this film: " + letterboxdHistUrl);
getLetterboxdHistogram(letterboxdUrl, letterboxdHistUrl);
}
else {
// We did not get redirected to the film's URL on Letterboxd, meaning it hasn't been added to their database
// or doesn't have an IMDb ID associated with it.
console.log("Film not found on Letterboxd.");
}
},
onerror: function() {
console.log("Letterboxd on IMDb user script: Request Error in getLetterboxdRating.");
},
onabort: function() {
console.log("Letterboxd on IMDb user script: Request is aborted in getLetterboxdRating");
},
ontimeout: function() {
console.log("Letterboxd on IMDb user script: Request timed out in getLetterboxdRating.");
}
});
}
function getLetterboxdHistogram(letterboxdUrl, letterboxdHistUrl)
{
// Scraping the rating histogram page is much more reliable than the film page
// especially for films with very few ratings
GM.xmlHttpRequest({
method: "GET",
timeout: 10000,
url: letterboxdHistUrl,
onload: function(response) {
const parser = new DOMParser();
const result = parser.parseFromString(response.responseText, "text/html");
// Parse the scraped HTML if we have a .display-rating element.
const letterboxdRatingA = result.getElementsByClassName("display-rating")[0];
if (letterboxdRatingA) {
const letterboxdRating = parseFloat(letterboxdRatingA.innerText);
const letterboxdTotalRatingsText = letterboxdRatingA.title;
const letterboxdTotalRatings = parseInt(letterboxdTotalRatingsText.match("based on \(.*\)ratings")[1].replaceAll(",",""));
addLetterboxdRatingToIMDb(letterboxdUrl, letterboxdRating, letterboxdTotalRatings);
}
else {
// If we reached this point it's almost certainly because the film does not yet have enough ratings for Letterboxd
// to calculate the weighted average. Check for "not enough ratings" text to confirm, then manually calculate.
let letterboxdTotalRatings = 0;
const notEnoughRatings = result.querySelector('[title="Not enough ratings to calculate average"]');
if (notEnoughRatings) {
// we can try to manually calculate the number of ratings
const regexCalc = /title="(\d) /gm;
const matches = response.responseText.matchAll(regexCalc);
for (let match of matches) {
letterboxdTotalRatings += parseInt(match[1]);
}
console.log("Manually counted " + letterboxdTotalRatings + " ratings on Letterboxd for this film.");
}
else {
// If the "not enough ratings" text can't be found that means there's no ratings at all.
// This will usually mean it's a currently unreleased film. We can still try to show
// a link to the Letterboxd page without any rating data.
console.log("Film exists on Letterboxd but has no ratings data.");
}
letterboxdTotalRatings = letterboxdTotalRatings > 0 ? letterboxdTotalRatings : "-";
addLetterboxdRatingToIMDb(letterboxdUrl, "-", letterboxdTotalRatings);
}
},
onerror: function() {
console.log("Letterboxd on IMDb user script: Request Error in getLetterboxdHistogram.");
},
onabort: function() {
console.log("Letterboxd on IMDb user script: Request is aborted in getLetterboxdHistogram.");
},
ontimeout: function() {
console.log("Letterboxd on IMDb user script: Request timed out in getLetterboxdHistogram.");
}
});
}
function addLetterboxdRatingToIMDb(letterboxdUrl, letterboxdRating, letterboxdTotalRatings)
{
// Since a lot of relevant class names are random on each page load... Basically we:
// 1. get the div.rating-bar__base-button elements
// 2. clone the first one (IMDb average user rating)
// 3. set its HTML to the information we just scraped from Letterboxd
// 4. add it to the DOM with the other div.rating-bar__base-button elements
// That way it keeps all IMDB's styling and looks like a normal part of the page
// Clone the node
let ratingBarBtns = document.querySelectorAll(".rating-bar__base-button");
let ratingBarBtnLetterboxd = ratingBarBtns[0].cloneNode(true);
// Add CSS (this forces it to the leftmost position in the ratings bar)
// Also adds CSS for Letterboxd button on films without a rating yet.
ratingBarBtnLetterboxd.classList.add('letterboxd-rating');
GM.addStyle(`
.letterboxd-rating { order: -1; }
.letterboxd-rating-bottom {
color: var(--ipt-on-baseAlt-textSecondary-color, rgba(255,255,255,0.7));
font-family: var(--ipt-font-family);
font-size: var(--ipt-type-bodySmall-size, .875rem);
font-weight: var(--ipt-type-bodySmall-weight, 400);
letter-spacing: var(--ipt-type-bodySmall-letterSpacing, .01786em);
line-height: var(--ipt-type-bodySmall-lineHeight, 1.25rem);
text-transform: var(--ipt-type-bodySmall-textTransform, none);
}
@media screen and (min-width: 1024px) {
.letterboxd-rating-bottom {
font-family: var(--ipt-font-family);
font-size: var(--ipt-type-copyright-size, .75rem);
font-weight: var(--ipt-type-copyright-weight, 400);
letter-spacing: var(--ipt-type-copyright-letterSpacing, .03333em);
line-height: var(--ipt-type-copyright-lineHeight, 1rem);
text-transform: var(--ipt-type-copyright-textTransform, none);
}
}
`);
// Set title
ratingBarBtnLetterboxd.children[0].innerHTML = "Letterboxd".toUpperCase();
// If the cloned node is the IMDb aggregate rating we can simply overwrite the child elements' innerHTML
// with data we've obtained from Letterboxd
if (ratingBarBtnLetterboxd.dataset.testid === "hero-rating-bar__aggregate-rating") {
console.log("We have a valid IMDb rating. Adding Letterboxd rating to DOM...");
// set a.href
let letterboxdElementA = ratingBarBtnLetterboxd.children[1];
letterboxdElementA.href = letterboxdUrl;
// edit all its child elements
let letterboxdElementADiv = letterboxdElementA.children[0].children[0];
// icon set to 24x24
letterboxdElementADiv.children[0].innerHTML = '<img src="https://www.google.com/s2/favicons?sz=64&domain=letterboxd.com" alt="" width="24" height="24">';
// ratings data
let letterboxdElementRatingDiv = letterboxdElementADiv.children[1];
// average rating
letterboxdElementRatingDiv.children[0].children[0].innerHTML = letterboxdRating;
letterboxdElementRatingDiv.children[0].children[1].innerHTML = "/5";
// total ratings
letterboxdElementRatingDiv.children[2].innerHTML = letterboxdTotalRatings != "-" ? numRound(letterboxdTotalRatings) : "-";
// data-testid
letterboxdElementRatingDiv.children[0].dataset.testid = "hero-rating-bar__letterboxd-rating";
}
// If the cloned node is NOT the IMDb aggregate rating (it doesn't have one) it'll be the button allowing us to rate it if logged in
// The child nodes of the <button> are very similar so we can still modify the HTML to show the Letterboxd rating, then add our own
// <div> to display the total number of ratings.
else if (ratingBarBtnLetterboxd.dataset.testid === "hero-rating-bar__user-rating") {
console.log("We don't have a valid IMDb rating. Adding Letterboxd link to DOM with manual rate count...");
let btnNode = ratingBarBtnLetterboxd.children[1];
let btnChildNode = ratingBarBtnLetterboxd.children[1].children[0];
// create <a> element
let letterboxdElementA = document.createElement("a");
letterboxdElementA.className = btnNode.classList.toString();
letterboxdElementA.href = letterboxdUrl;
// clone the <button>'s child node (should be a span) and append it to our <a>
letterboxdElementA.append(btnChildNode.cloneNode(true));
// edit all its child elements
let letterboxdElementADiv = letterboxdElementA.children[0].children[0];
// icon set to 24x24
letterboxdElementADiv.children[0].innerHTML = '<img src="https://www.google.com/s2/favicons?sz=64&domain=letterboxd.com" alt="" width="24" height="24">';
// ratings data container
let letterboxdElementRatingDiv = letterboxdElementADiv.children[1];
// average rating
letterboxdElementRatingDiv.children[0].children[0].innerHTML = letterboxdRating;
letterboxdElementRatingDiv.children[0].innerHTML = letterboxdElementRatingDiv.children[0].innerHTML.replace("/10", "/5");
// total ratings (need to make our own div for this)
let letterboxdTotalRatingsDiv = document.createElement("div");
letterboxdTotalRatingsDiv.className = "letterboxd-rating-bottom";
letterboxdTotalRatingsDiv.innerHTML = letterboxdTotalRatings;
letterboxdElementRatingDiv.append(letterboxdTotalRatingsDiv);
// replace the <button> with the <a> we created and modified above
btnNode.replaceWith(letterboxdElementA);
}
// If we get this far the film must be an upcoming one that's getting enough attention to trigger the Popularity Meter.
// We won't have any ratings to display here obviously, but we can at least link to the Letterboxd page for convenience.
else {
console.log("We don't have a valid IMDb rating. This is probably an unreleased film. Adding Letterboxd link to DOM...");
// set a.href
let letterboxdElementA = ratingBarBtnLetterboxd.children[1];
letterboxdElementA.href = letterboxdUrl;
// edit all its child elements
let letterboxdElementADiv = letterboxdElementA.children[0].children[0];
// icon set to 24x24
letterboxdElementADiv.children[0].innerHTML = '<img src="https://www.google.com/s2/favicons?domain=letterboxd.com&sz=64" alt="" width="24" height="24">';
// replace score and delta and change data-testid
letterboxdElementADiv.children[1].dataset.testid = "hero-rating-bar__letterboxd-link";
letterboxdElementADiv.children[1].children[0].dataset.testid = "";
letterboxdElementADiv.children[1].children[0].innerText = "View";
letterboxdElementADiv.children[1].children[1].remove();
}
// Add the finished element to the DOM
ratingBarBtnLetterboxd.dataset.testid = "hero-rating-bar__letterboxd-rating";
ratingBarBtns[0].parentNode.appendChild(ratingBarBtnLetterboxd);
}
function numRound(num)
{
// https://stackoverflow.com/a/68273755/403476
num = Math.abs(Number(num))
const billions = num/1.0e+9
const millions = num/1.0e+6
const thousands = num/1.0e+3
return num >= 1.0e+9 && billions >= 100 ? Math.round(billions) + "B"
: num >= 1.0e+9 && billions >= 10 ? billions.toFixed(1) + "B"
: num >= 1.0e+9 ? billions.toFixed(2) + "B"
: num >= 1.0e+6 && millions >= 100 ? Math.round(millions) + "M"
: num >= 1.0e+6 && millions >= 10 ? millions.toFixed(1) + "M"
: num >= 1.0e+6 ? millions.toFixed(2) + "M"
: num >= 1.0e+3 && thousands >= 100 ? Math.round(thousands) + "K"
: num >= 1.0e+3 && thousands >= 10 ? thousands.toFixed(1) + "K"
: num >= 1.0e+3 ? thousands.toFixed(2) + "K"
: num.toFixed()
}