Greasy Fork is available in English.

Add Play Time to Steamdb search

Add play time from howlongtobeat.com to Steamdb game row when you search with tags, sales or viewing your own library


    // ==UserScript==
    // @name         Add Play Time to Steamdb search
    // @namespace    http://tampermonkey.net/
    // @version      0.3
    // @description  Add play time from howlongtobeat.com to Steamdb game row when you search with tags, sales or viewing your own library
    // @author       Taha
    // @match        https://steamdb.info/tag/*
    // @match        https://steamdb.info/sales/*
    // @grant        GM_xmlhttpRequest
    // @grant        GM_addStyle
    // @grant        GM_setValue
    // @grant        GM_getValue
    // @license MIT
    // ==/UserScript==

    (function() {
        'use strict';

        // playtime column styles
        GM_addStyle(`
            .playtime-cell {
                text-align: right;
                padding-right: 10px !important;
            }
            .playtime-cell a {
                color: inherit;
                text-decoration: none;
            }
            .playtime-cell a:hover {
                text-decoration: underline;
            }
            .playtime-loading {
                opacity: 0.5;
            }
            th[data-name="playtime"] {
                cursor: pointer;
            }
        `);

        const CACHE_DURATION = 24 * 60 * 60 * 1000;
        const RATE_LIMIT = 100;
        let lastRequestTime = 0;
        let processedGames = new Set(); // Track which games we've already processed

        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;
        }

        async function searchGameWithFallback(originalName, loadingCell) {
            // First do a basic cleaning
            let searchName = basicCleanGameName(originalName);
            let words = searchName.split(' ');
            let results = null;

            // Try different variations of the name, starting with the full name
            // and removing one word from the end each time
            while (words.length > 0 && !results) {
                const currentSearch = words.join(' ');
    //            console.log(`Trying search with: "${currentSearch}"`);

                results = await searchGame(currentSearch);

                if (!results) {
                    words.pop(); // Remove the last word and try again
                }
            }

            return results;
        }

        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; rv:132.0) Gecko/20100101 Firefox/132.0',
                'Accept': '*/*',
                'Accept-Language': 'en-US,en;q=0.5',
                'Accept-Encoding': 'gzip, deflate, br, zstd',
                'Referer': 'https://howlongtobeat.com/?q=',
                'Content-Type': 'application/json',
                'Origin': 'https://howlongtobeat.com',
                'Sec-Fetch-Dest': 'empty',
                'Sec-Fetch-Mode': 'cors',
                'Sec-Fetch-Site': 'same-origin',
                'Priority': 'u=4',
                'TE': 'trailers'
            };

            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
                },
                "useCache": true
            };

            // Wait for 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();

            try {
                const response = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'POST',
                        url: url,
                        headers: headers,
                        data: JSON.stringify(requestData),
                        onload: resolve,
                        onerror: reject
                    });
                });

                const data = JSON.parse(response.responseText);
                if (data.data && data.data.length > 0) {
                    return data.data[0];
                }
            } catch (error) {
                console.error('Error searching for game:', error);
            }

            return null;
        }

        function addPlaytimeHeader() {
            if (!document.querySelector('th[data-name="playtime"]')) {
                const headerRow = document.querySelector('thead tr');
                if (headerRow) {
                    const playtimeHeader = document.createElement('th');
                    playtimeHeader.setAttribute('data-name', 'playtime');
                    playtimeHeader.classList.add('dt-type-numeric');
                    playtimeHeader.textContent = 'Playtime';
                    playtimeHeader.setAttribute('data-sort-direction', 'none');
                    playtimeHeader.addEventListener('click', handleSort);

                    const nameColumn = headerRow.querySelector('[data-name="name"]');
                    if (nameColumn && nameColumn.nextSibling) {
                        headerRow.insertBefore(playtimeHeader, nameColumn.nextSibling);
                    }
                }
            }
        }

        function handleSort(event) {
            const header = event.target;
            const table = document.querySelector('table');
            const tbody = table.querySelector('tbody');
            const rows = Array.from(tbody.querySelectorAll('tr.app'));

            const currentDirection = header.getAttribute('data-sort-direction');
            const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
            header.setAttribute('data-sort-direction', newDirection);

            rows.sort((a, b) => {
                const timeA = getPlaytimeValue(a);
                const timeB = getPlaytimeValue(b);

                if (isNaN(timeA) && isNaN(timeB)) return 0;
                if (isNaN(timeA)) return 1;
                if (isNaN(timeB)) return -1;

                return newDirection === 'asc' ? timeA - timeB : timeB - timeA;
            });

            rows.forEach(row => tbody.appendChild(row));
        }

        function getPlaytimeValue(row) {
            const cell = row.querySelector('.playtime-cell a');
            if (!cell) return NaN;

            const text = cell.textContent;
            let totalMinutes = 0;

            // Extract hours and minutes if they exist
            const hoursMatch = text.match(/(\d+)h/);
            const minutesMatch = text.match(/(\d+)m/);

            // Convert hours to minutes
            if (hoursMatch) {
                totalMinutes += parseInt(hoursMatch[1], 10) * 60;
            }

            // Add remaining minutes
            if (minutesMatch) {
                totalMinutes += parseInt(minutesMatch[1], 10);
            }

            return totalMinutes;
        }


        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 fetchGameTime(originalGameName, gameRow) {
            // Skip if we've already processed this game
            if (processedGames.has(originalGameName)) {
                return;
            }
            processedGames.add(originalGameName);

            // Check cache first
            const cachedData = getCachedTime(originalGameName);
            if (cachedData) {
                appendTimeToRow(cachedData.time, gameRow, cachedData.link);
                return;
            }

            // Add loading indicator
            const loadingCell = document.createElement('td');
            loadingCell.classList.add('dt-type-numeric', 'playtime-loading');
            loadingCell.textContent = 'Loading...';
            gameRow.insertBefore(loadingCell, gameRow.querySelector('a.b').parentNode.nextSibling);

            try {
                const gameData = await searchGameWithFallback(originalGameName, loadingCell);

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

                    setCachedTime(originalGameName, {
                        time: mainTime,
                        link: gameLink,
                        reviewScore: gameData.review_score
                    });

                    loadingCell.remove();
                    appendTimeToRow(mainTime, gameRow, gameLink);

    //                console.log(`Found match for "${originalGameName}": "${gameData.game_name}"`);
                } else {
                    loadingCell.textContent = 'N/A';
                    loadingCell.classList.remove('playtime-loading');
    //                console.log(`No results found for: ${originalGameName}`);
                }
            } catch (error) {
                console.error('Failed to fetch game time:', error);
                loadingCell.textContent = 'Error';
                loadingCell.classList.remove('playtime-loading');
            }
        }

        function appendTimeToRow(time, gameRow, gameLink) {
            // Check if playtime cell already exists
            if (!gameRow.querySelector('.playtime-cell')) {
                const newCell = document.createElement('td');
                newCell.classList.add('dt-type-numeric', 'playtime-cell');

                const link = document.createElement('a');
                link.href = gameLink;
                link.target = '_blank';
                link.title = 'View on HowLongToBeat';

                // Format time as hours and minutes
                const hours = Math.floor(time);
                const minutes = Math.round((time - hours) * 60);
                link.textContent = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;

                newCell.appendChild(link);

                const nameCell = gameRow.querySelector('a.b').parentNode;
                if (nameCell && nameCell.nextSibling) {
                    gameRow.insertBefore(newCell, nameCell.nextSibling);
                }
            }
        }

        function processNewGames() {
            const gameRows = document.querySelectorAll('tr.app');
            gameRows.forEach(row => {
                const gameNameElement = row.querySelector('a.b');
                if (gameNameElement) {
                    const gameName = gameNameElement.textContent.trim();
                    fetchGameTime(gameName, row);
                }
            });
        }

        // Debounce function to prevent too frequent updates
        function debounce(func, wait) {
            let timeout;
            return function executedFunction(...args) {
                const later = () => {
                    clearTimeout(timeout);
                    func(...args);
                };
                clearTimeout(timeout);
                timeout = setTimeout(later, wait);
            };
        }

        // Function to process updates after interactive elements are clicked
        function setupInteractionListeners() {
            // Debounced version of the process function
            const debouncedProcess = debounce(() => {
                addPlaytimeHeader();
                processNewGames();
            }, 1000); // 1 second debounce time

            // List of selectors for interactive elements
            const interactiveSelectors = [
                'a', // Links (including pagination)
                'button', // Buttons
                'select', // Dropdowns
                'input', // Input fields
                'th[data-name]', // Table headers (for sorting)
                '.paginate_button', // Pagination buttons
                '.dt-button', // DataTables buttons
                '.sorting', // Sorting elements
                'label' // Labels (often used for filters)
            ].join(', ');

            // Function to handle mutations
            const mutationCallback = function(mutationsList, observer) {
                for (const mutation of mutationsList) {
                    if (mutation.type === 'childList' &&
                        mutation.addedNodes.length > 0) {
                        debouncedProcess();
                        break;
                    }
                }
            };

            // Create an observer instance
            const observer = new MutationObserver(mutationCallback);

            // Start observing the document with the configured parameters
            observer.observe(document.body, {
                childList: true,
                subtree: true
            });

            // Add click listeners to interactive elements
            document.body.addEventListener('click', (event) => {
                if (event.target.matches(interactiveSelectors) ||
                    event.target.closest(interactiveSelectors)) {
                    debouncedProcess();
                }
            });

            // Listen for select changes
            document.body.addEventListener('change', (event) => {
                if (event.target.matches('select')) {
                    debouncedProcess();
                }
            });

            // Listen for DataTables events
            document.addEventListener('draw.dt', debouncedProcess);
            document.addEventListener('length.dt', debouncedProcess);
            document.addEventListener('page.dt', debouncedProcess);
            document.addEventListener('search.dt', debouncedProcess);
            document.addEventListener('order.dt', debouncedProcess);
        }

        // Initialize
        function init() {
            addPlaytimeHeader();
            processNewGames();
            setupInteractionListeners();
        }

        // Wait for the page to be fully loaded
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', init);
        } else {
            init();
        }
    })();