Greasy Fork 支持简体中文。

Bundle Helper Reborn

Marks owned/ignored/wishlisted games on several sites, and also adds a button to open the steam page for each game.

  1. // ==UserScript==
  2. // @name Bundle Helper Reborn
  3. // @namespace https://denilson.sa.nom.br/
  4. // @version 2.8.1
  5. // @description Marks owned/ignored/wishlisted games on several sites, and also adds a button to open the steam page for each game.
  6. // @match *://astats.astats.nl/*
  7. // @match *://dailyindiegame.com/*
  8. // @match *://groupees.com/*
  9. // @match *://old.reddit.com/*
  10. // @match *://sgtools.info/*
  11. // @match *://steamground.com/*
  12. // @match *://steamkeys.ovh/*
  13. // @match *://www.dailyindiegame.com/*
  14. // @match *://www.fanatical.com/*
  15. // @match *://www.indiegala.com/*
  16. // @match *://www.reddit.com/*
  17. // @match *://www.sgtools.info/*
  18. // @match *://www.steamgifts.com/*
  19. // @match *://www.steamkeys.ovh/*
  20. // @run-at document-end
  21. // @grant GM_addStyle
  22. // @grant GM_xmlhttpRequest
  23. // @grant GM_getValue
  24. // @grant GM_setValue
  25. // @connect store.steampowered.com
  26. // @icon https://store.steampowered.com/favicon.ico
  27. // @license GPL-3.0-only
  28. // ==/UserScript==
  29.  
  30. /*
  31.  
  32. # Bundle Helper Reborn
  33.  
  34. Marks owned/ignored/wishlisted games on several sites, and also adds a button to open the steam page for each game.
  35.  
  36. ## Purpose
  37.  
  38. If you have a Steam account, you are probably also buying games from other
  39. websites.
  40.  
  41. This user-script can help you by highlighting (on other sites) the games you
  42. already have, games you have ignored, and games you have wishlisted (on Steam).
  43.  
  44. It also adds a convenient button (actually, a link) to open the Steam page for
  45. each game on the supported third-party websites.
  46.  
  47. It is complementary to the amazing [AugmentedSteam browser extension](https://augmentedsteam.com/).
  48. While that extension only applies to the Steam website(s), this user-script
  49. applies to third-party websites.
  50.  
  51. It needs the permission to connect to `store.steampowered.com` to get the list
  52. of owned/ignored/wishlisted items for the current logged-in user.
  53.  
  54. ## History
  55.  
  56. This user-script is a fork of ["Bundle Helper" v1.09 by "7-elephant"](https://greasyfork.org/en/scripts/16105-bundle-helper).
  57.  
  58. It was initially based on 7-elephant's code, but has been completely rewritten
  59. for v2.0. Code for obsolete websites was removed. Additional code for
  60. extraneous poorly-documented functionality was also removed. This fork/version
  61. has a clear purpose and sticks to that purpose. It's also supposed to be easier
  62. to add support for more websites, or update the current ones when needed.
  63.  
  64. In order to avoid name clashes, I've decided to name it "Bundle Helper Reborn".
  65.  
  66. This fork also available at:
  67. * https://greasyfork.org/en/scripts/478401-bundle-helper-reborn
  68. * https://gist.github.com/denilsonsa/618ca8a9d04d574a162b10cbd3fce20f
  69.  
  70. * License: [GPL-3.0-only](https://spdx.org/licenses/GPL-3.0-only.html)
  71. * Copyright 2016-2019, 7-elephant
  72. * Copyright 2023, Denilson Sá Maia
  73.  
  74. */
  75.  
  76. (function () {
  77. "use strict";
  78. // jshint multistr:true
  79.  
  80. //////////////////////////////////////////////////
  81. // Convenience functions
  82.  
  83. // Returns the Unix timestamp in seconds (as an integer value).
  84. function getUnixTimestamp() {
  85. return Math.trunc(Date.now() / 1000);
  86. }
  87.  
  88. // Returns a human-readable amount of time.
  89. function humanReadableSecondsAmount(seconds) {
  90. if (!(Number.isFinite(seconds) && seconds >= 0)) {
  91. return "";
  92. }
  93.  
  94. const minutes = seconds / 60;
  95. const hours = minutes / 60;
  96. const days = hours / 24;
  97.  
  98. if (days >= 10 ) return days.toFixed(0) + " days";
  99. if (days >= 1.5) return days.toFixed(1) + " days";
  100. if (hours >= 10 ) return hours.toFixed(0) + " hours";
  101. if (hours >= 1.5) return hours.toFixed(1) + " hours";
  102. if (minutes >= 1) return minutes.toFixed(0) + " minutes";
  103. else return "just now";
  104. }
  105.  
  106. // Returns just the filename (i.e. basename) of a URL.
  107. function filenameFromURL(s) {
  108. if (!s) {
  109. return "";
  110. }
  111.  
  112. let url;
  113. try {
  114. url = new URL(s);
  115. } catch (ex) {
  116. // Invalid URL.
  117. return "";
  118. }
  119.  
  120. return url.pathname.replace(reX`^.*/`, "");
  121. }
  122.  
  123. // Returns a new function that will call the callback without arguments
  124. // after timeout milliseconds of quietness.
  125. function debounce(callback, timeout = 500) {
  126. let id = null;
  127. return function() {
  128. clearTimeout(id);
  129. id = setTimeout(callback, timeout);
  130. };
  131. }
  132.  
  133. const active_mutation_observers = [];
  134.  
  135. // Returns a new MutationObserver that observes a specific node.
  136. // The observer will be immediately active.
  137. function debouncedMutationObserver(rootNode, callback, timeout = 500) {
  138. const func = debounce(callback, timeout);
  139. func();
  140. const observer = new MutationObserver(func);
  141. observer.observe(rootNode, {
  142. subtree: true,
  143. childList: true,
  144. attributes: false,
  145. });
  146. active_mutation_observers.push(observer);
  147. return observer;
  148. }
  149.  
  150. // Adds a MutationObserver to each root node matched by the CSS selector.
  151. function debouncedMutationObserverSelectorAll(rootSelector, callback, timeout = 500) {
  152. for (const root of document.querySelectorAll(rootSelector)) {
  153. debouncedMutationObserver(root, callback, timeout);
  154. }
  155. }
  156.  
  157. function stopAllMutationObservers() {
  158. for (const mo of active_mutation_observers) {
  159. mo.disconnect();
  160. }
  161. active_mutation_observers.length = 0;
  162. }
  163.  
  164. //////////////////////////////////////////////////
  165. // Regular expressions
  166.  
  167. // Emulates the "x" flag for RegExp.
  168. // It's also known as "verbose" flag, as it allows whitespace and comments inside the regex.
  169. // It will probably break if the original string contains "$".
  170. function reX(re_string) {
  171. const raw = re_string.raw[0];
  172. let s = raw;
  173. // Removing comments.
  174. s = s.replace(/(?<!\\)\/\/.*$/gm, "");
  175. // Removing all whitespace.
  176. // Yes, even escaped whitespace.
  177. // Because I'm dealing with URLs, and these don't have any whitespace anyway.
  178. s = s.replace(/[ \t\r\n]+/g, "");
  179. return new RegExp(s);
  180. }
  181. // Same as reX, but ignoring case.
  182. function reXi(re_string) {
  183. return new RegExp(reX(re_string), "i");
  184. }
  185.  
  186. // Example URLs:
  187. // https://store.steampowered.com/app/20/Team_Fortress_Classic/
  188. // https://store.steampowered.com/agecheck/app/976310/
  189. // https://steamcommunity.com/app/20
  190. // https://steamdb.info/app/20/
  191. // https://www.protondb.com/app/20
  192. // https://isthereanydeal.com/steam/app/20/
  193. // https://barter.vg/steam/app/20/
  194. // https://pcgamingwiki.com/api/appid.php?appid=20
  195. //
  196. // Screenshots, images, and user manual URLs:
  197. // https://cdn.akamai.steamstatic.com/steam/apps/20/0000000165.1920x1080.jpg
  198. // https://cdn.akamai.steamstatic.com/steam/apps/440/extras/page_banner_english1.jpg
  199. // https://store.steampowered.com/manual/440
  200. //
  201. // For packages:
  202. // https://store.steampowered.com/sub/237
  203. // https://steamdb.info/sub/237/
  204. //
  205. // Note: bundles are not the same as packages!
  206. // https://store.steampowered.com/bundle/237/HalfLife_1_Anthology/
  207. const re_app = reX`
  208. ( /app/ | /apps/ | appid= )
  209. (?<id>[0-9]+)
  210. \b // Word boundary, the regex will match 123 but not 123abc
  211. `;
  212. const re_sub = reX`
  213. ( /sub/ | /subs/ )
  214. (?<id>[0-9]+)
  215. \b // Word boundary, the regex will match 123 but not 123abc
  216. `;
  217.  
  218. // Parses a string and tries to extract the app id or the sub id.
  219. function parseStringForSteamId(s) {
  220. const match_app = re_app.exec(s);
  221. const match_sub = re_sub.exec(s);
  222.  
  223. // Resetting RegExp persistent state.
  224. // This is just one of those JavaScript quirks.
  225. // Supposedly this is only needed to RegExp objects with the global
  226. // flag, but I'm doing it anyway just to be safe.
  227. // (And just in case in the future we change those regexes to be global.)
  228. re_app.lastIndex = 0;
  229. re_sub.lastIndex = 0;
  230.  
  231. if (match_app && match_sub) {
  232. console.warn("The string matched both app id and sub id. This is likely a mistake.", s, match_app, match_sub);
  233. }
  234.  
  235. return {
  236. app: Number(match_app?.groups.id ?? 0),
  237. sub: Number(match_sub?.groups.id ?? 0),
  238. };
  239. }
  240.  
  241. //////////////////////////////////////////////////
  242. // Steam profile data caching
  243.  
  244. // The cached data.
  245. const cachename_profile_data = "bh_profile_data";
  246. // The timestamp of the cached version.
  247. const cachename_profile_time = "bh_profile_time";
  248. // The maximum age of the cache.
  249. // Cache will be considered after this amount of time.
  250. const cache_max_age_seconds = 60 * 60 * 24; // 24 hours
  251. // For performance, we convert arrays into sets.
  252. let cached_sets = null;
  253.  
  254. // Sets the cached value, while also updating its timestamp.
  255. function setProfileCache(data) {
  256. cached_sets = null;
  257.  
  258. // WARNING: This is modifying the received data object in-place!
  259. // This is usually a bad idea, but it works fine for the purposes of
  260. // this script. And it doesn't add any extra overhead.
  261.  
  262. // Deleting rgCurations because it's massive.
  263. data.rgCurations = {};
  264. // Deleting curator-related data because it's not used in this script.
  265. data.rgCurators = {};
  266. data.rgCuratorsIgnored = [];
  267. // Deleting recommendations because there is little to no value in storing them.
  268. data.rgRecommendedApps = [];
  269. data.rgRecommendedTags = [];
  270.  
  271. GM_setValue(cachename_profile_data, data);
  272. GM_setValue(cachename_profile_time, getUnixTimestamp());
  273. }
  274.  
  275. // Clears the cached data.
  276. // Not sure why we would do it.
  277. function clearProfileCache() {
  278. cached_sets = null;
  279. GM_setValue(cachename_profile_data, {});
  280. GM_setValue(cachename_profile_time, 0);
  281. }
  282.  
  283. // Returns a human-readable string representation of the age.
  284. function getProfileCacheAge() {
  285. const now = getUnixTimestamp();
  286. const cached = GM_getValue(cachename_profile_time, 0);
  287. if (!cached) {
  288. return "";
  289. }
  290. return humanReadableSecondsAmount(now - cached);
  291. }
  292.  
  293. // Returns a boolean.
  294. function isProfileCacheExpired() {
  295. const now = getUnixTimestamp();
  296. const cached = GM_getValue(cachename_profile_time, 0);
  297. return now - cached > cache_max_age_seconds;
  298. }
  299.  
  300. // Returns a promise that resolves to the downloaded data.
  301. function downloadProfileData() {
  302. return new Promise((resolve, reject) => {
  303. function handleError(response) {
  304. console.error(`Error while loading the data: status=${response.status}; statusText=${response.statusText}`);
  305. reject();
  306. // I wish I had a better error-handling routine here.
  307. // But this is good enough for now.
  308. }
  309.  
  310. GM_xmlhttpRequest({
  311. method: "GET",
  312. url: "https://store.steampowered.com/dynamicstore/userdata/?t=" + getUnixTimestamp(),
  313. responseType: "json",
  314. onabort: handleError,
  315. onerror: handleError,
  316. onload: function(response) {
  317. if (response.response) {
  318. resolve(response.response);
  319. } else {
  320. console.error("Null response after loading. Was it a valid JSON?");
  321. reject();
  322. }
  323. },
  324. });
  325.  
  326. // There is also another API that can potentially be useful:
  327. // https://store.steampowered.com/api/appuserdetails/?appids=20,1234,5678
  328. });
  329. }
  330.  
  331. // Downloads and updates the profile cache.
  332. // Returns a promise that resolves after updating it successfully.
  333. function downloadAndUpdateProfileCache() {
  334. return downloadProfileData().then((data) => {
  335. setProfileCache(data);
  336. });
  337. }
  338.  
  339. // Returns a promise that resolves if the cache is fresh, or after updating it.
  340. function updateProfileCacheIfExpired() {
  341. if (isProfileCacheExpired()) {
  342. return downloadAndUpdateProfileCache();
  343. } else {
  344. return Promise.resolve();
  345. }
  346. }
  347.  
  348. // Returns an object with the relevant data as sets.
  349. function getCachedSets() {
  350. if (!cached_sets) {
  351. const data = GM_getValue(cachename_profile_data, {});
  352. cached_sets = {
  353. // Lists of integers being converted to sets.
  354. appsInCart: new Set(data.rgAppsInCart),
  355. // creatorsFollowed: new Set(data.rgCreatorsFollowed),
  356. // creatorsIgnored: new Set(data.rgCreatorsIgnored),
  357. // curatorsIgnored: new Set(data.rgCuratorsIgnored),
  358. // followedApps: new Set(data.rgFollowedApps),
  359. ignoredPackages: new Set(data.rgIgnoredPackages),
  360. ownedApps: new Set(data.rgOwnedApps),
  361. ownedPackages: new Set(data.rgOwnedPackages),
  362. packagesInCart: new Set(data.rgPackagesInCart),
  363. // recommendedApps: new Set(data.rgRecommendedApps),
  364. // secondaryLanguages: new Set(data.rgSecondaryLanguages),
  365. wishlist: new Set(data.rgWishlist),
  366.  
  367. // Ignored apps are a mapping of appids to zero.
  368. ignoredApps: new Set(Object.keys(data.rgIgnoredApps ?? {}).map((key) => Number(key))),
  369.  
  370. // Tags are objects with this data:
  371. // {
  372. // tagid: 1234,
  373. // name: "Foobar",
  374. // timestamp_added: 1672531200, // unix timestamp in seconds, only for rgExcludedTags, not for rgRecommendedTags.
  375. // }
  376. excludedTags: new Set(data.rgExcludedTags?.map((obj) => obj.name)),
  377. // recommendedTags: new Set(data.rgRecommendedTags?.map((obj) => obj.name)),
  378.  
  379. // Available arrays of integers in the profile data:
  380. // rgAppsInCart
  381. // rgCreatorsFollowed
  382. // rgCreatorsIgnored
  383. // rgCuratorsIgnored
  384. // rgFollowedApps
  385. // rgIgnoredPackages // Mostly empty, because there is no UI in steam to ignore a package.
  386. // rgOwnedApps
  387. // rgOwnedPackages
  388. // rgPackagesInCart
  389. // rgRecommendedApps
  390. // rgSecondaryLanguages
  391. // rgWishlist
  392. //
  393. // Available arrays of objects in the profile data:
  394. // rgExcludedTags
  395. // rgRecommendedTags
  396. //
  397. // Available arrays of unknown content in the profile data:
  398. // rgAutoGrantApps
  399. // rgExcludedContentDescriptorIDs
  400. // rgMasterSubApps
  401. // rgPreferredPlatforms
  402. //
  403. // Available objects (maps, associative arrays) in the profile data:
  404. // rgCurations
  405. // rgCurators
  406. // rgIgnoredApps
  407. };
  408. }
  409. return cached_sets;
  410. }
  411.  
  412. //////////////////////////////////////////////////
  413. // Bundle Helper UI
  414.  
  415. // Returns an object.
  416. function createBundleHelperUI() {
  417. const root = document.createElement("bundle-helper");
  418. const shadow = root.attachShadow({
  419. mode: "open",
  420. });
  421.  
  422. shadow.innerHTML = `
  423. <style>
  424. .container {
  425. background: #222;
  426. color: #ddd;
  427. padding: 0.5em;
  428. border-radius: 0 0.5em 0 0;
  429. border: 1px #ddd outset;
  430. border-width: 1px 1px 0 0 ;
  431. font: 12px sans-serif;
  432. }
  433. p {
  434. margin: 0;
  435. }
  436. a {
  437. font: inherit;
  438. color: inherit;
  439. text-decoration: none;
  440. }
  441. a:hover {
  442. color: #fff;
  443. text-decoration: underline;
  444. }
  445. #close {
  446. float: right;
  447. }
  448. </style>
  449. <div class="container">
  450. <p>
  451. Steam profile data <a href="javascript:;" id="refresh">last fetched <output id="age"></output> ago</a>.
  452. </p>
  453. <p>
  454. Owned:
  455. <output id="ownedApps"></output> apps,
  456. <output id="ownedPackages"></output> packages.
  457. </p>
  458. <p>
  459. Ignored:
  460. <output id="ignoredApps"></output> apps,
  461. <output id="ignoredPackages"></output> packages.
  462. </p>
  463. <p>
  464. <a href="javascript:;" id="close">[close]</a>
  465. Wishlisted:
  466. <output id="wishlist"></output> apps.
  467. </p>
  468. </div>
  469. `;
  470.  
  471. function updateUI() {
  472. const age = getProfileCacheAge() || "never";
  473. const sets = getCachedSets();
  474.  
  475. shadow.querySelector("#age").value = age;
  476. shadow.querySelector("#ownedApps").value = sets.ownedApps.size;
  477. shadow.querySelector("#ownedPackages").value = sets.ownedPackages.size;
  478. shadow.querySelector("#ignoredApps").value = sets.ignoredApps.size;
  479. shadow.querySelector("#ignoredPackages").value = sets.ignoredPackages.size;
  480. shadow.querySelector("#wishlist").value = sets.wishlist.size;
  481. }
  482.  
  483. shadow.querySelector("#refresh").addEventListener("click", function(ev) {
  484. ev.preventDefault();
  485. downloadAndUpdateProfileCache().finally(function() {
  486. unmarkAllElements();
  487. stopAllMutationObservers();
  488. updateUI();
  489. processSite();
  490. });
  491. });
  492. shadow.querySelector("#close").addEventListener("click", function(ev) {
  493. ev.preventDefault();
  494. root.remove();
  495. });
  496.  
  497. updateUI()
  498. return {
  499. element: root,
  500. update: updateUI,
  501. };
  502. }
  503.  
  504. // Adds the UI to the page.
  505. // It also triggers a profile data refresh if needed.
  506. function addBundleHelperUI(root) {
  507. if (typeof root == "string") {
  508. root = document.querySelector(root);
  509. }
  510. if (!root) {
  511. root = document.body;
  512. }
  513.  
  514. const UI = createBundleHelperUI();
  515. root.appendChild(UI.element);
  516. updateProfileCacheIfExpired().finally(UI.update);
  517. }
  518.  
  519. function getClassForAppId(id) {
  520. if (!id) return "";
  521. const sets = getCachedSets();
  522. if (sets.ownedApps.has(id) ) return "bh_owned";
  523. if (sets.wishlist.has(id) ) return "bh_wished";
  524. if (sets.ignoredApps.has(id)) return "bh_ignored";
  525. return "";
  526. }
  527. function getClassForSubId(id) {
  528. if (!id) return "";
  529. const sets = getCachedSets();
  530. if (sets.ownedPackages.has(id) ) return "bh_owned";
  531. if (sets.ignoredPackages.has(id)) return "bh_ignored";
  532. return "";
  533. }
  534.  
  535. // Create a new <a> link element to the appropriate Steam URL.
  536. // app_or_sub must be either "app" or "sub".
  537. // id must be the numeric id.
  538. // Returns the Node (HTMLElement).
  539. function createSteamLink(app_or_sub, id) {
  540. const url = `https://store.steampowered.com/${app_or_sub}/${id}`;
  541. // Copied from: https://github.com/edent/SuperTinyIcons/blob/master/images/svg/steam.svg
  542. const svg = `
  543. <svg xmlns="http://www.w3.org/2000/svg" aria-label="Steam" role="img" viewBox="0 0 512 512" fill="#ebebeb">
  544. <path d="m0 0H512V512H0" fill="#231f20"/>
  545. <path d="m183 280 41 28 27 41 87-62-94-96"/>
  546. <circle cx="340" cy="190" r="49"/>
  547. <g fill="none" stroke="#ebebeb">
  548. <circle cx="179" cy="352" r="63" stroke-width="19"/>
  549. <path d="m-18 271 195 81" stroke-width="80" stroke-linecap="round"/>
  550. <circle cx="340" cy="190" r="81" stroke-width="32"/>
  551. </g>
  552. </svg>
  553. `;
  554. const a = document.createElement("a");
  555. a.href = url;
  556. a.innerHTML = svg;
  557. a.className = "bh_steamlink";
  558. a.addEventListener("click", function(ev) {
  559. // Some pages have an onclick handler to the parent element.
  560. // Let's stop the even propagation to avoid that stupid handler.
  561. ev.stopPropagation();
  562. });
  563. return a;
  564. }
  565.  
  566. // The main function that does most of the work on the page DOM.
  567. // This is the function that makes the results visible to the user.
  568. // Receives many parameters:
  569. function markElements({
  570. // CSS selector for the root node(s) of the subtree(s) that will be searched.
  571. // Useful to restrict the search to the main content, skipping unrelated elements.
  572. rootSelector = "body",
  573. // CSS selector matching each individual element (i.e. each game or package).
  574. itemSelector = "a[href*='store.steampowered.com/']",
  575. // JS callback that receives one item (i.e. one Element) and should
  576. // return a string containing the URL or a URL fragment.
  577. // The returned string of this function will be matched against re_app and re_sub.
  578. itemStringExtractor = (a) => a.href,
  579. // CSS selector to be passed to item.closest().
  580. // Assuming this item matched a valid id, this helps navigating upwards in the tree
  581. // until we find the appropriate block/container for the game or package.
  582. // The matched element will receive the bh_owned/bh_wished/bh_ignored CSS class.
  583. closestSelector = "*",
  584. // JS callback that will append/prepend/insert the "steamlink" element into the DOM tree.
  585. addSteamLinkFunc = (item, closest, steam_link) => {},
  586. }) {
  587. // Debugging statistics:
  588. let total_items = 0;
  589. let valid_data_items = 0;
  590. let valid_closest_items = 0;
  591. let skipped_items = 0;
  592. let marked_items = 0;
  593. for (const root of document.querySelectorAll(rootSelector)) {
  594. // console.debug("Analyzing subtree under this root:", root);
  595. for (const item of root.querySelectorAll(itemSelector)) {
  596. // console.debug("Analyzing item:", item);
  597. total_items++;
  598. const data = itemStringExtractor(item);
  599. // console.debug("Item data:", data);
  600. if (!data) {
  601. // No valid data found, ignore this item.
  602. continue;
  603. }
  604. valid_data_items++;
  605. const closest = item.closest(closestSelector);
  606. // console.debug("Closest:", closest);
  607. if (!closest) {
  608. continue;
  609. }
  610. valid_closest_items++;
  611. if (closest.classList.contains("bh_already_processed")) {
  612. skipped_items++;
  613. continue;
  614. }
  615. closest.classList.add("bh_already_processed");
  616.  
  617. const {app, sub} = parseStringForSteamId(data);
  618. // console.debug("app:", app, "sub:", sub);
  619. if (app || sub) {
  620. marked_items++;
  621. closest.classList.remove("bh_owned", "bh_wished", "bh_ignored");
  622. // Figuring out if this app/sub is listed in the profile data.
  623. const cssClass = getClassForAppId(app) || getClassForSubId(sub);
  624. if (cssClass) {
  625. closest.classList.add(cssClass);
  626. }
  627.  
  628. const steam_link = createSteamLink(app ? "app" : "sub", app || sub);
  629. addSteamLinkFunc?.(item, closest, steam_link)
  630. }
  631. }
  632. }
  633.  
  634. console.info(
  635. "markElements(",
  636. "rootSelector=", rootSelector, ",",
  637. "itemSelector=", itemSelector, ",",
  638. "closestSelector=", closestSelector ,"):",
  639. `${total_items} total elements, ${valid_data_items} with valid data, ${valid_closest_items} with valid closest element, ${skipped_items} skipped, {$marked_items}`
  640. );
  641. }
  642.  
  643. // This function tries to undo the effects of markElements().
  644. // It may not be perfect, but works well enough.
  645. function unmarkAllElements() {
  646. const classes = [
  647. "bh_owned", "bh_wished", "bh_ignored", "bh_already_processed",
  648. ];
  649. for (const elem of document.querySelectorAll(classes.map((s) => `.${s}`).join(", "))) {
  650. elem.classList.remove(...classes);
  651. }
  652. for (const elem of document.querySelectorAll(".bh_steamlink")) {
  653. elem.remove();
  654. }
  655. }
  656.  
  657. //////////////////////////////////////////////////
  658. // Site-specific data and code
  659.  
  660. // Declaring some global variables here, so their value is preserved across
  661. // multiple calls to processSite().
  662.  
  663. // There are no visible ids in the DOM.
  664. // Let's use something unique as the key: the cover image filenames.
  665. // The values are the "steam" objects from Fanatical API:
  666. // steam: {
  667. // "type": "app",
  668. // "id": 123456,
  669. // "dlc": [],
  670. // "deck_support": "verified",
  671. // "deck_details": [],
  672. // "packages": [],
  673. // }
  674. const fanatical_cover_map = new Map();
  675.  
  676. const site_mapping = {
  677. "astats.astats.nl": function() {
  678. document.body.classList.add("bh_basic_style");
  679. GM_addStyle(`
  680. /* The website has this style that I have to override:
  681. * table.tablesorter tr:nth-child(2n+1) { background: ... !important; }
  682. */
  683. .bh_basic_style table.tablesorter tbody tr.bh_owned,
  684. .bh_basic_style table.tablesorter tbody tr.bh_wished,
  685. .bh_basic_style table.tablesorter tbody tr.bh_ignored {
  686. background: var(--bh-bgcolor) linear-gradient(135deg, rgba(0, 0, 0, 0.70) 10%, rgba(0, 0, 0, 0) 100%) !important;
  687. }
  688. `);
  689. markElements({
  690. rootSelector: "body",
  691. itemSelector: "td > a > img[alt='Logo']",
  692. itemStringExtractor: (img) => img.src,
  693. closestSelector: "tr",
  694. });
  695.  
  696. // Example URL for this markup:
  697. // https://astats.astats.nl/astats/Steam_Games.php
  698. markElements({
  699. rootSelector: "body",
  700. itemSelector: "td > a > img.teaser[data-src]",
  701. itemStringExtractor: (img) => img.dataset.src,
  702. closestSelector: "td",
  703. });
  704.  
  705. // This doesn't highlight much at all:
  706. // markElements({
  707. // itemSelector: "a[href*='store.steampowered.com/'], a[href*='steamcommunity.com/']",
  708. // itemStringExtractor: (a) => a.href,
  709. // });
  710.  
  711. // This highlights too much:
  712. // markElements({
  713. // itemSelector: "a[href*='AppID=']",
  714. // itemStringExtractor: (a) => a.href.replace(/.*\bAppID=([0-9]+)/, "/app/$1"),
  715. // });
  716. },
  717. "dailyindiegame.com": function() {
  718. document.body.classList.add("bh_basic_style");
  719.  
  720. // Applies to bundle pages:
  721. // /site_weeklybundle_1234.html
  722. markElements({
  723. rootSelector: ".DIG3_14_Gray",
  724. itemSelector: "td.DIG3_14_Orange a[href*='store.steampowered.com/']",
  725. itemStringExtractor: (a) => a.href,
  726. closestSelector: "td",
  727. addSteamLinkFunc: (item, closest, link) => {
  728. item.insertAdjacentElement("beforebegin", link);
  729. },
  730. });
  731. // Applies to game pages:
  732. // /site_gamelisting_123456.html
  733. markElements({
  734. rootSelector: "#DIG2TableGray",
  735. itemSelector: "a[href*='store.steampowered.com/']",
  736. itemStringExtractor: (a) => a.href,
  737. closestSelector: "tr:has(> .XDIGcontent)",
  738. addSteamLinkFunc: (item, closest, link) => {
  739. item.insertAdjacentElement("beforebegin", link);
  740. },
  741. });
  742. // Applies to lists of games, with images:
  743. // /site_list_topsellers.html
  744. // /site_list_whattoplay.html
  745. // /site_list_newgames.html
  746. // /site_list_category-action.html
  747. markElements({
  748. rootSelector: ".DIG-SiteLinksLarge, #DIG2TableGray",
  749. itemSelector: "a[href*='site_gamelisting_']:has(img)",
  750. itemStringExtractor: (a) => a.href.replace(/site_gamelisting_([0-9]+)\.html.*/, "app/$1"),
  751. closestSelector: "tr:has(> td.XDIGcontent), table#DIG2TableGray",
  752. addSteamLinkFunc: (item, closest, link) => {
  753. item.insertAdjacentElement("afterend", link);
  754. item.parentElement.style.position = "relative";
  755. link.style.position = "absolute";
  756. link.style.bottom = "0";
  757. link.style.right = "0";
  758. },
  759. });
  760. // Applies to lists of games, just text:
  761. // /site_content_marketplace.html
  762. markElements({
  763. rootSelector: "#TableKeys",
  764. itemSelector: "a[href*='site_gamelisting_']",
  765. itemStringExtractor: (a) => a.href.replace(/site_gamelisting_([0-9]+)\.html.*/, "app/$1"),
  766. closestSelector: "tr",
  767. addSteamLinkFunc: (item, closest, link) => {
  768. item.insertAdjacentElement("beforebegin", link);
  769. },
  770. });
  771. // Cannot get the right app id from this page:
  772. // /site_content_discountsteamkeys.html
  773. },
  774. "fanatical.com": function() {
  775. document.body.classList.add("bh_basic_style");
  776. GM_addStyle(`
  777. /* Custom styling for this page. */
  778. .bh_steamlink {
  779. position: absolute;
  780. bottom: 0;
  781. left: calc( 50% - var(--bh-steamlink-size) / 2 );
  782. z-index: 99;
  783. }
  784. .ProductHeader.container > .bh_steamlink {
  785. top: 0;
  786. right: 128px;
  787. left: auto;
  788. bottom: auto;
  789. }
  790. `);
  791.  
  792. // Intercepting fetch() requests.
  793. // With help from:
  794. // * https://blog.logrocket.com/intercepting-javascript-fetch-api-requests-responses/
  795. // * https://stackoverflow.com/a/29293383
  796. // Using unsafeWindow to access the page's window object:
  797. // * https://violentmonkey.github.io/api/metadata-block/#inject-into
  798. const original_fetch = unsafeWindow.fetch;
  799. unsafeWindow.fetch = async function(...args) {
  800. let [resource, options] = args;
  801. const response = await original_fetch(resource, options);
  802.  
  803. // Replacing the .json() method.
  804. const original_json = response.json;
  805. if (original_json) {
  806. response.json = function() {
  807. // Extracting useful data from the response.
  808. // We extract the cover art filenames and update the fanatical_cover_map.
  809. const p = original_json.apply(this);
  810. p.then((json_data) => {
  811. if (!json_data) {
  812. return;
  813. }
  814.  
  815. // Example URLs:
  816. // Page: https://www.fanatical.com/en/bundle/batman-arkham-collection
  817. // AJAX: https://www.fanatical.com/api/products-group/batman-arkham-collection/en
  818. // There is usually only one object in this "bundles" array.
  819. for (const bundle of json_data.bundles ?? []) {
  820. for (const game of bundle.games ?? []) {
  821. if (game.cover && game.steam) {
  822. fanatical_cover_map.set(game.cover, game.steam);
  823. }
  824. }
  825. }
  826.  
  827. // Example URLs:
  828. // Page: https://www.fanatical.com/en/pick-and-mix/build-your-own-bento-bundle
  829. // AJAX: https://www.fanatical.com/api/pick-and-mix/build-your-own-bento-bundle/en
  830. for (const game of json_data.products ?? []) {
  831. if (game.cover && game.steam) {
  832. fanatical_cover_map.set(game.cover, game.steam);
  833. }
  834. }
  835.  
  836. // Example URLs:
  837. // Page: https://www.fanatical.com/en/game/the-last-of-us-part-i
  838. // AJAX: https://www.fanatical.com/api/products-group/the-last-of-us-part-i/en
  839. if (json_data.cover && json_data.steam) {
  840. fanatical_cover_map.set(json_data.cover, json_data.steam);
  841. }
  842.  
  843. // Example URLs:
  844. // Page: https://www.fanatical.com/en/search
  845. // AJAX: https://w2m9492ddv-2.algolianet.com/1/indexes/*/queries?…
  846. // There is usually only one object in this "results" array.
  847. // for (const result of json_data.results ?? []) {
  848. // for (const game of result.hits ?? []) {
  849. // // We have game.cover, but there is no game.steam in this API result.
  850. // if (game.cover && game.steam) {
  851. // fanatical_cover_map.set(game.cover, game.steam);
  852. // }
  853. // }
  854. // }
  855.  
  856. // Example URLs:
  857. // Page: https://www.fanatical.com/en/search
  858. // AJAX: https://www.fanatical.com/api/algolia/megamenu?altRank=false
  859. // But again we don't have any steam object in this API result.
  860.  
  861. // console.debug("FANATICAL fanatical_cover_map:", fanatical_cover_map);
  862. });
  863. return p;
  864. }
  865. }
  866. return response;
  867. };
  868.  
  869. // Setting a MutationObserver on the whole document is bad for
  870. // performance, but I can't find any better way, given the website
  871. // rewrites the DOM at will. At least, I'm increasing the debouncing
  872. // time to at least 2 seconds.
  873. debouncedMutationObserverSelectorAll("body", function() {
  874. markElements({
  875. rootSelector: "main",
  876. itemSelector: "img.img-full[srcset]",
  877. itemStringExtractor: (img) => {
  878. const filename = filenameFromURL(img.src);
  879. const steam = fanatical_cover_map.get(filename);
  880. if (!steam) {
  881. return "";
  882. }
  883. // console.debug("FANATICAL itemStringExtractor", `/${steam.type}/${steam.id}`, img);
  884. return `/${steam.type}/${steam.id}`;
  885. },
  886. closestSelector: ".bundle-game-card, .bundle-product-card, .card, .HitCard, .header-content-container, .NewPickAndMixCard, .PickAndMixCard, .ProductHeader.container",
  887. addSteamLinkFunc: (item, closest, link) => {
  888. // console.debug("FANATICAL addSteamLinkFunc", item, closest);
  889. closest.style.position = "relative";
  890. closest.insertAdjacentElement("beforeend", link);
  891. },
  892. });
  893. }, 2000);
  894.  
  895. // We don't even try matching the dropdown results from the top bar.
  896. // It's not reliable and doesn't work properly.
  897. },
  898. "groupees.com": function() {
  899. // Not adding it because we need custom styles.
  900. // document.body.classList.add("bh_basic_style");
  901.  
  902. GM_addStyle(`
  903. /* Removing the moving marquee message at the top of the page. */
  904. .broadcast-message .scroll-left > div {
  905. animation: none;
  906. }
  907.  
  908. /* Custom styling for this page. */
  909. .product-tile.bh_owned,
  910. .product-tile.bh_wished,
  911. .product-tile.bh_ignored {
  912. outline: 3px solid var(--bh-bgcolor);
  913. }
  914. .product-tile.bh_ignored {
  915. opacity: 0.3;
  916. }
  917. .product-tile.bh_owned .product-tile-wrapper:before,
  918. .product-tile.bh_wished .product-tile-wrapper:before,
  919. .product-tile.bh_ignored .product-tile-wrapper:before {
  920. content: " ";
  921. position: absolute;
  922. top: 0;
  923. left: 0;
  924. right: 0;
  925. bottom: 0;
  926. z-index: 9;
  927. pointer-events: none;
  928. opacity: 0.5;
  929. background: var(--bh-bgcolor) linear-gradient(135deg, rgba(0, 0, 0, 0.70) 10%, rgba(0, 0, 0, 0) 100%) !important;
  930. }
  931. `);
  932. markElements({
  933. rootSelector: ".bundle-content",
  934. itemSelector: ".external-links a[href*='store.steampowered.com/']",
  935. itemStringExtractor: (a) => a.href,
  936. closestSelector: ".product-tile",
  937. addSteamLinkFunc: (item, closest, link) => {
  938. closest.querySelector(".product-info > p").insertAdjacentElement("afterbegin", link);
  939. },
  940. });
  941. },
  942. "indiegala.com": function() {
  943. document.body.classList.add("bh_basic_style");
  944.  
  945. // Applies to game pages:
  946. // /store/game/game-name-here/1234567
  947. markElements({
  948. rootSelector: ".store-product-main-container.product-main-container .product",
  949. itemSelector: "a[data-prod-id]",
  950. itemStringExtractor: (a) => "/app/" + a.dataset.prodId,
  951. closestSelector: "figcaption",
  952. addSteamLinkFunc: (item, closest, link) => {
  953. closest.insertAdjacentElement("afterbegin", link);
  954. },
  955. });
  956.  
  957. // Applies to store list pages:
  958. // /store/category/strategy
  959. GM_addStyle(`
  960. /* Moving the background color from the figcaption to the whole item. */
  961. .main-list-results-item figcaption {
  962. background: transparent;
  963. }
  964. .main-list-results-item-margin {
  965. background: #FFF;
  966. }
  967. /* Adjusting the "Add to cart" button size. */
  968. a.main-list-results-item-add-to-cart {
  969. left: calc( 2 * 10px + var(--bh-steamlink-size) );
  970. width: auto;
  971. right: 10px;
  972. }
  973. `);
  974. debouncedMutationObserverSelectorAll("#ajax-contents-container.main-list-ajax-container", function() {
  975. markElements({
  976. rootSelector: ".results-collections .main-list-results-cont",
  977. itemSelector: ".main-list-results-item a[data-prod-id]",
  978. itemStringExtractor: (a) => "/app/" + a.dataset.prodId,
  979. closestSelector: ".main-list-results-item-margin",
  980. addSteamLinkFunc: (item, closest, link) => {
  981. closest.querySelector("div.flex").insertAdjacentElement("afterbegin", link);
  982. },
  983. });
  984. });
  985.  
  986. // Applies to bundle pages:
  987. // //bundle/foo-bar-bundle
  988. GM_addStyle(`
  989. /* Moving the background color from the figcaption to the whole item. */
  990. .bundle-page-tier-item-outer figcaption {
  991. background: transparent;
  992. }
  993. .bundle-page-tier-item-outer {
  994. background: #FFF;
  995. }
  996. `);
  997. markElements({
  998. rootSelector: ".bundle-page-tier-games",
  999. itemSelector: "img.img-fit",
  1000. itemStringExtractor: (img) => img.src.replace(/\/bundle_games\/[0-9]+\/([0-9]+)(_adult)?/, "/app/$1"),
  1001. closestSelector: ".bundle-page-tier-item-outer",
  1002. addSteamLinkFunc: (item, closest, link) => {
  1003. closest.querySelector(".bundle-page-tier-item-platforms").insertAdjacentElement("afterbegin", link);
  1004. link.style.position = "relative";
  1005. link.style.zIndex = "99";
  1006. },
  1007. });
  1008.  
  1009. // Applies to the top bar, links pointing to game pages.
  1010. GM_addStyle(`
  1011. /* Fixing colors, because the webdesigner was setting the foreground color without setting the background. */
  1012. .header-search .results .results-item a,
  1013. .header-search .results .results-item .price .final-color-off {
  1014. background: transparent;
  1015. color: inherit;
  1016. }
  1017. `);
  1018. debouncedMutationObserverSelectorAll("header", function() {
  1019. markElements({
  1020. rootSelector: "header",
  1021. itemSelector: ".main-list-item a.fit-click",
  1022. itemStringExtractor: (a) => a.href.replace(/\/store\/game\/[^\/]+\/([0-9]+)/, "/app/$1"),
  1023. closestSelector: ".main-list-item",
  1024. addSteamLinkFunc: (item, closest, link) => {
  1025. item.insertAdjacentElement("afterend", link);
  1026. link.style.position = "absolute";
  1027. link.style.top = "0";
  1028. link.style.left = "0";
  1029. link.style.zIndex = "99";
  1030. },
  1031. });
  1032. markElements({
  1033. rootSelector: "#main-search-results",
  1034. itemSelector: "a[href*='/store/game/']",
  1035. itemStringExtractor: (a) => a.href.replace(/\/store\/game\/[^\/]+\/([0-9]+)/, "/app/$1"),
  1036. closestSelector: ".results-item",
  1037. addSteamLinkFunc: (item, closest, link) => {
  1038. closest.querySelector("div.title").insertAdjacentElement("afterbegin", link);
  1039. link.style.float = "left";
  1040. },
  1041. });
  1042. });
  1043. },
  1044. "reddit.com": function() {
  1045. document.body.classList.add("bh_basic_style");
  1046.  
  1047. // Basic feature: coloring links from normal text.
  1048. // Only works on the old reddit layout.
  1049. // Examples:
  1050. // https://old.reddit.com/r/GameDeals/
  1051. // https://old.reddit.com/r/steamdeals/
  1052. debouncedMutationObserverSelectorAll(".content", function() {
  1053. markElements({
  1054. itemSelector: "a[href*='store.steampowered.com/'], a[href*='steamcommunity.com/']",
  1055. itemStringExtractor: (a) => a.href,
  1056. });
  1057. });
  1058. },
  1059. "sgtools.info": function() {
  1060. document.body.classList.add("bh_basic_style");
  1061.  
  1062. // Last 50 Bundled Games page:
  1063. // /lastbundled
  1064. GM_addStyle(`
  1065. .bh_owned a,
  1066. .bh_wished a,
  1067. .bh_ignored a {
  1068. color: inherit;
  1069. }
  1070. `);
  1071. markElements({
  1072. rootSelector: "#content",
  1073. itemSelector: "table a[href*='store.steampowered.com/']",
  1074. itemStringExtractor: (a) => a.href,
  1075. closestSelector: "tr",
  1076. });
  1077.  
  1078. // Deals page:
  1079. // /deals
  1080. GM_addStyle(`
  1081. .bh_owned h2,
  1082. .bh_wished h2,
  1083. .bh_ignored h2,
  1084. .bh_owned h3,
  1085. .bh_wished h3,
  1086. .bh_ignored h3 {
  1087. color: inherit;
  1088. }
  1089. `);
  1090. markElements({
  1091. rootSelector: "#deals",
  1092. itemSelector: ".deal_game_image > img[src*='/steam/']",
  1093. itemStringExtractor: (img) => img.src,
  1094. closestSelector: ".game_deal_wrapper",
  1095. addSteamLinkFunc: (item, closest, link) => {
  1096. closest.querySelector(".deal_game_info").insertAdjacentElement("afterbegin", link);
  1097. link.style.float = "left";
  1098. },
  1099. });
  1100. },
  1101. "steamgifts.com": function() {
  1102. document.body.classList.add("bh_basic_style");
  1103.  
  1104. GM_addStyle(`
  1105. /* Removing insane text-shadow that is invisible, but still applied to the whole page text. */
  1106. .page__outer-wrap {
  1107. text-shadow: none;
  1108. }
  1109. `);
  1110.  
  1111. // Giveaway lists:
  1112. // /giveaways/search
  1113. GM_addStyle(`
  1114. /* Reordering the header, moving the icons to the left of the game title. */
  1115. .giveaway__heading > * {
  1116. order: 2;
  1117. }
  1118. .giveaway__heading > .giveaway__icon {
  1119. order: 1;
  1120. }
  1121. /* Fixing the colors */
  1122. .bh_owned .giveaway__summary .giveaway__heading > *,
  1123. .bh_wished .giveaway__summary .giveaway__heading > *,
  1124. .bh_ignored .giveaway__summary .giveaway__heading > *,
  1125. .bh_owned .giveaway__summary .giveaway__columns > *,
  1126. .bh_wished .giveaway__summary .giveaway__columns > *,
  1127. .bh_ignored .giveaway__summary .giveaway__columns > * {
  1128. color: inherit;
  1129. }
  1130. `);
  1131. markElements({
  1132. rootSelector: ".page__inner-wrap",
  1133. itemSelector: "a.giveaway_image_thumbnail[style]",
  1134. itemStringExtractor: (a) => a.style.backgroundImage,
  1135. closestSelector: ".giveaway__row-inner-wrap",
  1136. });
  1137.  
  1138. // Giveaway wishlist:
  1139. // /giveaways/wishlist
  1140. GM_addStyle(`
  1141. /* Fixing the colors */
  1142. .bh_owned .table__column__heading,
  1143. .bh_wished .table__column__heading,
  1144. .bh_ignored .table__column__heading {
  1145. color: inherit;
  1146. }
  1147. `);
  1148. markElements({
  1149. rootSelector: ".table",
  1150. itemSelector: "a[href*='store.steampowered.com/']",
  1151. itemStringExtractor: (a) => a.href,
  1152. closestSelector: ".table__row-outer-wrap",
  1153. });
  1154.  
  1155. // Basic feature: coloring links from normal text.
  1156. // https://www.steamgifts.com/discussion/iy081/steamground-wholesale-build-a-bundle-update-16-may
  1157. markElements({
  1158. itemSelector: "a[href*='store.steampowered.com/'], a[href*='steamcommunity.com/']",
  1159. itemStringExtractor: (a) => a.href,
  1160. });
  1161.  
  1162. },
  1163. "steamground.com": function() {
  1164. document.body.classList.add("bh_basic_style");
  1165.  
  1166. // The steam app id is only available on the pages for each individual game.
  1167. // It may be possible to do a bunch of requests and parse each page to
  1168. // get the steam id of each linked game… But that's a lot of work, more
  1169. // work than I'm willing to do right now. And that's also bad, as it
  1170. // will launch too many web requests.
  1171.  
  1172. // Applies to each game page:
  1173. // /games/foo-bar
  1174. // /en/games/foo-bar
  1175. GM_addStyle(`
  1176. .bh_owned .inner__slider,
  1177. .bh_wished .inner__slider,
  1178. .bh_ignored .inner__slider {
  1179. background-color: transparent;
  1180. }
  1181. `);
  1182. markElements({
  1183. rootSelector: ".content_inner",
  1184. itemSelector: "a[href*='store.steampowered.com/']",
  1185. itemStringExtractor: (a) => a.href,
  1186. closestSelector: ".content_inner",
  1187. });
  1188.  
  1189. // Applies to:
  1190. // /wholesale
  1191. // /en/wholesale
  1192. GM_addStyle(`
  1193. .wholesale-card_info_about {
  1194. display: inline-block;
  1195. position: static;
  1196. }
  1197. `);
  1198. // Doesn't work, because the steamground id is different than the steam id.
  1199. // markElements({
  1200. // rootSelector: ".opt-screen-container",
  1201. // itemSelector: ".wholesale-card a[data-product-id]",
  1202. // itemStringExtractor: (a) => "/app/" + a.dataset.productId,
  1203. // closestSelector: ".wholesale-card",
  1204. // addSteamLinkFunc: (item, closest, link) => {
  1205. // closest.querySelector(".wholesale-card_info_about").insertAdjacentElement("beforebegin", link);
  1206. // },
  1207. // });
  1208. },
  1209. "steamkeys.ovh": function() {
  1210. document.body.classList.add("bh_basic_style");
  1211.  
  1212. markElements({
  1213. rootSelector: "#gmm",
  1214. itemSelector: "a[href*='store.steampowered.com/']",
  1215. itemStringExtractor: (a) => a.href,
  1216. closestSelector: "div.demo",
  1217. });
  1218. },
  1219. };
  1220.  
  1221. function processSite() {
  1222. let hostname = document.location.hostname;
  1223. // Removing the www. prefix, if present.
  1224. hostname = hostname.replace(/^www\./, "");
  1225. // Calling the site-specific code, if found.
  1226. site_mapping[hostname]?.();
  1227. }
  1228.  
  1229. function main()
  1230. {
  1231.  
  1232. GM_addStyle(`
  1233. bundle-helper {
  1234. position: fixed;
  1235. bottom: 0;
  1236. left: 0;
  1237. z-index: 99;
  1238. }
  1239.  
  1240. /* Background colors and background gradient copied from Enhanced Steam browser extension */
  1241. body {
  1242. --bh-bgcolor-owned: #00CE67;
  1243. --bh-bgcolor-wished: #0491BF;
  1244. --bh-bgcolor-ignored: #4F4F4F;
  1245. --bh-fgcolor-owned: #FFFFFF;
  1246. --bh-fgcolor-wished: #FFFFFF;
  1247. --bh-fgcolor-ignored: #FFFFFF;
  1248. --bh-steamlink-size: 24px;
  1249. }
  1250. .bh_owned {
  1251. --bh-bgcolor: var(--bh-bgcolor-owned);
  1252. --bh-fgcolor: var(--bh-fgcolor-owned);
  1253. }
  1254. .bh_wished {
  1255. --bh-bgcolor: var(--bh-bgcolor-wished);
  1256. --bh-fgcolor: var(--bh-fgcolor-wished);
  1257. }
  1258. .bh_ignored {
  1259. --bh-bgcolor: var(--bh-bgcolor-ignored);
  1260. --bh-fgcolor: var(--bh-fgcolor-ignored);
  1261. }
  1262. .bh_basic_style .bh_owned,
  1263. .bh_basic_style .bh_wished,
  1264. .bh_basic_style .bh_ignored {
  1265. background: var(--bh-bgcolor) linear-gradient(135deg, rgba(0, 0, 0, 0.70) 10%, rgba(0, 0, 0, 0) 100%) !important;
  1266. color: var(--bh-fgcolor) !important;
  1267. }
  1268. .bh_basic_style .bh_ignored {
  1269. opacity: 0.3;
  1270. }
  1271.  
  1272. .bh_steamlink svg {
  1273. width: var(--bh-steamlink-size);
  1274. height: var(--bh-steamlink-size);
  1275. }
  1276. `);
  1277.  
  1278. // Adding some statistics to the corner of the screen.
  1279. addBundleHelperUI();
  1280.  
  1281. // Run site-specific code.
  1282. processSite();
  1283. }
  1284.  
  1285. main();
  1286.  
  1287. })();