IGG Helper Suite

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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