Skip to content

Instantly share code, notes, and snippets.

@SentaiBrad
Forked from denilsonsa/README.md
Last active March 13, 2026 08:08
Show Gist options
  • Select an option

  • Save SentaiBrad/076c6966ddfe4c328e90ccc8396720f7 to your computer and use it in GitHub Desktop.

Select an option

Save SentaiBrad/076c6966ddfe4c328e90ccc8396720f7 to your computer and use it in GitHub Desktop.
Bundle Helper Reborn 2.0 - Extended

Bundle Helper Reborn 2.0 - Extended

Marks owned, wishlisted, and ignored games on bundle and game store sites, and adds a convenient Steam page button to each game tile. If you're buying games outside of Steam, this script helps you instantly see what you already own, what's on your wishlist, and what you've ignored — without having to look each game up manually.

This is a fork of Bundle Helper Reborn by denilsonsa, which was itself a fork of Bundle Helper v1.09 by 7-elephant. The original script supported a solid set of sites but left several major storefronts and bundle platforms uncovered.

Sites added in this fork:

  • Humble Bundle
  • itch.io
  • SteamDB
  • Tiltify
  • HRK Game
  • GameSeal
  • G2A

All original sites are preserved: Fanatical, IndieGala, DailyIndieGame, Reddit, SGTools, SteamGifts, SteamGround, and others.

The script connects to your Steam account (read-only) to pull your owned, wishlisted, and ignored game lists. No data is sent anywhere — it all stays in your browser.

Install To Install, click the "Raw" button for the script. If TamperMonkey (or other like for like Userscript extensions) is installed, it should prompt to install this user script.

// ==UserScript==
// @name Bundle Helper Reborn 2.0 - Extended
// @namespace https://gist.github.com/SentaiBrad/
// @author SentaiBrad
// @version 2.9.6
// @description Marks owned/ignored/wishlisted games on several sites, and also adds a button to open the steam page for each game.
// @match *://dailyindiegame.com/*
// @match *://old.reddit.com/*
// @match *://sgtools.info/*
// @match *://www.sgtools.info/*
// @match *://steamgifts.com/*
// @match *://www.steamgifts.com/*
// @match *://astats.astats.nl/*
// @match *://www.fanatical.com/*
// @match *://fanatical.com/*
// @match *://www.indiegala.com/*
// @match *://indiegala.com/*
// @match *://www.humblebundle.com/*
// @match *://humblebundle.com/*
// @match *://steamdb.info/*
// @match *://www.steamdb.info/*
// @match *://steamground.com/*
// @match *://www.steamground.com/*
// @match *://hrkgame.com/*
// @match *://www.hrkgame.com/*
// @match *://gameseal.com/*
// @match *://www.gameseal.com/*
// @match *://itch.io/*
// @match *://tiltify.com/*
// @match *://www.tiltify.com/*
// @match *://g2a.com/*
// @match *://www.g2a.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect store.steampowered.com
// @icon https://store.steampowered.com/favicon.ico
// @license GPL-3.0-only
// @downloadURL https://gist.github.com/SentaiBrad/076c6966ddfe4c328e90ccc8396720f7/raw/bundle-helper-reborn-extended.user.js
// @updateURL https://gist.github.com/SentaiBrad/076c6966ddfe4c328e90ccc8396720f7/raw/bundle-helper-reborn-extended.user.js
// ==/UserScript==
/*
# Bundle Helper Reborn
Marks owned/ignored/wishlisted games on several sites, and also adds a button to open the steam page for each game.
## Purpose
If you have a Steam account, you are probably also buying games from other
websites.
This user-script can help you by highlighting (on other sites) the games you
already have, games you have ignored, and games you have wishlisted (on Steam).
It also adds a convenient button (actually, a link) to open the Steam page for
each game on the supported third-party websites.
It is complementary to the amazing [AugmentedSteam browser extension](https://augmentedsteam.com/).
While that extension only applies to the Steam website(s), this user-script
applies to third-party websites.
It needs the permission to connect to `store.steampowered.com` to get the list
of owned/ignored/wishlisted items for the current logged-in user.
## History
This user-script is a fork of ["Bundle Helper" v1.09 by "7-elephant"](https://greasyfork.org/en/scripts/16105-bundle-helper).
It was initially based on 7-elephant's code, but has been completely rewritten
for v2.0. Code for obsolete websites was removed. Additional code for
extraneous poorly-documented functionality was also removed. This fork/version
has a clear purpose and sticks to that purpose. It's also supposed to be easier
to add support for more websites, or update the current ones when needed.
In order to avoid name clashes, I've decided to name it "Bundle Helper Reborn".
This fork also available at:
* https://greasyfork.org/en/scripts/478401-bundle-helper-reborn
* https://gist.github.com/denilsonsa/618ca8a9d04d574a162b10cbd3fce20f
* License: [GPL-3.0-only](https://spdx.org/licenses/GPL-3.0-only.html)
* Copyright 2016-2019, 7-elephant
* Copyright 2023, Denilson Sá Maia
*/
(function () {
"use strict";
// jshint multistr:true
//////////////////////////////////////////////////
// Convenience functions
// Returns the Unix timestamp in seconds (as an integer value).
function getUnixTimestamp() {
return Math.trunc(Date.now() / 1000);
}
// Returns a human-readable amount of time.
function humanReadableSecondsAmount(seconds) {
if (!(Number.isFinite(seconds) && seconds >= 0)) {
return "";
}
const minutes = seconds / 60;
const hours = minutes / 60;
const days = hours / 24;
if (days >= 10 ) return days.toFixed(0) + " days";
if (days >= 1.5) return days.toFixed(1) + " days";
if (hours >= 10 ) return hours.toFixed(0) + " hours";
if (hours >= 1.5) return hours.toFixed(1) + " hours";
if (minutes >= 1) return minutes.toFixed(0) + " minutes";
else return "just now";
}
// Returns just the filename (i.e. basename) of a URL.
function filenameFromURL(s) {
if (!s) {
return "";
}
let url;
try {
url = new URL(s);
} catch (ex) {
// Invalid URL.
return "";
}
return url.pathname.replace(reX`^.*/`, "");
}
// Returns a new function that will call the callback without arguments
// after timeout milliseconds of quietness.
function debounce(callback, timeout = 500) {
let id = null;
return function() {
clearTimeout(id);
id = setTimeout(callback, timeout);
};
}
const active_mutation_observers = [];
function stopAllMutationObservers() {
for (const observer of active_mutation_observers) {
observer.disconnect();
}
active_mutation_observers.length = 0;
}
// Returns a new MutationObserver that observes a specific node.
// The observer will be immediately active.
function debouncedMutationObserver(rootNode, callback, timeout = 500) {
const func = debounce(callback, timeout);
func();
const observer = new MutationObserver(func);
observer.observe(rootNode, {
subtree: true,
childList: true,
attributes: true,
characterData: false,
});
active_mutation_observers.push(observer);
return observer;
}
// Adds a MutationObserver to each root node matched by the CSS selector.
function debouncedMutationObserverSelectorAll(rootSelector, callback, timeout = 500) {
for (const root of document.querySelectorAll(rootSelector)) {
debouncedMutationObserver(root, callback, timeout);
}
}
//////////////////////////////////////////////////
// Regular expressions
// Emulates the "x" flag for RegExp.
// It's also known as "verbose" flag, as it allows whitespace and comments inside the regex.
// It will probably break if the original string contains "$".
function reX(re_string) {
const raw = re_string.raw[0];
let s = raw;
// Removing comments.
s = s.replace(/(?<!\\)\/\/.*$/gm, "");
// Removing all whitespace.
s = s.replace(/[ \t\r\n]+/g, "");
return new RegExp(s);
}
// Same as reX, but ignoring case.
function reXi(re_string) {
return new RegExp(reX(re_string), "i");
}
const re_app = reX`
( /app/ | /apps/ | appid= )
(?<id>[0-9]+)
\b
`;
const re_sub = reX`
( /sub/ | /subs/ )
(?<id>[0-9]+)
\b
`;
// Parses a string and tries to extract the app id or the sub id.
function parseStringForSteamId(s) {
const match_app = re_app.exec(s);
const match_sub = re_sub.exec(s);
re_app.lastIndex = 0;
re_sub.lastIndex = 0;
if (match_app && match_sub) {
console.warn("The string matched both app id and sub id. This is likely a mistake.", s, match_app, match_sub);
}
return {
app: Number(match_app?.groups.id ?? 0),
sub: Number(match_sub?.groups.id ?? 0),
};
}
//////////////////////////////////////////////////
// Steam profile data caching
const cachename_profile_data = "bh_profile_data";
const cachename_profile_time = "bh_profile_time";
const cache_max_age_seconds = 60 * 60 * 24; // 24 hours
let cached_sets = null;
function setProfileCache(data) {
cached_sets = null;
data.rgCurations = {};
data.rgCurators = {};
data.rgCuratorsIgnored = [];
data.rgRecommendedApps = [];
data.rgRecommendedTags = [];
GM_setValue(cachename_profile_data, data);
GM_setValue(cachename_profile_time, getUnixTimestamp());
}
function clearProfileCache() {
cached_sets = null;
GM_setValue(cachename_profile_data, {});
GM_setValue(cachename_profile_time, 0);
}
function getProfileCacheAge() {
const now = getUnixTimestamp();
const cached = GM_getValue(cachename_profile_time, 0);
if (!cached) {
return "";
}
return humanReadableSecondsAmount(now - cached);
}
function isProfileCacheExpired() {
const now = getUnixTimestamp();
const cached = GM_getValue(cachename_profile_time, 0);
return now - cached > cache_max_age_seconds;
}
function downloadProfileData() {
return new Promise((resolve, reject) => {
function handleError(response) {
console.error(`Error while loading the data: status=${response.status}; statusText=${response.statusText}`);
reject();
}
GM_xmlhttpRequest({
method: "GET",
url: "https://store.steampowered.com/dynamicstore/userdata/?t=" + getUnixTimestamp(),
responseType: "json",
onload: function(response) {
if (response.status === 200) {
resolve(response.response);
} else {
handleError(response);
}
},
onerror: handleError,
onabort: function() {
reject();
},
});
});
}
function downloadAndUpdateProfileCache() {
return downloadProfileData().then((data) => {
setProfileCache(data);
});
}
function updateProfileCacheIfExpired() {
if (isProfileCacheExpired()) {
return downloadAndUpdateProfileCache();
} else {
return Promise.resolve();
}
}
function getCachedSets() {
if (!cached_sets) {
const data = GM_getValue(cachename_profile_data, {});
cached_sets = {
appsInCart: new Set(data.rgAppsInCart),
ignoredPackages: new Set(data.rgIgnoredPackages),
ownedApps: new Set(data.rgOwnedApps),
ownedPackages: new Set(data.rgOwnedPackages),
packagesInCart: new Set(data.rgPackagesInCart),
wishlist: new Set(data.rgWishlist),
ignoredApps: new Set(Object.keys(data.rgIgnoredApps ?? {}).map((key) => Number(key))),
excludedTags: new Set(data.rgExcludedTags?.map((obj) => obj.name)),
};
}
return cached_sets;
}
//////////////////////////////////////////////////
// Bundle Helper UI
function createBundleHelperUI() {
const root = document.createElement("bundle-helper");
const shadow = root.attachShadow({
mode: "open",
});
shadow.innerHTML = `
<style>
.container {
background: #1b2838;
color: #ddd;
padding: 0.5em;
border-radius: 0 0.5em 0 0;
border: 1px #ddd outset;
border-width: 1px 1px 0 0;
font: 12px sans-serif;
}
p {
margin: 0;
}
a {
font: inherit;
color: inherit;
text-decoration: none;
}
a:hover {
color: #fff;
text-decoration: underline;
}
#close {
float: right;
}
</style>
<div class="container">
<p>
Steam profile data <a href="javascript:;" id="refresh">last fetched <output id="age"></output> ago</a>.
</p>
<p>
Owned:
<output id="ownedApps"></output> apps,
<output id="ownedPackages"></output> packages.
</p>
<p>
Ignored:
<output id="ignoredApps"></output> apps,
<output id="ignoredPackages"></output> packages.
</p>
<p>
<a href="javascript:;" id="close">[close]</a>
Wishlisted:
<output id="wishlist"></output> apps.
</p>
</div>
`;
function updateUI() {
const age = getProfileCacheAge() || "never";
const sets = getCachedSets();
shadow.querySelector("#age").value = age;
shadow.querySelector("#ownedApps").value = sets.ownedApps.size;
shadow.querySelector("#ownedPackages").value = sets.ownedPackages.size;
shadow.querySelector("#ignoredApps").value = sets.ignoredApps.size;
shadow.querySelector("#ignoredPackages").value = sets.ignoredPackages.size;
shadow.querySelector("#wishlist").value = sets.wishlist.size;
}
shadow.querySelector("#refresh").addEventListener("click", function(ev) {
ev.preventDefault();
downloadAndUpdateProfileCache().finally(updateUI);
});
shadow.querySelector("#close").addEventListener("click", function(ev) {
ev.preventDefault();
root.remove();
});
updateUI()
return {
element: root,
update: updateUI,
};
}
function addBundleHelperUI(root) {
if (typeof root == "string") {
root = document.querySelector(root);
}
if (!root) {
root = document.body;
}
const UI = createBundleHelperUI();
root.appendChild(UI.element);
updateProfileCacheIfExpired().finally(UI.update);
}
//////////////////////////////////////////////////
// Marking functions
function getClassForAppId(app) {
if (!app) return "";
const sets = getCachedSets();
if (sets.ownedApps.has(app)) return "bh_owned";
if (sets.wishlist.has(app)) return "bh_wished";
if (sets.ignoredApps.has(app)) return "bh_ignored";
return "";
}
function getClassForSubId(sub) {
if (!sub) return "";
const sets = getCachedSets();
if (sets.ownedPackages.has(sub)) return "bh_owned";
if (sets.ignoredPackages.has(sub)) return "bh_ignored";
return "";
}
// Create a new <a> link element to the appropriate Steam URL.
function createSteamLink(app_or_sub, id) {
const url = `https://store.steampowered.com/${app_or_sub}/${id}`;
// Copied from: https://github.com/edent/SuperTinyIcons/blob/master/images/svg/steam.svg
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" aria-label="Steam" role="img" viewBox="0 0 512 512" fill="#ebebeb">
<path d="m0 0H512V512H0" fill="#231f20"/>
<path d="m183 280 41 28 27 41 87-62-94-96"/>
<circle cx="325" cy="191" r="74" fill="none" stroke="#ebebeb" stroke-width="33"/>
<ellipse cx="191" cy="340" fill="none" rx="56" ry="56" stroke="#ebebeb" stroke-width="33"/>
</svg>
`;
const a = document.createElement("a");
a.href = url;
a.innerHTML = svg;
a.className = "bh_steamlink";
a.addEventListener("click", function(ev) {
ev.stopPropagation();
});
return a;
}
function markElements({
rootSelector = "body",
itemSelector = "a[href*='store.steampowered.com/']",
itemStringExtractor = (a) => a.href,
closestSelector = "*",
addSteamLinkFunc = (item, closest, steam_link) => {},
}) {
let total_items = 0;
let valid_data_items = 0;
let valid_closest_items = 0;
let skipped_items = 0;
let marked_items = 0;
for (const root of document.querySelectorAll(rootSelector)) {
for (const item of root.querySelectorAll(itemSelector)) {
total_items++;
const data = itemStringExtractor(item);
if (!data) {
continue;
}
valid_data_items++;
const closest = item.closest(closestSelector);
if (!closest) {
continue;
}
valid_closest_items++;
if (closest.classList.contains("bh_already_processed")) {
skipped_items++;
continue;
}
closest.classList.add("bh_already_processed");
const {app, sub} = parseStringForSteamId(data);
if (app || sub) {
marked_items++;
closest.classList.remove("bh_owned", "bh_wished", "bh_ignored");
const cssClass = getClassForAppId(app) || getClassForSubId(sub);
if (cssClass) {
closest.classList.add(cssClass);
}
const steam_link = createSteamLink(app ? "app" : "sub", app || sub);
addSteamLinkFunc?.(item, closest, steam_link)
}
}
}
console.info(
"markElements(",
"rootSelector=", rootSelector, ",",
"itemSelector=", itemSelector, ",",
"closestSelector=", closestSelector, "):",
`${total_items} total elements, ${valid_data_items} with valid data, ${valid_closest_items} with valid closest element, ${skipped_items} skipped, ${marked_items} marked`
);
}
// This function tries to undo the effects of markElements().
function unmarkAllElements() {
const classes = [
"bh_owned", "bh_wished", "bh_ignored", "bh_already_processed",
];
for (const elem of document.querySelectorAll(classes.map((s) => `.${s}`).join(", "))) {
elem.classList.remove(...classes);
}
for (const elem of document.querySelectorAll(".bh_steamlink")) {
elem.remove();
}
}
//////////////////////////////////////////////////
// Site-specific data and code
// There are no visible ids in the DOM on Fanatical.
// Let's use something unique as the key: the cover image filenames.
// The values are the "steam" objects from Fanatical API.
const fanatical_cover_map = new Map();
// For sites that embed Steam CDN image URLs directly in <img> tags.
function markBySteamImageUrl(rootSelector, closestSelector) {
markElements({
rootSelector: rootSelector,
itemSelector: "img[src*='steamstatic.com/steam/apps/'], img[src*='cdn.akamai.steamstatic.com']",
itemStringExtractor: (img) => img.src,
closestSelector: closestSelector,
});
}
// For simple static sites that have plain Steam store links in the DOM.
function markBySteamHref(rootSelector, closestSelector) {
markElements({
rootSelector: rootSelector,
itemSelector: "a[href*='store.steampowered.com/app/'], a[href*='store.steampowered.com/sub/']",
itemStringExtractor: (a) => a.href,
closestSelector: closestSelector,
});
}
const title_appid_cache = new Map();
function resolveTitle(title) {
if (title_appid_cache.has(title)) {
return Promise.resolve(title_appid_cache.get(title));
}
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: "https://store.steampowered.com/search/suggest?term=" + encodeURIComponent(title) + "&f=games&cc=US&realm=1&l=english",
onload: function(response) {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, "text/html");
const results = doc.querySelectorAll("a[data-ds-appid]");
let appid = 0;
for (const result of results) {
const resultTitle = result.querySelector(".match_name")?.textContent?.trim() ?? "";
if (resultTitle.toLowerCase() === title.toLowerCase()) { appid = Number(result.dataset?.dsAppid ?? 0); break; }
}
title_appid_cache.set(title, appid);
resolve(appid);
},
onerror: function() {
resolve(0);
},
});
});
}
const site_mapping = {
"astats.astats.nl": function() {
document.body.classList.add("bh_basic_style");
GM_addStyle(`
.bh_basic_style table.tablesorter tbody tr.bh_owned,
.bh_basic_style table.tablesorter tbody tr.bh_wished,
.bh_basic_style table.tablesorter tbody tr.bh_ignored {
background: var(--bh-bgcolor) linear-gradient(135deg, rgba(0, 0, 0, 0.70) 10%, rgba(0, 0, 0, 0) 100%) !important;
color: var(--bh-fgcolor) !important;
}
`);
markElements({
rootSelector: "body",
itemSelector: "td > a > img[alt='Logo']",
itemStringExtractor: (img) => img.src,
closestSelector: "tr",
});
markElements({
rootSelector: "body",
itemSelector: "td > a > img.teaser[data-src]",
itemStringExtractor: (img) => img.dataset.src,
closestSelector: "td",
});
},
"dailyindiegame.com": function() {
document.body.classList.add("bh_basic_style");
markElements({
rootSelector: ".DIG3_14_Gray",
itemSelector: "td.DIG3_14_Orange a[href*='store.steampowered.com/']",
itemStringExtractor: (a) => a.href,
closestSelector: "td",
addSteamLinkFunc: (item, closest, link) => {
item.insertAdjacentElement("beforebegin", link);
},
});
markElements({
rootSelector: "#DIG2TableGray",
itemSelector: "a[href*='store.steampowered.com/']",
itemStringExtractor: (a) => a.href,
closestSelector: "tr:has(> .XDIGcontent)",
addSteamLinkFunc: (item, closest, link) => {
item.insertAdjacentElement("beforebegin", link);
},
});
markElements({
rootSelector: ".DIG-SiteLinksLarge, #DIG2TableGray",
itemSelector: "a[href*='site_gamelisting_']:has(img)",
itemStringExtractor: (a) => a.href.replace(/site_gamelisting_([0-9]+)\.html.*/, "app/$1"),
closestSelector: "li, td",
addSteamLinkFunc: (item, closest, link) => {
link.style.position = "absolute";
link.style.bottom = "0";
link.style.right = "0";
},
});
markElements({
rootSelector: "#TableKeys",
itemSelector: "a[href*='site_gamelisting_']",
itemStringExtractor: (a) => a.href.replace(/site_gamelisting_([0-9]+)\.html.*/, "app/$1"),
closestSelector: "tr",
addSteamLinkFunc: (item, closest, link) => {
item.insertAdjacentElement("beforebegin", link);
},
});
},
"fanatical.com": function() {
document.body.classList.add("bh_basic_style");
GM_addStyle(`
.bh_steamlink {
position: absolute;
bottom: 0;
left: calc( 50% - var(--bh-steamlink-size) / 2 );
}
.ProductHeader.container .bh_steamlink {
position: static;
bottom: auto;
}
`);
// Intercepting fetch() requests.
// With help from:
// * https://blog.logrocket.com/intercepting-javascript-fetch-api-requests-responses/
// * https://stackoverflow.com/a/29293383
// Using unsafeWindow to access the page's window object:
// * https://violentmonkey.github.io/api/metadata-block/#inject-into
const original_fetch = unsafeWindow.fetch;
unsafeWindow.fetch = async function(...args) {
let [resource, options] = args;
const response = await original_fetch(resource, options);
// Replacing the .json() method.
const original_json = response.json;
if (original_json) {
response.json = function() {
const p = original_json.apply(this);
p.then((json_data) => {
if (!json_data) {
return;
}
// Page: https://www.fanatical.com/en/bundle/batman-arkham-collection
// AJAX: https://www.fanatical.com/api/products-group/batman-arkham-collection/en
for (const bundle of json_data.bundles ?? []) {
for (const game of bundle.games ?? []) {
if (game.cover && game.steam) {
fanatical_cover_map.set(game.cover, game.steam);
}
}
}
// Page: https://www.fanatical.com/en/pick-and-mix/build-your-own-bento-bundle
// AJAX: https://www.fanatical.com/api/pick-and-mix/build-your-own-bento-bundle/en
for (const game of json_data.products ?? []) {
if (game.cover && game.steam) {
fanatical_cover_map.set(game.cover, game.steam);
}
}
// Page: https://www.fanatical.com/en/game/the-last-of-us-part-i
// AJAX: https://www.fanatical.com/api/products-group/the-last-of-us-part-i/en
if (json_data.cover && json_data.steam) {
fanatical_cover_map.set(json_data.cover, json_data.steam);
}
});
return p;
}
}
return response;
};
// Setting a MutationObserver on the whole document is bad for
// performance, but I can't find any better way, given the website
// rewrites the DOM at will. At least, I'm increasing the debouncing
// time to at least 2 seconds.
debouncedMutationObserverSelectorAll("body", function() {
markElements({
rootSelector: "main",
itemSelector: "img.img-full[srcset]",
itemStringExtractor: (img) => {
const filename = filenameFromURL(img.src);
const steam = fanatical_cover_map.get(filename);
if (!steam) {
return "";
}
return `/${steam.type}/${steam.id}`;
},
closestSelector: ".bundle-game-card, .bundle-product-card, .card, .HitCard, .header-content-container, .NewPickAndMixCard, .PickAndMixCard, .ProductHeader.container",
addSteamLinkFunc: (item, closest, link) => {
closest.style.position = "relative";
closest.insertAdjacentElement("beforeend", link);
},
});
}, 2000);
// We don't even try matching the dropdown results from the top bar.
// It's not reliable and doesn't work properly.
},
"indiegala.com": function() {
document.body.classList.add("bh_basic_style");
markElements({
rootSelector: ".store-product-main-container.product-main-container .product",
itemSelector: "a[data-prod-id]",
itemStringExtractor: (a) => "/app/" + a.dataset.prodId,
closestSelector: "figcaption",
addSteamLinkFunc: (item, closest, link) => {
closest.insertAdjacentElement("afterbegin", link);
},
});
GM_addStyle(`
.main-list-results-item figcaption {
background: transparent;
}
.main-list-results-item-margin {
background: #FFF;
}
a.main-list-results-item-add-to-cart {
left: calc( 2 * 10px + var(--bh-steamlink-size) );
width: auto;
right: 10px;
}
`);
debouncedMutationObserverSelectorAll(".main-list-results", function() {
markElements({
rootSelector: ".main-list-results",
itemSelector: "a[data-prod-id]",
itemStringExtractor: (a) => "/app/" + a.dataset.prodId,
closestSelector: ".main-list-results-item-margin",
addSteamLinkFunc: (item, closest, link) => {
closest.querySelector("div.flex").insertAdjacentElement("afterbegin", link);
},
});
});
GM_addStyle(`
.bundle-page-tier-item-outer figcaption {
background: transparent;
}
.bundle-page-tier-item-outer {
background: #FFF;
}
`);
markElements({
rootSelector: ".bundle-page-tier-games",
itemSelector: "img.img-fit",
itemStringExtractor: (img) => img.src,
closestSelector: ".bundle-page-tier-item-outer",
addSteamLinkFunc: (item, closest, link) => {
link.style.position = "relative";
link.style.zIndex = "99";
},
});
GM_addStyle(`
.header-search .results .results-item a,
.header-search .results .results-item .price .final-color-off {
background: transparent;
color: inherit;
}
`);
debouncedMutationObserverSelectorAll(".header-search .results", function() {
markElements({
rootSelector: ".header-search .results",
itemSelector: "a[data-prod-id]",
itemStringExtractor: (a) => "/app/" + a.dataset.prodId,
closestSelector: ".results-item",
addSteamLinkFunc: (item, closest, link) => {
closest.querySelector("div.flex").insertAdjacentElement("afterbegin", link);
},
});
});
},
"reddit.com": function() {
document.body.classList.add("bh_basic_style");
debouncedMutationObserverSelectorAll(".content", function() {
markElements({
itemSelector: "a[href*='store.steampowered.com/'], a[href*='steamcommunity.com/']",
itemStringExtractor: (a) => a.href,
});
});
},
"sgtools.info": function() {
document.body.classList.add("bh_basic_style");
GM_addStyle(`
.bh_owned a,
.bh_wished a,
.bh_ignored a {
color: inherit;
}
`);
markElements({
rootSelector: "#content",
itemSelector: "table a[href*='store.steampowered.com/']",
itemStringExtractor: (a) => a.href,
closestSelector: "tr",
});
GM_addStyle(`
.bh_owned h2,
.bh_wished h2,
.bh_ignored h2,
.bh_owned h3,
.bh_wished h3,
.bh_ignored h3 {
color: inherit;
}
`);
markElements({
rootSelector: "#deals",
itemSelector: ".deal_game_image > img[src*='/steam/']",
itemStringExtractor: (img) => img.src,
closestSelector: ".deal",
addSteamLinkFunc: (item, closest, link) => {
link.style.float = "left";
},
});
},
"steamgifts.com": function() {
document.body.classList.add("bh_basic_style");
GM_addStyle(`
.page__outer-wrap {
text-shadow: none;
}
`);
GM_addStyle(`
.giveaway__heading > * {
order: 2;
}
.giveaway__heading > .giveaway__icon {
order: 1;
}
.bh_owned .giveaway__summary .giveaway__heading > *,
.bh_wished .giveaway__summary .giveaway__heading > *,
.bh_ignored .giveaway__summary .giveaway__heading > *,
.bh_owned .giveaway__summary .giveaway__columns > *,
.bh_wished .giveaway__summary .giveaway__columns > *,
.bh_ignored .giveaway__summary .giveaway__columns > * {
color: inherit;
}
`);
markElements({
rootSelector: ".page__inner-wrap",
itemSelector: "a.giveaway_image_thumbnail[style]",
itemStringExtractor: (a) => a.style.backgroundImage,
closestSelector: ".giveaway__row-inner-wrap",
});
GM_addStyle(`
.bh_owned .table__column__heading,
.bh_wished .table__column__heading,
.bh_ignored .table__column__heading {
color: inherit;
}
`);
markElements({
rootSelector: ".table",
itemSelector: "a[href*='store.steampowered.com/']",
itemStringExtractor: (a) => a.href,
closestSelector: ".table__row-outer-wrap",
});
markElements({
itemSelector: "a[href*='store.steampowered.com/'], a[href*='steamcommunity.com/']",
itemStringExtractor: (a) => a.href,
});
},
"steamground.com": function() {
document.body.classList.add("bh_basic_style");
GM_addStyle(`
.bh_owned .inner__slider,
.bh_wished .inner__slider,
.bh_ignored .inner__slider {
background-color: transparent;
}
`);
markElements({
rootSelector: ".content_inner",
itemSelector: "a[href*='store.steampowered.com/']",
itemStringExtractor: (a) => a.href,
closestSelector: ".content_inner",
});
GM_addStyle(`
.wholesale-card_info_about {
display: inline-block;
position: static;
}
`);
},
"humblebundle.com": function() {
console.log("BH: humblebundle handler running");
document.body.classList.add("bh_basic_style");
GM_addStyle(`
.bh_basic_style .tier-item-view.bh_owned .img-container,
.bh_basic_style .tier-item-view.bh_wished .img-container,
.bh_basic_style .tier-item-view.bh_ignored .img-container {
outline: 3px solid var(--bh-bgcolor);
outline-offset: -3px;
}
.bh_basic_style .tier-item-view.bh_owned,
.bh_basic_style .tier-item-view.bh_wished,
.bh_basic_style .tier-item-view.bh_ignored {
background: unset;
box-shadow: none;
}
.tier-item-view .img-container {
position: relative;
}
.tier-item-view .img-container .bh_steamlink {
position: absolute;
bottom: 4px;
right: 4px;
z-index: 10;
}
.tier-item-view.bh_owned .item-flavor-text,
.tier-item-view.bh_wished .item-flavor-text,
.tier-item-view.bh_ignored .item-flavor-text {
color: #ccc !important;
}
`);
function processTiles() {
console.log("BH: processTiles called, tiles found:", document.querySelectorAll(".tier-item-view:not(.bh_already_processed)").length);
for (const tile of document.querySelectorAll(".tier-item-view:not(.bh_already_processed)")) {
tile.classList.add("bh_already_processed");
const title_elem = tile.querySelector(".item-title");
if (!title_elem) continue;
const title = ([...title_elem.childNodes].filter(n => n.nodeType === Node.TEXT_NODE).map(n => n.textContent.trim()).join("")).trim();
resolveTitle(title).then((appid) => {
if (!appid) return;
tile.classList.remove("bh_owned", "bh_wished", "bh_ignored");
const cssClass = getClassForAppId(appid);
if (cssClass) tile.classList.add(cssClass);
const img_container = tile.querySelector(".img-container");
if (img_container && !img_container.querySelector(".bh_steamlink")) {
img_container.appendChild(createSteamLink("app", appid));
}
});
}
}
console.log("BH: setting up observer, body exists:", !!document.body);
processTiles();
debouncedMutationObserverSelectorAll("body", processTiles, 2000);
},
"hrkgame.com": function() {
document.body.classList.add("bh_basic_style");
debouncedMutationObserverSelectorAll("body", () =>
markBySteamHref("body", ".product-item, article, li")
, 1000);
},
"gameseal.com": function() {
document.body.classList.add("bh_basic_style");
markBySteamHref("body", ".product, .game-card, article, li");
},
"itch.io": function() {
document.body.classList.add("bh_basic_style");
GM_addStyle(`
.bh_basic_style .index_game_cell_widget.bh_owned { outline: 3px solid var(--bh-bgcolor); outline-offset: -3px; }
.bh_basic_style .index_game_cell_widget.bh_wished { outline: 3px solid var(--bh-bgcolor); outline-offset: -3px; }
.bh_basic_style .index_game_cell_widget.bh_ignored { outline: 3px solid var(--bh-bgcolor); outline-offset: -3px; }
`);
function processItchTiles() {
const tiles = document.querySelectorAll(".index_game_cell_widget.game_cell:not(.bh_already_processed)");
console.log("BH itch.io: processItchTiles called, tiles found:", tiles.length);
for (const tile of tiles) {
tile.classList.add("bh_already_processed");
const title_elem = tile.querySelector(".game_title, .title");
if (!title_elem) continue;
const title = ([...title_elem.childNodes].filter(n => n.nodeType === Node.TEXT_NODE).map(n => n.textContent.trim()).join("")).trim();
resolveTitle(title).then((appid) => {
if (!appid) return;
tile.classList.remove("bh_owned", "bh_wished", "bh_ignored");
const cssClass = getClassForAppId(appid);
if (cssClass) tile.classList.add(cssClass);
const thumb = tile.querySelector("a.game_thumb");
if (thumb && !thumb.querySelector(".bh_steamlink")) {
const link = createSteamLink("app", appid);
link.style.position = "absolute";
link.style.bottom = "4px";
link.style.right = "4px";
link.style.zIndex = "10"; thumb.appendChild(link);
}
});
}
}
// Retry loop: tiles load after script fires, keep trying for 10 seconds
let _itchRetries = 0;
function _itchRetry() {
processItchTiles();
if (document.querySelectorAll(".index_game_cell_widget.game_cell").length === 0 && _itchRetries++ < 10) {
setTimeout(_itchRetry, 1000);
}
}
_itchRetry();
debouncedMutationObserverSelectorAll("body", processItchTiles, 2000);
},
"tiltify.com": function() {
document.body.classList.add("bh_basic_style");
debouncedMutationObserverSelectorAll("body", () =>
markBySteamHref("body", ".reward-card, article, li")
, 2000);
},
"steamdb.info": function() {
document.body.classList.add("bh_basic_style");
markBySteamHref("body", "tr, .app, li");
},
"g2a.com": function() {
document.body.classList.add("bh_basic_style");
debouncedMutationObserverSelectorAll("body", () =>
markBySteamHref("body", "[class*='tile'], [class*='card'], article, li")
, 2000);
},
};
function processSite() {
let hostname = document.location.hostname;
// Removing the www. prefix, if present.
hostname = hostname.replace(/^www\./, "");
// Calling the site-specific code, if found.
site_mapping[hostname]?.();
}
function main() {
GM_addStyle(`
bundle-helper {
position: fixed;
bottom: 0;
left: 0;
z-index: 99;
}
/* Background colors and background gradient copied from Enhanced Steam browser extension */
body {
--bh-bgcolor-owned: #00CE67;
--bh-bgcolor-wished: #0491BF;
--bh-bgcolor-ignored: #4F4F4F;
--bh-fgcolor-owned: #FFFFFF;
--bh-fgcolor-wished: #FFFFFF;
--bh-fgcolor-ignored: #FFFFFF;
--bh-steamlink-size: 24px;
}
.bh_owned {
--bh-bgcolor: var(--bh-bgcolor-owned);
--bh-fgcolor: var(--bh-fgcolor-owned);
}
.bh_wished {
--bh-bgcolor: var(--bh-bgcolor-wished);
--bh-fgcolor: var(--bh-fgcolor-wished);
}
.bh_ignored {
--bh-bgcolor: var(--bh-bgcolor-ignored);
--bh-fgcolor: var(--bh-fgcolor-ignored);
}
.bh_basic_style .bh_owned,
.bh_basic_style .bh_wished,
.bh_basic_style .bh_ignored {
background: var(--bh-bgcolor) linear-gradient(135deg, rgba(0, 0, 0, 0.70) 10%, rgba(0, 0, 0, 0) 100%) !important;
color: var(--bh-fgcolor) !important;
}
.bh_basic_style .bh_ignored {
opacity: 0.3;
}
.bh_steamlink svg {
width: var(--bh-steamlink-size);
height: var(--bh-steamlink-size);
}
`);
// Adding some statistics to the corner of the screen.
addBundleHelperUI();
// Run site-specific code.
processSite();
}
main();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment