IMDB List Importer (No Duplicates)

Import titles and people from CSV or text to IMDb lists with smart duplicate detection (both local and remote).

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name            IMDB List Importer (No Duplicates)
// @namespace       http://nimabehkar.ir
// @include         https://www.imdb.com/list/*
// @version         2
// @author          NimaBhk
// @license         GPL-3.0-or-later
// @description     Import titles and people from CSV or text to IMDb lists with smart duplicate detection (both local and remote).
// @grant           none
// ==/UserScript==

/**
 * This script is a derivative work based on the original IMDB List Importer by Neinei0k.
 * Portions of the UI and logic have been rewritten to support duplicate detection
 * and enhanced CSV parsing.
 */


// GraphQL query
var request_data_get_current_items = {
    "query": "query GetListItems($listId: ID!) {\n  list(id: $listId) {\n    items(first: 500) {\n      edges {\n        node {\n          listItem {\n            ... on Title { id }\n            ... on Name { id }\n          }\n        }\n      }\n    }\n  }\n}",
    "operationName": "GetListItems",
    "variables": {
        "listId": ""
    }
};

var request_data_add_item = {
    "query": "mutation AddConstToList($listId: ID!, $constId: ID!, $includeListItemMetadata: Boolean!, $refTagQueryParam: String, $originalTitleText: Boolean) {\n  addItemToList(input: {listId: $listId, item: {itemElementId: $constId}}) {\n    listId\n    modifiedItem {\n      ...ListItemMetadata\n      listItem @include(if: $includeListItemMetadata) {\n        ... on Title {\n          ...TitleListItemMetadata\n        }\n        ... on Name {\n          ...NameListItemMetadata\n        }\n        ... on Image {\n          ...ImageListItemMetadata\n        }\n        ... on Video {\n          ...VideoListItemMetadata\n        }\n      }\n    }\n  }\n}\n\nfragment ListItemMetadata on ListItemNode {\n  itemId\n  createdDate\n  description {\n    originalText {\n      markdown\n      plaidHtml(showLineBreak: true)\n      plainText\n    }\n  }\n}\n\nfragment TitleListItemMetadata on Title {\n  ...BaseTitleCard\n  plot {\n    plotText {\n      plainText\n    }\n  }\n  latestTrailer {\n    id\n  }\n  series {\n    series {\n      id\n      originalTitleText {\n        text\n      }\n      releaseYear {\n        endYear\n        year\n      }\n      titleText {\n        text\n      }\n    }\n  }\n}\n\nfragment BaseTitleCard on Title {\n  id\n  titleText {\n    text\n  }\n  titleType {\n    id\n    text\n    canHaveEpisodes\n    displayableProperty {\n      value {\n        plainText\n      }\n    }\n  }\n  originalTitleText {\n    text\n  }\n  primaryImage {\n    id\n    width\n    height\n    url\n    caption {\n      plainText\n    }\n  }\n  releaseYear {\n    year\n    endYear\n  }\n  ratingsSummary {\n    aggregateRating\n    voteCount\n  }\n  runtime {\n    seconds\n  }\n  certificate {\n    rating\n  }\n  canRate {\n    isRatable\n  }\n  titleGenres {\n    genres(limit: 3) {\n      genre {\n        text\n      }\n    }\n  }\n  canHaveEpisodes\n}\n\nfragment NameListItemMetadata on Name {\n  id\n  primaryImage {\n    url\n    caption {\n      plainText\n    }\n    width\n    height\n  }\n  nameText {\n    text\n  }\n  primaryProfessions {\n    category {\n      text\n    }\n  }\n  knownFor(first: 1) {\n    edges {\n      node {\n        summary {\n          yearRange {\n            year\n            endYear\n          }\n        }\n        title {\n          id\n          originalTitleText {\n            text\n          }\n          titleText {\n            text\n          }\n          titleType {\n            canHaveEpisodes\n          }\n        }\n      }\n    }\n  }\n  bio {\n    displayableArticle {\n      body {\n        plaidHtml(\n          queryParams: $refTagQueryParam\n          showOriginalTitleText: $originalTitleText\n        )\n      }\n    }\n  }\n}\n\nfragment ImageListItemMetadata on Image {\n  id\n  url\n  height\n  width\n  caption {\n    plainText\n  }\n  names(limit: 4) {\n    id\n    nameText {\n      text\n    }\n  }\n  titles(limit: 1) {\n    id\n    titleText {\n      text\n    }\n    originalTitleText {\n      text\n    }\n    releaseYear {\n      year\n      endYear\n    }\n  }\n}\n\nfragment VideoListItemMetadata on Video {\n  id\n  thumbnail {\n    url\n    width\n    height\n  }\n  name {\n    value\n    language\n  }\n  description {\n    value\n    language\n  }\n  runtime {\n    unit\n    value\n  }\n  primaryTitle {\n    id\n    originalTitleText {\n      text\n    }\n    titleText {\n      text\n    }\n    titleType {\n      canHaveEpisodes\n    }\n    releaseYear {\n      year\n      endYear\n    }\n  }\n}",
    "operationName": "AddConstToList",
    "variables": {
        "listId": "",
        "constId": "",
        "includeListItemMetadata": true,
        "refTagQueryParam": "lsedt_add_items",
        "originalTitleText": false
    }
};

var request_data_add_description = {
    "query": "mutation EditListItemDescription($listId: ID!, $itemId: ID!, $itemDescription: String!) {\n  editListItemDescription(\n    input: {listId: $listId, itemId: $itemId, itemDescription: $itemDescription}\n  ) {\n    formattedItemDescription {\n      originalText {\n        markdown\n        plaidHtml(showLineBreak: true)\n        plainText\n      }\n    }\n  }\n}",
    "operationName": "EditListItemDescription",
    "variables": {
        "listId": "",
        "itemId": "",
        "itemDescription": ""
    }
};

var request_data_reorder_item = {
    "query": "mutation reorderListItems($input: ReorderListInput!) {\n  reorderList(input: $input) {\n    listId\n  }\n}",
    "operationName": "reorderListItems",
    "variables": {
        "input": {
            "newPositions": [],
            "listId": ""
        }
    }
};

if (/^https:\/\/(www.)?imdb.com\/list\/ls[0-9]+\/edit/.test(document.location)) {
    var elements = createHTMLForm();
}

function log(level, message) {
    console.log("(IMDB List Importer) " + level + ": " + message);
}

function setStatus(message) {
    elements.status.textContent = message;
}

function createHTMLForm() {
    let elements = {};
    try {
        let root = createRoot();
        elements.text = createTextField(root);
        if (isFileAPISupported()) {
            elements.file = createFileInput(root);
            elements.isFromFile = createFromFileCheckbox(root);
        } else {
            createFileAPINotSupportedMessage(root);
        }
        elements.isCSV = createCSVCheckbox(root);
        elements.isUnique = createUniqueCheckbox(root);
        elements.isReverse = createReverseCheckbox(root);
        elements.insert = createInsertRadio(root);
        elements.insertOther = createInsertOtherInput(root);
        elements.status = createStatusBar(root);
        createImportButton(root);
    } catch (message) {
        log("Error", message);
    }
    return elements;
}

function isFileAPISupported() {
    return window.File && window.FileReader && window.FileList && window.Blob;
}

function createRoot() {
    let container = document.querySelector('section.ipc-page-section--base');
    if (container === null) throw "Container element not found";
    let root = document.createElement('div');
    root.setAttribute('class', 'search-bar ipc-list-card--base ipc-list-card--border-line');
    root.style.cssText = 'height: initial; margin: 30px 0; padding: 10px;';
    container.insertBefore(root, container.children[1]);
    return root;
}

function createTextField(root) {
    let text = document.createElement('textarea');
    text.style.cssText = "background-color: white; width: 100%; height: 100px; overflow: initial;";
    root.appendChild(text);
    root.appendChild(document.createElement('br'));
    return text;
}

function createFileInput(root) {
    let file = document.createElement('input');
    file.type = 'file';
    file.disabled = true;
    file.style.marginBottom = '10px';
    root.appendChild(file);
    root.appendChild(document.createElement('br'));
    return file;
}

function createFromFileCheckbox(root) {
    let isFromFile = createCheckbox("Import from file (otherwise import from text)");
    root.appendChild(isFromFile.label);
    root.appendChild(document.createElement('br'));
    isFromFile.checkbox.addEventListener('change', (e) => {
        elements.text.disabled = e.target.checked;
        elements.file.disabled = !e.target.checked;
    });
    return isFromFile.checkbox;
}

function createCheckbox(textContent) {
    let checkbox = document.createElement('input');
    checkbox.type = 'checkbox';
    checkbox.style.width = 'initial';
    let text = document.createElement('span');
    text.style.fontWeight = 'normal';
    text.textContent = textContent;
    let label = document.createElement('label');
    label.appendChild(checkbox);
    label.appendChild(text);
    return {label, checkbox};
}

function createRadio(name, value, textContent) {
    let radio = document.createElement('input');
    radio.type = 'radio';
    radio.style.width = 'initial';
    radio.name = name;
    radio.value = value;
    let text = document.createElement('span');
    text.style.fontWeight = 'normal';
    text.textContent = textContent;
    let label = document.createElement('label');
    label.appendChild(radio);
    label.appendChild(text);
    return {label, radio};
}

function createCSVCheckbox(root) {
    let isCSV = createCheckbox("Data from .csv file (otherwise extract ids from text)");
    isCSV.checkbox.checked = true;
    root.appendChild(isCSV.label);
    root.appendChild(document.createElement('br'));
    return isCSV.checkbox;
}

function createUniqueCheckbox(root) {
    let isUnique = createCheckbox("Check for duplicates (Skip titles already in list)");
    isUnique.checkbox.checked = true;
    root.appendChild(isUnique.label);
    root.appendChild(document.createElement('br'));
    return isUnique.checkbox;
}

function createReverseCheckbox(root) {
    let isReverse = createCheckbox("Reverse Items on Insertion");
    root.appendChild(document.createElement('br'));
    root.appendChild(isReverse.label);
    root.appendChild(document.createElement('br'));
    return isReverse.checkbox;
}

function createInsertRadio(root) {
    let begin = createRadio("imdb_list_importer_insert", "1", "Insert in the Beginning");
    let end = createRadio("imdb_list_importer_insert", "-1", "Insert in the End");
    let other = createRadio("imdb_list_importer_insert", "0", "Insert in Other Position");
    end.radio.checked = true;
    [begin, end, other].forEach(r => {
        root.appendChild(r.label);
        root.appendChild(document.createElement('br'));
        r.radio.addEventListener('change', (e) => elements.insertOther.disabled = e.target.value != "0");
    });
    return {'begin': begin.radio, 'end': end.radio, 'other': other.radio};
}

function createInsertOtherInput(root) {
    let input = document.createElement('input');
    input.type = 'text';
    input.disabled = true;
    root.appendChild(input);
    root.appendChild(document.createElement('br'));
    return input;
}

function createStatusBar(root) {
    let status = document.createElement('div');
    status.textContent = "Ready. Set parameters and click Import.";
    status.style.margin = '10px 0';
    root.appendChild(status);
    return status;
}

function createImportButton(root) {
    let btn = document.createElement('button');
    btn.textContent = "Import List";
    btn.addEventListener('click', () => {
        if (elements.isFromFile && elements.isFromFile.checked) readFile();
        else importList(extractItems(elements.text.value));
    });
    root.appendChild(btn);
}

function readFile() {
    let file = elements.file.files[0];
    if (file) {
        let reader = new FileReader();
        reader.onload = (e) => importList(extractItems(e.target.result));
        reader.readAsText(file);
    } else setStatus("Error: No file selected");
}

function extractItems(text) {
    try {
        let re = "[a-z]{2}[0-9]{7,8}";
        return elements.isCSV.checked ? extractItemsFromCSV(re, text) : extractItemsFromText(re, text);
    } catch (e) {
        setStatus("Error: " + e);
        return [];
    }
}

function extractItemsFromCSV(re, text) {
    let table = parseCSV(text);
    let fields = findFieldNumbers(table);
    let items = [];
    let regex = new RegExp("^" + re + "$");
    for (let i = 1; i < table.length; i++) {
        let row = table[i];
        if (!regex.test(row[fields.const])) throw "Invalid format on line " + (i+1);
        items.push({const: row[fields.const], description: fields.description == -1 ? "" : row[fields.description]});
    }
    return items;
}

function parseCSV(text) {
    let lines = text.split(/\r|\n/).filter(l => l.trim().length > 0);
    return lines.map(line => {
        let res = [], cell = '', inStr = false;
        for (let char of line) {
            if (char === '"') inStr = !inStr;
            else if (char === ',' && !inStr) { res.push(cell); cell = ''; }
            else cell += char;
        }
        res.push(cell);
        return res;
    });
}

function findFieldNumbers(table) {
    let head = table[0].map(h => h.toLowerCase().trim());
    let res = {const: head.indexOf('const'), description: head.indexOf('description')};
    if (res.const === -1) throw "Field 'const' not found.";
    return res;
}

function extractItemsFromText(re, text) {
    let regex = new RegExp(re, 'g'), items = [], match;
    while ((match = regex.exec(text)) !== null) items.push({const: match[0], description: ""});
    return items;
}

async function importList(list) {
    if (list.length === 0) return;
    let list_id = /ls[0-9]{1,}/.exec(location.href)[0];

    // Local Duplicate Detection (within the input data itself)
    let seenLocal = new Set();
    let uniqueLocalList = list.filter(item => {
        let duplicate = seenLocal.has(item.const);
        seenLocal.add(item.const);
        return !duplicate;
    });

    if (uniqueLocalList.length < list.length) {
        log("Info", `Local duplicates found and skipped: ${list.length - uniqueLocalList.length}`);
        list = uniqueLocalList;
    }

    // Remote Duplicate Detection (against IMDb existing items)
    if (elements.isUnique.checked) {
        setStatus("Fetching current list items to prevent duplicates...");
        request_data_get_current_items.variables.listId = list_id;
        try {
            let current = await sendRequest(request_data_get_current_items);
            let existingIds = new Set();
            if (current.data.list && current.data.list.items) {
                current.data.list.items.edges.forEach(e => {
                    if (e.node.listItem && e.node.listItem.id) existingIds.add(e.node.listItem.id);
                });
            }
            let originalCount = list.length;
            list = list.filter(item => !existingIds.has(item.const));
            log("Info", `Existing items in list skipped: ${originalCount - list.length}`);
        } catch (e) {
            log("Error", "Could not fetch existing items. Proceeding with caution.");
        }
    }

    if (list.length === 0) {
        setStatus("No new items to add (all duplicates skipped).");
        return;
    }

    if (elements.isReverse.checked) list.reverse();

    let addedItemIds = [];
    for (let i = 0; i < list.length; i++) {
        setStatus(`Adding ${i+1}/${list.length}: ${list[i].const}`);
        request_data_add_item.variables.listId = list_id;
        request_data_add_item.variables.constId = list[i].const;
        let res = await sendRequest(request_data_add_item);
        let newItemId = res.data.addItemToList.modifiedItem.itemId;
        addedItemIds.push(newItemId);

        if (list[i].description) {
            request_data_add_description.variables.listId = list_id;
            request_data_add_description.variables.itemId = newItemId;
            request_data_add_description.variables.itemDescription = list[i].description;
            await sendRequest(request_data_add_description);
        }
    }

    // Reorder logic
    let pos = elements.insert.begin.checked ? 1 : (elements.insert.other.checked ? Number(elements.insertOther.value) : -1);
    if (pos > 0) {
        request_data_reorder_item.variables.input.listId = list_id;
        request_data_reorder_item.variables.input.newPositions = addedItemIds.reverse().map(id => ({"position": pos, "itemId": id}));
        await sendRequest(request_data_reorder_item);
    }

    location.reload();
}

function sendRequest(data) {
    return fetch("https://api.graphql.imdb.com/", {
        "credentials": "include",
        "headers": {"Accept": "application/graphql+json, application/json", "content-type": "application/json"},
        "body": JSON.stringify(data),
        "method": "POST"
    }).then(r => r.ok ? r.json() : Promise.reject(r.status));
}