add playtime to Steam store pages

Adds HowLongToBeat completion time to Steam store pages

// ==UserScript==
// @name         add playtime to Steam store pages
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Adds HowLongToBeat completion time to Steam store pages
// @author       taha
// @match        https://store.steampowered.com/app/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function() {
    'use strict';

    // Add Material Icons font and styles
    const styleSheet = document.createElement("link");
    styleSheet.rel = "stylesheet";
    styleSheet.href = "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=timer";
    document.head.appendChild(styleSheet);

    // Add custom styles
    GM_addStyle(`
        .material-symbols-outlined {
            font-size: 24px;
            width: 24px;
            height: 24px;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .timer-icon-container {
            display: flex;
            align-items: center;
            justify-content: center;
            width: 24px;
            height: 24px;
        }
    `);

    const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
    const RATE_LIMIT = 100; // milliseconds between requests
    let lastRequestTime = 0;

    function extractGameName() {
        const path = window.location.pathname;
        const match = path.match(/\/app\/\d+\/([^/]+)/);
        if (match) {
            return decodeURIComponent(match[1].replace(/_/g, ' '));
        }
        return null;
    }

    function basicCleanGameName(name) {
        // Remove content within parentheses and brackets
        let cleaned = name.replace(/[\(\[\{].*?[\)\]\}]/g, '');

        // Remove trademark and copyright symbols
        cleaned = cleaned.replace(/[™®©]/g, '');

        // Remove special characters and extra spaces
        cleaned = cleaned.replace(/[:\-_]/g, ' ').replace(/\s+/g, ' ').trim();

        return cleaned;
    }

    function getCachedTime(gameName) {
        const cached = GM_getValue(gameName);
        if (cached) {
            const { timestamp, data } = JSON.parse(cached);
            if (Date.now() - timestamp < CACHE_DURATION) {
                return data;
            }
        }
        return null;
    }

    function setCachedTime(gameName, data) {
        GM_setValue(gameName, JSON.stringify({
            timestamp: Date.now(),
            data
        }));
    }

    async function searchGame(gameName) {
        const url = 'https://howlongtobeat.com/api/search/5356b6994c0cc3eb';
        const headers = {
            'Host': 'howlongtobeat.com',
            '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',
            'Accept': '*/*',
            'Accept-Language': 'en-US,en;q=0.5',
            'Accept-Encoding': 'gzip, deflate, br',
            'Content-Type': 'application/json',
            'Origin': 'https://howlongtobeat.com',
            'Referer': 'https://howlongtobeat.com/'
        };

        const requestData = {
            searchType: "games",
            searchTerms: gameName.split(' '),
            searchPage: 1,
            size: 20,
            searchOptions: {
                games: {
                    userId: 0,
                    platform: "",
                    sortCategory: "popular",
                    rangeCategory: "main",
                    rangeTime: { min: null, max: null },
                    gameplay: { perspective: "", flow: "", genre: "" },
                    rangeYear: { min: "", max: "" },
                    modifier: ""
                },
                users: { sortCategory: "postcount" },
                lists: { sortCategory: "follows" },
                filter: "",
                sort: 0,
                randomizer: 0
            }
        };

        // Rate limiting
        const now = Date.now();
        const timeToWait = Math.max(0, RATE_LIMIT - (now - lastRequestTime));
        if (timeToWait > 0) {
            await new Promise(resolve => setTimeout(resolve, timeToWait));
        }
        lastRequestTime = Date.now();

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: url,
                headers: headers,
                data: JSON.stringify(requestData),
                onload: function(response) {
                    try {
                        const data = JSON.parse(response.responseText);
                        if (data.data && data.data.length > 0) {
                            resolve(data.data[0]);
                        } else {
                            resolve(null);
                        }
                    } catch (error) {
                        reject(error);
                    }
                },
                onerror: reject
            });
        });
    }

    async function searchGameWithFallback(originalName) {
        let searchName = basicCleanGameName(originalName);
        let words = searchName.split(' ');
        let results = null;

        while (words.length > 0 && !results) {
            const currentSearch = words.join(' ');
            results = await searchGame(currentSearch);
            if (!results) {
                words.pop();
            }
        }

        return results;
    }

    function formatTime(hours) {
        const wholeHours = Math.floor(hours);
        const minutes = Math.round((hours - wholeHours) * 60);
        return wholeHours > 0 ? `${wholeHours}h ${minutes}m` : `${minutes}m`;
    }

    async function addHLTBInfo() {
        const gameName = extractGameName();
        if (!gameName) return;

        // Check cache first
        const cachedData = getCachedTime(gameName);
        if (cachedData) {
            createHLTBElement(cachedData.time, cachedData.link);
            return;
        }

        try {
            const gameData = await searchGameWithFallback(gameName);
            if (gameData) {
                const mainTime = gameData.comp_main / 3600;
                const gameLink = `https://howlongtobeat.com/game/${gameData.game_id}`;

                setCachedTime(gameName, {
                    time: mainTime,
                    link: gameLink
                });

                createHLTBElement(mainTime, gameLink);
            }
        } catch (error) {
            console.error('Failed to fetch game time:', error);
        }
    }

    function createHLTBElement(time, hltbLink) {
        const originalContainer = document.querySelector('a.game_area_details_specs_ctn[href*="category2=2"]');
        if (!originalContainer) return;

        const clonedContainer = originalContainer.cloneNode(true);
        clonedContainer.href = hltbLink;
        clonedContainer.classList.add('cloned-container');
        clonedContainer.querySelector('.label').textContent = formatTime(time);

        const iconDiv = clonedContainer.querySelector('.icon');
        iconDiv.innerHTML = `
            <div class="timer-icon-container">
                <span class="material-symbols-outlined">timer</span>
            </div>
        `;

        originalContainer.parentNode.insertBefore(clonedContainer, originalContainer.nextSibling);
    }

    // Run the script when the page loads
    window.addEventListener('load', addHLTBInfo);
})();