Netflix IMDB Ratings [fork]

Show IMDB ratings on Netflix

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Netflix IMDB Ratings [fork]
// @version      1.6
// @description  Show IMDB ratings on Netflix
// @author       ioannisioannou16, kraki5525, joeytwiddle
// @match        https://www.netflix.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @grant        GM_getResourceURL
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addValueChangeListener
// @grant        GM_removeValueChangeListener
// @grant        GM_openInTab
// @connect      imdb.com
// @connect      www.omdbapi.com
// @resource     customCSS  https://raw.githubusercontent.com/kraki5525/netflix-imdb/master/netflix-imdb.css
// @resource     imdbIcon   https://raw.githubusercontent.com/kraki5525/netflix-imdb/master/imdb-icon.png
// @namespace https://greasyfork.org/users/8615
// ==/UserScript==

(function () {
    "use strict";

    // Original
    //var source = 'imdb';
    // Faster
    var source = 'omdbapi';

    // If you are getting rate limited, then generate your own key from: https://www.omdbapi.com/apikey.aspx
    var omdbApiKey = '3e29acf0';

    GM_addStyle(GM_getResourceText("customCSS"));

    GM_addStyle(`
        /* We should change the link in some way when hovered, so users know they will be clicking to IMDB and not on the Netflix card. */
        .imdb-rating {
            transition: 200ms all;
        }
        .imdb-rating:hover {
            background: #fff2;
        }
        /* It looks a little better if we move the gap otuside the element, rather than inside it */
        .imdb-rating {
            padding: 0 !important;
            margin: 6px 0;
        }
    `);

    var domParser = new DOMParser();

    function GM_xmlhttpRequest_get(url, cb) {
        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            onload: function (x) { cb(null, x); },
            onerror: function () { cb("Request to " + url + " failed"); }
        });
    }

    function requestRating(title, cb) {
        if (source === 'imdb') {
            requestRatingImdb(title, cb);
        } else if (source === 'omdbapi') {
            requestRatingOmdbApi(title, cb);
        }
    }

    function requestRatingImdb(title, cb) {
        var searchUrl = "https://www.imdb.com/find?s=tt&q=" + title;
        GM_xmlhttpRequest_get(searchUrl, function (err, searchRes) {
            if (err) return cb(err);
            var searchResParsed = domParser.parseFromString(searchRes.responseText, "text/html");
            var link = searchResParsed.querySelector(".ipc-metadata-list-summary-item__tc > a");
            var titleEndpoint = link && link.getAttribute("href");
            if (!titleEndpoint) return cb(null, {});
            var titleUrl = "https://www.imdb.com" + titleEndpoint;
            GM_xmlhttpRequest_get(titleUrl, function (err, titleRes) {
                if (err) return cb(err);
                var titleResParsed = domParser.parseFromString(titleRes.responseText, "text/html");
                //
                //var score = titleResParsed.querySelector("span[class^='AggregateRatingButton__RatingScore']");
                //var votes = titleResParsed.querySelector("div[class^='AggregateRatingButton__TotalRatingAmount']");
                // kraki5525 method
                //var score = titleResParsed.querySelector("div[data-testid='hero-rating-bar__aggregate-rating__score'] span");
                //var votes = titleResParsed.querySelector("div[data-testid='hero-rating-bar__aggregate-rating__score'] ~ div:not(:empty)");
                // joey method
                var score = titleResParsed.querySelector("*[data-testid='hero-rating-bar__aggregate-rating__score'] > span:nth-child(1)");
                var votes = titleResParsed.querySelector("*[data-testid='hero-rating-bar__aggregate-rating__score'] + div + div");
                //
                if (!score || (!score.textContent)) return cb(null, {})
                cb(null, { score: score.textContent, votes: (votes || {}).textContent || "", url: titleUrl });
            });
        });
    }

    function requestRatingOmdbApi(title, cb) {
        var searchUrl = "http://www.omdbapi.com/?apikey=" + omdbApiKey + "&t=" + encodeURIComponent(title);
        GM_xmlhttpRequest_get(searchUrl, function (err, searchRes) {
            if (err) return cb(err);
            try {
                var data = JSON.parse(searchRes.responseText);
                if (!data.imdbRating || data.imdbRating === 'N/A' || !data.imdbVotes || data.imdbVotes === 'N/A' || !data.imdbID) {
                    console.warn('Some data missing from OMDB API:', data);
                    // Fall back to IMDB
                    return requestRatingImdb(title, cb);
                }
                var score = data.imdbRating;
                var votesNum = Number(String(data.imdbVotes).replace(/,/g, ''));
                var votesShow = (votesNum / 1000).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + 'k';
                var imdbID = data.imdbID;
                var url = "https://www.imdb.com/title/" + imdbID;
                cb(null, { score, votes: votesShow, url });
            } catch (error) {
                console.error('Failed to fetch OMDB API data:', error);
                cb(error);
            }
        });
    }

    var cache = (function () {

        var cacheKey = "netflix-cache";

        var oneDayMs = 86400000;

        function getRandom(start, end) {
            return Math.ceil(Math.random() * (end - start) + start);
        }

        function mergeWithOtherCache(otherCache) {
            Object.keys(otherCache).forEach(function (otherKey) {
                var thisValue = _cache[otherKey];
                var otherValue = otherCache[otherKey];
                if (!thisValue || otherValue.expiration > thisValue.expiration) {
                    _cache[otherKey] = otherValue;
                }
            });
        }

        var listener = GM_addValueChangeListener(cacheKey, function (name, oldV, newV, remote) {
            if (remote) {
                mergeWithOtherCache(JSON.parse(newV));
            }
        });

        var _cache = JSON.parse(GM_getValue(cacheKey) || "{}");

        function isValid(res) {
            return res && (res.expiration - (new Date()).getTime() > 0);
        }

        function get(key) {
            var res = _cache[key];
            if (isValid(res)) return res.value;
        }

        function set(key, value) {
            var valueObj = { value: value, expiration: (new Date()).getTime() + getRandom(60 * oneDayMs, 65 * oneDayMs) };
            _cache[key] = valueObj;
        }

        function removeInvalidEntries() {
            Object.keys(_cache).forEach(function (key) {
                if (!isValid(_cache[key])) {
                    delete _cache[key];
                }
            });
        }

        window.addEventListener("blur", function () {
            removeInvalidEntries();
            GM_setValue(cacheKey, JSON.stringify(_cache));
        });

        window.addEventListener("beforeunload", function () {
            removeInvalidEntries();
            GM_setValue(cacheKey, JSON.stringify(_cache));
            GM_removeValueChangeListener(listener);
        });

        return { get: get, set: set };
    })();

    function getRating(title, cb) {
        var cacheRes = cache.get(title);
        if (!cacheRes || Object.keys(cacheRes).length === 0) {
            requestRating(title, function (err, rating) {
                if (err) {
                    cb(err);
                } else {
                    cache.set(title, rating);
                    cb(null, rating);
                }
            });
        } else {
            cb(null, cacheRes);
        }
    }

    var imdbIconURL = GM_getResourceURL("imdbIcon");

    function getOutputFormatter() {
        var div = document.createElement("div");
        div.classList.add("imdb-rating");
        div.style.cursor = "default";
        div.addEventListener("click", function () { });
        var img = document.createElement("img");
        img.classList.add("imdb-image");
        img.src = imdbIconURL;
        img.style.marginRight = '0.25em';
        div.appendChild(img);
        div.appendChild(document.createElement("div"));
        return function (res) {
            var restDiv = document.createElement("div");
            var rating = res.rating;
            if (res.error) {
                var error = document.createElement("span");
                error.classList.add("imdb-error");
                error.appendChild(document.createTextNode("ERROR"));
                restDiv.appendChild(error);
            } else if (res.loading) {
                var loading = document.createElement("span");
                loading.classList.add("imdb-loading");
                loading.appendChild(document.createTextNode("Fetching..."));
                loading.style.opacity = 0.6;
                restDiv.appendChild(loading);
            } else if (rating && rating.score && rating.votes && rating.url) {
                var score = document.createElement("span");
                score.classList.add("imdb-score");
                //score.appendChild(document.createTextNode(rating.score + "/10"));
                score.appendChild(document.createTextNode(rating.score));
                score.style.fontWeight = 'bold';
                restDiv.appendChild(score);
                var votes = document.createElement("span");
                votes.classList.add("imdb-votes");
                votes.appendChild(document.createTextNode("(" + rating.votes + " votes)"));
                votes.style.opacity = 0.6;
                restDiv.appendChild(votes);
                div.addEventListener('click', function (evt) {
                    GM_openInTab(rating.url, { active: true, insert: true, setParent: true });
                    // If the card has been expanded, clicking the IMDB link will also play the show, which we don't really want!
                    // If the card hasn't been expanded, then clicking this link will also expand the card.
                    // Unfortunately, this doesn't prevent that, even with 'capture=true' below...
                    evt.preventDefault();
                });
                div.style.cursor = "pointer";
            } else {
                var noRating = document.createElement("span");
                noRating.classList.add("imdb-no-rating");
                noRating.appendChild(document.createTextNode("N/A"));
                restDiv.appendChild(noRating);
            }
            div.replaceChild(restDiv, div.querySelector("div"));
            return div;
        }
    }

    function getRatingNode(title) {
        var node = document.createElement("div");
        var outputFormatter = getOutputFormatter();
        node.appendChild(outputFormatter({ loading: true }));
        getRating(title, function (err, rating) {
            if (err) return node.appendChild(outputFormatter({ error: true }));
            node.appendChild(outputFormatter({ rating: rating }));
        });
        return node;
    }

    function findAncestor(el, cls) {
        while (el && !el.classList.contains(cls)) {
            el = el.parentNode;
        }
        return el;
    }

    var rootElement = document.getElementById("appMountPoint");

    if (!rootElement) return;

    function imdbRenderingForExpandedCard(node) {
            var titleNode = node;
            var title = titleNode && titleNode.getAttribute("alt");
            if (!title) return;
            var ratingNode = getRatingNode(title);
            var parentNode = node.parentElement;
            var buttonContainer = parentNode.querySelector(".buttonControls--container");
            if (!buttonContainer || !parentNode) return;
            parentNode.insertBefore(ratingNode, buttonContainer);
    }

    function imdbRenderingForHeroDisplay(node) {
        var titleNode = node.querySelector(".title-logo");
        var title = titleNode && titleNode.getAttribute("alt");
        if (!title) return;
        var ratingNode = getRatingNode(title);
        ratingNode.style.fontSize = "1.4vw";
        titleNode.parentNode.insertBefore(ratingNode, titleNode.nextSibling);
    }

    function imdbRenderingForOverview(node) {
        var text = node.querySelector(".image-fallback-text");
        var logo = node.querySelector(".logo");
        var titleFromText = text && text.textContent;
        var titleFromImage = logo && logo.getAttribute("alt");
        var title = titleFromText || titleFromImage;
        if (!title) return;
        var meta = node.querySelector(".meta");
        if (!meta) return;
        var ratingNode = getRatingNode(title);
        meta.parentNode.insertBefore(ratingNode, meta.nextSibling);
    }

    function imdbRenderingForMoreLikeThis(node) {
        var titleNode = node.querySelector(".ptrack-content > img");
        var title = titleNode && titleNode.getAttribute("alt");
        if (!title) return;
        var meta = node.querySelector(".titleCard--metadataWrapper .videoMetadata--container-container");
        if (!meta) return;
        var ratingNode = getRatingNode(title);

        meta.parentNode.insertBefore(ratingNode, meta.nextSibling);
    }

    function imdbRenderingForCard(node) {
        var titleNode = node.querySelector(".previewModal--boxart")
        var title = titleNode && titleNode.getAttribute("alt");
        if (!title) return;
        var ratingNode = getRatingNode(title);
        ratingNode.classList.add("imdb-overlay");
        // If the show/film has already been viewed, then Netflix might show a progress-bar instead of the info for the show.
        // In that case, the metadatAndControls-info element might not be in the DOM, but we can add to the metadatAndControls element instead.
        // We prefer the metadatAndControls-info element when it is available, so that the iMDB rating appears above other info.
        //
        // BUG: But it gets worse.  Sometimes the metadatAndControls is there, and we add our element, then Netflix goes and removes it, and replaces it with a fresh metadatAndControls element!  I have not fixed that yet.
        var destination = node.querySelector(".previewModal--metadatAndControls-info")
                          || node.querySelector(".previewModal--metadatAndControls");
                          //|| node.querySelector(".videoMetadata--container")?.parentNode;
        if (!destination) return;
        destination.appendChild(ratingNode);
    }

    function cacheTitleRanking(node) {
        var titleNode = node.querySelector(".titleCard-imageWrapper img");
        var title = titleNode && titleNode.getAttribute("alt");
        if (!title) return;
        getRating(title, function () { });
    }

    var observerCallback = function (mutationsList) {
        for (var i = 0; i < mutationsList.length; i++) {

            var newNodes = mutationsList[i].addedNodes;

            for (var j = 0; j < newNodes.length; j++) {
                var newNode = newNodes[j];
                if (!(newNode instanceof HTMLElement)) continue;

                if (newNode.classList.contains("previewModal--player-titleTreatment-logo")) {
                    imdbRenderingForExpandedCard(newNode);
                    continue;
                }

                if (newNode.classList.contains("previewModal--wrapper")) {
                    imdbRenderingForCard(newNode);
                    continue;
                }

                // Hero Display
                var trailer = newNode.querySelector(".billboard-row");
                if (trailer) {
                    imdbRenderingForHeroDisplay(trailer);
                    continue;
                }

                const moreLikeItems = newNode.querySelectorAll(".moreLikeThis--container .more-like-this-item");
                if (moreLikeItems && moreLikeItems.length > 0) {
                    for (const item of moreLikeItems.entries()) {
                        imdbRenderingForMoreLikeThis(item[1]);
                    }
                    //continue;
                }
            }
        }
    };

    var observer = new MutationObserver(observerCallback);

    var observerConfig = { childList: true, subtree: true };

    observer.observe(document, observerConfig);

    var existingOverview = document.querySelector(".jawBone");
    existingOverview && imdbRenderingForOverview(existingOverview);

    var existingTrailer = document.querySelector(".billboard-row");
    existingTrailer && imdbRenderingForHeroDisplay(existingTrailer);

    // var existingCard = document.querySelector(".previewModal--player-titleTreatment-logo");
    var existingCard = document.querySelector("previewModal--wrapper");
    existingCard && imdbRenderingForCard(existingCard);

    window.addEventListener("beforeunload", function () {
        observer.disconnect();
    });
})();