// ==UserScript==
// @name GOG - Price Charts
// @namespace https://github.com/idkicarus/
// @homepageURL https://github.com/idkicarus/GOG-price-charts
// @supportURL https://github.com/idkicarus/GOG-price-charts/issues
// @match https://www.gog.com/*/game/*
// @description Fetches price history from GOGDB.org to generate price charts for games on GOG
// @version 1.0
// @grant GM.xmlHttpRequest
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]
// @license MIT
// ==/UserScript==
/* global Chart */
(function() {
const DEBUG_MODE = false; // Enable debug mode for logging errors and status messages during script execution.
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.
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)
/**
* Injects static styles into the document for elements used by the script.
* This ensures that the custom UI elements have consistent styling.
*/
function addStaticStyles() {
// Define a block of CSS styles to be applied to the page.
const staticStyles = `
.gog_ph_shadow { box-shadow: 0 1px 5px rgba(0,0,0,.15); }
.gog_ph_whitebg { background-color: #e1e1e1; }
#gog_ph_div { max-height: 300px; overflow: hidden; margin-bottom: 20px; width: 100%; height: 300px; }
#gog_ph_chart_canvas {
max-height: 200px;
visibility: hidden;
width: 100%;
}
#gog_ph_placeholder {
height: 200px;
background-color: #e1e1e1;
display: flex;
justify-content: center;
align-items: center;
border-radius: 5px;
}
`;
// Create a <style> element to hold the CSS styles.
const styleTag = document.createElement('style');
styleTag.textContent = staticStyles;
document.head.appendChild(styleTag);
// Create a container div for the price history chart and related elements.
const gog_ph_div = document.createElement("div");
gog_ph_div.setAttribute("id", "gog_ph_div");
gog_ph_div.innerHTML = `
<div class="title">
<div class="title__underline-text">Price history</div>
<div class="title__additional-options"></div>
</div>
<div id="gog_ph_placeholder" class="gog_ph_whitebg gog_ph_shadow">Loading price history...</div>
<canvas id="gog_ph_chart_canvas" class="gog_ph_whitebg gog_ph_shadow"></canvas>
<p style="margin-top: 10px;">
<span id="gog_ph_lowest_price"></span>
<span id="gog_ph_data_source"></span>
</p>
`;
// Append the container div to the body of the document.
document.body.appendChild(gog_ph_div);
}
/**
* Moves the placeholder to a specific location in the DOM once the target is available.
* This ensures the price history UI is displayed in the appropriate section of the page.
*/
function relocatePlaceholder() {
const placeholderDiv = document.getElementById("gog_ph_div");
// Use a MutationObserver to monitor DOM changes and relocate the placeholder when possible.
const observer = new MutationObserver(() => {
const targetElement = document.querySelector("div.layout-container:nth-child(9)");
if (targetElement && placeholderDiv) {
targetElement.prepend(placeholderDiv);
observer.disconnect(); // Stop observing once the placeholder is relocated.
}
});
// Check the document's readiness state and start observing accordingly.
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
});
} else {
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
}
}
/**
* Waits for the product data to be loaded, then triggers the provided callback.
* The product data is required to fetch the price history for a specific game.
* @param {Function} callback - Function to call with the product ID once available.
*/
function waitForProductData(callback) {
let pollingInterval;
// Start polling for product data once the window has loaded.
window.addEventListener("load", () => {
pollingInterval = setInterval(() => {
try {
// Check if the productcardData object is available and contains the product ID.
if (unsafeWindow.productcardData?.cardProductId) {
clearInterval(pollingInterval); // Stop polling once the data is available.
callback(unsafeWindow.productcardData.cardProductId);
}
} catch (error) {
if (DEBUG_MODE) console.error("Error accessing productcardData:", error);
clearInterval(pollingInterval);
}
}, 100); // Poll every 100ms.
});
// Set a timeout to stop polling if the data is not loaded within 10 seconds.
setTimeout(() => {
if (pollingInterval) {
clearInterval(pollingInterval);
if (DEBUG_MODE) console.warn("Timeout: Unable to load product data.");
}
}, 10000);
}
/**
* Fetches price history data from the GOGDB API for a given product.
* @param {string} cacheKey - Key to cache the API response.
* @param {string} productId - Product ID to fetch data for.
*/
function fetchPriceData(cacheKey, productId) {
const cachedData = localStorage.getItem(cacheKey);
const cacheTimestamp = localStorage.getItem(`${cacheKey}_timestamp`);
// Check if cached data is available and not expired (e.g., 24 hours).
if (cachedData && cacheTimestamp && Date.now() - cacheTimestamp < CACHE_LENGTH) {
if (DEBUG_MODE) console.log("Using cached data:", JSON.parse(cachedData));
processPriceData(JSON.parse(cachedData), productId);
return;
}
// Fetch data from the API if no valid cache is found.
GM.xmlHttpRequest({
method: "GET",
url: `https://www.gogdb.org/data/products/${productId}/prices.json`,
onload: function(response) {
if (response.status === 200) {
const jsonData = JSON.parse(response.responseText);
if (DEBUG_MODE) console.log("Fetched price data:", jsonData);
// Cache the response and timestamp.
localStorage.setItem(cacheKey, response.responseText);
localStorage.setItem(`${cacheKey}_timestamp`, Date.now());
processPriceData(jsonData, productId);
} else {
document.getElementById("gog_ph_placeholder").textContent =
response.status === 404 ?
"No historical price data available." :
"Failed to load price history.";
}
},
onerror: function() {
document.getElementById("gog_ph_placeholder").textContent =
"Failed to load price history.";
},
});
}
/**
* Processes the price history data and updates the UI with a chart and additional information.
* @param {Object} jsonData - Raw price data fetched from the API.
* @param {string} productId - Product ID associated with the data.
*/
function processPriceData(jsonData, productId) {
// Parse the price history data into labels (dates), prices, and key metrics.
const {
labels,
prices,
lowestPrice,
highestBasePrice
} = parsePriceHistory(jsonData);
if (DEBUG_MODE) {
console.log("Processed labels:", labels);
console.log("Processed prices:", prices);
}
// Check if there is valid data to display.
if (labels.length > 0 && prices.length > 0) {
// Create the price history chart using Chart.js.
createChart(labels, prices, highestBasePrice);
document.getElementById("gog_ph_placeholder").remove();
// Update the lowest price and data source information in the UI.
const lowestPriceElement = document.getElementById("gog_ph_lowest_price");
const dataSourceElement = document.getElementById("gog_ph_data_source");
if (lowestPrice > 0 && lowestPrice < highestBasePrice) {
// Display the lowest price if it is valid and less than the highest base price.
lowestPriceElement.textContent = `Historical low: $${lowestPrice.toFixed(2)}.`;
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>.)`;
} else {
lowestPriceElement.textContent = "";
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>.`;
}
} else {
// Display a message if no price history data is available.
document.getElementById("gog_ph_placeholder").textContent =
"No historical price data available.";
}
}
/**
* Parses the price history data into labels, prices, and key metrics.
* @param {Object} jsonData - Raw price data from the API.
* @returns {Object} Parsed data including labels, prices, lowest price, and highest base price.
*/
function parsePriceHistory(jsonData) {
const history = jsonData?.US?.USD || []; // Extract the price history for USD or default to an empty array.
const labels = []; // Array to store dates for the x-axis of the chart.
const prices = []; // Array to store prices for the y-axis of the chart.
let lowestPrice = Infinity; // Initialize the lowest price to a very high value.
let highestBasePrice = 0; // Initialize the highest base price to zero.
// Handle the edge case where there is only one entry in the price history by creating a second data point at the current date.
if (history.length === 1) {
const singleEntry = history[0];
const originalDate = new Date(singleEntry.date);
labels.push(originalDate);
const currentDate = new Date();
labels.push(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1));
// Create constants for price_base and price_final converted to decimals
const singlePriceBase = singleEntry.price_base / 100;
const singlePriceFinal = singleEntry.price_final / 100;
// Determine the lowest price between price_base and price_final, then store it. Handles games with launch/pre-launch discounts.
//Probably better than defaulting to price_final for all games with a single price entry.
lowestPrice = Math.min(singlePriceBase, singlePriceFinal);
highestBasePrice = singlePriceBase;
prices.push(lowestPrice, lowestPrice); // Display the lower entry in the chart
return {
labels,
prices,
lowestPrice,
highestBasePrice
};
}
// Process each entry in the price history.
history.forEach(entry => {
const date = new Date(entry.date); // Convert the entry date string to a Date object.
const price = entry.price_final ? entry.price_final / 100 : null; // Convert the final price to a number if available.
if (price && price > 0) {
labels.push(date); // Add the date to the labels array.
prices.push(price); // Add the price to the prices array.
lowestPrice = Math.min(lowestPrice, price); // Update the lowest price if the current price is lower.
highestBasePrice = Math.max(highestBasePrice, entry.price_base / 100 || 0); // Update the highest base price.
}
});
return {
labels,
prices,
lowestPrice: lowestPrice === Infinity ? 0 : lowestPrice, // Handle the case where no valid prices are found.
highestBasePrice,
};
}
/**
* Creates a chart using Chart.js with multiple data points.
* @param {Array} labels - Array of dates for the x-axis.
* @param {Array} prices - Array of prices for the y-axis.
* @param {number} highestBasePrice - Highest base price for scaling.
*/
function createChart(labels, prices, highestBasePrice) {
if (labels.length === 2) {
// Handle the case where there are only two data points.
createChartSingleEntry(labels, prices);
} else {
// Handle the case with multiple data points.
createChartMultipleEntries(labels, prices);
}
}
/**
* Creates a chart for multiple price entries.
* @param {Array} labels - Array of dates for the x-axis.
* @param {Array} prices - Array of prices for the y-axis.
*/
function createChartMultipleEntries(labels, prices) {
const ctx = document.getElementById("gog_ph_chart_canvas").getContext("2d"); // Get the 2D rendering context for the canvas.
new Chart(ctx, {
type: "line", // Specify the chart type as a line chart.
data: {
labels, // Use the provided labels for the x-axis.
datasets: [{
label: "Price", // Label for the dataset.
borderColor: "rgb(241, 142, 0)", // Set the line color.
backgroundColor: "rgba(241, 142, 0, 0.5)", // Set the fill color.
data: prices, // Use the provided prices for the y-axis.
stepped: true, // Use stepped lines to indicate discrete changes.
fill: false, // Disable filling under the line.
}],
},
options: {
scales: {
x: {
type: "time", // Use a time scale for the x-axis.
time: {
tooltipFormat: "MMM d, yyyy", // Format for tooltips (i.e., short month, day, 4-digit year).
displayFormats: {
month: "MMM yyyy", // Format for month labels (i.e., short month, 4-digit year).
},
},
ticks: {
autoSkip: true, // Automatically skip ticks to avoid overcrowding.
maxTicksLimit: Math.floor(labels.length / 3), // Limit the number of ticks based on the data length.
},
},
y: {
beginAtZero: true, // Start the y-axis at zero.
ticks: {
callback: value => `$${value.toFixed(2)}`, // Format the y-axis values as currency.
},
},
},
plugins: {
legend: {
display: false, // Disable the legend for simplicity.
},
},
maintainAspectRatio: false, // Allow the chart to resize dynamically.
},
});
document.getElementById("gog_ph_chart_canvas").style.visibility = "visible"; // Make the chart visible after rendering.
}
/**
* Creates a chart for a single price entry.
* @param {Array} labels - Array of two dates for the x-axis.
* @param {Array} prices - Array of two prices for the y-axis.
*/
function createChartSingleEntry(labels, prices) {
const ctx = document.getElementById("gog_ph_chart_canvas").getContext("2d"); // Get the 2D rendering context for the canvas.
new Chart(ctx, {
type: "line", // Specify the chart type as a line chart.
data: {
labels, // Use the provided labels for the x-axis.
datasets: [{
label: "Price", // Label for the dataset.
borderColor: "rgb(241, 142, 0)", // Set the line color.
backgroundColor: "rgba(241, 142, 0, 0.5)", // Set the fill color.
data: prices, // Use the provided prices for the y-axis.
stepped: true, // Use stepped lines to indicate discrete changes.
fill: false, // Disable filling under the line.
}],
},
options: {
scales: {
x: {
type: "category", // Use a category scale for the x-axis.
ticks: {
autoSkip: false, // Do not skip ticks.
callback: function(value, index) {
return labels[index].toLocaleDateString("en-US", {
month: "short",
year: "numeric",
}); // Format x-axis labels as short month and year.
},
},
},
y: {
beginAtZero: true, // Start the y-axis at zero.
ticks: {
callback: value => `$${value.toFixed(2)}`, // Format the y-axis values as currency.
},
},
},
plugins: {
legend: {
display: false, // Disable the legend for simplicity.
},
tooltip: {
callbacks: {
title: function(context) {
const index = context[0].dataIndex;
// Format the tooltip title to display the date as "Month Day, Year"
return labels[index].toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
},
},
},
},
maintainAspectRatio: false, // Allow the chart to dynamically resize without distorting the aspect ratio.
},
});
// Make the chart visible once rendering is complete.
document.getElementById("gog_ph_chart_canvas").style.visibility = "visible";
}
// Add the necessary static styles to the page.
addStaticStyles();
// Relocate the placeholder element to its correct position on the page.
relocatePlaceholder();
// Wait for the product data to load and then fetch the price history data for the product.
waitForProductData(productId => {
fetchPriceData(`${CACHE_KEY_PREFIX}${productId}`, productId);
});
})();