// ==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 // @downloadURL none // ==/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(/(?[0-9]+) \b // Word boundary, the regex will match 123 but not 123abc `; const re_sub = reX` ( /sub/ | /subs/ ) (?[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 = `

Steam profile data last fetched ago.

Owned: apps, packages.

Ignored: apps, packages.

Wishlisted: apps.

`; 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 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 = ` `; 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(); })();