HLTB Bulk Import

Bulk import games from CSV into HowLongToBeat lists

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         HLTB Bulk Import
// @namespace    https://howlongtobeat.com/
// @author       badmannersteam
// @version      1.0
// @description  Bulk import games from CSV into HowLongToBeat lists
// @license MIT
// @match        https://howlongtobeat.com/*
// @grant        GM_xmlhttpRequest
// @connect      howlongtobeat.com
// ==/UserScript==

// Expected CSV format (no header):
//   list_title,rating,finish_date,game_name
// e.g.:
//   completed,10,23.07.2022,The Witcher 3: Wild Hunt - Game of the Year Edition

(function() {
    'use strict';

    // Rate-limit delay (in milliseconds) between each submission:
    const RATE_LIMIT_DELAY = 2000;

    // The "search" API endpoint:
    // TODO: retrieve hash from js sources
    const SEARCH_ENDPOINT = "https://howlongtobeat.com/api/lookup/e6e71df581a39f40";

    // The "submit" API endpoint:
    const SUBMIT_ENDPOINT = "https://howlongtobeat.com/api/submit";

    // The "user" API endpoint to retrieve user info:
    const USER_INFO_ENDPOINT = "https://howlongtobeat.com/api/user";

    // We'll store the retrieved user ID here:
    let userId = null;

    // First, fetch the user ID automatically.
    fetchUserId().then(id => {
        userId = id;
        console.log("HLTB User ID retrieved:", userId);
        // Once we have userId, add the UI so user can import CSV.
        addUI();
    }).catch(err => {
        console.error("Could not retrieve HLTB user ID:", err);
        alert("Error: Could not retrieve your HowLongToBeat user ID. Make sure you're logged in, then refresh the page.");
    });

    /**
     * Creates a small UI panel with a file input for CSV and an import button.
     */
    function addUI() {
        const container = document.createElement('div');
        container.style.cssText = `
            position: fixed;
            z-index: 99999;
            top: 10px;
            right: 10px;
            padding: 10px;
            background: #222;
            color: #fff;
            font-family: Arial, sans-serif;
        `;

        const infoLabel = document.createElement('div');
        infoLabel.textContent = "HLTB Bulk Import (User ID: " + userId + ")";
        infoLabel.style.marginBottom = "5px";

        const fileInput = document.createElement('input');
        fileInput.type = 'file';
        fileInput.accept = '.csv';
        fileInput.style.marginRight = '5px';

        const importBtn = document.createElement('button');
        importBtn.textContent = 'Import CSV to HLTB';
        importBtn.onclick = () => {
            if (!fileInput.files || fileInput.files.length === 0) {
                alert('Please choose a CSV file first!');
                return;
            }
            const file = fileInput.files[0];
            parseAndProcessCSV(file);
        };

        container.appendChild(infoLabel);
        container.appendChild(fileInput);
        container.appendChild(importBtn);
        document.body.appendChild(container);
    }

    /**
     * Parses the CSV file and processes each row.
     */
    function parseAndProcessCSV(file) {
        const reader = new FileReader();
        reader.onload = function(e) {
            const content = e.target.result;
            // Basic line-splitting parse (assumes no commas inside fields except for game name).
            const lines = content.split(/\r?\n/).map(l => l.trim()).filter(Boolean);

            const entries = [];
            for (const line of lines) {
                const parts = line.split(',');
                if (parts.length < 4) {
                    console.warn('Skipping malformed line:', line);
                    continue;
                }
                const listTitle = parts[0].trim().toLowerCase();
                const rating = parts[1].trim();

                const date = parts[2].trim().split('.');
                const day = date[0];
                const month = date[1];
                const year = date[2];

                const gameName = parts.slice(3).join(',').trim();

                entries.push({ listTitle, gameName, rating, "date":{"month":month,"day":day,"year":year}});
            }

            // Process in sequence to respect rate limits
            processEntriesSequentially(entries, 0);
        };
        reader.readAsText(file);
    }

    /**
     * Recursively processes each entry in the array with a delay between requests.
     */
    async function processEntriesSequentially(entries, index) {
        if (index >= entries.length) {
            alert('All entries processed!');
            return;
        }

        const { listTitle, gameName, rating, date } = entries[index];
        console.log(`Processing [${index+1}/${entries.length}]: ${gameName} → list "${listTitle}" with rating ${rating} and date ${JSON.stringify(date)}`);

        try {
            // 1) Search for the game by name
            const gameInfo = await searchGame(gameName);

            if (!gameInfo) {
                console.warn(`Game not found for: "${gameName}"`);
            } else {
                // 2) Submit the game to the specified list with the rating
                await submitGame(gameInfo.game_id, gameInfo.game_name, listTitle, rating, date);
            }
        } catch (err) {
            console.error('Error processing entry:', err);
        }

        // Wait a bit before processing the next entry
        setTimeout(() => {
            processEntriesSequentially(entries, index + 1);
        }, RATE_LIMIT_DELAY);
    }

    /**
     * Retrieves user ID.
     */
    function fetchUserId() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: USER_INFO_ENDPOINT,
                headers: {
                    'Content-Type': 'application/json'
                },
                anonymous: false,
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const json = JSON.parse(response.responseText);
                            if (json.data && json.data[0] && json.data[0].user_id) {
                                resolve(json.data[0].user_id);
                            } else {
                                reject(new Error("No user_id in response"));
                            }
                        } catch(e) {
                            reject(e);
                        }
                    } else {
                        reject(new Error(`User info request failed: ${response.status}`));
                    }
                },
                onerror: function(err) {
                    reject(err);
                }
            });
        });
    }

    /**
     * Searches the game in HLTB base.
     * Returns: { game_id, game_name } or null if no result.
     */
    function searchGame(gameName) {
        const searchTerms = gameName.split(/\s+/).filter(Boolean);

        const payload = {
            "searchType": "games",
            "searchTerms": searchTerms,
            "searchPage": 1,
            "size": 20,
            "searchOptions": {
                "games": {
                    "platform": "",
                    "sortCategory": "popular",
                    "rangeCategory": "",
                    "rangeTime": { "min": "", "max": "" },
                    "gameplay": { "perspective": "", "flow": "", "genre": "", "difficulty": "" },
                    "rangeYear": { "min": "", "max": "" },
                    "modifier": ""
                },
                "users": { "sortCategory": "" },
                "filter": "",
                "sort": "desc",
                "randomizer": 0
            }
        };

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: SEARCH_ENDPOINT,
                data: JSON.stringify(payload),
                headers: {
                    'Referer': 'https://howlongtobeat.com',
                    'Content-Type': 'application/json'
                },
                anonymous: false,
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const json = JSON.parse(response.responseText);
                            if (json.data && json.data.length > 0) {
                                // We pick the first search result
                                resolve(json.data[0]);
                            } else {
                                resolve(null);
                            }
                        } catch(e) {
                            reject(e);
                        }
                    } else {
                        reject(new Error(`Search request failed: ${response.status}`));
                    }
                },
                onerror: function(err) {
                    reject(err);
                }
            });
        });
    }

    /**
     * Submits the game to HLTB with the specified list, rating and date.
     */
    function submitGame(gameId, gameTitle, listTitle, rating, date) {
        // Convert rating (1–10) to HLTB’s 0–100 scale
        const intRating = parseInt(rating, 10);
        const finalScore = isNaN(intRating) ? 0 : (intRating * 10);

        // Map the CSV's listTitle to HLTB’s known boolean fields
        const listsObj = {
            playing: false,
            backlog: false,
            replay: false,
            custom: false,
            completed: false,
            retired: false
        };

        // If the CSV includes one of the known list keys, set that to true:
        if (Object.hasOwn(listsObj, listTitle)) {
            listsObj[listTitle] = true;
        } else {
            // Otherwise, default to the "custom" list
            listsObj.custom = true;
        }

        // Build the payload for submission:
        const payload = {
            "manualTimer": {"time":{"hours":null,"minutes":null,"seconds":null}},
            "platform": "PC",
            "title": gameTitle,
            "lists": listsObj,
            "general": {
                "progress": {"hours":null,"minutes":null,"seconds":null},
                "startDate": {"month":"00","day":"00","year":"0000"},
                "completionDate": date
            },
            "review": {
                "score": finalScore,
                "notes": ""
            },
            "multiPlayer": {
                "coOp": {"time":{"hours":null,"minutes":null,"seconds":null}},
                "vs":   {"time":{"hours":null,"minutes":null,"seconds":null}}
            },
            "additionals": {
                "notes":"",
                "video":""
            },
            "singlePlayer": {
                "includesDLC": false,
                "playCount": false,
                "compMain":  {"time":{"hours":null,"minutes":null,"seconds":null},"notes":""},
                "compPlus":  {"time":{"hours":null,"minutes":null,"seconds":null},"notes":""},
                "comp100":   {"time":{"hours":null,"minutes":null,"seconds":null},"notes":""}
            },
            "speedRuns": {
                "percAny":   {"time":{"hours":null,"minutes":null,"seconds":null},"notes":""},
                "perc100":   {"time":{"hours":null,"minutes":null,"seconds":null},"notes":""}
            },
            "userId": userId,
            "adminId": null,
            "gameId": gameId,
            "customLabels": {
                "custom": "Custom Tab",
                "custom2": "",
                "custom3": ""
            }
        };

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: SUBMIT_ENDPOINT,
                data: JSON.stringify(payload),
                headers: {
                    'Content-Type': 'application/json'
                },
                anonymous: false,
                onload: function(response) {
                    if (response.status === 200) {
                        console.log(`Submitted "${gameTitle}" to list "${listTitle}" with rating "${rating}"`);
                        resolve();
                    } else {
                        reject(new Error(`Submit request failed: ${response.status}`));
                    }
                },
                onerror: function(err) {
                    reject(err);
                }
            });
        });
    }
})();