IMDb - List Helper

Makes creating IMDb lists more efficient and convenient

当前为 2018-01-29 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name           IMDb - List Helper
// @description    Makes creating IMDb lists more efficient and convenient
// @namespace      imdb
// @author         themagician, monk-time
// @include        http://*imdb.com/list/*/edit
// @include        http://*imdb.com/list/*/edit?*
// @require        https://cdnjs.cloudflare.com/ajax/libs/d3-dsv/1.0.8/d3-dsv.min.js
// @icon           http://www.imdb.com/favicon.ico
// @version        3.0.1
// ==/UserScript==

//
// CHANGELOG
//
// 3.0.1
// fixed: New IMDb search results layout
// changed: Search only for regex matches by default
// added: A checkbox to toggle search mode (matches only vs. matches or full string)
//
// 3.0
// fixed: Search by movie title
// fixed: No longer requires jQuery; jquery-csv is replaced with d3-dsv
// fixed: Remove delay before auto-clicking on a search result
// changed: Criticker score conversion: 0..10 -> 1, 11..20 -> 2, 91..100 -> 10
// changed: Criticker importer requires .csv
// changed: If the regex fails, try searching for the whole string
//
// 2.4.1
// fixed: In some instances the script wasn't loaded (bad @include)
// changed: Limit number of setTimeOut calls
//
// 2.4.0
// fixed: IMDb changed layout
//
// 2.3.0
// fixed: importing ratings works again
//
// 2.2.0
// added: support for people
//
// 2.1.1
// added: only show import form if ratings is selected
//
// 2.1
// added: importers for imdb, rateyourmusic, criticker
//
// 2.0
// added: import ratings
// added: if regex doesn't match, skip entry
//
// 1.6.1.2
// added: input text suggestion as a placeholder
//
// 1.6.1.1
// fixed: some entries are skipped when adding imdb ids/urls
//

/* global d3 */

'use strict';

// milliseconds between each request
const REQUEST_DELAY = 1000;

// ----- DOM ELEMENTS: STYLING, CREATION AND TRACKING -----

document.head.insertAdjacentHTML('beforeend', `<style>
    #ilh-ui {
        margin: 0 5% 5% 5%;
        padding: 10px;
        border: 1px solid #e8e8e8;
    }

    #ilh-ui div {
        margin: 0.5em 0 0.75em
    }

    #ilh-ui label {
        font-weight: normal;
    }

    #ilh-ui input[type=text] {
        width: 100%;
        font-family: monospace;
    }

    #ilh-ui #ilh-regexp {
        margin-top: 4px;
        margin-left: 1px;
    }

    #ilh-ui textarea {
        width: 100%;
        background-color: lightyellow;
        overflow: auto;
    }

    #ilh-ui span {
        font-weight: bold;
    }
</style>`);

const uiHTML = `
    <div id="ilh-ui">
        <div>
            <span>Import mode:</span>
            <label>
                <input type="radio" name="importmode" value="list" checked>List</input>
            </label>
            <label>
                <input type="radio" name="importmode" value="ratings">Ratings</input>
            </label>
        </div>
        <textarea id="ilh-film-list" rows="7" cols="60" placeholder="Input titles or IMDb IDs and click Start"></textarea>
        <div>
            <input type="button" value="Start" id="ilh-start">
            <input type="button" value="Skip"  id="ilh-skip">
            <input type="button" value="Retry" id="ilh-retry">
            <span>Remaining: <span id="ilh-films-remaining">0</span></span>
        </div>
        <div>
            <span>Current:</span>
            <input type="text" id="ilh-current-film" size="65"">
        </div>
        <div>
            <span>Regexp:</span>
            <input type="checkbox" id="ilh-matches-only" checked>
            <label for="ilh-matches-only">matches only  (disable to search for film titles)</label>
            <input type="text" id="ilh-regexp" size="65">
        </div>
        <div id="ilh-import" style="display: none">
            <b>Import .csv from:</b>
            <select name="import" id="ilh-import-sel">
                <option value="" selected disabled hidden>Select</option>
                <option value="imdb">IMDb</option>
                <option value="rym">RateYourMusic</option>
                <option value="criticker">Criticker</option>
            </select>
            <span>File:</span>
            <input type="file" id="ilh-file-import" disabled>
        </div>
    </div>`;

document.querySelector('div.lister-search').insertAdjacentHTML('afterend', uiHTML);

const innerIDs = [
    'film-list',
    'start',
    'skip',
    'retry',
    'films-remaining',
    'current-film',
    'regexp',
    'matches-only',
    'import',
    'import-sel',
    'file-import',
];

const camelCase = s => s.replace(/-[a-z]/g, m => m[1].toUpperCase());

// Main object for interacting with the script's UI; keys match element ids
const ui = Object.assign(...innerIDs.map(id => ({
    [camelCase(id)]: document.getElementById(`ilh-${id}`),
})));

[ui.radioList, ui.radioRatings] = document.querySelectorAll('#ilh-ui input[name=importmode]');
const elIMDbSearch = document.getElementById('add-to-list-search');
const elIMDbResults = document.getElementById('add-to-list-search-results');

// ----- HANDLERS AND ACTIONS -----

const convertRating = n => Math.ceil(n / 10) || 1; // 0..100 -> 1..10
// d3 skips a row if a row conversion function returns null
const joinOrSkip = (...fields) => (fields.includes(undefined) ? null : fields.join(','));
const rowConverters = {
    imdb: row => joinOrSkip(row['Your Rating'], row.Const),
    rym: row => joinOrSkip(row.Rating, row.Title),
    // .csv exported from Criticker have spaces between column names
    criticker: row => joinOrSkip(convertRating(+row.Score), row[' IMDB ID']),
};

const prepareImport = e => {
    const isLegalParser = Boolean(rowConverters[e.target.value]); // in case of html-js mismatch
    ui.fileImport.disabled = !isLegalParser;
};

const handleImport = e => {
    const format = ui.importSel.value;
    const reader = new FileReader();
    reader.onload = event => {
        const fileStr = event.target.result;
        ui.filmList.value = d3.csvParse(fileStr, rowConverters[format]).join('\n');
    };

    const [file] = e.target.files;
    reader.readAsText(file);
};

const RatingManager = {
    rating: 0,
    regex: '^([1-9]|10),(.*)$',
    processRegexMatch: ([, rating, filmTitle], callback) => {
        RatingManager.rating = rating;
        callback(filmTitle);
    },
    handleSelection: async (imdbID, callback) => {
        console.log(`RatingManager::handleSelection: Rating ${imdbID}`);
        const moviePage = await fetch(
            `http://www.imdb.com/title/${imdbID}/`,
            { credentials: 'same-origin' },
        );
        const authHash = new DOMParser()
            .parseFromString(await moviePage.text(), 'text/html')
            .getElementById('star-rating-widget')
            .dataset.auth;

        const params = {
            tconst: imdbID,
            rating: RatingManager.rating,
            auth: authHash,
            tracking_tag: 'list',
        };

        const postResp = await fetch('http://www.imdb.com/ratings/_ajax/title', {
            method: 'POST',
            body: new URLSearchParams(params),
            credentials: 'same-origin',
        });

        if (postResp.ok) {
            callback();
        } else {
            alert(`Rating failed. Status code ${postResp.status}`);
        }
    },
};

const ListManager = {
    regex: '((?:tt|nm)\\d+)',
    processRegexMatch: ([, filmTitle], callback) => callback(filmTitle),
    handleSelection: (imdbID, callback) => callback(),
};

const App = {
    manager: ListManager,
    films: [],
    regexObj: null,
    run: () => {
        ui.regexp.value = App.manager.regex;

        ui.importSel.addEventListener('change', prepareImport);
        ui.fileImport.addEventListener('change', handleImport);

        ui.radioList.addEventListener('change', () => {
            App.manager = ListManager;
            ui.import.style.display = 'none';
            ui.regexp.value = App.manager.regex;
        });

        ui.radioRatings.addEventListener('change', () => {
            App.manager = RatingManager;
            ui.import.style.display = 'block';
            ui.regexp.value = App.manager.regex;
        });

        // When start button is clicked
        ui.start.addEventListener('click', () => {
            App.regexObj = RegExp(ui.regexp.value || App.manager.regex, 'i');

            // Disable relevant UI elements
            [ui.filmList, ui.start, ui.regexp, ui.radioList, ui.radioRatings]
                .forEach(el => { el.disabled = true; });

            App.films = ui.filmList.value.trim().split('\n');
            App.handleNext();
        });

        // when skip button is clicked
        ui.skip.addEventListener('click', () => App.handleNext());

        // Sometimes the request fails forcing the user to skip an entry to continue
        ui.retry.addEventListener('click', () => {
            elIMDbSearch.dispatchEvent(new Event('keydown'));
        });
    },
    handleNext: () => {
        if (App.films.length) {
            App.search(App.films.shift());
        } else { // if last film
            App.reset();
        }
    },
    reset: () => {
        App.films = [];
        App.regexObj = null;

        [ui.filmList, ui.start, ui.regexp, ui.radioList, ui.radioRatings]
            .forEach(el => { el.disabled = false; });

        ui.currentFilm.value = '';
        elIMDbSearch.value = '';
    },
    search: filmTitle => {
        // Remove unnecessary whitespace
        filmTitle = filmTitle.trim();

        // Set current text to what we're searching
        ui.currentFilm.value = filmTitle;

        // Remove the first title from the text box and set the remaining number
        ui.filmsRemaining.textContent = App.films.length;
        ui.filmList.value = App.films.join('\n');

        // Run regex if it matches and let the manager process the result.
        // Otherwise, if "matches only" is unchecked,
        // try searching for the whole line (if it's not empty) before skipping it.
        const result = App.regexObj.exec(filmTitle) ||
            // eslint-disable-next-line no-sparse-arrays
            (!ui.matchesOnly.checked && filmTitle && [, filmTitle]);
        if (result) {
            App.manager.processRegexMatch(result, filmTitle2 => {
                // Set imdb search input field to film title and trigger search
                elIMDbSearch.value = filmTitle2;
                elIMDbSearch.dispatchEvent(new Event('keydown'));
            });
        } else {
            App.handleNext();
        }
    },
};

// Handle clicks on search results by a user or the script
elIMDbResults.addEventListener('click', e => {
    const imdbID = e.target.closest('a').dataset.const;
    if (!imdbID || !imdbID.startsWith('tt')) return;
    App.manager.handleSelection(imdbID, () => {
        setTimeout(() => App.handleNext(), REQUEST_DELAY);
    });
});

// Monitor for changes to the search result box.
// If the search was for IMDb URL/ID, the only result is clicked automatically
const mut = new MutationObserver(mutList => mutList.forEach(({ addedNodes }) => {
    if (!addedNodes.length || !/(nm|tt)\d{7}/i.test(ui.currentFilm.value)) return;
    addedNodes[0].click();
}));
mut.observe(elIMDbResults, { childList: true });

App.run();