GOG.com - Sale Helper

A simple helper to make GOG.com's grid-view catalogue listings more scannable during a sale

  1. // ==UserScript==
  2. // @name GOG.com - Sale Helper
  3. // @version 2
  4. // @namespace ssokolow.com
  5. // @description A simple helper to make GOG.com's grid-view catalogue listings more scannable during a sale
  6. //
  7. // @compatible firefox Tested under Greasemonkey 4 and ViolentMonkey
  8. // @compatible chrome Tested under TamperMonkey
  9. //
  10. // @include https://www.gog.com/games?*
  11. //
  12. // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
  13. // @require https://openuserjs.org/src/libs/sizzle/GM_config.js
  14. //
  15. // @grant GM_registerMenuCommand
  16. // @grant GM.registerMenuCommand
  17. //
  18. // @grant GM_getValue
  19. // @grant GM_setValue
  20. // NOTE: GM_config doesn't currently support GM4 APIs, so allowing the GM4
  21. // versions of these isn't helpful and could result in users losing
  22. // access to their settings if GM_config suddenly starts supporting
  23. // them without transparently migrating data from its localStorage
  24. // fallback.
  25. // ==/UserScript==
  26. /* jshint esversion: 6 */
  27.  
  28. var fieldDefs = {
  29. 'discount_min': {
  30. 'section': 'Minimum Discount Percentage',
  31. 'label': 'Minimum',
  32. 'labelPos': 'left',
  33. 'type': 'number',
  34. 'min': 0,
  35. 'max': 99,
  36. 'default': 50,
  37. 'tail': '%',
  38. },
  39. 'discount_preferred': {
  40. 'label': 'Preferred',
  41. 'type': 'number',
  42. 'min': 0,
  43. 'max': 99,
  44. 'default': 75,
  45. 'tail': '%',
  46. },
  47. 'discount_preferred_fgcolor': {
  48. 'label': 'Foreground',
  49. 'type': 'color',
  50. 'default': '#ffffff',
  51. },
  52. 'discount_preferred_bgcolor': {
  53. 'label': 'Background',
  54. 'type': 'color',
  55. 'default': '#00aa00',
  56. },
  57. 'discount_rare': {
  58. 'label': 'Rare Bargain',
  59. 'type': 'number',
  60. 'min': 0,
  61. 'max': 99,
  62. 'default': 76,
  63. 'tail': '%',
  64. },
  65. 'discount_rare_fgcolor': {
  66. 'label': 'Foreground',
  67. 'type': 'color',
  68. 'default': '#ffffff',
  69. },
  70. 'discount_rare_bgcolor': {
  71. 'label': 'Background',
  72. 'type': 'color',
  73. 'default': '#ff0000',
  74. },
  75. 'price_max': {
  76. 'section': 'Maximum Price',
  77. 'label': 'Maximum',
  78. 'labelPos': 'left',
  79. 'type': 'number',
  80. 'min': '0',
  81. 'step': '0.01',
  82. 'default': '15.00',
  83. },
  84. 'price_low': {
  85. 'label': 'Preferred',
  86. 'type': 'number',
  87. 'min': '0',
  88. 'step': '0.01',
  89. 'default': '5.00',
  90. },
  91. 'price_low_fgcolor': {
  92. 'label': 'Foreground',
  93. 'type': 'color',
  94. 'default': '#ffffff',
  95. },
  96. 'price_low_bgcolor': {
  97. 'label': 'Background',
  98. 'type': 'color',
  99. 'default': '#00aa00',
  100. },
  101. 'price_impulse': {
  102. 'label': 'Impulse Buys',
  103. 'type': 'number',
  104. 'min': '0',
  105. 'step': '0.01',
  106. 'default': '2.00',
  107. },
  108. 'price_impulse_fgcolor': {
  109. 'label': 'Foreground',
  110. 'type': 'color',
  111. 'default': '#ffffff',
  112. },
  113. 'price_impulse_bgcolor': {
  114. 'label': 'Background',
  115. 'type': 'color',
  116. 'default': '#ff0000',
  117. },
  118. 'hide_owned': {
  119. 'section': 'Other Filters',
  120. 'label': 'Hide entries marked "In Library"',
  121. 'labelPos': 'left',
  122. 'type': 'checkbox',
  123. 'default': true,
  124. },
  125. 'hide_title_regex': {
  126.  
  127. 'label': 'Hide entries with titles matching:',
  128. 'labelPos': 'left',
  129. 'type': 'text',
  130. 'size': 35,
  131. 'default': "(Upgrade|[ ]OST|Soundtrack|Artbook)$",
  132. },
  133. };
  134.  
  135. function remove_tile(mutation_event) {
  136. mutation_event.target.closest(".product-tile").remove();
  137. }
  138.  
  139. let observer = new MutationObserver(function (mutations) {
  140. mutations.forEach(function (mut) {
  141. if (mut.type == "attributes" && mut.attributeName == "class" &&
  142. mut.target.classList.contains("product-tile__labels--in-library")) {
  143. // Hide entries that are already owned
  144. remove_tile(mut);
  145.  
  146. } else if (mut.type == "childList" && mut.target.classList.contains("product-tile__discount")) {
  147. // Hide entries that are below the minimum discount
  148. // and highlight ones with abnormally high discounts
  149. let discount = mut.target.innerText;
  150. let discount_parsed = Number.parseInt(discount.substring(1, discount.length - 1));
  151. if (discount_parsed < GM_config.get('discount_min')) {
  152. remove_tile(mut);
  153. } else if (discount_parsed >= GM_config.get('discount_rare')) {
  154. mut.target.style.color = GM_config.get('discount_rare_fgcolor');
  155. mut.target.style.backgroundColor = GM_config.get('discount_rare_bgcolor');
  156. } else if (discount_parsed >= GM_config.get('discount_preferred')) {
  157. mut.target.style.color = GM_config.get('discount_preferred_fgcolor');
  158. mut.target.style.backgroundColor = GM_config.get('discount_preferred_bgcolor');
  159. }
  160. } else if (mut.type == "childList" && mut.target.classList.contains("product-tile__prices")) {
  161. // Hide entries that are over the maximum price
  162. // and highlight ones at or below the preferred price
  163. let discounted = mut.target.querySelector(".product-tile__price-discounted");
  164. let price = Number.parseFloat(discounted.innerText);
  165.  
  166. if (price > GM_config.get('price_max')) {
  167. remove_tile(mut);
  168. } else if (price <= GM_config.get('price_impulse')) {
  169. discounted.style.color = GM_config.get('price_impulse_fgcolor');
  170. discounted.style.backgroundColor = GM_config.get('price_impulse_bgcolor');
  171. } else if (price <= GM_config.get('price_low')) {
  172. discounted.style.color = GM_config.get('price_low_fgcolor');
  173. discounted.style.backgroundColor = GM_config.get('price_low_bgcolor');
  174. }
  175.  
  176. discounted.style.padding = '2px';
  177. discounted.style.borderRadius = '2px';
  178.  
  179. } else if (mut.type == "childList" && mut.target.classList.contains("product-tile__title")) {
  180. let regex_str = GM_config.get('hide_title_regex');
  181. if (regex_str && regex_str.trim() && RegExp(regex_str).test(mut.target.innerText)) {
  182. remove_tile(mut);
  183. }
  184. }
  185.  
  186. });
  187. });
  188.  
  189. let frame = document.createElement('div');
  190. document.body.append(frame);
  191.  
  192. GM_config.init({
  193. id: 'sale_filter_GM_config',
  194. title: "Sale Helper Settings",
  195. fields: fieldDefs,
  196. css: ('#sale_filter_GM_config ' + [
  197. // Match GOG.com styling more closely
  198. "{ box-shadow: 0 0 15px rgba(0,0,0,.15),0 1px 3px rgba(0,0,0,.15); " +
  199. " background: #ccc; color: #212121; border: 0 !important; " +
  200. " height: auto !important; width: auto !important; margin: auto; padding: 1ex !important; }",
  201. " * { font-family: Lato GOG,Lato GOG Latin,sans-serif; }",
  202. ".config_header, .section_header { font-weight: 700; margin: 1ex 0 -1ex; }",
  203. ".section_header { color: #212121; background-color: inherit; border-width: 0 0 1px 0; " +
  204. " font-size: 16px; border-bottom: 1px solid #bfbfbf; " +
  205. " margin: 1em 1em 1ex 1em; text-align: left !important; " +
  206. " clear: both; }",
  207. " .title_underline { display: inline-block; border-bottom: 1px solid #212121; padding: 5px 0; }",
  208. ".field_label { display: inline-block; min-width: 9em; text-align: right; }",
  209. ".field_label, .field_tail { font-size: 12px; }",
  210. ".field_tail { margin-left: 0.5ex; font-weight: 700; }",
  211. ".config_var { float: left; margin-right: 1ex; }",
  212. ".reset { margin-right: 12px; }",
  213. "button, input, .field_tail { vertical-align: middle; }",
  214. "input[type='checkbox'] { margin: 0 0.5ex 0 2.5ex; }",
  215. "input[type='number'] { width: 5em; }",
  216. "input[type='color'] { box-sizing: content-box; height: 1.1em; width: 2em; }",
  217. "#sale_filter_GM_config_field_discount_min, #sale_filter_GM_config_field_discount_preferred, " +
  218. " #sale_filter_GM_config_field_discount_rare { width: 4em !important; }",
  219. "#sale_filter_GM_config_buttons_holder { padding-top: 1ex; }",
  220. "#sale_filter_GM_config_hide_title_regex_var, #sale_filter_GM_config_hide_owned_var " +
  221. " { margin-left: 3ex !important; }",
  222. "#sale_filter_GM_config_field_hide_owned { margin-left: 1ex; }",
  223. "#sale_filter_GM_config_discount_min_var, #sale_filter_GM_config_discount_preferred_var, " +
  224. " #sale_filter_GM_config_discount_rare_var, #sale_filter_GM_config_price_impulse_var, " +
  225. " #sale_filter_GM_config_price_low_var, #sale_filter_GM_config_price_max_var, " +
  226. " #sale_filter_GM_config_hide_title_regex_var, #sale_filter_GM_config_buttons_holder " +
  227. " { clear: left; }",
  228. ].join('\n#sale_filter_GM_config ')),
  229. events: {
  230. open: function(doc) {
  231. this.frame.querySelectorAll('.section_header').forEach(function(node) {
  232. let inner = document.createElement('div');
  233. inner.classList.add("title_underline");
  234.  
  235. inner.innerText = node.innerText;
  236. node.firstChild.replaceWith(inner);
  237. });
  238. },
  239. },
  240. types: {
  241. 'color': {
  242. default: '#ffffff',
  243. toNode: function(configId) {
  244. var field = this.settings,
  245. id = this.id,
  246. value = this.value,
  247. create = this.create,
  248. retNode = create('div', { className: 'config_var',
  249. id: configId + '_' + id + '_var',
  250. title: field.title || '' });
  251.  
  252. // Create the field lable
  253. retNode.appendChild(create('label', {
  254. innerHTML: field.label,
  255. id: configId + '_' + id + '_field_label',
  256. for: configId + '_field_' + id,
  257. className: 'field_label'
  258. }));
  259.  
  260. // Actually create and append the input element
  261. retNode.appendChild(create('input', {
  262. id: configId + '_field_' + id,
  263. type: 'color',
  264. value: value ? value : this['default'],
  265. }));
  266.  
  267. return retNode;
  268. },
  269. toValue: function() {
  270. if (this.wrapper) {
  271. return this.wrapper.getElementsByTagName('input')[0].value;
  272. }
  273. },
  274. reset: function() {
  275. if (this.wrapper) {
  276. this.wrapper.getElementsByTagName('input')[0].value = this['default'];
  277. }
  278. }
  279. },
  280. 'number': {
  281. default: '0',
  282. toNode: function() {
  283. console.log(this);
  284. var field = this.settings,
  285. id = this.id,
  286. value = this.value,
  287. create = this.create,
  288. configId = this.configId,
  289. retNode = create('div', { className: 'config_var',
  290. id: configId + '_' + id + '_var',
  291. title: field.title || '' });
  292.  
  293. // Create the field label
  294. retNode.appendChild(create('label', {
  295. innerHTML: field.label,
  296. id: configId + '_' + id + '_field_label',
  297. for: configId + '_field_' + id,
  298. className: 'field_label'
  299. }));
  300.  
  301. let params = {
  302. id: configId + '_field_' + id,
  303. type: 'number',
  304. value: value ? value : this['default'],
  305. };
  306. if (field.min || field.min === 0) { params.min = field.min; }
  307. if (field.max || field.max === 0) { params.max = field.max; }
  308. if (field.step) { params.step = field.step; }
  309.  
  310. // Actually create and append the input element
  311. retNode.appendChild(create('input', params));
  312.  
  313. if (field.tail) {
  314. retNode.appendChild(create('span', {
  315. innerHTML: field.tail ,
  316. className: 'field_tail',
  317. }));
  318. }
  319. return retNode;
  320. },
  321. toValue: function() {
  322. if (this.wrapper) {
  323. let val = Number(this.wrapper.getElementsByTagName('input')[0].value);
  324. if (isNaN(val)) { return null; }
  325. return val;
  326. }
  327. },
  328. reset: function() {
  329. if (this.wrapper) {
  330. this.wrapper.getElementsByTagName('input')[0].value = this['default'];
  331. }
  332. }
  333. }
  334. },
  335. frame: frame,
  336. });
  337. GM.registerMenuCommand("Configure Sale Helper...",
  338. function() { GM_config.open(); }, 'C');
  339.  
  340.  
  341. let catalog = document.querySelector(".catalog__wrapper");
  342. observer.observe(catalog, { attributes: GM_config.get('hide_owned'), childList: true, subtree: true });