Enhance titles - Netflix

Emphasize or hide titles on Netflix according to IMDb and local lists

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// Enhance titles - Netflix
//
// Loads lists of movies from a local list and an IMDb account and uses
// them to highlight or hide titles on Netflix.
//
// https://greasyfork.org/scripts/390631-enhance-titles-netflix
// Copyright (C) 2019, Guido Villa
// IMDb list management is taken from IMDb 'My Movies' enhancer:
// Copyright (C) 2008-2018, Ricardo Mendonça Ferreira
// Released under the GPL license - http://www.gnu.org/copyleft/gpl.html
//
// For information/instructions on user scripts, see:
// https://greasyfork.org/help/installing-user-scripts
//
// --------------------------------------------------------------------
//
// ==UserScript==
// @name            Enhance titles - Netflix
// @description     Emphasize or hide titles on Netflix according to IMDb and local lists
// @version         1.7
// @author          guidovilla
// @date            01.11.2019
// @copyright       2019, Guido Villa (https://greasyfork.org/users/373199-guido-villa)
// @license         GPL-3.0-or-later
// @homepageURL     https://greasyfork.org/scripts/390631-enhance-titles-netflix
// @supportURL      https://gitlab.com/gv-browser/userscripts/issues
// @contributionURL https://tinyurl.com/gv-donate-7e
// @attribution     Ricardo Mendonça Ferreira (https://openuserjs.org/users/AltoRetrato)
//
// @namespace       https://greasyfork.org/users/373199-guido-villa
//
// @match           https://www.netflix.com/*
// @match           https://www.imdb.com/user/*/lists*
// @exclude         https://www.netflix.com/watch*
//
// @require         https://greasyfork.org/scripts/391648/code/userscript-utils.js
// @require         https://greasyfork.org/scripts/390248/code/entry-list.js
// @require         https://greasyfork.org/scripts/391236/code/progress-bar.js
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_deleteValue
// @grant           GM_listValues
// @grant           GM_notification
// @grant           GM_addStyle
// @grant           GM_xmlhttpRequest
// @connect         www.imdb.com
// ==/UserScript==
//
// --------------------------------------------------------------------
//
// To-do (priority: [H]igh, [M]edium, [L]ow):
//   - [H] List/color configuration is hard-coded -> make configurable
//         Also, configuration should allow to skip downloading of unused lists
//   - [H] Not all IMDb movies are recognized because matching is done by title
//         (maybe use https://greasyfork.org/en/scripts/390115-imdb-utility-library-api)
//   - [M] Move IMDb list functions to an IMDb utility library
//   - [M] Download lists from GM_Config or similar, not from IMDb/Netflix list page
//   - [M] Show name in tooltip? Maybe not needed if above is solved
//   - [M] Make triangles more visible
//   - [M] Show in tooltip all lists where title is present?
//   - [M] Lots of clean-up
//   - [M] Add comments
//   - [M] Delay autopreview for hidden movies?
//   - [L] No link between IMDb user and Netflix user, implement getSourceUserFromTargetUser
//   - [L] hide selective titles?
//
// Changelog:
// ----------
// 2019.11.01 [1.7] Adopt Userscript Utils and move some functions there
//                  Modifications due to changes in Entry List library
//                  Some additional refactoring, cleanup and optimizations
// 2019.10.21 [1.6] Add download of rating and check-in list
//                  Filter out non-title IMDb lists
//                  Normalize apostrophes to increase NF<->IMDb name matching
// 2019.10.20 [1.5] Refactor using EntryList library (first version)
// 2019.09.30 [1.4] First public version, correct @namespace and other headers
// 2019.08.28 [1.3] Make the list more visible (top right triangle instead of border, with tooltip)
//                  Fix unhide method (bug added in 1.2)
//                  Add priority in todo list
// 2019.07.06 [1.2] Fix working in pages without rows (i.e. search page)
//                  Fix opacity not applied in some cases/pages
// 2019.06.20 [1.1] Load My List from My List page
// 2019.06.01 [1.0] Hide "My List" titles outside "My List" (row and page) and "Continue watching"
//                  Fix user name detection
//                  Gets data both from locally hidden movies and from IMDb lists
// 2019.03.30 [0.1] First test version, private use only
//
// --------------------------------------------------------------------

/* jshint -W008 */
/* global UU: readonly, EL: readonly, ProgressBar: readonly */

(function() {
    'use strict';

    /* BEGIN CONTEXT DEFINITION */

    var netflix = EL.newContext('Netflix');
    var imdb    = EL.newContext('IMDb');

    // other variables
    // TODO ci deve essere un modo migliore di questo
    var LIST_HIDE    = 'localHide';
    var LIST_NF_MY   = 'nfMyList';
    var LIST_NO      = 'no';
    var LIST_SEEN    = 'Visti';
    var LIST_TBD     = 'tbd';
    var LIST_WATCH   = 'Your Watchlist';
    var LIST_RATING  = 'Your ratings';
    var LIST_CHECKIN = 'Your check-ins';

    var IMDB_LIST_PAGE = 1; // any context-wide unique, non-falsy value is good
    var NF_LIST_PAGE   = 2; // any context-wide unique, non-falsy value is good


    var HIDE_BUTTON_STYLE_NAME = 'entrylist-nf-hide-button';
    var HIDE_BUTTON_STYLE = '.' + HIDE_BUTTON_STYLE_NAME + '{bottom:0;position:absolute; z-index: 10}';
    var TRIANGLE_STYLE_NAME = 'entrylist-netflix-triangle';
    var TRIANGLE_STYLE = '.' + TRIANGLE_STYLE_NAME + '{'
            + 'border-right: 20px solid;'
            + 'border-bottom: 20px solid transparent;'
            + 'height: 0;'
            + 'width: 0;'
            + 'position: absolute;'
            + 'top: 0;'
            + 'right: 0;'
            + 'z-index: 2;'
            + '}';

    // Netflix

    netflix.getUser = function() {
        var user = document.querySelector('div.account-menu-item div.account-dropdown-button > a');

        if (user) user = user.getAttribute("aria-label");
        if (user) user = user.match(/^(.+) - Account & Settings$/);
        if (user && user.length >= 2) user = user[1];

        return user;
    };


    netflix.isEntryPage = function() {
        return !document.location.href.match(/www\.imdb\.com\//);
    };


    netflix.getPageEntries = function() {
        return document.getElementsByClassName("title-card");
    };


    netflix.modifyEntry = function(entry) {
        var b           = document.createElement('a');
        b.className     = "nf-svg-button simpleround";
        b.textContent   = 'H';
        b.title         = 'Hide/show this title';
        var d           = document.createElement('div');
        d.className     = "nf-svg-button-wrapper " + HIDE_BUTTON_STYLE_NAME;
        d.appendChild(b);
        EL.addToggleEventOnClick(b, 2, LIST_HIDE, 'H');
        entry.appendChild(d);
    };


    netflix.getEntryData = function(entry) {
        var a = entry.getElementsByTagName('a');
        var idx, i;
        for (i = 0; i < a.length; i++) {
            if (a[i] && a[i].href && (idx = a[i].href.indexOf('/watch/')) != -1) break;
        }
        var id = '';
        var tmp = a[i].href;
        for (var j = idx + '/watch/'.length; j < tmp.length; j++) {
            if ('/?&'.indexOf(tmp[j]) != -1) break;
            else id += tmp[j];
        }
        if (!id) return null;

        var title = entry.getElementsByClassName("fallback-text")[0];
        if (title) title = title.innerText;
        if (!title) UU.le('Cannot find title for entry with id ' + id + ' on URL ' + document.URL, entry);
        else title = title.replace(/’/g, "'");

        return { 'id': id, 'name': (title || id) };
    };


    netflix.determineType = function(lists, _I_entryData, entry) {
        var type = null;

        if (entry.classList.contains('is-disliked')) type = 'D';
        else if (lists[EL.ln(LIST_WATCH, imdb)])     type = 'W';
        else if (lists[EL.ln(LIST_TBD,   imdb)])     type = 'T';
        else if (lists[EL.ln(LIST_SEEN,  imdb)])     type = 'S';
        else if (lists[EL.ln(LIST_NO,    imdb)])     type = 'N';

        else if (lists[EL.ln(LIST_HIDE)])            type = 'H';

        if (lists[EL.ln(LIST_NF_MY)] && (!type || type === 'W' || type === 'T') && this.pageType != NF_LIST_PAGE) {
            var row = entry.closest('div.lolomoRow');
            if (!row || ['queue', 'continueWatching'].indexOf(row.dataset.listContext) == -1) type = 'M';
        }
        return type;
    };


    var hideTypes = {
        "H": { "name": 'Hidden',    "colour": 'white' },
        "D": { "name": 'Disliked',  "colour": 'black' },
        "W": { "name": 'Watchlist', "colour": 'darkgoldenrod', "visible": true },
        "T": { "name": 'TBD',       "colour": 'Maroon',        "visible": true },
        "S": { "name": 'Watched',   "colour": 'seagreen' },
        "N": { "name": 'NO',        "colour": 'darkgrey' },
        "M": { "name": 'My list',   "colour": 'yellow' },
        "MISSING": { "name": 'Hide type not known', "colour": 'red' },
    };

    netflix.processItem = function(entry, _I_entryData, processingType) {
        if (!processingType || !hideTypes[processingType]) processingType = 'MISSING';
        var triangle = document.createElement('div');
        triangle.className = 'NHT-triangle ' + TRIANGLE_STYLE_NAME;
        triangle.style.borderRightColor = hideTypes[processingType].colour;
        triangle.title = hideTypes[processingType].name;
        entry.parentNode.appendChild(triangle);

        if (!hideTypes[processingType].visible) entry.parentNode.style.opacity = .1;
/*
        var parent = entry.parentNode;
        parent.parentNode.style.width = '5%';

        var field = parent.querySelector('fieldset#hideTitle' + entryData.id);
        if (!field) {
            field = document.createElement('fieldset');
            field.id = 'hideTitle' + entryData.id;
            field.style.border = 0;
            field.appendChild(document.createTextNode(entryData.name));
            parent.appendChild(field);
        } else {
            field.style.display = 'block';
        }
*/
    };


    netflix.unProcessItem = function(entry, _I_entryData, _I_processingType) {
        entry.parentNode.style.opacity = 1;
        var triangle = entry.parentNode.getElementsByClassName('NHT-triangle')[0];
        if (triangle) triangle.parentNode.removeChild(triangle);
/*
        entry.parentNode.parentNode.style.width = null;
        entry.parentNode.querySelector('fieldset#hideTitle' + entryData.id).style.display = 'none';
*/
    };


    netflix.getPageType = function() {
        return ( document.location.href == 'https://www.netflix.com/browse/my-list' && NF_LIST_PAGE );
    };


    // add buttons on the Netflix "My List" page
    netflix.processPage = function(_I_pageType, _I_isEntryPage) {
        // no need to check pageType: as of now there is only one
        var main = document.getElementsByClassName('mainView')[0];
        if (!main) {
            UU.le('Could not find "main <div>" to insert buttons');
            return;
        }
        var div  = document.createElement('div');
        var btnStyle = 'margin-left: 20px; margin-bottom: 20px; font-size: 13px; padding: .5em; background: 0 0; color: grey; border: soli 1px grey;';
        addBtn(div, btnNFMyListRefresh, "Load My List data",  "Reload information from 'My List'", btnStyle);
        addBtn(div, btnNFMyListClear,   "Clear My List data", "Empty the data from 'My List'",     btnStyle);
        main.appendChild(div);
    };



    // IMDb

    imdb.getUser = function() {
        var account = document.getElementById('nbusername');
        if (!account) return;
        var user = account.textContent.trim();

        var ur = account.href;
        if (ur) ur = ur.match(/\.imdb\..{2,3}\/.*\/(ur[0-9]+)/);
        if (ur && ur[1]) ur = ur[1];
        else UU.le('Cannot retrieve the ur id for user:', user);

        return { 'name': user, 'payload': ur };
    };


    imdb.getPageType = function() {
        return ( document.location.href.match(/\.imdb\..{2,3}\/user\/[^/]+\/lists/) && IMDB_LIST_PAGE );
    };


    // add buttons on the IMDb lists page
    imdb.processPage = function(_I_pageType, _I_isEntryPage) {
        // no need to check pageType: as of now there is only one
        var main = document.getElementById("main");
        var h1 = ( main && main.getElementsByTagName("h1")[0] );
        if (!h1) {
            UU.le('Could not find element to insert buttons.');
            return;
        }
        var div = document.createElement('div');
        div.className     = "aux-content-widget-2";
        div.style.cssText = "margin-top: 10px;";
        addBtn(div, btnIMDbListRefresh, "NF - Refresh highlight data", "Reload information from lists - might take a few seconds");
        addBtn(div, btnIMDbListClear,   "NF - Clear highlight data",   "Remove list data");
        h1.appendChild(div);
    };


    // lookup IMDb movies by name
    imdb.inList = function(entryData, list) {
        return !!(list[entryData.name]);
    };


    /* END CONTEXT DEFINITION */



    /* BEGIN COMMON FUNCTIONS */


    function addBtn(div, func, txt, help, style) {
        var b = document.createElement('button');
        b.className     = "btn";
        if (!style) style = "margin-right: 10px; font-size: 11px;";
        b.style.cssText = style;
        b.textContent   = txt;
        b.title         = help;
        b.addEventListener('click', func, false);
        div.appendChild(b);
        return b;
    }


    /* END COMMON FUNCTIONS */



    /* BEGIN NETFLIX FUNCTIONS */


    function btnNFMyListClear() {
        NFMyListClear();
        GM_notification({'text': "Information from 'My List' cleared.", 'title': UU.me + ' - Clear Netflix My List', 'timeout': 0});
    }

    function btnNFMyListRefresh() {
        var txt;
        if (NFMyListRefresh()) txt = "'My List' loaded.";
        else txt = "An error occurred. It was not possible to load 'My List' data.";
        GM_notification({'text': txt, 'title': UU.me + ' - Load Netflix My List', 'timeout': 0});
    }


    function NFMyListClear() {
        EL.deleteList(LIST_NF_MY);
        delete netflix.allLists[LIST_NF_MY];
    }

    function NFMyListRefresh() {
        NFMyListClear();

        var gallery = document.querySelector('div.mainView div.gallery');
        var cards   = ( gallery && gallery.getElementsByClassName('title-card') );
        if (!cards) return false;

        var list = {};
        var entry, entryData;
        for (var i = 0; i < cards.length; i++) {
            entry = cards[i];
            entryData          = netflix.getEntryData(entry);
            list[entryData.id] = entryData.name;
        }

        EL.saveList(list, LIST_NF_MY);
        return true;
    }


    /* END NETFLIX FUNCTIONS */



    /* BEGIN IMDB FUNCTIONS */


    function btnIMDbListClear() {
        IMDbListClear();
        GM_notification({'text': "Information from IMDb cleared.", 'title': UU.me + ' - Clear IMDb lists', 'timeout': 0});
    }

    function btnIMDbListRefresh() {
        GM_notification({
            'text':    'Click to start loading the IMDb lists. This may take several seconds',
            'title':   UU.me + ' - Load IMDb lists',
            'timeout': 0,
            'onclick': IMDbListRefresh,
        });
    }


    function IMDbListClear() {
        EL.deleteAllLists(imdb);
        delete imdb.allLists;
    }


    function IMDbListRefresh() {
        var pb = new ProgressBar(-1, 'Loading {#}/{$}...');
        var closeMsg = 'An error occurred. It was not possible to download the IMDb lists.';

        getIMDbLists()
            .then(function(lists) { pb.update(0, null, lists.length); return lists; })
            .then(function(lists) { return IMDbListDownload(lists, pb); } )
            .then(function(outcomes) {
                var msg = outcomes.reduce(function(msg, outcome) {
                    if (outcome.status === 'rejected') {
                        msg.txt += "\n * " + outcome.reason;
                        msg.numKO++;
                    }
                    return msg;
                }, { 'txt': '', 'numKO': 0 });

                if (msg.numKO === 0) {
                    closeMsg = 'Loading complete!';
                } else if (msg.numKO < outcomes.length) {
                    closeMsg = 'Done, but with errors:' + msg.txt;
                    UU.le('Errors in list download:', msg.txt);
                } else {
                    throw msg.txt;
                }
            })
            .catch(function(err) { UU.le(err); closeMsg = 'Error - It was not possible to download the IMDb lists: ' + err; })
            .finally(function() {
                GM_notification({
                    'text':      closeMsg,
                    'title':     UU.me + ' - Load IMDb lists',
                    'highlight': true,
                    'timeout':   5,
                    'ondone':    pb.close,
                });
            });
    }

    // Return a Promise to download and save all lists
    function IMDbListDownload(lists, pb) {
        IMDbListClear();

        var allDnd = lists.map(function(list) {
            return downloadList(list.id, list.type)
                       .then(function(listData) { EL.saveList(listData, list.name, imdb); })
                       .then(pb.advance)
                       .catch(function(error) { pb.advance(); throw "list '" + list.name + "' - " + error; });
        });
        return Promise.allSettled(allDnd);
    }


    var WATCHLIST  = "watchlist";
    var RATINGLIST = "ratings";
    var CHECKINS   = "checkins";
    var TITLES = "Titles";
    var PEOPLE = "People";
    var IMAGES = "Images";
    // Return a Promise to get all lists (name, id, type) for current user
    // filter out all non-title lists
    function getIMDbLists() {
        return findIMDbLists().then(getIMDbListFromPage)
                   .then(function(lists) {
                       return lists.filter(function(list) { return (list.type === TITLES); });
                   });
    }
    function findIMDbLists() {
        if (document.location.href.match(/\.imdb\..{2,3}\/user\/[^/]+\/lists/)) {
            return Promise.resolve(document);

        } else {
            UU.li('Not in the IMdb list page, downloading it.');
            var url = 'https://www.imdb.com/user/' + imdb.userPayload + '/lists';
            return UU.GM_xhR('GET', url, 'Get IMDb list page', { 'responseType': 'document' })
                       .then(function(response) { return response.responseXML2; });
        }
    }
    function getIMDbListFromPage(document) {
        var listElements = document.getElementsByClassName('user-list');

        var lists = Array.prototype.map.call(listElements, function(listElem) {
            var name = listElem.getElementsByClassName("list-name")[0];
            if (name) {
                name = name.text;
            } else {
                UU.le("Error reading name of list", listElem);
                name = listElem.id;
            }
            return {"name": name, "id": listElem.id, 'type': listElem.dataset.listType };
        });
        lists.push({"name": LIST_WATCH,   "id": WATCHLIST,  'type': TITLES });
        lists.push({"name": LIST_RATING,  "id": RATINGLIST, "type": TITLES });
        lists.push({"name": LIST_CHECKIN, "id": CHECKINS,   "type": TITLES });
        return lists;
    }


    // Return a promise to download a list
    function downloadList(id, type) {
        var getUrl;
        if (id == WATCHLIST || id == CHECKINS) {
            // Watchlist & check-ins are not easily available (requires another fetch to find export link)
            // http://www.imdb.com/user/ur???????/watchlist | HTML page w/ "export link" at the bottom
            var url = 'https://www.imdb.com/user/' + imdb.userPayload + '/' + id;
            getUrl = UU.GM_xhR('GET', url, "Get list page", { 'responseType': 'document' })
                .then(function(response) {
                    var lsId = response.responseXML2.querySelector('meta[property="pageId"]');
                    if (lsId) lsId = lsId.content;
                    if (!lsId) throw 'Cannot get list id';
                    return "https://www.imdb.com/list/" + lsId + "/export";
                });
        } else if (id == RATINGLIST) {
            getUrl = Promise.resolve("https://www.imdb.com/user/" + imdb.userPayload + "/" + id + "/export");
        } else {
            getUrl = Promise.resolve("https://www.imdb.com/list/" + id + "/export");
        }
        return getUrl
                   .then(function(url)      { return UU.GM_xhR('GET', url, "download"); })
                   .then(function(response) { return parseList(response, type); });
    }


    // Process a downloaded list
    function parseList(response, type) {
        if (response.responseText.startsWith("<!DOCTYPE html")) {
            throw 'received HTML instead of CSV file';
        }

        var data = UU.parseCSV(response.responseText);
        var f    = UU.getCSVheader(data);
        var list = {};

        var id_fld, name_fld;
        switch (type) {
            case TITLES:
                id_fld   = "Title";  // "Const";
                name_fld = "Title";
                break;
            default:
                throw 'downloaded list of unmanaged type ' + type + ', discarded';
        }

        var id_idx   = f[id_fld];
        var name_idx = f[name_fld];

        var id, name;
        for (var i=1; i < data.length; i++) {
            id   = data[i][id_idx];
            name = data[i][name_idx];

            if (id === "") {
                UU.le('parse ' + response.finalUrl + ": no id found at row " + i);
                continue;
            }
            if (list[id]) {
                UU.le('parse ' + response.finalUrl + ": duplicate id " + id + " found at row " + i);
                continue;
            }
            list[id] = name;
        }
        return list;
    }



    /* END IMDB FUNCTIONS */



    //-------- "main" --------
    GM_addStyle(TRIANGLE_STYLE + HIDE_BUTTON_STYLE);
    EL.init(netflix, true);
    EL.addSource(imdb);
    EL.startup();



}());