Add movie ratings to IMDB links [adopted]

Adds movie ratings and number of voters to links on IMDB. Modified version of http://userscripts.org/scripts/show/96884

// ==UserScript==
// @name         Add movie ratings to IMDB links [adopted]
// @description  Adds movie ratings and number of voters to links on IMDB. Modified version of http://userscripts.org/scripts/show/96884
// @author       StackOverflow community (especially Brock Adams)
// @version      2015-11-24-41-joeytwiddle
// @license      MIT
// @match        *://www.imdb.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @require      http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js
// @namespace    https://greasyfork.org/users/8615
// @derived-from https://greasyfork.org/en/scripts/2033-add-imdb-rating-votes-next-to-all-imdb-movie-series-links-improved
// ==/UserScript==
// Special Thanks to Brock Adams for this script: http://stackoverflow.com/questions/23974801/gm-xmlhttprequest-data-is-being-placed-in-the-wrong-places/23992742

var maxLinksAtATime     = 100;   //-- Pages can have 100's of links to fetch. Don't spam server or browser.
var skipEpisodes        = true;  //-- I only want to see ratings for movies or TV shows, not TV episodes.
var showAsStar          = false; //-- Use IMDB star instead of colored div, less info but more consistent with the rest of the site.
var addRatingToTitle    = true;  //-- Adds the rating to the browser's title bar (so rating will appear in browser bookmarks).
var showMetaScore       = true;  //-- When the metascore is available, show it
var useLightBackground  = false; //-- If you prefer the site to have a light grey background
var showGetMoreRatings  = false; //-- Turn this on if the "See more" button stops pulling down ratings

if (useLightBackground) {
    GM_addStyle('.ipc-page-background { background: #e3e2dd !important; color: black !important; }');
    // You could also try #262626 for a dark grey but not black background
}

// Nov 2022 design has `display: flex` to make all/some info flow downwards, which causes our rating to appear below the link, instead of after it
// TODO: A better solution might be to replace the <a> with a <div> containing the <a> and our rating
// TODO: Or we could try putting the rating inside the link
// TODO: Or we could make the rating float after the link, using position: absolute
GM_addStyle(`
    /* For the "Known For" section */
    .ipc-primary-image-list-card__content-top {
        flex-direction: row;
    }

    /* For the "Credits" section */
    .ipc-metadata-list-summary-item__tc {
        display: initial;
    }
`);

// The old iMDB site exposed jQuery, but the new one does not
//var $ = unsafeWindow.$;
// This was exposed by the @require
var $ = jQuery;

var fetchedLinkCnt = 0;

//const ratingSelectorNew = '.ipc-button > div > div > div > div > span:first-child';
//const ratingSelectorNew = '.ipc-button > div > div > div > div > span:first-child';
const ratingSelectorNew = "*[data-testid='hero-rating-bar__aggregate-rating__score'] > span:nth-child(1)";
const voteCountSelectorNew = "*[data-testid='hero-rating-bar__aggregate-rating__score'] + div + div";

function processIMDB_Links () {
    //--- Get only links that could be to IMBD movie/TV pages.
    var linksToIMBD_Shows   = document.querySelectorAll ("a[href*='/title/']");

    var lastLinkProcessed;

    for (var J = 0, L = linksToIMBD_Shows.length;  J < L;  J++) {
        const currentLink = linksToIMBD_Shows[J];

        /*--- Strict tests for the correct IMDB link to keep from spamming the page
            with erroneous results.
        */
        if (    ! /^(?:www\.)?IMDB\.com$/i.test (currentLink.hostname)
            ||  ! /^\/title\/tt\d+\/?$/i.test (currentLink.pathname)
        )
            continue;

        // I am beginning to think a whitelist might be better than this blacklist!

        // Skip if in Bio
        if ($(currentLink).hasClass('ipc-md-link')) {
            continue;
        }

        // Skip thumbnails on the search results page
        if ($(currentLink).closest('.primary_photo').length) {
            continue;
        }

        // Skip thumbnails in the six recommendations area of a title page
        if ($(currentLink).closest('.rec_item, .rec_poster').length) {
            continue;
        }

        // Skip top-rated episodes on the right-hand sidebar of TV series pages; they already display a rating anyway!
        if ($(currentLink).closest('#top-rated-episodes-rhs').length) {
            continue;
        }

        // Skip thumbnail of title at top of Season page
        if ($(currentLink).find(':only-child').prop('tagName') === 'IMG') {
            continue;
        }

        // Skip the thumbnail of each episode on a season page (episode names still processed)
        if ($(currentLink).closest('.image').length) {
            continue;
        }

        // Skip thumbnails in "Known For" section of actor pages
        if ($(currentLink).closest('.known-for, .knownfor-title').length && $(currentLink).find('img').length) {
            continue;
        }

        // Skip links to character pages
        // || currentLink.href.includes('/characters/')
        if ($(currentLink).closest('td.character').length) {
            continue;
        }

        // Skip episodes on actor pages
        if (skipEpisodes && $(currentLink).closest('.filmo-episodes').length) {
            continue;
        }

        // On an episode page, skip the next/previous buttons
        if ($(currentLink).closest('.bp_item').length) {
            continue;
        }

        // New layout 2021

        // The thumbnails on the "More like this" video cards
        if ($(currentLink).closest('.ipc-lockup-overlay').length) {
            continue;
        }

        // Nov 2022: In the list of titles for an actor, there are now two <a>s in each row.
        if (lastLinkProcessed && currentLink.href === lastLinkProcessed.href) {
            continue;
        }

        // Even if we skip this link, we remember it, to prevent duplicate ratings appearing when we "Get more ratings"
        lastLinkProcessed = currentLink;

        if (! currentLink.getAttribute ("data-gm-fetched") ){
            if (fetchedLinkCnt >= maxLinksAtATime){
                //--- Position the "continue" button.
                continueBttn.style.display = 'inline';
                currentLink.parentNode.insertBefore (continueBttn, currentLink);
                break;
            }

            //fetchTargetLink (currentLink); //-- AJAX-in the ratings for a given link.

            // Stagger the fetches, so we don't overwhelm IMDB's servers (or trigger any throttles they might have)
            // Needs currentLink to be a const, or a closure around it
            setTimeout(() => fetchTargetLink(currentLink), 300 * fetchedLinkCnt);

            //---Mark the link with a data attribute, so we know it's been fetched.
            currentLink.setAttribute ("data-gm-fetched", "true");
            fetchedLinkCnt++;
        }
    }
}

function fetchTargetLink (linkNode) {
    //--- This function provides a closure so that the callbacks can work correctly.

    //console.log("Fetching " + linkNode.href + ' for ', linkNode);

    /*--- Must either call AJAX in a closure or pass a context.
        But Tampermonkey does not implement context correctly!
        (Tries to JSON serialize a DOM node.)
    */
    GM_xmlhttpRequest ( {
        method:     'get',
        url:        linkNode.href,
        //context:    linkNode,
        onload:     function (response) {
            prependIMDB_Rating (response, linkNode);
        },
        onload:     function (response) {
            prependIMDB_Rating (response, linkNode);
        },
        onabort:     function (response) {
            prependIMDB_Rating (response, linkNode);
        }
    } );
}

function prependIMDB_Rating (resp, targetLink) {
    var isError     = true;
    var ratingTxt   = "** Unknown Error!";
    var colnumber   = 0;
    var justrate    = 'RATING_NOT_FOUND';

    if (resp.status != 200  &&  resp.status != 304) {
        ratingTxt   = '** ' + resp.status + ' Error!';
    }
    else {
        // Example value: ["Users rated this 8.5/10 (", "8.5/10"]
        //var ratingM = resp.responseText.match (/Users rated this (.*) \(/);
        // Example value: ["(1,914 votes) -", "1,914"]
        //var votesM  = resp.responseText.match (/\((.*) votes\) -/);

        var doc = document.createElement('div');
        doc.innerHTML = resp.responseText;
        var elem = doc.querySelector('.title-overview .vital .ratingValue strong');

        var ratingT, votesT;
        if (elem) {
            // Old site
            var title = elem && elem.title || '';

            ratingT = title.replace(/ based on .*$/, '');
            votesT  = title.replace(/.* based on /, '').replace(/ user ratings/, '');
        } else {
            // New site
            var ratingElem = doc.querySelector(ratingSelectorNew);
            ratingT = ratingElem && ratingElem.textContent || '';

            var votesElem = doc.querySelector(voteCountSelectorNew);
            votesT = votesElem && votesElem.textContent || '';

            //console.log('ratingElem', ratingElem);
            //console.log('votesElem', votesElem);

            if (votesT.slice(-1) == 'K') {
                votesT = String(1000 * votesT.slice(0, -1));
            } else if (votesT.slice(-1) == 'M') {
                votesT = String(1000000 * votesT.slice(0, -1));
            }
            // Add in commas (to match old format)
            votesT = votesT.replace(/(\d)(\d\d\d)(\d\d\d)$/, '$1,$2,$3').replace(/(\d)(\d\d\d$)/, '$1,$2');
            //console.log('votesT:', votesT);
        }
        // The code below expects arrays (originally returned by string match)
        var ratingM = [ratingT, ratingT + "/10"];
        var votesM  = [votesT, votesT];

        //console.log('ratingM', ratingM);
        //console.log('votesM', votesM);

        // This doesn't work on the new version of the site
        //if (/\(awaiting \d+ votes\)|\(voting begins after release\)|in development,/i.test (resp.responseText) ) {
        // hopefully this will work better
        if (ratingT == '' || votesT == '') {
                ratingTxt   = "NR";
                isError     = false;
                colnumber = 0;
        } else {
           if (ratingM  &&  ratingM.length > 1  &&  votesM  &&  votesM.length > 1) {
               isError = false;

               justrate = ratingM[1].substr(0, ratingM[1].indexOf("/"));
               // For countries which use ',' instead of '.' for decimal point
               justrate = justrate.replace(',', '.');

               // Let's try the metascore instead
               // Not all movied have a metascore
               var metaScoreElem = showMetaScore && doc.querySelector('.score-meta');
               //var metaScore = metaScoreElem && (Number(metaScoreElem.textContent) / 10).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
               var metaScore = metaScoreElem && metaScoreElem.textContent;
               var metaScoreColor = metaScoreElem && metaScoreElem.style.backgroundColor;

               var votes = votesM[1];
               var votesNum = Number(votes.replace(',', '', 'g'));
               var commas_found = (votes.match(/,/g) || []).length;
               if (commas_found === 1) {
                   votes = votes.replace(/,\d\d\d$/, 'k');
               } else if (commas_found === 2) {
                   votes = votes.replace(/,\d\d\d,\d\d\d$/, 'M');
               }

               // ratingTxt   = ratingM[1] + " - " + votesM[1];
               // We use the element style to override IMDB's reset
               ratingTxt   = "<strong style=\"font-weight: bolder\">" + justrate + "</strong>" + " / " + votes;
               //ratingTxt   = "<strong>" + (metaScoreElem ? metaScore : justrate) + "</strong>" + " / " + votes;
               //ratingTxt   = "<strong>" + (metaScoreElem ? metaScore : justrate) + "</strong>" + " / " + votes + (metaScoreElem ? " (" + justrate + "i)" : "" );
               //ratingTxt   = "<strong>" + justrate + "</strong>" + " / " + votes + (metaScoreElem ? " (<strong>" + metaScore + "</strong> meta)" : "" );
               colnumber = Math.round(justrate);
               // If metaScore was found, use that for the colour instead of the IMDB rating.  But since metascores are lower than imdb scores, add 1.5.
               //colnumber = Math.round(metaScoreElem ? metaScore / 10 + 1.5 : justrate);

               //if (metaScoreElem) {
               //    justRate = metaScore / 10;
               //}
           }
        }
    }

    //console.log('ratingTxt', ratingTxt);
    //console.log('justrate', justrate);

    // NOTE: I switched from <b> to <strong> simply because on Season pages, the rating injected after episode titles was getting uglified by an IMDB CSS rule: .list_item .info b { font-size: 15px; }
    //targetLink.setAttribute("title", "Rated " + ratingTxt.replace(/<\/*strong>/g,'').replace(/\//,'by') + " users." );
    targetLink.setAttribute("title", `Rated ${justrate} by ${votes} users.`);

    if (!(justrate > 0)) {
        return;
    }


    // Slowly approach full opacity as votesNum increases.  10,000 votes results in opacity 0.5 (actually 0.6 when adjusted).
    var opacity = 1 - 1 / (1 + 0.0001 * votesNum);
    // Actually let's not start from 0; we may still want to see the numbers!
    opacity = 0.2 + 0.8*opacity;
    // Don't use too many decimal points; it's ugly!
    //opacity = Math.round(opacity * 10000) / 10000;
    opacity = opacity.toFixed(3);

    var colors = ["#Faa", "#Faa","#Faa", "#Faa","#Faa", "#F88","#Faa", "#ff7","#7e7", "#5e5", "#0e0", "#ddd"];
    var bgCol = colors[colnumber];
    //var hue = justrate <= 6 ? 0 : justrate <= 8 ? 120 * (justrate - 6) / 2 : 120;
    //var bgCol = `hsla(${hue}, 100%, 60%, ${opacity})`;
    var resltSpan       = document.createElement ("span");
    // resltSpan.innerHTML = '<b><font style="border-radius: 5px;padding: 1px;border: #575757 solid 1px; background-color:' + color[colnumber] + ';">' + ' [' + ratingTxt + '] </font></b>&nbsp;';
    // resltSpan.innerHTML = '<b><font style="background-color:' + justrate + '">' + ' [' + ratingTxt + '] </font></b>&nbsp;';
    // I wanted vertical padding 1px but then the element does not fit in the "also liked" area, causing the top border to disappear!  Although reducing the font size to 70% is an alternative.
    resltSpan.innerHTML = '&nbsp;<font style="font-weight: normal;font-size: 80%;opacity: '+opacity+';border-radius: 3px;padding: 0.1em 0.6em;border: rgba(0,0,0,0.1) solid 1px; background-color:' + bgCol + ';color: black;">' + '' + ratingTxt + '</font>';

    if (showAsStar) {
        resltSpan.innerHTML = `
            <div class="ipl-rating-star" style="font-weight: normal">
                <span class="ipl-rating-star__star">
                    <svg class="ipl-icon ipl-star-icon  " xmlns="http://www.w3.org/2000/svg" fill="#000000" height="24" viewBox="0 0 24 24" width="24">
                        <path d="M0 0h24v24H0z" fill="none"></path>
                        <path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path>
                        <path d="M0 0h24v24H0z" fill="none"></path>
                    </svg>
                </span>
                <span class="ipl-rating-star__rating">${justrate}</span>
            </div>
        `;
    }

    if (isError)
        resltSpan.style.color = 'red';

    //var targetLink      = resp.context;
    //console.log ("targetLink: ", targetLink);

    // The "More like this" cards have a vertical flowing grid, so if we want rating and metascore to appear next to each other, they will need a container
    var container = document.createElement('div');
    container.style.display = 'inline-block';
    container.appendChild(resltSpan);

    //targetLink.parentNode.insertBefore (container, targetLink);
    targetLink.parentNode.insertBefore (container, targetLink.nextSibling);

    if (metaScoreElem) {
        // I am reluctant to move an element from another document into this one, multiple times.
        // Therefore we create a new element, like the original.
        var newMetaScoreElem = document.createElement(metaScoreElem.tagName);
        //newMetaScoreElem.outerHTML = metaScoreElem.outerHTML;
        newMetaScoreElem.className = metaScoreElem.className;
        newMetaScoreElem.textContent = metaScoreElem.textContent;
        newMetaScoreElem.style.backgroundColor = metaScoreElem.style.backgroundColor;
        // Missing despite the class.  It seems some pages don't include the .score-meta CSS
        newMetaScoreElem.style.color = 'white';
        newMetaScoreElem.style.padding = '2px';
        //resltSpan.parentNode.insertBefore (newMetaScoreElem, resltSpan.nextSibling);
        //resltSpan.parentNode.insertBefore (document.createTextNode(' '), resltSpan.nextSibling);
        container.appendChild(document.createTextNode(' '));
        container.appendChild(newMetaScoreElem);
    }
}

//--- Create the continue button
var continueBttn        = document.createElement ("button");
continueBttn.innerHTML  = "Get more ratings";

continueBttn.addEventListener ("click", function (){
        fetchedLinkCnt              = 0;
        continueBttn.style.display  = 'none';
        processIMDB_Links ();
    },
    false
);

if (showGetMoreRatings) {
    // Old way: Show the "Get more ratings" button at the top of the screen, so the user can click it after loading more titles
    // Now the site hides some of the Credits of an individual behind a "See all" button
    // Ideally we would trigger automatically when the new results are loaded
    // But until then, we present this button, so the user can manually trigger a refetch
    // Styling from Pharaoh2k
    continueBttn.style.display = 'inline';
    continueBttn.style.top = '0px';
    continueBttn.style.left = '50%';
    continueBttn.style.position = 'fixed';
    //continueBttn.style.height = '30px';
    //continueBttn.style.width = '170px';
    //continueBttn.style.color = 'black';
    continueBttn.style.zIndex = '1000';
    //continueBttn.style.backgroundColor = 'rgba(245, 245, 149, 0.7)';
    //continueBttn.style.boxShadow = '0 6px 6px rgb(0 0 0 / 60%)';
    continueBttn.style.boxShadow = '0 6px 6px #0004';
    continueBttn.style.cursor = 'pointer';
    // Borrow styling from the website
    continueBttn.className = 'ipc-btn ipc-btn--theme-base ipc-btn--core-accent1';
    continueBttn.style.padding = '0 1rem';
    document.body.appendChild(continueBttn);
} else {
    var seeMoreButton = document.querySelector('.ipc-see-more__button');
    if (seeMoreButton) {
        // New way: Load the new ratings automatically after the "See more" button is clicked
        seeMoreButton.addEventListener('click', function (){
            setTimeout(() => { continueBttn.click() }, 3000);
        });
    }
}

processIMDB_Links ();

if (addRatingToTitle) {
    setTimeout(function () {
        // Selectors for old site and new site
        var foundRating = document.querySelectorAll('.ratingValue [itemprop=ratingValue], ' + ratingSelectorNew);
        if (foundRating.length >= 1) {
            var rating = foundRating[0].textContent;
            if (rating.match(/^[0-9]\.[0-9]$/)) {
                document.title = `(${rating}) ` + document.title;
            }
        }
    }, 2000);
}