// ==UserScript==
// @name Bundle Helper Reborn
// @namespace https://denilson.sa.nom.br/
// @version 2.0rc
// @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 *://groupees.com/*
// @match *://old.reddit.com/*
// @match *://sgtools.info/*
// @match *://steamground.com/*
// @match *://steamkeys.ovh/*
// @match *://www.dailyindiegame.com/*
// @match *://www.fanatical.com/*
// @match *://www.indiegala.com/*
// @match *://www.reddit.com/*
// @match *://www.sgtools.info/*
// @match *://www.steamgifts.com/*
// @match *://www.steamkeys.ovh/*
// @run-at document-end
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect store.steampowered.com
// @icon https://store.steampowered.com/favicon.ico
// @license GPL-3.0-only
// ==/UserScript==
// # Bundle Helper Reborn
//
// ## Purpose
//
// If you have a Steam account, you are probably also buying games from other
// websites.
//
// This user-script helps you, by highlighting (on other sites) games you
// already have, games you have ignored, and games you have wishlisted (on
// Steam).
//
// It is complementary to the amazing AugmentedSteam browser extension. While
// that extension only applies to the Steam website(s), this user-script
// applies to third-party websites. https://augmentedsteam.com/
//
// It needs the permission to connect to store.steampowered.com to get
// 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.
// In order to avoid name clashes, I'm naming it "Bundle Helper Reborn".
//
// License: GPL-3.0-only - https://spdx.org/licenses/GPL-3.0-only.html
// Copyright 2016-2019, 7-elephant - https://greasyfork.org/en/scripts/16105-bundle-helper
// Copyright 2023, Denilson Sá Maia - https://greasyfork.org/en/scripts/????
(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 = [];
// 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: 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);
}
}
function stopAllMutationObservers() {
for (const mo of active_mutation_observers) {
mo.disconnect();
}
active_mutation_observers.length = 0;
}
//////////////////////////////////////////////////
// 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.
// Yes, even escaped whitespace.
// Because I'm dealing with URLs, and these don't have any whitespace anyway.
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");
}
// Example URLs:
// https://store.steampowered.com/app/20/Team_Fortress_Classic/
// https://store.steampowered.com/agecheck/app/976310/
// https://steamcommunity.com/app/20
// https://steamdb.info/app/20/
// https://www.protondb.com/app/20
// https://isthereanydeal.com/steam/app/20/
// https://barter.vg/steam/app/20/
// https://pcgamingwiki.com/api/appid.php?appid=20
//
// Screenshots, images, and user manual URLs:
// https://cdn.akamai.steamstatic.com/steam/apps/20/0000000165.1920x1080.jpg
// https://cdn.akamai.steamstatic.com/steam/apps/440/extras/page_banner_english1.jpg
// https://store.steampowered.com/manual/440
//
// For packages:
// https://store.steampowered.com/sub/237
// https://steamdb.info/sub/237/
//
// Note: bundles are not the same as packages!
// https://store.steampowered.com/bundle/237/HalfLife_1_Anthology/
const re_app = reX`
( /app/ | /apps/ | appid= )
(?<id>[0-9]+)
\b // Word boundary, the regex will match 123 but not 123abc
`;
const re_sub = reX`
( /sub/ | /subs/ )
(?<id>[0-9]+)
\b // Word boundary, the regex will match 123 but not 123abc
`;
// 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);
// Resetting RegExp persistent state.
// This is just one of those JavaScript quirks.
// Supposedly this is only needed to RegExp objects with the global
// flag, but I'm doing it anyway just to be safe.
// (And just in case in the future we change those regexes to be global.)
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
// The cached data.
const cachename_profile_data = "bh_profile_data";
// The timestamp of the cached version.
const cachename_profile_time = "bh_profile_time";
// The maximum age of the cache.
// Cache will be considered after this amount of time.
const cache_max_age_seconds = 60 * 60 * 24; // 24 hours
// For performance, we convert arrays into sets.
let cached_sets = null;
// Sets the cached value, while also updating its timestamp.
function setProfileCache(data) {
cached_sets = null;
// WARNING: This is modifying the received data object in-place!
// This is usually a bad idea, but it works fine for the purposes of
// this script. And it doesn't add any extra overhead.
// Deleting rgCurations because it's massive.
data.rgCurations = {};
// Deleting curator-related data because it's not used in this script.
data.rgCurators = {};
data.rgCuratorsIgnored = [];
// Deleting recommendations because there is little to no value in storing them.
data.rgRecommendedApps = [];
data.rgRecommendedTags = [];
GM_setValue(cachename_profile_data, data);
GM_setValue(cachename_profile_time, getUnixTimestamp());
}
// Clears the cached data.
// Not sure why we would do it.
function clearProfileCache() {
cached_sets = null;
GM_setValue(cachename_profile_data, {});
GM_setValue(cachename_profile_time, 0);
}
// Returns a human-readable string representation of the age.
function getProfileCacheAge() {
const now = getUnixTimestamp();
const cached = GM_getValue(cachename_profile_time, 0);
if (!cached) {
return "";
}
return humanReadableSecondsAmount(now - cached);
}
// Returns a boolean.
function isProfileCacheExpired() {
const now = getUnixTimestamp();
const cached = GM_getValue(cachename_profile_time, 0);
return now - cached > cache_max_age_seconds;
}
// Returns a promise that resolves to the downloaded data.
function downloadProfileData() {
return new Promise((resolve, reject) => {
function handleError(response) {
console.error(`Error while loading the data: status=${response.status}; statusText=${response.statusText}`);
reject();
// I wish I had a better error-handling routine here.
// But this is good enough for now.
}
GM_xmlhttpRequest({
method: "GET",
url: "https://store.steampowered.com/dynamicstore/userdata/?t=" + getUnixTimestamp(),
responseType: "json",
onabort: handleError,
onerror: handleError,
onload: function(response) {
if (response.response) {
resolve(response.response);
} else {
console.error("Null response after loading. Was it a valid JSON?");
reject();
}
},
});
// There is also another API that can potentially be useful:
// https://store.steampowered.com/api/appuserdetails/?appids=20,1234,5678
});
}
// Downloads and updates the profile cache.
// Returns a promise that resolves after updating it successfully.
function downloadAndUpdateProfileCache() {
return downloadProfileData().then((data) => {
setProfileCache(data);
});
}
// Returns a promise that resolves if the cache is fresh, or after updating it.
function updateProfileCacheIfExpired() {
if (isProfileCacheExpired()) {
return downloadAndUpdateProfileCache();
} else {
return Promise.resolve();
}
}
// Returns an object with the relevant data as sets.
function getCachedSets() {
if (!cached_sets) {
const data = GM_getValue(cachename_profile_data, {});
cached_sets = {
// Lists of integers being converted to sets.
appsInCart: new Set(data.rgAppsInCart),
// creatorsFollowed: new Set(data.rgCreatorsFollowed),
// creatorsIgnored: new Set(data.rgCreatorsIgnored),
// curatorsIgnored: new Set(data.rgCuratorsIgnored),
// followedApps: new Set(data.rgFollowedApps),
ignoredPackages: new Set(data.rgIgnoredPackages),
ownedApps: new Set(data.rgOwnedApps),
ownedPackages: new Set(data.rgOwnedPackages),
packagesInCart: new Set(data.rgPackagesInCart),
// recommendedApps: new Set(data.rgRecommendedApps),
// secondaryLanguages: new Set(data.rgSecondaryLanguages),
wishlist: new Set(data.rgWishlist),
// Ignored apps are a mapping of appids to zero.
ignoredApps: new Set(Object.keys(data.rgIgnoredApps ?? {}).map((key) => Number(key))),
// Tags are objects with this data:
// {
// tagid: 1234,
// name: "Foobar",
// timestamp_added: 1672531200, // unix timestamp in seconds, only for rgExcludedTags, not for rgRecommendedTags.
// }
excludedTags: new Set(data.rgExcludedTags?.map((obj) => obj.name)),
// recommendedTags: new Set(data.rgRecommendedTags?.map((obj) => obj.name)),
// Available arrays of integers in the profile data:
// rgAppsInCart
// rgCreatorsFollowed
// rgCreatorsIgnored
// rgCuratorsIgnored
// rgFollowedApps
// rgIgnoredPackages // Mostly empty, because there is no UI in steam to ignore a package.
// rgOwnedApps
// rgOwnedPackages
// rgPackagesInCart
// rgRecommendedApps
// rgSecondaryLanguages
// rgWishlist
//
// Available arrays of objects in the profile data:
// rgExcludedTags
// rgRecommendedTags
//
// Available arrays of unknown content in the profile data:
// rgAutoGrantApps
// rgExcludedContentDescriptorIDs
// rgMasterSubApps
// rgPreferredPlatforms
//
// Available objects (maps, associative arrays) in the profile data:
// rgCurations
// rgCurators
// rgIgnoredApps
};
}
return cached_sets;
}
//////////////////////////////////////////////////
// Bundle Helper UI
// Returns an object.
function createBundleHelperUI() {
const root = document.createElement("bundle-helper");
const shadow = root.attachShadow({
mode: "open",
});
shadow.innerHTML = `
<style>
.container {
background: #222;
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;
}
</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>
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(function() {
unmarkAllElements();
stopAllMutationObservers();
updateUI();
processSite();
});
});
updateUI()
return {
element: root,
update: updateUI,
};
}
// Adds the UI to the page.
// It also triggers a profile data refresh if needed.
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);
}
function getClassForAppId(id) {
if (!id) return "";
const sets = getCachedSets();
if (sets.ownedApps.has(id) ) return "bh_owned";
if (sets.wishlist.has(id) ) return "bh_wished";
if (sets.ignoredApps.has(id)) return "bh_ignored";
return "";
}
function getClassForSubId(id) {
if (!id) return "";
const sets = getCachedSets();
if (sets.ownedPackages.has(id) ) return "bh_owned";
if (sets.ignoredPackages.has(id)) return "bh_ignored";
return "";
}
// Create a new <a> link element to the appropriate Steam URL.
// app_or_sub must be either "app" or "sub".
// id must be the numeric id.
// Returns the Node (HTMLElement).
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="340" cy="190" r="49"/>
<g fill="none" stroke="#ebebeb">
<circle cx="179" cy="352" r="63" stroke-width="19"/>
<path d="m-18 271 195 81" stroke-width="80" stroke-linecap="round"/>
<circle cx="340" cy="190" r="81" stroke-width="32"/>
</g>
</svg>
`;
const a = document.createElement("a");
a.href = url;
a.innerHTML = svg;
a.className = "bh_steamlink";
a.addEventListener("click", function(ev) {
// Some pages have an onclick handler to the parent element.
// Let's stop the even propagation to avoid that stupid handler.
ev.stopPropagation();
});
return a;
}
// The main function that does most of the work on the page DOM.
// This is the function that makes the results visible to the user.
// Receives many parameters:
function markElements({
// CSS selector for the root node(s) of the subtree(s) that will be searched.
// Useful to restrict the search to the main content, skipping unrelated elements.
rootSelector = "body",
// CSS selector matching each individual element (i.e. each game or package).
itemSelector = "a[href*='store.steampowered.com/']",
// JS callback that receives one item (i.e. one Element) and should
// return a string containing the URL or a URL fragment.
// The returned string of this function will be matched against re_app and re_sub.
itemStringExtractor = (a) => a.href,
// CSS selector to be passed to item.closest().
// Assuming this item matched a valid id, this helps navigating upwards in the tree
// until we find the appropriate block/container for the game or package.
// The matched element will receive the bh_owned/bh_wished/bh_ignored CSS class.
closestSelector = "*",
// JS callback that will append/prepend/insert the "steamlink" element into the DOM tree.
addSteamLinkFunc = (item, closest, steam_link) => {},
}) {
for (const root of document.querySelectorAll(rootSelector)) {
// console.debug("Analyzing subtree under this root:", root);
for (const item of root.querySelectorAll(itemSelector)) {
// console.debug("Analyzing item:", item);
const data = itemStringExtractor(item);
// console.debug("Item data:", data);
if (!data) {
// No valid data found, ignore this item.
continue;
}
const closest = item.closest(closestSelector);
// console.debug("Closest:", closest);
if (!closest) {
continue;
}
if (closest.classList.contains("bh_already_processed")) {
continue;
}
closest.classList.add("bh_already_processed");
const {app, sub} = parseStringForSteamId(data);
// console.debug("app:", app, "sub:", sub);
if (app || sub) {
closest.classList.remove("bh_owned", "bh_wished", "bh_ignored");
// Figuring out if this app/sub is listed in the profile data.
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)
}
}
}
}
// This function tries to undo the effects of markElements().
// It may not be perfect, but works well enough.
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
// Declaring some global variables here, so their value is preserved across
// multiple calls to processSite().
// There are no visible ids in the DOM.
// Let's use something unique as the key: the cover image filenames.
// The values are the "steam" objects from Fanatical API:
// steam: {
// "type": "app",
// "id": 123456,
// "dlc": [],
// "deck_support": "verified",
// "deck_details": [],
// "packages": [],
// }
const fanatical_cover_map = new Map();
const site_mapping = {
"dailyindiegame.com": function() {
document.body.classList.add("bh_basic_style");
// Applies to bundle pages:
// /site_weeklybundle_1234.html
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);
},
});
// Applies to game pages:
// /site_gamelisting_123456.html
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);
},
});
// Applies to lists of games, with images:
// /site_list_topsellers.html
// /site_list_whattoplay.html
// /site_list_newgames.html
// /site_list_category-action.html
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: "tr:has(> td.XDIGcontent), table#DIG2TableGray",
addSteamLinkFunc: (item, closest, link) => {
item.insertAdjacentElement("afterend", link);
item.parentElement.style.position = "relative";
link.style.position = "absolute";
link.style.bottom = "0";
link.style.right = "0";
},
});
// Applies to lists of games, just text:
// /site_content_marketplace.html
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);
},
});
// Cannot get the right app id from this page:
// /site_content_discountsteamkeys.html
},
"fanatical.com": function() {
document.body.classList.add("bh_basic_style");
// 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() {
// Extracting useful data from the response.
// We extract the cover art filenames and update the fanatical_cover_map.
const p = original_json.apply(this);
p.then((json_data) => {
if (!json_data) {
return;
}
// Example URLs:
// Page: https://www.fanatical.com/en/bundle/batman-arkham-collection
// AJAX: https://www.fanatical.com/api/products-group/batman-arkham-collection/en
// There is usually only one object in this "bundles" array.
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);
}
}
}
// Example URLs:
// 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);
}
}
// Example URLs:
// 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);
}
// Example URLs:
// Page: https://www.fanatical.com/en/search
// AJAX: https://w2m9492ddv-2.algolianet.com/1/indexes/*/queries?…
// There is usually only one object in this "results" array.
// for (const result of json_data.results ?? []) {
// for (const game of result.hits ?? []) {
// // We have game.cover, but there is no game.steam in this API result.
// if (game.cover && game.steam) {
// fanatical_cover_map.set(game.cover, game.steam);
// }
// }
// }
// Example URLs:
// Page: https://www.fanatical.com/en/search
// AJAX: https://www.fanatical.com/api/algolia/megamenu?altRank=false
// But again we don't have any steam object in this API result.
// console.debug("FANATICAL fanatical_cover_map:", fanatical_cover_map);
});
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 "";
}
// console.debug("FANATICAL itemStringExtractor", `/${steam.type}/${steam.id}`, img);
return `/${steam.type}/${steam.id}`;
},
closestSelector: ".bundle-game-card, .bundle-product-card, .card, .HitCard, .header-content-container",
addSteamLinkFunc: (item, closest, link) => {
// console.debug("FANATICAL addSteamLinkFunc", item, closest);
closest.style.position = "relative";
closest.insertAdjacentElement("beforeend", link);
link.style.position = "absolute";
link.style.bottom = "0";
link.style.left = "calc( 50% - var(--bh-steamlink-size) / 2 )";
},
});
}, 2000);
// We don't even try matching the dropdown results from the top bar.
// It's not reliable and doesn't work properly.
},
"groupees.com": function() {
// Not adding it because we need custom styles.
// document.body.classList.add("bh_basic_style");
GM_addStyle(`
/* Removing the moving marquee message at the top of the page. */
.broadcast-message .scroll-left > div {
animation: none;
}
/* Custom styling for this page. */
.product-tile.bh_owned,
.product-tile.bh_wished,
.product-tile.bh_ignored {
outline: 3px solid var(--bh-bgcolor);
}
.product-tile.bh_ignored {
opacity: 0.3;
}
.product-tile.bh_owned .product-tile-wrapper:before,
.product-tile.bh_wished .product-tile-wrapper:before,
.product-tile.bh_ignored .product-tile-wrapper:before {
content: " ";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9;
pointer-events: none;
opacity: 0.5;
background: var(--bh-bgcolor) linear-gradient(135deg, rgba(0, 0, 0, 0.70) 10%, rgba(0, 0, 0, 0) 100%) !important;
}
`);
markElements({
rootSelector: ".bundle-content",
itemSelector: ".external-links a[href*='store.steampowered.com/']",
itemStringExtractor: (a) => a.href,
closestSelector: ".product-tile",
addSteamLinkFunc: (item, closest, link) => {
closest.querySelector(".product-info > p").insertAdjacentElement("afterbegin", link);
},
});
},
"indiegala.com": function() {
document.body.classList.add("bh_basic_style");
// Applies to game pages:
// /store/game/game-name-here/1234567
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);
},
});
// Applies to store list pages:
// /store/category/strategy
GM_addStyle(`
/* Moving the background color from the figcaption to the whole item. */
.main-list-results-item figcaption {
background: transparent;
}
.main-list-results-item-margin {
background: #FFF;
}
/* Adjusting the "Add to cart" button size. */
a.main-list-results-item-add-to-cart {
left: calc( 2 * 10px + var(--bh-steamlink-size) );
width: auto;
right: 10px;
}
`);
debouncedMutationObserverSelectorAll("#ajax-contents-container.main-list-ajax-container", function() {
markElements({
rootSelector: ".results-collections .main-list-results-cont",
itemSelector: ".main-list-results-item 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);
},
});
});
// Applies to bundle pages:
// //bundle/foo-bar-bundle
GM_addStyle(`
/* Moving the background color from the figcaption to the whole item. */
.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.replace(/\/bundle_games\/[0-9]+\/([0-9]+)(_adult)?/, "/app/$1"),
closestSelector: ".bundle-page-tier-item-outer",
addSteamLinkFunc: (item, closest, link) => {
closest.querySelector(".bundle-page-tier-item-platforms").insertAdjacentElement("afterbegin", link);
link.style.position = "relative";
link.style.zIndex = "99";
},
});
// Applies to the top bar, links pointing to game pages.
GM_addStyle(`
/* Fixing colors, because the webdesigner was setting the foreground color without setting the background. */
.header-search .results .results-item a,
.header-search .results .results-item .price .final-color-off {
background: transparent;
color: inherit;
}
`);
debouncedMutationObserverSelectorAll("header", function() {
markElements({
rootSelector: "header",
itemSelector: ".main-list-item a.fit-click",
itemStringExtractor: (a) => a.href.replace(/\/store\/game\/[^\/]+\/([0-9]+)/, "/app/$1"),
closestSelector: ".main-list-item",
addSteamLinkFunc: (item, closest, link) => {
item.insertAdjacentElement("afterend", link);
link.style.position = "absolute";
link.style.top = "0";
link.style.left = "0";
link.style.zIndex = "99";
},
});
markElements({
rootSelector: "#main-search-results",
itemSelector: "a[href*='/store/game/']",
itemStringExtractor: (a) => a.href.replace(/\/store\/game\/[^\/]+\/([0-9]+)/, "/app/$1"),
closestSelector: ".results-item",
addSteamLinkFunc: (item, closest, link) => {
closest.querySelector("div.title").insertAdjacentElement("afterbegin", link);
link.style.float = "left";
},
});
});
},
"reddit.com": function() {
document.body.classList.add("bh_basic_style");
// Basic feature: coloring links from normal text.
// Only works on the old reddit layout.
// Examples:
// https://old.reddit.com/r/GameDeals/
// https://old.reddit.com/r/steamdeals/
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");
// Last 50 Bundled Games page:
// /lastbundled
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",
});
// Deals page:
// /deals
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: ".game_deal_wrapper",
addSteamLinkFunc: (item, closest, link) => {
closest.querySelector(".deal_game_info").insertAdjacentElement("afterbegin", link);
link.style.float = "left";
},
});
},
"steamgifts.com": function() {
document.body.classList.add("bh_basic_style");
GM_addStyle(`
/* Removing insane text-shadow that is invisible, but still applied to the whole page text. */
.page__outer-wrap {
text-shadow: none;
}
`);
// Giveaway lists:
// /giveaways/search
GM_addStyle(`
/* Reordering the header, moving the icons to the left of the game title. */
.giveaway__heading > * {
order: 2;
}
.giveaway__heading > .giveaway__icon {
order: 1;
}
/* Fixing the colors */
.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",
});
// Giveaway wishlist:
// /giveaways/wishlist
GM_addStyle(`
/* Fixing the colors */
.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",
});
// Basic feature: coloring links from normal text.
// https://www.steamgifts.com/discussion/iy081/steamground-wholesale-build-a-bundle-update-16-may
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");
// The steam app id is only available on the pages for each individual game.
// It may be possible to do a bunch of requests and parse each page to
// get the steam id of each linked game… But that's a lot of work, more
// work than I'm willing to do right now. And that's also bad, as it
// will launch too many web requests.
// Applies to each game page:
// /games/foo-bar
// /en/games/foo-bar
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",
});
// Applies to:
// /wholesale
// /en/wholesale
GM_addStyle(`
.wholesale-card_info_about {
display: inline-block;
position: static;
}
`);
// Doesn't work, because the steamground id is different than the steam id.
// markElements({
// rootSelector: ".opt-screen-container",
// itemSelector: ".wholesale-card a[data-product-id]",
// itemStringExtractor: (a) => "/app/" + a.dataset.productId,
// closestSelector: ".wholesale-card",
// addSteamLinkFunc: (item, closest, link) => {
// closest.querySelector(".wholesale-card_info_about").insertAdjacentElement("beforebegin", link);
// },
// });
},
"steamkeys.ovh": function() {
document.body.classList.add("bh_basic_style");
markElements({
rootSelector: "#gmm",
itemSelector: "a[href*='store.steampowered.com/']",
itemStringExtractor: (a) => a.href,
closestSelector: "div.demo",
});
},
};
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();
})();