// ==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);
}
});
});
}
})();