Sleazy Fork is available in English.
Suite of toggleable features for IGG-Games.com: better summary, simplify game names, add steam rating, ignore games
// ==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();
}
})();