- // ==UserScript==
- // @name Bundle Helper Reborn
- // @namespace https://denilson.sa.nom.br/
- // @version 2.8.1
- // @description Marks owned/ignored/wishlisted games on several sites, and also adds a button to open the steam page for each game.
- // @match *://astats.astats.nl/*
- // @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
-
- 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 = [];
-
- // 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;
- }
- #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(function() {
- unmarkAllElements();
- stopAllMutationObservers();
- updateUI();
- processSite();
- });
- });
- shadow.querySelector("#close").addEventListener("click", function(ev) {
- ev.preventDefault();
- root.remove();
- });
-
- 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) => {},
- }) {
- // Debugging statistics:
- 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)) {
- // console.debug("Analyzing subtree under this root:", root);
- for (const item of root.querySelectorAll(itemSelector)) {
- // console.debug("Analyzing item:", item);
- total_items++;
- const data = itemStringExtractor(item);
- // console.debug("Item data:", data);
- if (!data) {
- // No valid data found, ignore this item.
- continue;
- }
- valid_data_items++;
- const closest = item.closest(closestSelector);
- // console.debug("Closest:", closest);
- 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);
- // console.debug("app:", app, "sub:", sub);
- if (app || sub) {
- marked_items++;
- 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)
- }
- }
- }
-
- 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}`
- );
- }
-
- // 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 = {
- "astats.astats.nl": function() {
- document.body.classList.add("bh_basic_style");
- GM_addStyle(`
- /* The website has this style that I have to override:
- * table.tablesorter tr:nth-child(2n+1) { background: ... !important; }
- */
- .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;
- }
- `);
- markElements({
- rootSelector: "body",
- itemSelector: "td > a > img[alt='Logo']",
- itemStringExtractor: (img) => img.src,
- closestSelector: "tr",
- });
-
- // Example URL for this markup:
- // https://astats.astats.nl/astats/Steam_Games.php
- markElements({
- rootSelector: "body",
- itemSelector: "td > a > img.teaser[data-src]",
- itemStringExtractor: (img) => img.dataset.src,
- closestSelector: "td",
- });
-
- // This doesn't highlight much at all:
- // markElements({
- // itemSelector: "a[href*='store.steampowered.com/'], a[href*='steamcommunity.com/']",
- // itemStringExtractor: (a) => a.href,
- // });
-
- // This highlights too much:
- // markElements({
- // itemSelector: "a[href*='AppID=']",
- // itemStringExtractor: (a) => a.href.replace(/.*\bAppID=([0-9]+)/, "/app/$1"),
- // });
- },
- "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");
- GM_addStyle(`
- /* Custom styling for this page. */
- .bh_steamlink {
- position: absolute;
- bottom: 0;
- left: calc( 50% - var(--bh-steamlink-size) / 2 );
- z-index: 99;
- }
- .ProductHeader.container > .bh_steamlink {
- top: 0;
- right: 128px;
- left: auto;
- 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() {
- // 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, .NewPickAndMixCard, .PickAndMixCard, .ProductHeader.container",
- addSteamLinkFunc: (item, closest, link) => {
- // console.debug("FANATICAL addSteamLinkFunc", item, closest);
- 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.
- },
- "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();
-
- })();