GOG - Price Charts

Fetches price history from GOGDB.org to generate price charts for games on GOG

  1. // ==UserScript==
  2. // @name GOG - Price Charts
  3. // @namespace https://github.com/idkicarus/
  4. // @homepageURL https://github.com/idkicarus/GOG-price-charts
  5. // @supportURL https://github.com/idkicarus/GOG-price-charts/issues
  6. // @match https://www.gog.com/*/game/*
  7. // @description Fetches price history from GOGDB.org to generate price charts for games on GOG
  8. // @version 1.0
  9. // @grant GM.xmlHttpRequest
  10. // @require https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js
  11. // @require https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. /* global Chart */
  16.  
  17. (function() {
  18.  
  19. const DEBUG_MODE = false; // Enable debug mode for logging errors and status messages during script execution.
  20. const CACHE_KEY_PREFIX = "gogdb_price_"; // Define a prefix for cache keys used to store API responses. This helps uniquely identify data for specific products.
  21. const CACHE_LENGTH = 1000 * 60 * 60 * 24; // 24 hours in milliseconds (1,000 ms per second, 60 s per minute, 60 mins per hour, 24 hrs per day)
  22.  
  23. /**
  24. * Injects static styles into the document for elements used by the script.
  25. * This ensures that the custom UI elements have consistent styling.
  26. */
  27. function addStaticStyles() {
  28. // Define a block of CSS styles to be applied to the page.
  29. const staticStyles = `
  30. .gog_ph_shadow { box-shadow: 0 1px 5px rgba(0,0,0,.15); }
  31. .gog_ph_whitebg { background-color: #e1e1e1; }
  32. #gog_ph_div { max-height: 300px; overflow: hidden; margin-bottom: 20px; width: 100%; height: 300px; }
  33. #gog_ph_chart_canvas {
  34. max-height: 200px;
  35. visibility: hidden;
  36. width: 100%;
  37. }
  38. #gog_ph_placeholder {
  39. height: 200px;
  40. background-color: #e1e1e1;
  41. display: flex;
  42. justify-content: center;
  43. align-items: center;
  44. border-radius: 5px;
  45. }
  46. `;
  47.  
  48. // Create a <style> element to hold the CSS styles.
  49. const styleTag = document.createElement('style');
  50. styleTag.textContent = staticStyles;
  51. document.head.appendChild(styleTag);
  52.  
  53. // Create a container div for the price history chart and related elements.
  54. const gog_ph_div = document.createElement("div");
  55. gog_ph_div.setAttribute("id", "gog_ph_div");
  56. gog_ph_div.innerHTML = `
  57. <div class="title">
  58. <div class="title__underline-text">Price history</div>
  59. <div class="title__additional-options"></div>
  60. </div>
  61. <div id="gog_ph_placeholder" class="gog_ph_whitebg gog_ph_shadow">Loading price history...</div>
  62. <canvas id="gog_ph_chart_canvas" class="gog_ph_whitebg gog_ph_shadow"></canvas>
  63. <p style="margin-top: 10px;">
  64. <span id="gog_ph_lowest_price"></span>
  65. <span id="gog_ph_data_source"></span>
  66. </p>
  67. `;
  68.  
  69. // Append the container div to the body of the document.
  70. document.body.appendChild(gog_ph_div);
  71. }
  72.  
  73. /**
  74. * Moves the placeholder to a specific location in the DOM once the target is available.
  75. * This ensures the price history UI is displayed in the appropriate section of the page.
  76. */
  77. function relocatePlaceholder() {
  78. const placeholderDiv = document.getElementById("gog_ph_div");
  79.  
  80. // Use a MutationObserver to monitor DOM changes and relocate the placeholder when possible.
  81. const observer = new MutationObserver(() => {
  82. const targetElement = document.querySelector("div.layout-container:nth-child(9)");
  83. if (targetElement && placeholderDiv) {
  84. targetElement.prepend(placeholderDiv);
  85. observer.disconnect(); // Stop observing once the placeholder is relocated.
  86. }
  87. });
  88.  
  89. // Check the document's readiness state and start observing accordingly.
  90. if (document.readyState === "loading") {
  91. document.addEventListener("DOMContentLoaded", () => {
  92. observer.observe(document.documentElement, {
  93. childList: true,
  94. subtree: true
  95. });
  96. });
  97. } else {
  98. observer.observe(document.documentElement, {
  99. childList: true,
  100. subtree: true
  101. });
  102. }
  103. }
  104.  
  105. /**
  106. * Waits for the product data to be loaded, then triggers the provided callback.
  107. * The product data is required to fetch the price history for a specific game.
  108. * @param {Function} callback - Function to call with the product ID once available.
  109. */
  110. function waitForProductData(callback) {
  111. let pollingInterval;
  112.  
  113. // Start polling for product data once the window has loaded.
  114. window.addEventListener("load", () => {
  115. pollingInterval = setInterval(() => {
  116. try {
  117. // Check if the productcardData object is available and contains the product ID.
  118. if (unsafeWindow.productcardData?.cardProductId) {
  119. clearInterval(pollingInterval); // Stop polling once the data is available.
  120. callback(unsafeWindow.productcardData.cardProductId);
  121. }
  122. } catch (error) {
  123. if (DEBUG_MODE) console.error("Error accessing productcardData:", error);
  124. clearInterval(pollingInterval);
  125. }
  126. }, 100); // Poll every 100ms.
  127. });
  128.  
  129. // Set a timeout to stop polling if the data is not loaded within 10 seconds.
  130. setTimeout(() => {
  131. if (pollingInterval) {
  132. clearInterval(pollingInterval);
  133. if (DEBUG_MODE) console.warn("Timeout: Unable to load product data.");
  134. }
  135. }, 10000);
  136. }
  137.  
  138. /**
  139. * Fetches price history data from the GOGDB API for a given product.
  140. * @param {string} cacheKey - Key to cache the API response.
  141. * @param {string} productId - Product ID to fetch data for.
  142. */
  143. function fetchPriceData(cacheKey, productId) {
  144. const cachedData = localStorage.getItem(cacheKey);
  145. const cacheTimestamp = localStorage.getItem(`${cacheKey}_timestamp`);
  146.  
  147. // Check if cached data is available and not expired (e.g., 24 hours).
  148. if (cachedData && cacheTimestamp && Date.now() - cacheTimestamp < CACHE_LENGTH) {
  149. if (DEBUG_MODE) console.log("Using cached data:", JSON.parse(cachedData));
  150. processPriceData(JSON.parse(cachedData), productId);
  151. return;
  152. }
  153.  
  154. // Fetch data from the API if no valid cache is found.
  155. GM.xmlHttpRequest({
  156. method: "GET",
  157. url: `https://www.gogdb.org/data/products/${productId}/prices.json`,
  158. onload: function(response) {
  159. if (response.status === 200) {
  160. const jsonData = JSON.parse(response.responseText);
  161. if (DEBUG_MODE) console.log("Fetched price data:", jsonData);
  162.  
  163. // Cache the response and timestamp.
  164. localStorage.setItem(cacheKey, response.responseText);
  165. localStorage.setItem(`${cacheKey}_timestamp`, Date.now());
  166.  
  167. processPriceData(jsonData, productId);
  168. } else {
  169. document.getElementById("gog_ph_placeholder").textContent =
  170. response.status === 404 ?
  171. "No historical price data available." :
  172. "Failed to load price history.";
  173. }
  174. },
  175. onerror: function() {
  176. document.getElementById("gog_ph_placeholder").textContent =
  177. "Failed to load price history.";
  178. },
  179. });
  180. }
  181.  
  182.  
  183. /**
  184. * Processes the price history data and updates the UI with a chart and additional information.
  185. * @param {Object} jsonData - Raw price data fetched from the API.
  186. * @param {string} productId - Product ID associated with the data.
  187. */
  188. function processPriceData(jsonData, productId) {
  189. // Parse the price history data into labels (dates), prices, and key metrics.
  190. const {
  191. labels,
  192. prices,
  193. lowestPrice,
  194. highestBasePrice
  195. } = parsePriceHistory(jsonData);
  196.  
  197. if (DEBUG_MODE) {
  198. console.log("Processed labels:", labels);
  199. console.log("Processed prices:", prices);
  200. }
  201.  
  202. // Check if there is valid data to display.
  203. if (labels.length > 0 && prices.length > 0) {
  204. // Create the price history chart using Chart.js.
  205. createChart(labels, prices, highestBasePrice);
  206. document.getElementById("gog_ph_placeholder").remove();
  207.  
  208. // Update the lowest price and data source information in the UI.
  209. const lowestPriceElement = document.getElementById("gog_ph_lowest_price");
  210. const dataSourceElement = document.getElementById("gog_ph_data_source");
  211.  
  212. if (lowestPrice > 0 && lowestPrice < highestBasePrice) {
  213. // Display the lowest price if it is valid and less than the highest base price.
  214. lowestPriceElement.textContent = `Historical low: $${lowestPrice.toFixed(2)}.`;
  215. dataSourceElement.innerHTML = ` (Data retrieved from <a id="gog_ph_gogdb_link" class="un" href="https://www.gogdb.org/product/${productId}" target="_blank"><u>GOG Database</u></a>.)`;
  216. } else {
  217. lowestPriceElement.textContent = "";
  218. dataSourceElement.innerHTML = `Data retrieved from <a id="gog_ph_gogdb_link" class="un" href="https://www.gogdb.org/product/${productId}" target="_blank"><u>GOG Database</u></a>.`;
  219. }
  220. } else {
  221. // Display a message if no price history data is available.
  222. document.getElementById("gog_ph_placeholder").textContent =
  223. "No historical price data available.";
  224. }
  225. }
  226.  
  227. /**
  228. * Parses the price history data into labels, prices, and key metrics.
  229. * @param {Object} jsonData - Raw price data from the API.
  230. * @returns {Object} Parsed data including labels, prices, lowest price, and highest base price.
  231. */
  232. function parsePriceHistory(jsonData) {
  233. const history = jsonData?.US?.USD || []; // Extract the price history for USD or default to an empty array.
  234. const labels = []; // Array to store dates for the x-axis of the chart.
  235. const prices = []; // Array to store prices for the y-axis of the chart.
  236. let lowestPrice = Infinity; // Initialize the lowest price to a very high value.
  237. let highestBasePrice = 0; // Initialize the highest base price to zero.
  238.  
  239. // Handle the edge case where there is only one entry in the price history by creating a second data point at the current date.
  240. if (history.length === 1) {
  241. const singleEntry = history[0];
  242. const originalDate = new Date(singleEntry.date);
  243. labels.push(originalDate);
  244.  
  245. const currentDate = new Date();
  246. labels.push(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1));
  247.  
  248. // Create constants for price_base and price_final converted to decimals
  249. const singlePriceBase = singleEntry.price_base / 100;
  250. const singlePriceFinal = singleEntry.price_final / 100;
  251.  
  252. // Determine the lowest price between price_base and price_final, then store it. Handles games with launch/pre-launch discounts.
  253. //Probably better than defaulting to price_final for all games with a single price entry.
  254. lowestPrice = Math.min(singlePriceBase, singlePriceFinal);
  255. highestBasePrice = singlePriceBase;
  256.  
  257. prices.push(lowestPrice, lowestPrice); // Display the lower entry in the chart
  258.  
  259. return {
  260. labels,
  261. prices,
  262. lowestPrice,
  263. highestBasePrice
  264. };
  265. }
  266.  
  267. // Process each entry in the price history.
  268. history.forEach(entry => {
  269. const date = new Date(entry.date); // Convert the entry date string to a Date object.
  270. const price = entry.price_final ? entry.price_final / 100 : null; // Convert the final price to a number if available.
  271.  
  272. if (price && price > 0) {
  273. labels.push(date); // Add the date to the labels array.
  274. prices.push(price); // Add the price to the prices array.
  275. lowestPrice = Math.min(lowestPrice, price); // Update the lowest price if the current price is lower.
  276. highestBasePrice = Math.max(highestBasePrice, entry.price_base / 100 || 0); // Update the highest base price.
  277. }
  278. });
  279.  
  280. return {
  281. labels,
  282. prices,
  283. lowestPrice: lowestPrice === Infinity ? 0 : lowestPrice, // Handle the case where no valid prices are found.
  284. highestBasePrice,
  285. };
  286. }
  287.  
  288. /**
  289. * Creates a chart using Chart.js with multiple data points.
  290. * @param {Array} labels - Array of dates for the x-axis.
  291. * @param {Array} prices - Array of prices for the y-axis.
  292. * @param {number} highestBasePrice - Highest base price for scaling.
  293. */
  294. function createChart(labels, prices, highestBasePrice) {
  295. if (labels.length === 2) {
  296. // Handle the case where there are only two data points.
  297. createChartSingleEntry(labels, prices);
  298. } else {
  299. // Handle the case with multiple data points.
  300. createChartMultipleEntries(labels, prices);
  301. }
  302. }
  303.  
  304. /**
  305. * Creates a chart for multiple price entries.
  306. * @param {Array} labels - Array of dates for the x-axis.
  307. * @param {Array} prices - Array of prices for the y-axis.
  308. */
  309. function createChartMultipleEntries(labels, prices) {
  310. const ctx = document.getElementById("gog_ph_chart_canvas").getContext("2d"); // Get the 2D rendering context for the canvas.
  311.  
  312. new Chart(ctx, {
  313. type: "line", // Specify the chart type as a line chart.
  314. data: {
  315. labels, // Use the provided labels for the x-axis.
  316. datasets: [{
  317. label: "Price", // Label for the dataset.
  318. borderColor: "rgb(241, 142, 0)", // Set the line color.
  319. backgroundColor: "rgba(241, 142, 0, 0.5)", // Set the fill color.
  320. data: prices, // Use the provided prices for the y-axis.
  321. stepped: true, // Use stepped lines to indicate discrete changes.
  322. fill: false, // Disable filling under the line.
  323. }],
  324. },
  325. options: {
  326. scales: {
  327. x: {
  328. type: "time", // Use a time scale for the x-axis.
  329. time: {
  330. tooltipFormat: "MMM d, yyyy", // Format for tooltips (i.e., short month, day, 4-digit year).
  331. displayFormats: {
  332. month: "MMM yyyy", // Format for month labels (i.e., short month, 4-digit year).
  333. },
  334. },
  335. ticks: {
  336. autoSkip: true, // Automatically skip ticks to avoid overcrowding.
  337. maxTicksLimit: Math.floor(labels.length / 3), // Limit the number of ticks based on the data length.
  338. },
  339. },
  340. y: {
  341. beginAtZero: true, // Start the y-axis at zero.
  342. ticks: {
  343. callback: value => `$${value.toFixed(2)}`, // Format the y-axis values as currency.
  344. },
  345. },
  346. },
  347. plugins: {
  348. legend: {
  349. display: false, // Disable the legend for simplicity.
  350. },
  351. },
  352. maintainAspectRatio: false, // Allow the chart to resize dynamically.
  353. },
  354. });
  355.  
  356. document.getElementById("gog_ph_chart_canvas").style.visibility = "visible"; // Make the chart visible after rendering.
  357. }
  358.  
  359. /**
  360. * Creates a chart for a single price entry.
  361. * @param {Array} labels - Array of two dates for the x-axis.
  362. * @param {Array} prices - Array of two prices for the y-axis.
  363. */
  364. function createChartSingleEntry(labels, prices) {
  365. const ctx = document.getElementById("gog_ph_chart_canvas").getContext("2d"); // Get the 2D rendering context for the canvas.
  366.  
  367. new Chart(ctx, {
  368. type: "line", // Specify the chart type as a line chart.
  369. data: {
  370. labels, // Use the provided labels for the x-axis.
  371. datasets: [{
  372. label: "Price", // Label for the dataset.
  373. borderColor: "rgb(241, 142, 0)", // Set the line color.
  374. backgroundColor: "rgba(241, 142, 0, 0.5)", // Set the fill color.
  375. data: prices, // Use the provided prices for the y-axis.
  376. stepped: true, // Use stepped lines to indicate discrete changes.
  377. fill: false, // Disable filling under the line.
  378. }],
  379. },
  380. options: {
  381. scales: {
  382. x: {
  383. type: "category", // Use a category scale for the x-axis.
  384. ticks: {
  385. autoSkip: false, // Do not skip ticks.
  386. callback: function(value, index) {
  387. return labels[index].toLocaleDateString("en-US", {
  388. month: "short",
  389. year: "numeric",
  390. }); // Format x-axis labels as short month and year.
  391. },
  392. },
  393. },
  394. y: {
  395. beginAtZero: true, // Start the y-axis at zero.
  396. ticks: {
  397. callback: value => `$${value.toFixed(2)}`, // Format the y-axis values as currency.
  398. },
  399. },
  400. },
  401. plugins: {
  402. legend: {
  403. display: false, // Disable the legend for simplicity.
  404. },
  405. tooltip: {
  406. callbacks: {
  407. title: function(context) {
  408. const index = context[0].dataIndex;
  409. // Format the tooltip title to display the date as "Month Day, Year"
  410. return labels[index].toLocaleDateString("en-US", {
  411. month: "short",
  412. day: "numeric",
  413. year: "numeric",
  414. });
  415. },
  416. },
  417. },
  418. },
  419. maintainAspectRatio: false, // Allow the chart to dynamically resize without distorting the aspect ratio.
  420. },
  421. });
  422.  
  423. // Make the chart visible once rendering is complete.
  424. document.getElementById("gog_ph_chart_canvas").style.visibility = "visible";
  425. }
  426.  
  427. // Add the necessary static styles to the page.
  428. addStaticStyles();
  429. // Relocate the placeholder element to its correct position on the page.
  430. relocatePlaceholder();
  431. // Wait for the product data to load and then fetch the price history data for the product.
  432. waitForProductData(productId => {
  433. fetchPriceData(`${CACHE_KEY_PREFIX}${productId}`, productId);
  434. });
  435. })();