luumpsoft / Add rotten tomatoes Ratings to Netflix

// ==UserScript==
// @name        Add rotten tomatoes Ratings to Netflix
// @namespace   lumpSoftware
// @description Adds one or more Rotten Tomatoes ratings under each netflix title.  If there are multiple exact matches (different years, tv or movie), it adds multiple lines (netflix doesn't contain the year until you hover over title, so I don't know the year of the item being displayed).  The script maintains a cache of search titles for up to a week.  Link added to top of page so cache may be copied to the clipboard in csv format for searching.
// @include     https://*.netflix.com/*
// @content     www.rottentomatoes.com
// @version     0.0.1.0
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_listValues
// @grant       GM_deleteValue
// @grant       GM_setClipboard
// @license     MIT
// ==/UserScript==

//  based on: https://greasyfork.org/en/scripts/2271-netflix-ratings

document.netflixRatingsObject =
{
    debug: true,
    clearCacheInd: false,  //by default the clear cache option is not displayed
    httpRequestCount: 0,
    typeRottenTomatoes: 'RottenTomatoes',
    classLoading: 'nr-loading',
    classLoaded: 'nr-loaded',
    netflixPageTitleCard: "title-card-container",
    netflixRatingClassName : "nr-ratings-container",
    fetchCacheExpiry: 3,
    cacheExpiryInMinutes: 1440,
    load: function()
    {
        this.addMenu();

        this.httpRequestCount = 0;
        try {
            const netFlixItems = document.getElementsByClassName("fallback-text");
            if (netFlixItems)
            {
                for (let i = 0; i < netFlixItems.length; i++) {
                    // at th moment, the nextflix page consists of a bunc of title cards
                    const titleCard = document.getElementsByClassName(this.netflixPageTitleCard)[i];
                    if (!titleCard) continue;

                    const title = this.getTitle(titleCard);
                    if (!title) continue;
                    const searchURL = this.getRottenTomatoesUrl(title);

                    // if we already have a link element then we we're already done (previous iteration)
                    const previousLinkElement = titleCard.parentNode.getElementsByClassName(this.netflixRatingClassName);
                    if ( previousLinkElement && previousLinkElement.length == 0)
                        this.fetchRating(searchURL, title, "20??", titleCard, "Search", "??");
                    if (this.httpRequestCount > 15) return;
                }
            }
        }
        catch (error) {
            console.log("Error in load \n" + error + "\n" + title);
        }
    },

    // this adds links to the top of the netflix page
    addMenu(){
        let m = document.getElementById("lummpyCopyCache");
        if (m) return;

        let x =document.getElementsByClassName("tabbed-primary-navigation")

        if (this.clearCacheInd)
        {
            m = document.createElement("li");
            m.id="lummpyClearCache"
            m.innerHTML="<a onclick='document.netflixRatingsObject.clearCache()' >Clear Cache</a>"
            m.className = "navigation-tab";
            x[0].appendChild(m);
        }

        let m2 = document.createElement("li");
        m2.id="lummpyCopyCache"
        m2.innerHTML="<a onclick='document.netflixRatingsObject.copyCacheToClipboard()' >Copy Cache</a>"
        m2.className = "navigation-tab";
        x[0].appendChild(m2);

        this.cleanCache();
    },

    // on initial start up remove expired or ...fetching... (rotten tomato lookups that never returned) cache items
    cleanCache() {
        let cache = GM_listValues();

        const now = new Date();

        cache.forEach((c) =>{
            let v = GM_getValue(c);
            let t = GM_getValue(c + "_time");

            if (v == "...fetching..." || new Date(t) < now) {
                GM_deleteValue(c);
                GM_deleteValue(c + "_url");
                GM_deleteValue(c + "_time");
                GM_deleteValue(c + "_critic");
                GM_deleteValue(c + "_audience");
                GM_deleteValue(c + "_movieOrTV");
            }
        });
    },

    clearCache() {
        alert("Clearing Ratings Cache");
        let cache = GM_listValues();
        alert("Clearing Ratings Cache of " + cache.length + " items");
        cache.forEach((c) => GM_deleteValue(c));
    },

    copyCacheToClipboard() {

        let cache = GM_listValues();
        alert("Copying Ratings Cache of " + cache.length + " items to clipboard");
        let clipBrdText = "";
        cache.forEach((c) =>{
            let v = GM_getValue(c);
            let u = GM_getValue(c + "_url");
            let t = GM_getValue(c + "_time");
            let critic = GM_getValue(c + "_critic");
            let audience = GM_getValue(c + "_audience");
            let movieOrTV = GM_getValue(c + "_movieOrTV");
            let clipBrdItem = "";

            if (v && u && t ) {
                clipBrdItem += '"';
                clipBrdItem += c;
                clipBrdItem +='", "';
                v = v.replace(/\n/g,"").replace(/  /g,"").replace("liked it", "").replace("%","").replace("%","");
                clipBrdItem += v;
                clipBrdItem +='", "';
                clipBrdItem += u
                clipBrdItem +='", "';
                clipBrdItem += t;
                clipBrdItem +='", "';
                clipBrdItem += critic || "";
                clipBrdItem +='", "';
                clipBrdItem += audience || "";
                clipBrdItem +='", "';
                clipBrdItem += movieOrTV || "";
                clipBrdItem +='"\n';
            }
            clipBrdText += clipBrdItem;
        });
        GM_setClipboard(clipBrdText);
    },

    getTitle(titleCard) {
        const title = (titleCard.getElementsByClassName("fallback-text") || [{innerText:"error"}])[0].innerText;
        return title;
    },

    getRottenTomatoesUrl: function(title) {
        return "http://www.rottentomatoes.com/search/?search=" + this.encodeTitle(title);
    },

    encodeTitle: function(title) {
        return title.replace(/-/g, " ").replace(/ /g, "+");
    },

    fetchRating: function(url, title, year, titleCard, detailOrSearch, movieOrTV) {
        // Check for a cached rating

        let cachedRating = this.getCachedRating(year, title);
        if (cachedRating) {
            if (cachedRating[0] == "...fetching..." || cachedRating[0] == "...about to parse...") return; //waiting for search page or for it to be parsed so don't make another call, it's time will come

            if (cachedRating[0] == "...parsed...") { //search page have previously been returned, so do details from cache

                if (cachedRating[2].length > 0)
                    cachedRating[2].forEach((searchObj) => {
                        this.fetchRating(searchObj.url, title, searchObj.year, titleCard, "Detail", searchObj.movieOrTV);
                    });

                if (cachedRating[2].length == 0)
                    this.addNewLinkElement(titleCard, title, "no matches" , cachedRating[1], this.classLoaded);

                return;
            }

            this.addNewLinkElement(titleCard, title, cachedRating[0], cachedRating[1], this.classLoaded);
            return;
        } else
            this.saveCachedRating(year, title, "...fetching...", url);

        // Make a cross-domain request for this url
        console.log("\nSearching RT with:\t\t" + url + "\n");
        this.httpRequestCount += 1;
        GM_xmlhttpRequest({
            method: 'get',
            url: url,
            onload: function(response) {
                //GM_setClipboard(response.responseText,"text");

                if (detailOrSearch == "Search")
                    document.netflixRatingsObject.parseRottenTomatoesSearchResponse(response.responseText, url, title, year, titleCard);
                else
                    document.netflixRatingsObject.parseRottenTomatoesDetailResponse(response.responseText, url, title, year, titleCard, movieOrTV);
            },
            onerror: function(response) {
                console.log('failed to get a response from rotten tomatoes for ' + url);
            }
        });
    },

    // -----------------------------------
    // Caching functions
    // -----------------------------------
    getCachedRating: function(year, title) {
        try {
            const valueKey = year + "_" + this.encodeTitle(title);
            const urlKey = valueKey + '_url';
            const timeKey = valueKey + "_time";
            const searchKeys = valueKey + "_searchKeys";
            const criticsKeys = valueKey + "_criticsKeys";
            const audienceKeys = valueKey + "_audienceKeys";
            const movieTVKeys = valueKey + "_movieOrTV";

            const displayRatingValue = GM_getValue(valueKey);
            if (!displayRatingValue) {
                return null;
            }

            const url = GM_getValue(urlKey);
            if (!url) {
                return null;
            }
            const time = new Date(GM_getValue(timeKey));
            if (!time) {
                return null;
            }
            const now = new Date();
            if (this.debug) console.log('time ' + time.toString() + ' < now ' + now.toString());
            if (time < now) {
                // cache is stale, clear it
                GM_setValue(valueKey,null);
                GM_setValue(urlKey,null);
                GM_setValue(timeKey,null);
                GM_setValue(searchKeys,null);
                GM_setValue(criticsKeys,null);
                GM_setValue(audienceKeys,null);
                return null;
            }
            if (this.debug) console.log('found cached rating [' + valueKey + ', ' + time + ']: ' + displayRatingValue + ', ' + url);

            const searchURL = GM_getValue(searchKeys) || [];

            return [displayRatingValue, url, searchURL, GM_getValue(criticsKeys), GM_getValue(audienceKeys), GM_getValue(movieTVKeys) ];
        }
        catch (error) {
            console.log("Error in getCachedRating\n" + error + " " + title);
        }
    },

    saveCachedRating: function(year, title, displayRatingsValue, url, criticsRating, audienceRating, movieOrTV) {
        try {
            const valueKey = year + '_' + this.encodeTitle(title);
            const urlKey = valueKey + '_url';
            const timeKey = valueKey + '_time';
            const criticsKeys = valueKey + "_criticsKeys";
            const audienceKeys = valueKey + "_audienceKeys";
            const movieOrTVKeys = valueKey + "_movieOrTV";

            GM_setValue(valueKey, displayRatingsValue);
            GM_setValue(urlKey, url);
            let expireDate = new Date();
            let waitTime = this.cacheExpiryInMinutes;
            if (displayRatingsValue == "...fetching...") waitTime = this.fetchCacheExpiry;

            expireDate.setMinutes(expireDate.getMinutes() + waitTime);
            if (this.debug) console.log('caching ' + valueKey + ' until ' + expireDate.toString());
            GM_setValue(timeKey, expireDate);
            if (criticsRating) GM_setValue(criticsKeys, criticsRating);
            if (audienceRating) GM_setValue(audienceKeys, audienceRating);
            if (movieOrTV) GM_setValue(movieOrTVKeys, movieOrTV);
        }
        catch (error) {
            console.log("Error in saveCachedRating\n" + error + " " + title);
        }
    },

    addSearchURLToCache: function(year, title, searchURL, movieOrTV) {
        try {
            const valueKey = '20??_' + this.encodeTitle(title);
            const searchKeys = valueKey + "_searchKeys";
            let cachedSearchObj = GM_getValue(searchKeys) || [];

            if (cachedSearchObj.map((i) =>  i.url == searchObj.url).includes(true)) return;

            const searchObj = {"year": year, "movieOrTV":movieOrTV, "url":searchURL};
            cachedSearchObj.push(searchObj);
            GM_setValue(searchKeys, cachedSearchObj);
        }
        catch (error) {
            console.log("Error in addSearchURLToCache\n" + error + " " + title);
        }
    },

    // -----------------------------------
    // Parsing functions
    // -----------------------------------
    parseRottenTomatoesSearchResponse: function(responseText, searchPageURL, title, year, titleCard) {
        try {
            this.saveCachedRating(year, title, "...about to parse...", searchPageURL);

            // Check for no results
            if (responseText.match(/Sorry, no results found for/))
            {
                this.addNewLinkElement(titleCard, title, "not found", "", this.classLoaded);

                this.saveCachedRating(year, title, "not found" , searchPageURL);
                return;
            }

            // Look for movie in search page
            let result1 = this.tryToFindMovie(responseText, title, year, titleCard);

            // Look for TV Series
            let result2 = this.tryToFindTVSeries(responseText, title, year, titleCard);

            if (result1 <= 0 && result2 <= 0) this.addNewLinkElement(titleCard, title, "no matches", "", this.classLoaded);

            this.saveCachedRating(year, title, "...parsed...", searchPageURL);

        }
        catch (error) {
            console.log("Error in parseRottenTomatoesSearchResponse\n" + error + " " + title);
        }
    },

    // this adds a new link item underneth the netflix title.
    addNewLinkElement: function( titleCard, title, displayText, url, className){
        if (title !== this.getTitle(titleCard))
            return;

        const id = "nrRottenTomatoesContainer";
        const ratingSite = "Rotten Tomatoes..";
        let containerDiv = document.createElement("div");
        containerDiv.className = this.netflixRatingClassName;
        let descriptionElement = document.createElement("span");
        descriptionElement.innerHTML = "Rot: ";
        let linkElement = document.createElement("a");
        linkElement.id = id;
        linkElement.innerHTML = displayText;
        linkElement.href = url;
        this.addClass(linkElement, className);

        containerDiv.appendChild(descriptionElement);
        containerDiv.appendChild(linkElement);
        this.insertAfter(titleCard, containerDiv);

        return linkElement;
    },


    tryToFindMovie: function(responseText, title, year, titleCard) {
        const movieRegex = /{.*"movies":(\[.*\]),"tvCount".*}/;
        const match = movieRegex.exec(responseText);
        if (match) {
            const movieItems = JSON.parse(match[1]);
            if (movieItems.length > 0) {
                // Look for a full match
                for (let i = 0; i < movieItems.length; i++) {
                    let parsedTitle = movieItems[i].name;
                    let parsedYear = movieItems[i].year;
                    let parsedDetMovieUrl = "http://www.rottentomatoes.com" + movieItems[i].url;
                    // Check the title // and year
                    if (parsedTitle.toLowerCase().replace(/[^a-z0-9]/g, "") == title.toLowerCase().replace(/[^a-z0-9]/g, "")) // && parsedYear == year
                    {
                        this.addSearchURLToCache(parsedYear, parsedTitle, parsedDetMovieUrl, "M");
                        // found movie in search result so now get detail page (might be wrong year, but oh well this is as good as it gets - for now)
                        document.netflixRatingsObject.fetchRating( parsedDetMovieUrl, title, parsedYear, titleCard, "Detail", "M");
                        this.saveCachedRating(year, title, "...fetching...", parsedDetMovieUrl);
                    }
                }
                return movieItems.length;
            }
            // Did not find movie with exact name in search result (sure hope there is a tv series with that name)
            return 0;
        }
        return -1;
    },

    tryToFindTVSeries: function(responseText, title, year, titleCard) {
        const tvRegex = /{.*,"tvCount":.*,.*"tvSeries":(\[.*\]).*}/;
        const match = tvRegex.exec(responseText);
        if (match) {
            const tvItems = JSON.parse(match[1]);
            if (tvItems.length > 0) {
                for (let i = 0; i< tvItems.length; i++){
                    let parsedTitle = tvItems[i].title;
                    let parsedDetTVUrl = "http://www.rottentomatoes.com" + tvItems[i].url;
                    let parsedYear = tvItems[i].startYear;
                    // Check the title
                    if (parsedTitle.toLowerCase().replace(/[^a-z0-9]/g, "") == title.toLowerCase().replace(/[^a-z0-9]/g, ""))
                    {
                        this.addSearchURLToCache(parsedYear, parsedTitle, parsedDetTVUrl, "T");
                        // found tv series in search result so now get detail page (might be wrong year, but oh well this is as good as it gets - for now)
                        document.netflixRatingsObject.fetchRating(parsedDetTVUrl, title, parsedYear, titleCard, "Details", "T");
                        this.saveCachedRating(year, title, "...fetching...", parsedDetTVUrl);
                    }
                }
                return tvItems.length;
            }
            // Did not find any matches
            return 0;
        }
        return -1;
    },

    parseRottenTomatoesDetailResponse: function(responseText, detailPageURL, title, year, titleCard, movieOrTV) {
        let dataElement = this.addNewLinkElement(titleCard, title, "about to parse " + movieOrTV, detailPageURL, this.classLoading);
        try {
            // Set the link
            dataElement.href = detailPageURL;

            // Check for no results
            if (responseText.match(/Sorry, no results found for/))
            {
                dataElement.innerHTML = "no details??";
                this.addClass(dataElement, this.classLoaded);
                this.saveCachedRating(year, title, "not found", detailPageURL);
                return;
            }

            let responseDoc = this.getDom(responseText);
            let text = (movieOrTV == "T" ? "(" : "(") + year + ") ";

            // Set the loaded flag
            this.addClass(dataElement, this.classLoaded);
            let critText, audText;
            // Get the critics rating
            if (movieOrTV == "T"){
                const criticsRating = responseDoc.getElementsByClassName("critic-score");
                const allCriticsRating = criticsRating.length > 0 ? criticsRating[0].getElementsByClassName("meter-value") : criticsRating;
                critText = allCriticsRating.length > 0 ? allCriticsRating[0].textContent : "#"

                const audienceRating = responseDoc.getElementsByClassName("audience-score");
                const audienceRating2 = audienceRating.length > 0 ? audienceRating[0].getElementsByClassName("meter-value") : audienceRating;
                audText = audienceRating2.length > 0 ? audienceRating2[0].textContent : " # "

            }
            if (movieOrTV == "M") {
                const criticsRating = responseDoc.querySelector('#tomato_meter_link > span.mop-ratings-wrap__percentage');
                critText = (criticsRating)? criticsRating.innerText : "#"

                // Get the users rating
                let audienceRating2 = responseDoc.getElementsByClassName('mop-ratings-wrap__percentage mop-ratings-wrap__percentage--audience');
                audText = audienceRating2.length > 0 ? audienceRating2[0].textContent : " # "

            }
            critText = critText.replace(/\n/g,"").replace(/  /g,"").replace("liked it", "");
            audText = audText.replace(/\n/g,"").replace(/  /g,"").replace("liked it", "");

            text += critText + " | " + audText + " " + movieOrTV.substring(0,1);
            // Assign to element
            dataElement.innerHTML = text + " ";
            // Save in cache
            this.saveCachedRating(year, title, text, detailPageURL, critText, audText, movieOrTV);
        }
        catch (error) {
            console.log("Error in parseRottenTomatoesDetailResponse\n" + error + " " + title);
        }
    },

    // -----------------------------------
    // Helper functions
    // -----------------------------------

    hasClass: function(element, cls) {
        return (' ' + element.className + ' ').indexOf(' ' + cls + ' ') > -1;
    },

    addClass: function(element, cls) {
        if (!this.hasClass(element, cls)) {
            element.className += ' ' + cls;
        }
    },

    insertAfter: function(referenceNode, newNode) {
        referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
    },

    getDom: function(responseText) {
        let parser = new DOMParser();
        return parser.parseFromString(responseText, 'text/html');
    }

};

// Inject CSS
var styleElement = document.createElement('style');
styleElement.type = 'text/css';
styleElement.innerHTML = '.nr-ratings-container { margin-top: 8px; color: #666666; }';
document.head.appendChild(styleElement);

// Load ratings
let count = 0;
function doRatings() {
    //debugger;
    count = count+1;
    document.netflixRatingsObject.load();
    if (count < 500) setTimeout( function() {doRatings()}, 2000);
}
setTimeout( function() {doRatings()}, 5000);