GOG - Price Charts

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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