Skip to content

Instantly share code, notes, and snippets.

@DickHorner
Forked from SentaiBrad/README.md
Last active July 26, 2026 15:13
Show Gist options
  • Select an option

  • Save DickHorner/cc55aa516c389b11b93661a49f9e658a to your computer and use it in GitHub Desktop.

Select an option

Save DickHorner/cc55aa516c389b11b93661a49f9e658a to your computer and use it in GitHub Desktop.
Bundle Dude

Bundle Dude

A userscript that marks Steam games you already own, have wishlisted, or have ignored while browsing supported bundle and game-store websites. It also adds a direct Steam link where the page exposes a usable Steam product ID.

Current version: 2.9.13

What it does

The script reads Steam's logged-in store data and compares Steam app or package IDs with products shown by supported websites.

Most handlers obtain the Steam ID directly from a Steam link, Steam CDN URL, data attribute, or site-specific API response. Humble Bundle currently exposes product titles but no usable Steam IDs on its bundle tiles. Its handler therefore resolves each title through Steam's own store search, first in the page or browser language and then in English, and uses the resulting Steam app ID for the ownership check.

The title resolver is deliberately conservative: it accepts normalized exact title matches and the narrow Standard Edition variant, but does not use semantic or fuzzy guessing.

A product is marked after a Steam app ID or package ID has been resolved:

  • green: owned
  • blue: wishlisted
  • gray and faded: ignored

Requirements

  • A userscript manager such as Tampermonkey, Violentmonkey, or a compatible extension
  • An active login at store.steampowered.com in the same browser profile
  • A supported page that exposes a Steam app/package ID or a handler-specific equivalent

Installation

  1. Open bundle-dude.user.js.
  2. Confirm the installation in your userscript manager.
  3. Sign in to the Steam Store.
  4. Open a supported bundle or store page.

The userscript's update URL points to this Gist, so updates published here can be detected by the userscript manager.

Supported sites

Direct Steam-ID support

These handlers read Steam links, Steam CDN URLs, or Steam ID attributes directly from the page:

  • AStats
  • DailyIndieGame
  • Humble Bundle
  • IndieGala
  • old.reddit.com
  • SGTools
  • SteamDB
  • SteamGifts
  • SteamGround
  • itch.io, including *.itch.io subdomains

Handler-specific support

  • Fanatical: obtains Steam product data from Fanatical API responses and associates it with product cover images.

Conditional support

These handlers can work when the relevant product card contains a direct Steam link or Steam CDN image. They intentionally do nothing when no reliable Steam ID is available:

  • G2A
  • GameSeal
  • HRK Game
  • Tiltify

Humble Bundle behavior

Humble Bundle is the primary maintained use case in this fork.

The handler:

  • waits for valid Steam profile data before processing the page;
  • detects Steam app and package IDs without title matching;
  • supports dynamically rendered bundle tiles;
  • leaves incomplete tiles retryable until a Steam ID appears;
  • avoids repeated processing after a tile has been checked successfully;
  • refreshes visible markings after the Steam data cache is manually refreshed.

Network and local data

The script requests the current user's owned, wishlisted, ignored, and owned-package lists from store.steampowered.com. The resulting cache and processing state are stored locally by the userscript manager.

No Steam API key is required. The script does not upload the Steam library to a separate service.

Known limitations

  • Storefront HTML and API structures can change without notice; a handler may need updated selectors later.
  • A product cannot be identified when the page exposes neither a Steam ID nor handler-specific product data.
  • Bundles may contain non-Steam products, DLC, packages, or editions whose available ID does not correspond to an individually owned Steam app.
  • The handlers have been statically checked for viable ID paths, but not every supported storefront and page type has been live-tested in version 2.9.8.

Troubleshooting

If no items are marked:

  1. Confirm that you are signed in at store.steampowered.com.
  2. Reload the store or bundle page after signing in.
  3. Use the Bundle Helper panel to refresh the Steam data cache.
  4. Check the browser console for messages prefixed by the script.
  5. Inspect the product card: if it contains no Steam link, Steam CDN URL, Steam ID attribute, or supported handler data, the script intentionally cannot classify it.

Changes in 2.9.8

  • switched Humble Bundle and itch.io matching to deterministic Steam IDs;
  • removed title-search and language-dependent matching;
  • fixed routing for old.reddit.com and itch.io subdomains;
  • added srcset support for Steam CDN image detection;
  • improved Steam profile loading, refresh, observer cleanup, and retry behavior;
  • aligned update and download URLs with this fork's Gist.

Project history

This fork is based on Bundle Helper Reborn 2.0 - Extended, which itself was based on Bundle Helper Reborn by Denilson Sá Maia, which was itself based on Bundle Helper v1.09 by 7-elephant.

License

GPL-3.0-only

// ==UserScript==
// @name Bundle Dude
// @namespace https://gist.github.com/DickHorner/
// @author DickHorner
// @version 2.9.13
// @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 *://*.itch.io/*
// @match *://tiltify.com/*
// @match *://www.tiltify.com/*
// @match *://g2a.com/*
// @match *://www.g2a.com/*
// @run-at document-start
// @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/DickHorner/cc55aa516c389b11b93661a49f9e658a/raw/bundle-dude.user.js
// @updateURL https://gist.github.com/DickHorner/cc55aa516c389b11b93661a49f9e658a/raw/bundle-dude.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 isProfileDataValid(data) {
return data && typeof data === "object"
&& Array.isArray(data.rgAppsInCart)
&& Array.isArray(data.rgIgnoredPackages)
&& Array.isArray(data.rgOwnedApps)
&& Array.isArray(data.rgOwnedPackages)
&& Array.isArray(data.rgPackagesInCart)
&& Array.isArray(data.rgWishlist)
&& data.rgIgnoredApps && typeof data.rgIgnoredApps === "object";
}
function hasValidProfileCache() {
return isProfileDataValid(GM_getValue(cachename_profile_data, {}));
}
function setProfileCache(data) {
if (!isProfileDataValid(data)) {
throw new TypeError("Steam returned invalid profile 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) {
const status = response?.status ?? "unknown";
const statusText = response?.statusText ?? "unknown";
const message = `Error while loading the data: status=${status}; statusText=${statusText}`;
console.error(message);
reject(new Error(message));
}
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(new Error("Steam profile request was aborted."));
},
});
});
}
function downloadAndUpdateProfileCache() {
return downloadProfileData().then((data) => {
setProfileCache(data);
});
}
function updateProfileCacheIfExpired() {
if (!hasValidProfileCache() || 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>
<p id="siteStatus" hidden></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", async function(ev) {
ev.preventDefault();
try {
await downloadAndUpdateProfileCache();
stopAllMutationObservers();
unmarkAllElements();
processSite();
} catch (error) {
console.error("BH: Steam profile refresh failed.", error);
} 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);
return UI;
}
//////////////////////////////////////////////////
// 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;
}
const {app, sub} = parseStringForSteamId(data);
if (app || sub) {
closest.classList.add("bh_already_processed");
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();
function captureFanaticalSteamData(json_data) {
if (!json_data) {
return;
}
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);
}
}
}
for (const game of json_data.products ?? []) {
if (game.cover && game.steam) {
fanatical_cover_map.set(game.cover, game.steam);
}
}
if (json_data.cover && json_data.steam) {
fanatical_cover_map.set(json_data.cover, json_data.steam);
}
}
function installFanaticalFetchInterceptor() {
if (document.location.hostname.replace(/^www\./u, "") !== "fanatical.com") {
return;
}
const original_fetch = unsafeWindow.fetch;
if (typeof original_fetch !== "function" || original_fetch.bh_intercepted) {
return;
}
async function interceptedFetch(...args) {
const response = await original_fetch.apply(this, args);
const original_json = response.json;
if (typeof original_json === "function") {
response.json = function() {
const promise = original_json.apply(this);
promise.then(captureFanaticalSteamData, () => {});
return promise;
};
}
return response;
}
interceptedFetch.bh_intercepted = true;
unsafeWindow.fetch = interceptedFetch;
}
const title_appid_cache = new Map();
const steam_language_names = new Map([
["de", "german"],
["en", "english"],
["es", "spanish"],
["fr", "french"],
["it", "italian"],
["ja", "japanese"],
["ko", "koreana"],
["nl", "dutch"],
["pl", "polish"],
["pt", "portuguese"],
["pt-br", "brazilian"],
["ru", "russian"],
["tr", "turkish"],
["zh", "schinese"],
["zh-hans", "schinese"],
["zh-hant", "tchinese"],
["zh-hk", "tchinese"],
["zh-tw", "tchinese"],
]);
const standard_edition_suffix = /\bstandard edition$/u;
function normalizeGameTitle(title) {
return title
.replace(/[™®©]/gu, "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.replace(/&/gu, " and ")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
// Stable App IDs for products whose public/store/library names differ materially.
const known_title_appids = new Map([
["carcassonne tiles and tactics", 598810],
["carcassonne the official board game", 598810],
]);
function getKnownTitleAppId(title) {
return known_title_appids.get(normalizeGameTitle(title)) ?? 0;
}
function gameTitlesMatch(expected, candidate) {
const normalized_expected = normalizeGameTitle(expected);
const normalized_candidate = normalizeGameTitle(candidate);
if (!normalized_expected || !normalized_candidate) {
return false;
}
if (normalized_expected === normalized_candidate) {
return true;
}
return normalized_expected.replace(standard_edition_suffix, "").trim()
=== normalized_candidate.replace(standard_edition_suffix, "").trim();
}
function getSteamLookupTitles(title) {
const titles = [title];
const base_title = title.replace(/\s*(?:[:\-–—]\s*)?standard edition\s*$/iu, "").trim();
if (base_title && normalizeGameTitle(base_title) !== normalizeGameTitle(title)) {
titles.push(base_title);
}
return titles;
}
function extractSteamSearchCandidates(doc) {
const candidates = [];
for (const result of doc.querySelectorAll("a[data-ds-appid]")) {
const result_title = result.querySelector(".match_name, .title")?.textContent?.trim() ?? "";
const dataset_appid = Number.parseInt(result.dataset.dsAppid?.split(",")[0] ?? "", 10);
const appid = dataset_appid || parseStringForSteamId(result.href ?? "").app;
if (appid > 0 && result_title) {
candidates.push({appid: appid, title: result_title});
}
}
return candidates;
}
function chooseSteamSearchCandidate(title, candidates) {
const exact = candidates.find((candidate) => gameTitlesMatch(title, candidate.title));
if (exact) {
return exact.appid;
}
// For renamed products, accept only one candidate already known by the
// logged-in Steam profile. Ambiguous candidate sets remain unmatched.
const known_appids = new Set();
for (const candidate of candidates) {
if (getClassForAppId(candidate.appid)) {
known_appids.add(candidate.appid);
}
}
if (known_appids.size === 1) {
return known_appids.values().next().value;
}
return 0;
}
function getSteamLookupLanguages() {
const languages = [];
for (const locale_value of [document.documentElement.lang, navigator.language, "en"]) {
if (!locale_value) {
continue;
}
const locale = locale_value.toLowerCase().replace("_", "-");
const language = steam_language_names.get(locale)
?? steam_language_names.get(locale.split("-")[0])
?? "english";
if (!languages.includes(language)) {
languages.push(language);
}
}
return languages;
}
function getSteamLookupCountry() {
const match = (navigator.language || "").match(/[-_]([a-z]{2})$/iu);
return match?.[1].toUpperCase() ?? "US";
}
function requestSteamTitleLookup(title, language) {
return new Promise((resolve) => {
const query = new URLSearchParams({
term: title,
f: "games",
cc: getSteamLookupCountry(),
realm: "1",
l: language,
});
GM_xmlhttpRequest({
method: "GET",
url: "https://store.steampowered.com/search/suggest?" + query,
onload: function(response) {
if (response.status !== 200) {
console.error(`Steam title lookup failed with status ${response.status}.`);
resolve(null);
return;
}
try {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, "text/html");
resolve(chooseSteamSearchCandidate(title, extractSteamSearchCandidates(doc)));
} catch (error) {
console.error("Steam title lookup response could not be parsed.", error);
resolve(null);
}
},
onerror: function() {
console.error("Steam title lookup failed.");
resolve(null);
},
onabort: function() {
console.error("Steam title lookup was aborted.");
resolve(null);
},
});
});
}
function requestSteamDlcLookup(title, language) {
return new Promise((resolve) => {
const query = new URLSearchParams({
term: title,
category1: "21",
cc: getSteamLookupCountry(),
l: language,
json: "1",
});
GM_xmlhttpRequest({
method: "GET",
url: "https://store.steampowered.com/search/results/?" + query,
responseType: "json",
onload: function(response) {
if (response.status !== 200) {
console.error(`Steam DLC lookup failed with status ${response.status}.`);
resolve(null);
return;
}
try {
const data = response.response && typeof response.response === "object"
? response.response
: JSON.parse(response.responseText);
const parser = new DOMParser();
const doc = parser.parseFromString(data?.results_html ?? "", "text/html");
resolve(chooseSteamSearchCandidate(title, extractSteamSearchCandidates(doc)));
} catch (error) {
console.error("Steam DLC lookup response could not be parsed.", error);
resolve(null);
}
},
onerror: function() {
console.error("Steam DLC lookup failed.");
resolve(null);
},
onabort: function() {
console.error("Steam DLC lookup was aborted.");
resolve(null);
},
});
});
}
async function resolveSteamAppIdFromTitle(title) {
const languages = getSteamLookupLanguages();
const cache_key = `${languages.join(",")}:${normalizeGameTitle(title)}`;
if (title_appid_cache.has(cache_key)) {
return title_appid_cache.get(cache_key);
}
const known_appid = getKnownTitleAppId(title);
if (known_appid) {
title_appid_cache.set(cache_key, known_appid);
return known_appid;
}
const lookup_titles = getSteamLookupTitles(title);
for (const lookup_title of lookup_titles) {
for (const language of languages) {
const appid = await requestSteamTitleLookup(lookup_title, language);
if (appid === null) {
return null;
}
if (appid) {
title_appid_cache.set(cache_key, appid);
return appid;
}
}
}
// Steam's suggestion endpoint is game-focused. Search the DLC category
// only after all game-title variants and languages have failed.
for (const lookup_title of lookup_titles) {
for (const language of languages) {
const appid = await requestSteamDlcLookup(lookup_title, language);
if (appid === null) {
return null;
}
if (appid) {
title_appid_cache.set(cache_key, appid);
return appid;
}
}
}
console.info(`BH: No conservative Steam title or DLC match found for "${title}".`);
title_appid_cache.set(cache_key, 0);
return 0;
}
// For sites that embed Steam CDN image URLs directly in <img> tags.
function markBySteamImageUrl(rootSelector, closestSelector) {
markElements({
rootSelector: rootSelector,
itemSelector: "img[src*='/steam/apps/'], img[srcset*='/steam/apps/']",
itemStringExtractor: (img) => img.srcset || 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/'], a[href*='steamcommunity.com/app/']",
itemStringExtractor: (a) => a.href,
closestSelector: closestSelector,
});
}
function findSteamProductId(root) {
const selectors = [
"[data-ds-appid]",
"[data-appid]",
"[data-app-id]",
"[data-steam-app-id]",
"[data-subid]",
"[data-packageid]",
"[data-steam-sub-id]",
"a[href*='store.steampowered.com/app/']",
"a[href*='store.steampowered.com/sub/']",
"a[href*='steamcommunity.com/app/']",
"img[src*='/steam/apps/']",
"img[srcset*='/steam/apps/']",
];
const elements = [root];
for (const selector of selectors) {
elements.push(...root.querySelectorAll(selector));
}
for (const element of elements) {
for (const attribute of ["href", "src", "srcset"]) {
const value = element.getAttribute?.(attribute);
const ids = parseStringForSteamId(value ?? "");
if (ids.app || ids.sub) {
return ids;
}
}
for (const attribute of ["data-ds-appid", "data-appid", "data-app-id", "data-steam-app-id"]) {
const app = Number.parseInt(element.getAttribute?.(attribute) ?? "", 10);
if (app > 0) {
return {app: app, sub: 0};
}
}
for (const attribute of ["data-subid", "data-packageid", "data-steam-sub-id"]) {
const sub = Number.parseInt(element.getAttribute?.(attribute) ?? "", 10);
if (sub > 0) {
return {app: 0, sub: sub};
}
}
}
return {app: 0, sub: 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;
}
`);
// 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);
},
});
});
},
"old.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() {
document.body.classList.add("bh_basic_style");
GM_addStyle(`
.bh_humble_visual {
position: relative !important;
}
.bh_humble_card.bh_owned .bh_humble_visual,
.bh_humble_card.bh_wished .bh_humble_visual,
.bh_humble_card.bh_ignored .bh_humble_visual {
outline: 3px solid var(--bh-bgcolor);
outline-offset: -3px;
}
.bh_humble_card.bh_owned,
.bh_humble_card.bh_wished,
.bh_humble_card.bh_ignored {
background: unset;
box-shadow: none;
}
.bh_humble_visual > .bh_steamlink {
position: absolute;
bottom: 4px;
right: 4px;
z-index: 10;
}
.bh_humble_card.bh_owned .item-flavor-text,
.bh_humble_card.bh_wished .item-flavor-text,
.bh_humble_card.bh_ignored .item-flavor-text {
color: #ccc !important;
}
`);
const title_selector = [
".tier-item-view .item-title",
".item-title",
".content-choice-title .entity-title",
".dd-image-box-caption .dd-image-box-text",
"[class*='content-choice'] [class*='title']",
".game-name h4",
".game-name",
].join(", ");
const card_selector = [
".tier-item-view",
"[class*='tier-item']",
".content-choice-view",
"[class*='content-choice-item']",
"[class*='content-choice-card']",
".dd-image-box",
".game-name",
"article",
"li",
].join(", ");
const visual_selector = [
".img-container",
".dd-image-box-image",
"[class*='image-container']",
"[class*='image-wrapper']",
"[class*='cover']",
].join(", ");
let processing_titles = false;
async function processTitles() {
if (processing_titles) {
return;
}
processing_titles = true;
let found_titles = 0;
let resolved_titles = 0;
try {
const seen_cards = new Set();
for (const title_element of document.querySelectorAll(title_selector)) {
const card = title_element.closest(card_selector) ?? title_element.closest("a") ?? title_element.parentElement;
if (!card || seen_cards.has(card) || card.classList.contains("bh_already_processed")) {
continue;
}
seen_cards.add(card);
card.classList.add("bh_humble_card");
found_titles++;
const title = title_element.textContent?.trim() ?? "";
if (!title) {
continue;
}
const direct_id = findSteamProductId(card);
let app = direct_id.app;
const sub = direct_id.sub;
if (!app && !sub) {
app = await resolveSteamAppIdFromTitle(title);
if (app === null) {
continue;
}
}
const product_id = app || sub;
if (product_id) {
resolved_titles++;
card.classList.remove("bh_owned", "bh_wished", "bh_ignored");
const cssClass = getClassForAppId(app) || getClassForSubId(sub);
if (cssClass) {
card.classList.add(cssClass);
}
const visual = card.querySelector(visual_selector) ?? title_element.parentElement;
if (visual) {
visual.classList.add("bh_humble_visual");
if (!visual.querySelector(".bh_steamlink")) {
visual.appendChild(createSteamLink(app ? "app" : "sub", product_id));
}
}
}
card.classList.add("bh_already_processed");
}
} finally {
processing_titles = false;
const total_cards = document.querySelectorAll(".bh_humble_card").length;
const matched_cards = document.querySelectorAll(".bh_humble_card .bh_steamlink").length;
const status_output = document.querySelector("bundle-helper")?.shadowRoot?.querySelector("#siteStatus");
if (status_output) {
status_output.hidden = false;
status_output.textContent = `Humble: ${total_cards} items found, ${matched_cards} matched to Steam.`;
}
console.info(`BH Humble: ${found_titles} new item cards found, ${resolved_titles} Steam products resolved; ${total_cards} total.`);
}
}
void processTitles();
debouncedMutationObserverSelectorAll("body", () => void processTitles(), 1000);
},
"hrkgame.com": function() {
document.body.classList.add("bh_basic_style");
debouncedMutationObserverSelectorAll("body", () => {
markBySteamHref("body", ".product-item, article, li");
markBySteamImageUrl("body", ".product-item, article, li");
}, 1000);
},
"gameseal.com": function() {
document.body.classList.add("bh_basic_style");
debouncedMutationObserverSelectorAll("body", () => {
markBySteamHref("body", ".product, .game-card, article, li");
markBySteamImageUrl("body", ".product, .game-card, article, li");
}, 1000);
},
"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)");
for (const tile of tiles) {
const {app, sub} = findSteamProductId(tile);
const product_id = app || sub;
if (!product_id) {
continue;
}
tile.classList.remove("bh_owned", "bh_wished", "bh_ignored");
const cssClass = getClassForAppId(app) || getClassForSubId(sub);
if (cssClass) {
tile.classList.add(cssClass);
}
const thumb = tile.querySelector("a.game_thumb");
if (thumb && !thumb.querySelector(".bh_steamlink")) {
const link = createSteamLink(app ? "app" : "sub", product_id);
link.style.position = "absolute";
link.style.bottom = "4px";
link.style.right = "4px";
link.style.zIndex = "10";
thumb.appendChild(link);
}
tile.classList.add("bh_already_processed");
}
}
processItchTiles();
debouncedMutationObserverSelectorAll("body", processItchTiles, 2000);
},
"tiltify.com": function() {
document.body.classList.add("bh_basic_style");
debouncedMutationObserverSelectorAll("body", () => {
markBySteamHref("body", ".reward-card, article, li");
markBySteamImageUrl("body", ".reward-card, article, li");
}, 2000);
},
"steamdb.info": function() {
document.body.classList.add("bh_basic_style");
debouncedMutationObserverSelectorAll("body", () => {
markElements({
rootSelector: "body",
itemSelector: "[data-appid]",
itemStringExtractor: (element) => "/app/" + element.dataset.appid,
closestSelector: "tr, .app, li",
});
markElements({
rootSelector: "body",
itemSelector: "[data-subid]",
itemStringExtractor: (element) => "/sub/" + element.dataset.subid,
closestSelector: "tr, .package, li",
});
markBySteamHref("body", "tr, .app, .package, li");
}, 1000);
},
"g2a.com": function() {
document.body.classList.add("bh_basic_style");
debouncedMutationObserverSelectorAll("body", () => {
markBySteamHref("body", "[class*='tile'], [class*='card'], article, li");
markBySteamImageUrl("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\./, "");
if (hostname.endsWith(".itch.io")) {
hostname = "itch.io";
}
// Calling the site-specific code, if found.
site_mapping[hostname]?.();
}
async 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.
const UI = addBundleHelperUI();
try {
await updateProfileCacheIfExpired();
} catch (error) {
console.error("BH: Steam profile initialization failed.", error);
}
UI.update();
if (!hasValidProfileCache()) {
console.error("BH: Site processing skipped because no valid Steam profile data is available.");
return;
}
// Run site-specific code.
processSite();
}
installFanaticalFetchInterceptor();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", main, {once: true});
} else {
main();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment