IGG Helper Suite

Suite of toggleable features for IGG-Games.com: better summary, simplify game names, add steam rating, ignore games

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

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

(У мене вже є менеджер скриптів, дайте мені встановити його!)

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.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         IGG Helper Suite
// @author       Lavodan
// @namespace    https://igg-games.com/
// @version      1.8.1
// @license MIT
//
// @description  Suite of toggleable features for IGG-Games.com: better summary, simplify game names, add steam rating, ignore games
// @match        https://igg-games.com/*
// @grant        GM_xmlhttpRequest
// @connect      store.steampowered.com
// ==/UserScript==

(function () {
    "use strict";

    /***********************
     * SETTINGS
     ***********************/
    const STORAGE_KEY = "igg-helper-suite-settings";
    const IGNORED_KEY = "igg-helper-suite-ignored";

    let currentSettings = {};
    const DEFAULT_SETTINGS = {
        summaryReplacement: true,
        simplifyNames: true,
        steamRatings: true,
        ignoreFeature: true,
        steamFilterEnabled: false,
        steamMinPercent: 0,
        steamMinRatings: 0,
    };

    function loadSettings() {
        try {
            const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
            return { ...DEFAULT_SETTINGS, ...saved };
        } catch {
            return { ...DEFAULT_SETTINGS };
        }
    }

    function saveSettings(settings) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
    }

    /***********************
     * IGNORE MANAGEMENT
     ***********************/
    let ignoredTitles = new Set(JSON.parse(localStorage.getItem(IGNORED_KEY) || "[]"));
    let newlyIgnoredInSession = [];

    function saveIgnored() {
        localStorage.setItem(IGNORED_KEY, JSON.stringify([...ignoredTitles]));
    }

    /***********************
     * SINGLE RATE LIMITER FOR ALL REQUESTS
     ***********************/
    class RateLimiter {
        constructor(maxRequestsPerSecond = 2) {
            this.maxRequestsPerSecond = maxRequestsPerSecond;
            this.minDelay = 1000 / maxRequestsPerSecond;
            this.lastRequestTime = 0;
            this.queue = [];
            this.processing = false;
        }

        async acquire() {
            const now = Date.now();
            const timeSinceLastRequest = now - this.lastRequestTime;

            if (timeSinceLastRequest >= this.minDelay) {
                this.lastRequestTime = now;
                return;
            }

            const waitTime = this.minDelay - timeSinceLastRequest;
            await new Promise(resolve => setTimeout(resolve, waitTime));
            this.lastRequestTime = Date.now();
        }

        async run(fn) {
            await this.acquire();
            return await fn();
        }
    }

    // Single rate limiter for ALL requests (2 per second max)
    const globalRateLimiter = new RateLimiter(2);

    /***********************
     * DOM HELPERS
     ***********************/
    function getArticles() {
        return document.querySelectorAll("article");
    }

    function getLink(article) {
        return article.querySelector("a.uk-link-reset");
    }

    function getTitle(article) {
        const link = getLink(article);
        return link?.textContent?.trim() || null;
    }

    /***********************
     * UI
     ***********************/
    function createSettingsUI(settings) {
        const container = document.createElement("div");

        Object.assign(container.style, {
            position: "fixed",
            top: "10px",
            left: "10px",
            zIndex: 999999,
            fontFamily: "Arial, sans-serif",
            fontSize: "13px",
        });

        const button = document.createElement("button");
        button.textContent = "IGG Helper";

        Object.assign(button.style, {
            padding: "6px 10px",
            cursor: "pointer",
            border: "1px solid #ccc",
            background: "#fff",
        });

        const panel = document.createElement("div");
        let open = false;

        Object.assign(panel.style, {
            display: "none",
            marginTop: "6px",
            padding: "10px",
            background: "#fff",
            border: "1px solid #ccc",
            minWidth: "240px",
        });

        panel.innerHTML = `
            <div style="font-weight:bold;margin-bottom:8px;">
                IGG Helper Suite
            </div>

            <label><input type="checkbox" id="s1"> Summary Replacement</label><br>
            <label><input type="checkbox" id="s2"> Simplify Names</label><br>
            <label><input type="checkbox" id="s3"> Steam Ratings</label><br>
            <div id="steam-filter-options" style="margin-left:20px; display:none; margin-top:4px;">
                <label><input type="checkbox" id="s5"> Enable rating filter</label><br>
                <div style="margin-top:4px;">
                    Min %: <input type="number" id="s5_min" min="0" max="100" step="1" style="width:50px;">
                    Min reviews: <input type="number" id="s5_cnt" min="0" step="50" style="width:70px;">
                </div>
            </div>
            <label><input type="checkbox" id="s4"> Ignore Games (hide excluded)</label>

            <div style="margin-top:10px; border-top:1px solid #ddd; padding-top:8px;">
                <div style="font-size:12px;margin-bottom:4px;">
                    Ignored games: <span id="igg-ignored-count">${ignoredTitles.size}</span>
                </div>
                <button id="undo-ignores" style="display:none; font-size:12px;">
                    Undo session ignores
                </button>
            </div>

            <div style="margin-top:8px;font-size:11px;opacity:0.7;">
                Refresh page to apply changes
            </div>
        `;

        const s1 = panel.querySelector("#s1");
        const s2 = panel.querySelector("#s2");
        const s3 = panel.querySelector("#s3");
        const s4 = panel.querySelector("#s4");
        const undoBtn = panel.querySelector("#undo-ignores");

        s1.checked = settings.summaryReplacement;
        s2.checked = settings.simplifyNames;
        s3.checked = settings.steamRatings;
        s4.checked = settings.ignoreFeature;

        s1.onchange = () => {
            settings.summaryReplacement = s1.checked;
            saveSettings(settings);
        };
        s2.onchange = () => {
            settings.simplifyNames = s2.checked;
            saveSettings(settings);
        };
        s3.onchange = () => {
            settings.steamRatings = s3.checked;
            saveSettings(settings);
        };
        s4.onchange = () => {
            settings.ignoreFeature = s4.checked;
            saveSettings(settings);
        };

        const s5 = panel.querySelector("#s5");
        const s5_min = panel.querySelector("#s5_min");
        const s5_cnt = panel.querySelector("#s5_cnt");
        const filterOptions = panel.querySelector("#steam-filter-options");

        s5.checked = settings.steamFilterEnabled;
        s5_min.value = settings.steamMinPercent;
        s5_cnt.value = settings.steamMinRatings;

        s5.onchange = () => {
            settings.steamFilterEnabled = s5.checked;
            saveSettings(settings);
            applyFilterToAllArticles(settings);
        };
        s5_min.onchange = () => {
            settings.steamMinPercent = parseInt(s5_min.value) || 0;
            saveSettings(settings);
            applyFilterToAllArticles(settings);
        };
        s5_cnt.onchange = () => {
            settings.steamMinRatings = parseInt(s5_cnt.value) || 0;
            saveSettings(settings);
            applyFilterToAllArticles(settings);
        };

        // Show/hide the filter options when Steam feature is toggled
        function updateFilterOptionsVisibility() {
            filterOptions.style.display = settings.steamRatings ? "block" : "none";
        }
        updateFilterOptionsVisibility();
        s3.onchange = () => {
            settings.steamRatings = s3.checked;
            saveSettings(settings);
            updateFilterOptionsVisibility();
            if (!settings.steamRatings) {
                // If Steam is turned off, reset any filtered games to visible
                showAllArticles();
            } else {
                // Reapply filter if now enabled
                applyFilterToAllArticles(settings);
            }
        };

        undoBtn.onclick = undoSessionIgnores;

        function refreshUndoButton() {
            if (settings.ignoreFeature) {
                undoBtn.style.display = newlyIgnoredInSession.length > 0 ? "inline-block" : "none";
            } else {
                undoBtn.style.display = "none";
            }
            document.getElementById("igg-ignored-count").textContent = ignoredTitles.size;
        }

        button.onclick = () => {
            open = !open;
            panel.style.display = open ? "block" : "none";
            if (open) refreshUndoButton();
        };

        container.appendChild(button);
        container.appendChild(panel);
        document.body.appendChild(container);
    }

    /***********************
     * TITLE CLEANING
     ***********************/
    function simplifyTitle(text) {
        if (!text) return text;

        const idx = text.lastIndexOf("Free Download");
        if (idx === -1) return text;

        return text.slice(0, idx) + text.slice(idx + "Free Download".length).trim();
    }

    function simplifyNames() {
        getArticles().forEach((article) => {
            const link = getLink(article);
            if (!link) return;
            link.textContent = simplifyTitle(link.textContent);
        });
    }

    function cleanTitleForSteamLookup(title) {
        if (!title) return title;

        let cleaned = title
            .replace(/\s*Free\s+Download\s*/i, " ")
            .replace(/\s*v[\d\.\-]+/gi, "")
            .replace(/\s*&?\s*(all\s+dlcs?|dlcs?)\s*/gi, " ")
            .replace(/\s+/g, " ")
            .trim();

        return cleaned;
    }

    function getCleanedTitle(article) {
        const raw = getTitle(article);
        if (!raw) return null;
        return cleanTitleForSteamLookup(raw);
    }

    /***********************
     * IGNORE BUTTON & HIDING
     ***********************/
    function addIgnoreButton(article, cleanedTitle) {
        if (article.dataset.ignoreButtonAdded === "1") return;
        article.dataset.ignoreButtonAdded = "1";

        const btn = document.createElement("button");
        btn.textContent = "✕";
        btn.title = "Ignore this game";
        Object.assign(btn.style, {
            marginLeft: "6px",
            background: "none",
            border: "none",
            cursor: "pointer",
            fontSize: "14px",
            opacity: 0.6,
            padding: "0",
            lineHeight: "1",
            verticalAlign: "middle",
            color: "red",
        });

        btn.onclick = () => {
            ignoredTitles.add(cleanedTitle);
            newlyIgnoredInSession.push(cleanedTitle);
            saveIgnored();
            article.style.display = "none";
            showUndoSnackbar();
        };

        const link = getLink(article);
        if (link) {
            link.insertAdjacentElement("afterend", btn);
        }
    }

    function hideIfIgnored(article, cleanedTitle) {
        if (ignoredTitles.has(cleanedTitle)) {
            article.style.display = "none";
        }
    }

    function setupIgnoreButtons() {
        getArticles().forEach((article) => {
            const cleaned = getCleanedTitle(article);
            if (!cleaned) return;
            addIgnoreButton(article, cleaned);
            hideIfIgnored(article, cleaned);
        });
    }

    /***********************
     * UNDO SNACKBAR
     ***********************/
    function showUndoSnackbar() {
        const old = document.getElementById("igg-undo-snackbar");
        if (old) old.remove();

        const bar = document.createElement("div");
        bar.id = "igg-undo-snackbar";
        Object.assign(bar.style, {
            position: "fixed",
            bottom: "20px",
            right: "20px",
            background: "#333",
            color: "#fff",
            padding: "8px 12px",
            borderRadius: "4px",
            zIndex: "999999",
            fontSize: "13px",
            boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
        });

        bar.innerHTML = `Game ignored. <button id="igg-undo-btn" style="margin-left:8px; cursor:pointer; background:#555; border:none; color:#fff; padding:2px 8px; border-radius:3px;">Undo</button>`;
        document.body.appendChild(bar);

        document.getElementById("igg-undo-btn").onclick = undoSessionIgnores;

        setTimeout(() => {
            const el = document.getElementById("igg-undo-snackbar");
            if (el) el.remove();
        }, 8000);
    }

    function undoSessionIgnores() {
        newlyIgnoredInSession.forEach((t) => ignoredTitles.delete(t));
        newlyIgnoredInSession = [];
        saveIgnored();
        location.reload();
    }

    /***********************
     * STEAM FEATURE (LAZY + DYNAMIC COLORS)
     ***********************/
    const STEAM_CACHE = new Map();
    const TTL = 1000 * 60 * 60;

    const processedArticles = new WeakSet();
    const articleSteamData = new Map(); // article -> { percent, total }

    function getCached(appid) {
        const entry = STEAM_CACHE.get(appid);
        if (!entry) return null;

        if (Date.now() > entry.expires) {
            STEAM_CACHE.delete(appid);
            return null;
        }

        return entry.data;
    }

    function setCached(appid, data) {
        STEAM_CACHE.set(appid, {
            data,
            expires: Date.now() + TTL,
        });
    }

    function gmGetJSON(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url,
                onload: (res) => {
                    try {
                        const text = res.responseText || res.response;
                        if (!text) return resolve(null);
                        resolve(JSON.parse(text));
                    } catch (e) {
                        console.error("[Steam] JSON parse error:", url);
                        reject(e);
                    }
                },
                onerror: reject,
            });
        });
    }

    async function steamSearch(title) {
        const url = `https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(title)}&l=english&cc=us`;
        return await globalRateLimiter.run(() => gmGetJSON(url));
    }

    async function fetchReviews(appid) {
        const url = `https://store.steampowered.com/appreviews/${appid}?json=1&language=all&purchase_type=all`;
        return await globalRateLimiter.run(() => gmGetJSON(url));
    }

    async function getSteamData(title) {
        const searchResult = await steamSearch(title);
        if (!searchResult?.items?.length) return null;

        const appid = searchResult.items[0].id;
        const cached = getCached(appid);
        if (cached) return cached;

        const reviews = await fetchReviews(appid);

        if (!reviews?.success || !reviews?.query_summary) {
            return null;
        }

        const q = reviews.query_summary;

        if (!q.total_reviews) return null;

        const percent = (q.total_positive / q.total_reviews) * 100;

        const result = {
            appid,
            percent: isFinite(percent) ? percent : 0,
            total: q.total_reviews,
        };
        setCached(appid, result);
        return result;
    }

    function getRatingColor(percent) {
        const stops = [
            { p: 40, h: 0, s: 100, l: 40 },
            { p: 60, h: 30, s: 100, l: 50 },
            { p: 95, h: 210, s: 80, l: 60 },
        ];

        if (percent <= stops[0].p) return `hsl(${stops[0].h}, ${stops[0].s}%, ${stops[0].l}%)`;
        if (percent >= stops[2].p) return `hsl(${stops[2].h}, ${stops[2].s}%, ${stops[2].l}%)`;

        for (let i = 0; i < stops.length - 1; i++) {
            const a = stops[i];
            const b = stops[i + 1];
            if (percent >= a.p && percent <= b.p) {
                const t = (percent - a.p) / (b.p - a.p);
                const h = a.h + t * (b.h - a.h);
                const s = a.s + t * (b.s - a.s);
                const l = a.l + t * (b.l - a.l);
                return `hsl(${h}, ${s}%, ${l}%)`;
            }
        }
        return `hsl(210, 80%, 60%)`;
    }

    function injectSteamInfo(article, data, link) {
        if (!data || !link) return;
        if (article.dataset.steamInjected === "1") return;
        article.dataset.steamInjected = "1";

        const url = `https://store.steampowered.com/app/${data.appid}`;
        const color = getRatingColor(data.percent);

        const span = document.createElement("span");
        const a = document.createElement("a");
        a.href = url;
        a.target = "_blank";
        a.rel = "noreferrer";
        a.style.fontSize = "11px";
        a.style.color = color;
        a.style.textDecoration = "none";
        a.style.marginLeft = "6px";
        a.textContent = `STEAM: ${data.percent.toFixed(1)}% (${data.total})`;

        span.appendChild(a);
        link.insertAdjacentElement("afterend", span);
    }

    async function processSteam(article, currentSettings) {
        if (processedArticles.has(article)) return;
        processedArticles.add(article);

        const link = getLink(article);
        const rawTitle = getTitle(article);
        const cleanedTitle = cleanTitleForSteamLookup(rawTitle);

        if (!cleanedTitle) return;
        const data = await getSteamData(cleanedTitle);
        if (data) {
            injectSteamInfo(article, data, link);
            articleSteamData.set(article, data);
            applyFilterToArticle(article, currentSettings);
        }
    }

    function runSteamFeature() {
        const articles = [...getArticles()];
        if (!articles.length) return;
        const currentSettings = loadSettings();

        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const article = entry.target;
                    observer.unobserve(article);
                    processSteam(article, currentSettings);
                }
            });
        }, { rootMargin: "200px" });

        articles.forEach(article => observer.observe(article));
    }

    /***********************
     * STEAM FILTER
     ***********************/

    function applyFilterToArticle(article, settings) {
        // Only filter if Steam feature is on and filter itself is enabled
        if (!settings.steamRatings || !settings.steamFilterEnabled) {
            // If filter is off, ensure article is visible (unless ignored)
            if (!ignoredTitles.has(getCleanedTitle(article))) {
                article.style.display = "";
            }
            return;
        }

        const data = articleSteamData.get(article);
        if (!data) {
            // No Steam data yet or lookup failed – keep visible for now
            return;
        }

        const meetsPercent = data.percent >= settings.steamMinPercent;
        const meetsCount = data.total >= settings.steamMinRatings;

        const shouldHide = !(meetsPercent && meetsCount);
        if (shouldHide) {
            article.style.display = "none";
        } else {
            // If it meets criteria, still respect the ignore list
            if (!ignoredTitles.has(getCleanedTitle(article))) {
                article.style.display = "";
            }
        }
    }

    function applyFilterToAllArticles(settings) {
        getArticles().forEach((article) => {
            // Only apply if the article has already been processed (has stored data or steam injected)
            if (processedArticles.has(article)) {
                applyFilterToArticle(article, settings);
            }
        });
    }

    function showAllArticles() {
        getArticles().forEach((article) => {
            if (!ignoredTitles.has(getCleanedTitle(article))) {
                article.style.display = "";
            }
        });
    }

    /***********************
     * SUMMARY FEATURE
     ***********************/
    const summaryCache = new Map();

    async function fetchDoc(url) {
        // Use the global rate limiter for all IGG requests
        return await globalRateLimiter.run(async () => {
            const response = await fetch(url, { credentials: "same-origin" });
            const html = await response.text();
            return new DOMParser().parseFromString(html, "text/html");
        });
    }

    function extractSummary(doc) {
        const a = doc.querySelector("p.uk-dropcap");
        const b = a?.nextElementSibling;

        if (!a || !b || b.tagName !== "P") return null;

        return `${a.textContent.trim()}\n\n${b.textContent.trim()}`;
    }

    async function getSummary(url) {
        if (summaryCache.has(url)) return summaryCache.get(url);

        try {
            const doc = await fetchDoc(url);
            const summary = extractSummary(doc);
            summaryCache.set(url, summary);
            return summary;
        } catch (error) {
            console.error(`[IGG Helper] Failed to fetch summary for ${url}:`, error);
            summaryCache.set(url, null);
            return null;
        }
    }

    function replaceSummary(container, text) {
        container.style.whiteSpace = "pre-line";
        container.textContent = text;
    }

    async function processSummary(article) {
        const link = getLink(article);
        const container = article.querySelector('div.uk-margin-medium-top[property="text"]');

        if (!link || !container) return;

        const url = link.href;
        const summary = await getSummary(url);

        if (summary) replaceSummary(container, summary);
    }

    async function runSummaries() {
        const articles = [...getArticles()];
        // Process summaries sequentially with global rate limiting
        for (const article of articles) {
            await processSummary(article);
        }
    }

    /***********************
     * INIT
     ***********************/
    function init() {
        const settings = loadSettings();

        createSettingsUI(settings);

        if (settings.ignoreFeature) {
            setupIgnoreButtons();
        }

        if (settings.summaryReplacement) runSummaries();
        if (settings.simplifyNames) simplifyNames();
        if (settings.steamRatings) runSteamFeature();
        if (settings.steamRatings && settings.steamFilterEnabled) {
            applyFilterToAllArticles(settings);
        }

        console.log("[IGG Helper Suite] loaded with single rate limiter (2 requests/sec)");
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init, { once: true });
    } else {
        init();
    }
})();