// ==UserScript==
// @name Backloggd - Metacritic & HowLongToBeat Integration
// @name:zh-CN Backloggd - 集成Metacritic和HowLongToBeat
// @namespace https://greasyfork.org/en/users/1410951-nzar-bayezid
// @author Nzar Bayezid
// @version 1.5
// @description Adds Metacritic ratings and HowLongToBeat completion times on Backloggd
// @description:zh-CN 在Backloggd上添加Metacritic评分和HowLongToBeat完成时间
// @icon https://www.backloggd.com/favicon.ico
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js
// @match https://backloggd.com/*
// @match https://www.backloggd.com/*
// @grant GM_xmlhttpRequest
// @connect metacritic.com
// @connect howlongtobeat.com
// @connect umadb.ro
// @license MIT
// @noframes
// ==/UserScript==
/*========================= Version History ==================================
v1.5 -
- Combined Metacritic and HowLongToBeat integrations
- Unified UI styling for both services
- Added proper service ordering (HLTB above Metacritic)
- Optimized API requests and error handling
*/
(function() {
'use strict';
const OBSERVER_CONFIG = {
childList: true,
subtree: true,
attributes: false,
characterData: false
};
let processing = false;
let currentPath = '';
function mainExecutor() {
if (processing) return;
if (location.pathname === currentPath) return;
if (!document.querySelector('#game-body')) return;
currentPath = location.pathname;
processing = true;
cleanExistingElements();
addLoader();
processGameData();
}
function cleanExistingElements() {
$('#loader, .integration-container').remove();
}
function addLoader() {
const target = $("#game-body > div.col > div:nth-child(2) > div.col-12.col-lg-cus-32.mt-1.mt-lg-2");
if (target.length) {
target.append('<div id="loader" style="display:inline-block;margin-left:10px;">'
+ '<div class="loadingio-spinner-ellipsis-xiqce8pxsmm">'
+ '<div class="ldio-www0qkokjy"><div></div><div></div><div></div><div></div><div></div></div>'
+ '</div></div>');
}
}
async function processGameData() {
try {
const gameName = getNormalizedGameName();
const [metacriticData, hltbData] = await Promise.all([
fetchMetacriticData(gameName),
fetchHLTBData(gameName)
]);
cleanExistingElements();
renderIntegrationSection(metacriticData, hltbData);
} catch (error) {
console.error('Integration Error:', error);
} finally {
processing = false;
}
}
function getNormalizedGameName() {
const rawName = document.querySelector("#title h1").textContent;
return rawName.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z _0-9`~!@#$%^&*()-=+|\\\]}[{;:'",<.>/?]/gi, '')
.toLowerCase();
}
// Metacritic Functions
async function fetchMetacriticData(gameName) {
const normalizedTitle = gameName
.replace(/[^a-z0-9 ]/gi, '')
.replace(/\s+/g, '-')
.toLowerCase();
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: `https://www.metacritic.com/game/${normalizedTitle}/`,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
},
onload: function(response) {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, "text/html");
const criticRating = doc.querySelector('.c-siteReviewScore_background-critic_medium .c-siteReviewScore span')?.textContent || 'N/A';
const userRating = doc.querySelector('.c-siteReviewScore_background-user .c-siteReviewScore span')?.textContent || 'N/A';
resolve({
critic: criticRating,
user: userRating,
url: `https://www.metacritic.com/game/${normalizedTitle}/`
});
},
onerror: () => resolve(null),
timeout: 10000
});
});
}
// HLTB Functions
async function fetchHLTBData(gameName) {
try {
const key = await fetchHLTBKey();
return await fetchHLTBGameData(gameName, key);
} catch (error) {
console.error('HLTB Error:', error);
return null;
}
}
async function fetchHLTBKey() {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: "https://umadb.ro/hltb/fetch.php",
onload: (res) => res.status === 200 ? resolve(res.responseText.trim()) : reject(),
onerror: reject
});
});
}
async function fetchHLTBGameData(gameName, key) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: `https://howlongtobeat.com${key}`,
headers: {
"Content-Type": "application/json",
"Origin": "https://howlongtobeat.com",
"Referer": "https://howlongtobeat.com/"
},
data: JSON.stringify({
searchType: "games",
searchTerms: [gameName.replace(/"/g, '')],
searchPage: 1,
size: 20,
searchOptions: {
games: {
userId: 0,
platform: "",
sortCategory: "popular",
rangeCategory: "main",
rangeTime: {min: null, max: null},
gameplay: {perspective: "", flow: "", genre: "", difficulty: ""},
rangeYear: {min: "", max: ""},
modifier: ""
},
users: {sortCategory: "postcount"},
lists: {sortCategory: "follows"},
filter: "",
sort: 0,
randomizer: 0
},
useCache: true
}),
onload: (res) => res.status === 200 ? resolve(JSON.parse(res.responseText)) : reject(),
onerror: reject,
timeout: 10000
});
});
}
function processHLTBData(response) {
if (!response?.count) return { text: 'HLTB Data Unavailable', color: 'ff8c00' };
const mainEntry = response.data.find(item =>
item.game_name.toLowerCase() === document.querySelector("#title h1").textContent.toLowerCase()
) || response.data[0];
if (!mainEntry.comp_main) return { text: '--', color: '222222' };
const formatTime = (seconds) => {
const hours = Math.round((seconds / 3600) * 2) / 2;
return `${hours} Hour${hours !== 1 ? 's' : ''}`.replace('.5', '½');
};
return {
text: [
`Main Story: ${formatTime(mainEntry.comp_main)}`,
`Main + Sides: ${formatTime(mainEntry.comp_plus)}`,
`Completionist: ${formatTime(mainEntry.comp_100)}`,
`All Styles: ${formatTime(mainEntry.comp_all)}`
].join('<br>'),
color: getConfidenceColor(mainEntry.comp_main_count)
};
}
function getConfidenceColor(confidence) {
const colors = {
5: "FF3A3A",
10: "cc3b51",
15: "824985",
20: "5650a1",
25: "485cab",
30: "3a6db5",
Infinity: "287FC2"
};
return Object.entries(colors).find(([threshold]) => confidence < threshold)[1];
}
// Unified Rendering
function renderIntegrationSection(metacriticData, hltbData) {
const target = $("#game-body > div.col > div:nth-child(2) > div.col-12.col-lg-cus-32.mt-1.mt-lg-2");
if (!target.length) return;
const originalTitle = document.querySelector("#title h1").textContent;
const hltbProcessed = processHLTBData(hltbData);
// Common styling
const containerStyle = "margin-top:10px; margin-bottom:15px;";
const linkStyle = "display:inline-block; text-decoration:none; color:white; "
+ "border-radius:4px; padding:8px 12px; border:1px solid #d1d1d1; "
+ "font-size:14.4px; line-height:1.5; white-space: normal; "
+ "min-height: 54px; display: flex; align-items: center; justify-content: center;";
const metacriticTextStyle = "display: flex; flex-direction: column; align-items: center;";
// HLTB Box
target.append(`
<div class="integration-container" style="${containerStyle}">
<a href="https://howlongtobeat.com/?q=${encodeURIComponent(originalTitle)}"
target="_blank"
style="${linkStyle} background-color:#${hltbProcessed.color};">
${hltbProcessed.text}
</a>
</div>
`);
// Metacritic Box
if (metacriticData) {
target.append(`
<div class="integration-container" style="${containerStyle}">
<a href="${metacriticData.url}"
target="_blank"
style="${linkStyle} background-color:#287FC2;">
<div style="${metacriticTextStyle}">
<div>
<span style="color:#ffffff;">Metacritic: </span>
<span style="color:#ffffff;">Critic: ${metacriticData.critic}</span> |
<span style="color:#ffffff;">User: ${metacriticData.user}</span>
</div>
</div>
</a>
</div>
`);
}
}
// Observation system
new MutationObserver(mutations => {
if (!document.body.matches('#game-body') && !mutations.some(m => m.addedNodes.length)) return;
mainExecutor();
}).observe(document.documentElement, OBSERVER_CONFIG);
// Initial check
addEventListener('DOMContentLoaded', mainExecutor);
mainExecutor();
})();