// ==UserScript==
// @name GG.deals Steam Companion
// @namespace http://tampermonkey.net/
// @version 1.6
// @description Shows lowest price from gg.deals on Steam game pages
// @author Crimsab
// @license GPL-3.0-or-later
// @match https://store.steampowered.com/app/*
// @match https://store.steampowered.com/sub/*
// @match https://store.steampowered.com/bundle/*
// @icon https://gg.deals/favicon.ico
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant unsafeWindow
// @connect gg.deals
// @connect api.gg.deals
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
// KNOWN LIMITATIONS:
// Bundles always use web scraping, never API. The official api does not support steam bundles, giving null results.
// Subs (packages) and Apps can use either API or web scraping.
// "Enable Scraping" toggle controls whether web scraping is used when API is disabled or fails.
(function () {
"use strict";
// Default color scheme
const defaultColors = {
background: "#16202d",
headerBackground: "#0d141c",
officialText: "#67c1f5",
officialPrice: "#ffffff",
keyshopText: "#67c1f5",
keyshopPrice: "#ffffff",
bestPrice: "#a4d007",
buttonBackground: "linear-gradient(to right, #67c1f5 0%, #4a9bd5 100%)",
buttonText: "#ffffff",
borderColor: "#67c1f530"
};
// Get saved colors or use defaults
const savedColors = {};
Object.keys(defaultColors).forEach(key => {
savedColors[key] = GM_getValue(`color_${key}`, defaultColors[key]);
});
// Function to apply colors
function applyCustomColors() {
let customCSS = `
:root {
--gg-deals-background: ${savedColors.background};
--gg-deals-header-bg: ${savedColors.headerBackground};
--gg-deals-official-text: ${savedColors.officialText};
--gg-deals-official-price: ${savedColors.officialPrice};
--gg-deals-keyshop-text: ${savedColors.keyshopText};
--gg-deals-keyshop-price: ${savedColors.keyshopPrice};
--gg-deals-best-price: ${savedColors.bestPrice};
--gg-deals-button-bg: ${savedColors.buttonBackground};
--gg-deals-button-text: ${savedColors.buttonText};
--gg-deals-border-color: ${savedColors.borderColor};
}
`;
// Create a style element for our custom colors
const styleEl = document.getElementById('gg-deals-custom-colors') || document.createElement('style');
styleEl.id = 'gg-deals-custom-colors';
styleEl.textContent = customCSS;
document.head.appendChild(styleEl);
}
GM_addStyle(`
.gg-deals-container {
background: var(--gg-deals-background, #16202d) !important;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
border: 1px solid var(--gg-deals-border-color, #67c1f530);
width: 100%;
max-width: 100%;
box-sizing: border-box;
clear: both;
}
.gg-deals-container.compact {
padding: 10px;
margin: 10px 0;
}
.gg-deals-container.compact .gg-header,
.gg-deals-container.compact .gg-attribution,
.gg-deals-container.compact .gg-price-sections {
display: none;
}
.gg-compact-row {
display: none;
align-items: center;
gap: 15px;
padding: 5px;
flex-wrap: nowrap;
min-width: 0;
}
.gg-deals-container.compact .gg-compact-row {
display: flex;
}
.gg-compact-prices {
display: flex;
align-items: center;
gap: 20px;
flex: 1;
min-width: 0;
overflow: visible;
}
.gg-compact-price-item {
display: flex;
align-items: center;
position: relative;
gap: 8px;
min-width: 0;
flex-shrink: 1;
}
.gg-compact-price-item .gg-price-value {
font-size: 18px;
}
.gg-price-value.best-price {
color: var(--gg-deals-best-price, #a4d007);
position: relative;
padding-top: 16px;
}
.gg-price-value.best-price:before {
content: "✓ Best Price";
position: absolute;
right: 0;
top: 0;
font-size: 12px;
opacity: 0.9;
color: var(--gg-deals-best-price, #a4d007);
white-space: nowrap;
}
/* Hide the "Best Price" text in compact view */
.gg-compact-price-item .gg-price-value.best-price {
padding-top: 0;
}
.gg-compact-price-item .gg-price-value.best-price:before {
display: none;
}
.gg-settings-dropdown {
position: relative;
display: inline-block;
}
.gg-settings-icon {
cursor: pointer;
padding: 5px;
opacity: 0.7;
transition: opacity 0.2s;
}
.gg-settings-icon:hover {
opacity: 1;
}
.gg-settings-icon svg {
width: 20px;
height: 20px;
fill: var(--gg-deals-official-text, #67c1f5);
}
.gg-settings-content {
display: none;
position: absolute;
right: 0;
background: var(--gg-deals-background, #16202d);
min-width: 160px;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
border: 1px solid var(--gg-deals-border-color, #67c1f530);
border-radius: 4px;
z-index: 1000;
padding: 10px;
}
.gg-settings-content.show {
display: block;
}
.gg-compact-controls {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.gg-tooltip {
position: relative;
display: inline-block;
}
.gg-tooltip:hover .gg-tooltip-text {
visibility: visible;
opacity: 1;
}
.gg-tooltip-text {
visibility: hidden;
opacity: 0;
background-color: var(--gg-deals-background, #16202d);
color: #fff;
text-align: center;
padding: 5px 10px;
border-radius: 4px;
border: 1px solid var(--gg-deals-border-color, #67c1f530);
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
transition: opacity 0.2s;
font-size: 12px;
}
/* New historical tooltip styles */
.gg-historical-tooltip {
position: relative;
display: inline-block;
}
.gg-historical-tooltip:hover .gg-historical-tooltip-text {
visibility: visible;
opacity: 1;
}
.gg-historical-tooltip-text {
visibility: hidden;
opacity: 0;
background-color: var(--gg-deals-background, #16202d);
color: #fff;
text-align: center;
padding: 5px 10px;
border-radius: 4px;
border: 1px solid var(--gg-deals-border-color, #67c1f530);
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
transition: opacity 0.2s;
font-size: 12px;
}
.gg-controls {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--gg-deals-border-color, rgba(103, 193, 245, 0.1));
}
.gg-header {
display: flex;
flex-direction: column;
align-items: center;
margin: -15px -15px 15px -15px;
padding: 15px;
background: var(--gg-deals-header-bg, rgb(13, 20, 28));
border-radius: 4px 4px 0 0;
border-bottom: 1px solid var(--gg-deals-border-color, rgba(103, 193, 245, 0.2));
text-align: center;
}
.gg-title {
color: var(--gg-deals-official-text, #67c1f5);
font-size: 24px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
display: flex;
align-items: center;
gap: 12px;
}
.gg-title img {
width: 32px;
height: 32px;
filter: brightness(1.2) drop-shadow(1px 1px 2px rgba(0,0,0,0.5));
}
.gg-attribution {
color: #8f98a0;
font-size: 11px;
opacity: 0.8;
font-style: italic;
text-align: center;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--gg-deals-border-color, rgba(103, 193, 245, 0.1));
}
.gg-price-sections {
display: flex;
justify-content: space-between;
margin: 8px 0;
padding: 12px;
background: #1b2838;
border-radius: 3px;
transition: all 0.3s ease;
position: relative;
min-height: 60px;
}
.gg-price-section {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.gg-price-left {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.gg-price-label {
color: var(--gg-deals-official-text, #67c1f5);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.gg-price-info {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 120px;
text-align: center;
margin-left: 20px;
}
.gg-price-value {
color: var(--gg-deals-official-price, #fff);
font-weight: bold;
font-size: 24px;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
transition: color 0.3s ease;
white-space: nowrap;
}
.gg-price-value.historical {
font-size: 13px;
color: var(--gg-deals-official-text, #acdbf5);
opacity: 0.9;
margin-top: 4px;
}
.gg-icon {
width: 20px;
height: 20px;
filter: brightness(0.8);
flex-shrink: 0;
}
.gg-footer {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.gg-view-offers {
width: 100%;
background: var(--gg-deals-button-bg, linear-gradient(to right, #67c1f5 0%, #4a9bd5 100%));
padding: 8px 20px;
border-radius: 3px;
color: var(--gg-deals-button-text, #fff) !important;
font-size: 14px;
text-decoration: none !important;
transition: all 0.2s ease;
text-shadow: 1px 1px 1px rgba(0,0,0,0.3);
text-align: center;
white-space: nowrap;
}
.gg-view-offers:hover {
background: var(--gg-deals-button-bg, linear-gradient(to right, #7dcbff 0%, #4a9bd5 100%));
transform: translateY(-1px);
}
.gg-toggles {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.gg-toggle {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
user-select: none;
opacity: 0.7;
transition: opacity 0.2s ease;
white-space: nowrap;
color: var(--gg-deals-official-text, #67c1f5);
}
.gg-toggle:hover {
opacity: 1;
}
.gg-toggle.active {
opacity: 1;
}
.gg-toggle input {
margin: 0;
}
.gg-toggle label {
color: var(--gg-deals-official-text, #67c1f5);
font-size: 12px;
}
@media (max-width: 640px) {
.gg-price-sections {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.gg-price-info {
align-items: flex-start;
margin-left: 28px;
}
.gg-price-value.best-price {
padding-top: 0;
padding-right: 80px;
}
.gg-price-value.best-price:before {
top: 50%;
transform: translateY(-50%);
right: 0;
}
.gg-footer {
flex-direction: column-reverse;
align-items: stretch;
}
.gg-view-offers {
text-align: center;
}
.gg-toggles {
justify-content: center;
}
}
.gg-icon-button {
background: none;
border: none;
color: var(--gg-deals-official-text, #67c1f5);
cursor: pointer;
padding: 5px;
border-radius: 3px;
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
opacity: 0.7;
transition: all 0.2s ease;
}
.gg-icon-button:hover {
opacity: 1;
background: rgba(103, 193, 245, 0.1);
}
.gg-icon-button svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.gg-refresh {
padding: 5px 8px;
display: flex;
align-items: center;
min-width: max-content;
flex-shrink: 0;
position: relative;
}
.gg-refresh svg {
transition: transform 0.5s ease;
stroke: currentColor;
fill: none;
}
.gg-refresh.loading svg {
transform: rotate(360deg);
}
.gg-refresh-text {
display: none;
}
.gg-refresh:hover .gg-tooltip-text {
visibility: visible;
opacity: 1;
}
.github-icon {
width: 16px;
height: 16px;
vertical-align: middle;
margin: -2px 4px 0 2px;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.github-icon:hover {
opacity: 1;
}
.gg-deals-container.compact .gg-controls {
display: none;
}
.bundle-sub-display {
background: var(--gg-deals-background, #16202d) !important;
border-radius: 4px;
border: 1px solid var(--gg-deals-border-color, #67c1f530);
position: relative;
z-index: 1;
}
.game_area_purchase_game + .bundle-sub-display {
margin-top: -10px !important;
}
.bundle_contents_preview + .gg-deals-container {
margin-top: 0 !important;
}
.game_area_purchase + .gg-deals-container {
margin-top: 0 !important;
}
.gg-view-offers {
display: inline-block;
text-align: center;
transition: transform 0.2s ease;
}
.gg-view-offers:hover {
transform: translateY(-1px);
}
.gg-price-value {
display: inline-block;
min-width: 80px;
}
.gg-deals-container.compact .gg-view-offers {
width: auto;
min-width: 90px;
white-space: nowrap;
flex-shrink: 0;
}
.gg-api-key-input {
width: 100%;
padding: 6px 8px;
margin: 8px 0;
border: 1px solid var(--gg-deals-border-color, #67c1f530);
border-radius: 3px;
background: #121b28;
color: #fff;
font-size: 12px;
box-sizing: border-box;
}
.gg-region-select {
width: 100%;
padding: 6px 8px;
margin: 8px 0;
border: 1px solid var(--gg-deals-border-color, #67c1f530);
border-radius: 3px;
background: #121b28;
color: #fff;
font-size: 12px;
box-sizing: border-box;
}
.gg-settings-section {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--gg-deals-border-color, rgba(103, 193, 245, 0.1));
}
.gg-settings-title {
color: var(--gg-deals-official-text, #67c1f5);
font-size: 13px;
margin-bottom: 5px;
}
.gg-settings-content {
width: 270px !important;
max-width: 270px !important;
}
.gg-api-status {
font-size: 11px;
margin-top: 4px;
}
.gg-api-status.active {
color: var(--gg-deals-best-price, #a4d007);
}
.gg-api-status.inactive {
color: #ff7b7b;
}
.gg-save-button {
background: var(--gg-deals-button-bg, linear-gradient(to right, #67c1f5 0%, #4a9bd5 100%));
border: none;
padding: 5px 10px;
border-radius: 3px;
color: var(--gg-deals-button-text, #fff);
font-size: 12px;
cursor: pointer;
margin-top: 5px;
transition: all 0.2s ease;
width: 100%;
}
.gg-save-button:hover {
background: var(--gg-deals-button-bg, linear-gradient(to right, #7dcbff 0%, #4a9bd5 100%));
}
.gg-api-key-wrapper {
position: relative;
width: 100%;
}
.gg-toggle-visibility {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--gg-deals-official-text, #67c1f5);
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
padding: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.gg-toggle-visibility:hover {
opacity: 1;
}
.gg-toggle-visibility svg {
width: 18px;
height: 18px;
fill: currentColor;
}
/* Color picker styles */
.gg-color-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 10px 0;
}
.gg-color-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.gg-color-label {
font-size: 11px;
color: var(--gg-deals-official-text, #67c1f5);
}
.gg-color-input {
width: 100%;
height: 24px;
border: 1px solid var(--gg-deals-border-color, #67c1f530);
border-radius: 3px;
background: none;
cursor: pointer;
}
.gg-reset-colors {
background: none;
border: 1px solid var(--gg-deals-border-color, #67c1f530);
padding: 5px 10px;
border-radius: 3px;
color: var(--gg-deals-official-text, #67c1f5);
font-size: 12px;
cursor: pointer;
margin-top: 5px;
transition: all 0.2s ease;
width: 100%;
}
.gg-reset-colors:hover {
background: rgba(103, 193, 245, 0.1);
}
/* New styles for full view controls layout */
.gg-deals-container:not(.compact) .gg-controls {
display: flex;
flex-direction: column; /* Main sections stacked */
gap: 20px; /* Increased gap for better separation */
margin-top: 20px; /* Increased margin */
padding-top: 15px;
border-top: 1px solid var(--gg-deals-border-color, rgba(103, 193, 245, 0.1));
align-items: stretch; /* Allow children to define their own width fully */
}
.gg-deals-container:not(.compact) .gg-main-actions {
display: flex;
gap: 15px;
align-items: center;
}
.gg-deals-container:not(.compact) .gg-main-actions .gg-view-offers {
flex-grow: 1; /* Takes available space */
width: auto; /* Override general width: 100% for flex context */
}
.gg-deals-container:not(.compact) .gg-main-actions .gg-refresh {
flex-shrink: 0; /* Prevent refresh icon from shrinking */
}
.gg-deals-container:not(.compact) .gg-settings-panels {
display: flex;
flex-direction: row;
gap: 15px;
flex-wrap: wrap; /* Allow panels to wrap to next line if not enough space */
}
.gg-deals-container:not(.compact) .gg-settings-panels .gg-settings-section {
flex: 1 1 240px; /* Grow, Shrink, Basis */
padding: 15px;
border: 1px solid var(--gg-deals-border-color);
border-radius: 4px;
background: rgba(0,0,0,0.05); /* Slightly lighter background for panels */
margin-bottom: 0;
/* border-bottom property from general .gg-settings-section will be overridden by the border property here */
}
.gg-deals-container:not(.compact) .gg-settings-panels .gg-settings-title { /* More specific selector for title */
margin-bottom: 12px;
font-weight: bold;
color: var(--gg-deals-official-text);
}
/* End of new styles */
`);
// Get saved toggle states or set defaults
const toggleStates = {
official: GM_getValue("showOfficial", true),
keyshop: GM_getValue("showKeyshop", true),
compact: GM_getValue("compactView", false),
subDisplay: GM_getValue("showSubDisplay", true),
useApi: GM_getValue("useApi", false),
enableScraping: GM_getValue("enableScraping", true)
};
// Force set enableScraping in GM storage if it doesn't exist yet
if (GM_getValue("enableScraping") === undefined) {
GM_setValue("enableScraping", true);
console.log("GG.deals: Initializing enableScraping setting to true");
}
// Get API key if saved
const apiKey = GM_getValue("apiKey", "");
// Get preferred region/currency (default: us)
const preferredRegion = GM_getValue("preferredRegion", "us");
// Available regions for API
const availableRegions = [
{ code: "us", name: "USA (USD)" },
{ code: "gb", name: "UK (GBP)" },
{ code: "eu", name: "Europe (EUR)" },
{ code: "ca", name: "Canada (CAD)" },
{ code: "au", name: "Australia (AUD)" },
{ code: "br", name: "Brazil (BRL)" },
{ code: "ru", name: "Russia (RUB)" },
{ code: "tr", name: "Turkey (TRY)" },
{ code: "pl", name: "Poland (PLN)" },
{ code: "de", name: "Germany (EUR)" },
{ code: "fr", name: "France (EUR)" },
{ code: "es", name: "Spain (EUR)" },
{ code: "it", name: "Italy (EUR)" },
{ code: "ch", name: "Switzerland (CHF)" },
{ code: "nl", name: "Netherlands (EUR)" },
{ code: "se", name: "Sweden (SEK)" },
{ code: "no", name: "Norway (NOK)" },
{ code: "dk", name: "Denmark (DKK)" },
{ code: "fi", name: "Finland (EUR)" },
{ code: "ie", name: "Ireland (EUR)" },
{ code: "be", name: "Belgium (EUR)" }
];
// Apply custom colors on script load
applyCustomColors();
// Cache configuration
const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
const RATE_LIMIT_DELAY = 2000; // 2 seconds between requests
const MAX_RETRIES = 1;
// Global variables for easy access
window.ggDealsApiKey = apiKey;
window.ggDealsRegion = preferredRegion;
// In-memory cache to reduce GM_getValue calls
const memoryCache = {};
// Cache structure with force refresh option and memory caching
const priceCache = {
get: function (key, forceRefresh = false) {
if (forceRefresh) {
// Clear both memory and persistent cache
delete memoryCache[key];
GM_setValue(`cache_${key}`, "");
return null;
}
// Check memory cache first for better performance
if (memoryCache[key]) {
const { data, timestamp } = memoryCache[key];
if (Date.now() - timestamp <= CACHE_EXPIRY) {
return data;
}
// Expired cache, remove from memory
delete memoryCache[key];
}
// Otherwise check persistent storage
const cached = GM_getValue(`cache_${key}`);
if (!cached) return null;
try {
const cacheObject = JSON.parse(cached);
const { data, timestamp, source } = cacheObject;
if (Date.now() - timestamp > CACHE_EXPIRY) {
// Expired cache, clear it
GM_setValue(`cache_${key}`, "");
return null;
}
// Store in memory cache for future access
memoryCache[key] = { data, timestamp, source };
return data;
} catch (e) {
// Invalid cache data
console.warn(`GG.deals: Invalid cache data for ${key}`, e);
GM_setValue(`cache_${key}`, "");
return null;
}
},
set: function (key, data, source = "web") {
if (!data) return; // Don't cache null/undefined data
const cacheObject = {
data: data,
timestamp: Date.now(),
source: source
};
// Update memory cache
memoryCache[key] = cacheObject;
// Update persistent storage
GM_setValue(`cache_${key}`, JSON.stringify(cacheObject));
// Periodically clean old cache entries
this.cleanExpiredEntries();
},
getTimestamp: function (key) {
// Check memory cache first
if (memoryCache[key]) {
return memoryCache[key].timestamp;
}
const cached = GM_getValue(`cache_${key}`);
if (!cached) return null;
try {
return JSON.parse(cached).timestamp;
} catch (e) {
return null;
}
},
getSource: function (key) {
// Check memory cache first
if (memoryCache[key]) {
return memoryCache[key].source;
}
const cached = GM_getValue(`cache_${key}`);
if (!cached) return null;
try {
return JSON.parse(cached).source;
} catch (e) {
return null;
}
},
// Method to clean expired entries (runs occasionally)
cleanExpiredEntries: function() {
// Only run cleanup occasionally (1 in 10 chance)
if (Math.random() < 0.1) {
const now = Date.now();
// Clean memory cache
Object.keys(memoryCache).forEach(key => {
if (now - memoryCache[key].timestamp > CACHE_EXPIRY) {
delete memoryCache[key];
}
});
// We could do this for GM storage but it's expensive to enumerate all keys
// Let individual expired entries be cleared on access instead
}
}
};
// Rate limiter with memory-based tracking and cross-tab synchronization
const requestTracker = {
lastRequestTime: 0,
activeRequests: {}
};
async function rateLimitedRequest(url) {
const now = Date.now();
const urlHash = url.split('?')[0]; // Base URL without params for tracking
// Check if we have an active request for this URL
if (requestTracker.activeRequests[urlHash]) {
const activeRequest = requestTracker.activeRequests[urlHash];
try {
// Reuse the existing request
console.log(`GG.deals: Reusing in-flight request for ${urlHash}`);
return await activeRequest;
} catch (error) {
// If the existing request failed, continue with a new one
console.warn(`GG.deals: Reused request failed, trying again: ${error}`);
}
}
// Get last request time from global storage (shared between tabs)
const storedLastRequest = GM_getValue("lastRequestTime", 0);
requestTracker.lastRequestTime = Math.max(requestTracker.lastRequestTime, storedLastRequest);
const timeToWait = Math.max(0, RATE_LIMIT_DELAY - (now - requestTracker.lastRequestTime));
if (timeToWait > 0) {
await new Promise((resolve) => setTimeout(resolve, timeToWait));
}
// Update both local tracker and global storage
requestTracker.lastRequestTime = Date.now();
GM_setValue("lastRequestTime", requestTracker.lastRequestTime);
// Create a new request
const requestPromise = new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
timeout: 10000,
onload: (response) => {
// Clear the tracked request when done
delete requestTracker.activeRequests[urlHash];
resolve(response);
},
onerror: (error) => {
// Clear the tracked request when failed
delete requestTracker.activeRequests[urlHash];
reject(error);
},
ontimeout: (error) => {
// Clear the tracked request when timed out
delete requestTracker.activeRequests[urlHash];
reject(error);
}
});
});
// Track this request
requestTracker.activeRequests[urlHash] = requestPromise;
return requestPromise;
}
// New function to fetch data using GG.deals API
async function fetchGamePricesApi(steamId, steamType) {
// Check for valid API key
const apiKey = GM_getValue("apiKey", "");
if (!apiKey) {
throw new Error("No API key provided");
}
// Get preferred region
const region = GM_getValue("preferredRegion", "us");
// Set up the API URL based on the steam type
let apiUrl;
if (steamType === 'sub') {
apiUrl = `https://api.gg.deals/v1/prices/by-steam-sub-id/?ids=${steamId}&key=${apiKey}®ion=${region}`;
} else {
apiUrl = `https://api.gg.deals/v1/prices/by-steam-app-id/?ids=${steamId}&key=${apiKey}®ion=${region}`;
}
try {
const response = await rateLimitedRequest(apiUrl);
// Check for successful response
if (response.status !== 200) {
throw new Error(`API returned status ${response.status}`);
}
// Parse the JSON response
const jsonData = JSON.parse(response.responseText);
// Check for successful API response
if (!jsonData.success) {
throw new Error(jsonData.data?.message || "API error");
}
// Check if data exists for this game
if (!jsonData.data || !jsonData.data[steamId]) {
throw new Error("No data found for this game");
}
const gameData = jsonData.data[steamId];
// Handle case where game is not found in API
if (gameData === null) {
throw new Error("Game not found in GG.deals database");
}
// Get currency symbol based on region
const currencySymbols = {
us: "$", eu: "€", gb: "£", ca: "CA$", au: "A$", br: "R$",
ru: "₽", tr: "₺", pl: "zł", fr: "€", de: "€", es: "€",
it: "€", ch: "CHF", nl: "€", se: "kr", no: "kr", dk: "kr",
fi: "€", ie: "€", be: "€"
};
const currencySymbol = currencySymbols[region] || gameData.prices.currency || "";
// Format prices with currency symbol
const formatPrice = (price) => {
if (!price) return "No data";
// Check if currency is before or after the number based on region
if (["us", "ca", "au", "br"].includes(region)) {
return `${currencySymbol}${price}`;
} else {
return `${price}${currencySymbol}`;
}
};
// Format the data to match our expected structure
const formattedData = {
officialPrice: formatPrice(gameData.prices.currentRetail),
keyshopPrice: formatPrice(gameData.prices.currentKeyshops),
historicalData: [],
lowestPriceType: null,
url: gameData.url,
isCorrectGame: true
};
// Add historical data if available
if (gameData.prices.historicalRetail) {
formattedData.historicalData.push({
type: "official",
price: gameData.prices.historicalRetail,
historical: `Historical Low: ${formatPrice(gameData.prices.historicalRetail)}`
});
}
if (gameData.prices.historicalKeyshops) {
formattedData.historicalData.push({
type: "keyshop",
price: gameData.prices.historicalKeyshops,
historical: `Historical Low: ${formatPrice(gameData.prices.historicalKeyshops)}`
});
}
// Determine lowest price type
if (gameData.prices.currentRetail && gameData.prices.currentKeyshops) {
const officialPriceNum = parseFloat(gameData.prices.currentRetail);
const keyshopPriceNum = parseFloat(gameData.prices.currentKeyshops);
formattedData.lowestPriceType = officialPriceNum <= keyshopPriceNum ? "official" : "keyshop";
} else if (gameData.prices.currentRetail) {
formattedData.lowestPriceType = "official";
} else if (gameData.prices.currentKeyshops) {
formattedData.lowestPriceType = "keyshop";
}
return formattedData;
} catch (error) {
console.error("GG.deals API error:", error);
throw error;
}
}
function createPriceContainer() {
const container = document.createElement("div");
// Get the saved compact state
const isCompact = GM_getValue("compactView", false);
container.className = "gg-deals-container" + (isCompact ? " compact" : "");
container.innerHTML = `
<div class="gg-header">
<div class="gg-title">
<img src="https://gg.deals/favicon.ico" alt="GG.deals">
GG.deals Steam Companion
</div>
</div>
<div class="gg-compact-row">
<img src="https://gg.deals/favicon.ico" alt="GG.deals" class="gg-icon">
<div class="gg-compact-prices">
<div class="gg-compact-price-item" id="gg-compact-official" style="${
!toggleStates.official ? "display:none" : ""
}">
<span>Official:</span>
<span class="gg-historical-tooltip">
<span class="gg-price-value" id="gg-compact-official-price">Loading...</span>
<span class="gg-historical-tooltip-text" id="gg-compact-official-historical"></span>
</span>
</div>
<div class="gg-compact-price-item" id="gg-compact-keyshop" style="${
!toggleStates.keyshop ? "display:none" : ""
}">
<span>Keyshop:</span>
<span class="gg-historical-tooltip">
<span class="gg-price-value" id="gg-compact-keyshop-price">Loading...</span>
<span class="gg-historical-tooltip-text" id="gg-compact-keyshop-historical"></span>
</span>
</div>
</div>
<div class="gg-compact-controls">
<button class="gg-icon-button gg-refresh" title="Refresh Prices">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span class="gg-tooltip-text">Click to refresh prices</span>
</button>
<div class="gg-settings-dropdown">
<div class="gg-icon-button gg-settings-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97 0-.33-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69-.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.65.07.97l-2.11 1.65c-.19.15-.24.42-.12-.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65z"/>
</svg>
</div>
<div class="gg-settings-content">
<div class="gg-settings-section">
<div class="gg-settings-title">Display Options</div>
<label class="gg-toggle ${toggleStates.official ? "active" : ""}" title="Toggle Official Stores">
<input type="checkbox" id="gg-toggle-official-compact" ${toggleStates.official ? "checked" : ""}>
<label>Official</label>
</label>
<label class="gg-toggle ${toggleStates.keyshop ? "active" : ""}" title="Toggle Keyshops">
<input type="checkbox" id="gg-toggle-keyshop-compact" ${toggleStates.keyshop ? "checked" : ""}>
<label>Keyshops</label>
</label>
<label class="gg-toggle ${toggleStates.compact ? "active" : ""}" title="Toggle Compact View">
<input type="checkbox" id="gg-toggle-compact-menu" ${toggleStates.compact ? "checked" : ""}>
<label>Compact</label>
</label>
<label class="gg-toggle ${toggleStates.subDisplay ? "active" : ""}" title="Toggle Sub/Bundle Displays">
<input type="checkbox" id="gg-toggle-sub-display-compact" ${toggleStates.subDisplay ? "checked" : ""}>
<label>Bundle Display</label>
</label>
<label class="gg-toggle ${toggleStates.enableScraping ? "active" : ""}" title="Enable/disable web scraping for non-API requests">
<input type="checkbox" id="gg-toggle-enable-scraping-compact" ${toggleStates.enableScraping ? "checked" : ""}>
<label>Enable Scraping</label>
</label>
</div>
<div class="gg-settings-section">
<div class="gg-settings-title">API Settings</div>
<label class="gg-toggle ${toggleStates.useApi ? "active" : ""}" title="Use GG.deals API">
<input type="checkbox" id="gg-toggle-use-api-compact" ${toggleStates.useApi ? "checked" : ""}>
<label>Use API</label>
</label>
<div>
<div class="gg-api-key-wrapper">
<input type="password" id="gg-api-key-compact" class="gg-api-key-input"
placeholder="Enter your GG.deals API key" value="${apiKey}">
<button type="button" class="gg-toggle-visibility" title="Show/Hide API Key">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
</button>
</div>
<select id="gg-region-select-compact" class="gg-region-select">
${availableRegions.map(region =>
`<option value="${region.code}" ${region.code === preferredRegion ? 'selected' : ''}>${region.name}</option>`
).join('')}
</select>
<div class="gg-api-status ${apiKey && toggleStates.useApi ? "active" : "inactive"}">
API: ${apiKey && toggleStates.useApi ? "Active" : "Inactive"}
</div>
<button id="gg-save-api-key-compact" class="gg-save-button">Save Settings</button>
</div>
</div>
<div class="gg-settings-section">
<div class="gg-settings-title">Color Settings</div>
<div class="gg-color-grid">
<div class="gg-color-item">
<div class="gg-color-label">Background</div>
<input type="color" id="gg-color-background-compact" class="gg-color-input" value="${savedColors.background}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Header Background</div>
<input type="color" id="gg-color-header-bg-compact" class="gg-color-input" value="${savedColors.headerBackground}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Official Text</div>
<input type="color" id="gg-color-official-text-compact" class="gg-color-input" value="${savedColors.officialText}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Official Price</div>
<input type="color" id="gg-color-official-price-compact" class="gg-color-input" value="${savedColors.officialPrice}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Keyshop Text</div>
<input type="color" id="gg-color-keyshop-text-compact" class="gg-color-input" value="${savedColors.keyshopText}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Keyshop Price</div>
<input type="color" id="gg-color-keyshop-price-compact" class="gg-color-input" value="${savedColors.keyshopPrice}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Best Price</div>
<input type="color" id="gg-color-best-price-compact" class="gg-color-input" value="${savedColors.bestPrice}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Button Background</div>
<input type="text" id="gg-color-button-bg-compact" class="gg-api-key-input" value="${savedColors.buttonBackground}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Button Text</div>
<input type="color" id="gg-color-button-text-compact" class="gg-color-input" value="${savedColors.buttonText}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Border Color</div>
<input type="color" id="gg-color-border-compact" class="gg-color-input" value="${savedColors.borderColor.replace('30', '')}">
</div>
</div>
<button id="gg-save-colors-compact" class="gg-save-button">Save Colors</button>
<button id="gg-reset-colors-compact" class="gg-reset-colors">Reset to Default</button>
</div>
</div>
</div>
<a href="#" target="_blank" class="gg-view-offers">View Offers</a>
</div>
</div>
<div class="gg-price-sections">
<div class="gg-price-section ${
toggleStates.official ? "" : "hidden"
}" id="gg-official-section">
<div class="gg-price-left">
<span class="gg-price-label">
<img src="https://gg.deals/favicon.ico" class="gg-icon">
Official Stores
</span>
</div>
<div class="gg-price-info">
<span class="gg-price-value" id="gg-official-price">Loading...</span>
<span class="gg-price-value historical" id="gg-official-historical"></span>
</div>
</div>
<div class="gg-price-section ${
toggleStates.keyshop ? "" : "hidden"
}" id="gg-keyshop-section">
<div class="gg-price-left">
<span class="gg-price-label">
<img src="https://gg.deals/favicon.ico" class="gg-icon">
Keyshops
</span>
</div>
<div class="gg-price-info">
<span class="gg-price-value" id="gg-keyshop-price">Loading...</span>
<span class="gg-price-value historical" id="gg-keyshop-historical"></span>
</div>
</div>
</div>
<div class="gg-controls">
<div class="gg-main-actions">
<a href="#" target="_blank" class="gg-view-offers">View Offers</a>
<button class="gg-icon-button gg-refresh" title="Refresh Prices">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span class="gg-tooltip-text">Click to refresh prices</span>
</button>
</div>
<div class="gg-settings-panels">
<div class="gg-settings-section">
<div class="gg-settings-title">Display Options</div>
<label class="gg-toggle ${toggleStates.official ? "active" : ""}" title="Toggle Official Stores">
<input type="checkbox" id="gg-toggle-official" ${toggleStates.official ? "checked" : ""}>
<label>Official</label>
</label>
<label class="gg-toggle ${toggleStates.keyshop ? "active" : ""}" title="Toggle Keyshops">
<input type="checkbox" id="gg-toggle-keyshop" ${toggleStates.keyshop ? "checked" : ""}>
<label>Keyshops</label>
</label>
<label class="gg-toggle ${toggleStates.compact ? "active" : ""}" title="Toggle Compact View">
<input type="checkbox" id="gg-toggle-compact" ${toggleStates.compact ? "checked" : ""}>
<label>Compact</label>
</label>
<label class="gg-toggle ${toggleStates.subDisplay ? "active" : ""}" title="Toggle Sub/Bundle Displays">
<input type="checkbox" id="gg-toggle-sub-display" ${toggleStates.subDisplay ? "checked" : ""}>
<label>Bundle Display</label>
</label>
<label class="gg-toggle ${toggleStates.enableScraping ? "active" : ""}" title="Enable/disable web scraping for non-API requests">
<input type="checkbox" id="gg-toggle-enable-scraping" ${toggleStates.enableScraping ? "checked" : ""}>
<label>Enable Scraping</label>
</label>
</div>
<div class="gg-settings-section">
<div class="gg-settings-title">API Settings</div>
<label class="gg-toggle ${toggleStates.useApi ? "active" : ""}" title="Use GG.deals API">
<input type="checkbox" id="gg-toggle-use-api" ${toggleStates.useApi ? "checked" : ""}>
<label>Use API</label>
</label>
<div>
<div class="gg-api-key-wrapper">
<input type="password" id="gg-api-key" class="gg-api-key-input"
placeholder="Enter your GG.deals API key" value="${apiKey}">
<button type="button" class="gg-toggle-visibility" title="Show/Hide API Key">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
</button>
</div>
<select id="gg-region-select" class="gg-region-select">
${availableRegions.map(region =>
`<option value="${region.code}" ${region.code === preferredRegion ? 'selected' : ''}>${region.name}</option>`
).join('')}
</select>
<div class="gg-api-status ${apiKey && toggleStates.useApi ? "active" : "inactive"}">
API: ${apiKey && toggleStates.useApi ? "Active" : "Inactive"}
</div>
<button id="gg-save-api-key" class="gg-save-button">Save Settings</button>
</div>
</div>
<div class="gg-settings-section">
<div class="gg-settings-title">Color Settings</div>
<div class="gg-color-grid">
<div class="gg-color-item">
<div class="gg-color-label">Background</div>
<input type="color" id="gg-color-background" class="gg-color-input" value="${savedColors.background}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Header Background</div>
<input type="color" id="gg-color-header-bg" class="gg-color-input" value="${savedColors.headerBackground}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Official Text</div>
<input type="color" id="gg-color-official-text" class="gg-color-input" value="${savedColors.officialText}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Official Price</div>
<input type="color" id="gg-color-official-price" class="gg-color-input" value="${savedColors.officialPrice}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Keyshop Text</div>
<input type="color" id="gg-color-keyshop-text" class="gg-color-input" value="${savedColors.keyshopText}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Keyshop Price</div>
<input type="color" id="gg-color-keyshop-price" class="gg-color-input" value="${savedColors.keyshopPrice}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Best Price</div>
<input type="color" id="gg-color-best-price" class="gg-color-input" value="${savedColors.bestPrice}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Button Background</div>
<input type="text" id="gg-color-button-bg" class="gg-api-key-input" value="${savedColors.buttonBackground}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Button Text</div>
<input type="color" id="gg-color-button-text" class="gg-color-input" value="${savedColors.buttonText}">
</div>
<div class="gg-color-item">
<div class="gg-color-label">Border Color</div>
<input type="color" id="gg-color-border" class="gg-color-input" value="${savedColors.borderColor.replace('30', '')}">
</div>
</div>
<button id="gg-save-colors" class="gg-save-button">Save Colors</button>
<button id="gg-reset-colors" class="gg-reset-colors">Reset to Default</button>
</div>
</div>
</div>
<div class="gg-attribution">
Extension by <a href="https://steamcommunity.com/profiles/76561199186030286">Crimsab</a>
<a href="https://github.com/Crimsab/ggdeals-steam-companion" title="View on GitHub">
<svg class="github-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a> ·
Data by <a href="https://gg.deals">gg.deals</a>
<span id="gg-api-indicator" class="gg-api-status ${toggleStates.useApi && apiKey ? "active" : "inactive"}" style="margin-left: 5px; display: inline-block;">
${toggleStates.useApi && apiKey ? "· API Active" : ""}
</span>
</div>
`;
// Add toggle listeners for both sets of controls
const toggleOfficialCompact = container.querySelector(
"#gg-toggle-official-compact"
);
const toggleKeyshopCompact = container.querySelector(
"#gg-toggle-keyshop-compact"
);
const toggleCompactMenu = container.querySelector(
"#gg-toggle-compact-menu"
);
const toggleOfficial = container.querySelector("#gg-toggle-official");
const toggleKeyshop = container.querySelector("#gg-toggle-keyshop");
const toggleCompact = container.querySelector("#gg-toggle-compact");
const toggleSubDisplay = container.querySelector("#gg-toggle-sub-display");
function updateToggleState(type, checked) {
toggleStates[type] = checked;
if (type === "compact") {
GM_setValue("compactView", checked);
} else if (type === "useApi") {
GM_setValue("useApi", checked);
} else if (type === "enableScraping") {
// Make sure to explicitly save enableScraping setting
GM_setValue("enableScraping", checked);
console.log(`GG.deals: Saving enableScraping = ${checked}`);
} else {
GM_setValue(`show${type.charAt(0).toUpperCase() + type.slice(1)}`, checked);
}
if (type === "official" || type === "keyshop") {
container.querySelector(`#gg-compact-${type}`).style.display = checked
? ""
: "none";
container
.querySelector(`#gg-${type}-section`)
.classList.toggle("hidden", !checked);
} else if (type === "compact") {
// Update all containers on the page, preserving sub-display containers
document.querySelectorAll('.gg-deals-container').forEach(cont => {
// Skip sub-display containers if we're switching to full view
if (!checked && cont.classList.contains('bundle-sub-display')) {
return;
}
cont.classList.toggle("compact", checked);
});
} else if (type === "subDisplay") {
document.querySelectorAll('.gg-deals-container.bundle-sub-display').forEach(el => {
el.style.display = checked ? "" : "none";
});
}
// Update all related toggle buttons
document.querySelectorAll(`input[id*=toggle-${type}]`).forEach((input) => {
input.checked = checked;
input.closest(".gg-toggle").classList.toggle("active", checked);
});
}
// Add event listeners for all toggles
[toggleOfficialCompact, toggleOfficial].forEach((toggle) => {
if (toggle) {
toggle.addEventListener("change", (e) =>
updateToggleState("official", e.target.checked)
);
}
});
[toggleKeyshopCompact, toggleKeyshop].forEach((toggle) => {
if (toggle) {
toggle.addEventListener("change", (e) =>
updateToggleState("keyshop", e.target.checked)
);
}
});
[toggleCompactMenu, toggleCompact].forEach((toggle) => {
if (toggle) {
toggle.addEventListener("change", (e) =>
updateToggleState("compact", e.target.checked)
);
}
});
const toggleSubDisplayCompact = container.querySelector("#gg-toggle-sub-display-compact");
[toggleSubDisplay, toggleSubDisplayCompact].forEach((toggle) => {
if (toggle) {
toggle.addEventListener("change", (e) => updateToggleState("subDisplay", e.target.checked));
}
});
// Add web scraping toggle event listeners
const toggleEnableScraping = container.querySelector("#gg-toggle-enable-scraping");
const toggleEnableScrapingCompact = container.querySelector("#gg-toggle-enable-scraping-compact");
[toggleEnableScraping, toggleEnableScrapingCompact].forEach((toggle) => {
if (toggle) {
toggle.addEventListener("change", (e) => {
updateToggleState("enableScraping", e.target.checked);
});
}
});
// Add API toggle event listeners
const toggleUseApi = container.querySelector("#gg-toggle-use-api");
const toggleUseApiCompact = container.querySelector("#gg-toggle-use-api-compact");
[toggleUseApi, toggleUseApiCompact].forEach((toggle) => {
if (toggle) {
toggle.addEventListener("change", (e) => {
updateToggleState("useApi", e.target.checked);
// Update API status text in settings
document.querySelectorAll('.gg-api-status:not(#gg-api-indicator)').forEach(status => {
status.classList.toggle('active', e.target.checked && apiKey);
status.classList.toggle('inactive', !e.target.checked || !apiKey);
status.textContent = `API: ${e.target.checked && apiKey ? "Active" : "Inactive"}`;
});
// Update API indicator in attribution
const apiIndicator = document.getElementById('gg-api-indicator');
if (apiIndicator) {
apiIndicator.classList.toggle('active', e.target.checked && apiKey);
apiIndicator.classList.toggle('inactive', !e.target.checked || !apiKey);
apiIndicator.textContent = e.target.checked && apiKey ? "· API Active" : "";
}
});
}
});
// Add API key visibility toggle listeners
const toggleVisibilityBtns = container.querySelectorAll(".gg-toggle-visibility");
toggleVisibilityBtns.forEach(btn => {
if (btn) {
btn.addEventListener("click", (e) => {
// Stop event propagation to prevent closing the settings dropdown
e.stopPropagation();
// Find the corresponding input field (sibling of parent element)
const inputField = btn.closest(".gg-api-key-wrapper").querySelector(".gg-api-key-input");
if (inputField) {
// Toggle between password and text type
inputField.type = inputField.type === "password" ? "text" : "password";
// Update the icon to reflect the current state
const eyeIcon = btn.querySelector("svg");
if (eyeIcon) {
if (inputField.type === "password") {
// Show the "eye" icon to indicate the user can click to show the password
eyeIcon.innerHTML = '<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>';
} else {
// Show the "eye-off" icon to indicate the user can click to hide the password
eyeIcon.innerHTML = '<path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/>';
}
}
}
});
}
});
// Add save API key button event listeners
const saveApiKeyBtn = container.querySelector("#gg-save-api-key");
const saveApiKeyBtnCompact = container.querySelector("#gg-save-api-key-compact");
const apiKeyInput = container.querySelector("#gg-api-key");
const apiKeyInputCompact = container.querySelector("#gg-api-key-compact");
const regionSelect = container.querySelector("#gg-region-select");
const regionSelectCompact = container.querySelector("#gg-region-select-compact");
[saveApiKeyBtn, saveApiKeyBtnCompact].forEach(btn => {
if (btn) {
btn.addEventListener("click", () => {
// Get value from the inputs in the same container
const isCompact = btn.id.includes('compact');
const input = isCompact ? apiKeyInputCompact : apiKeyInput;
const regionInput = isCompact ? regionSelectCompact : regionSelect;
const newApiKey = input.value.trim();
const newRegion = regionInput.value;
// Save to both inputs
if (apiKeyInput) apiKeyInput.value = newApiKey;
if (apiKeyInputCompact) apiKeyInputCompact.value = newApiKey;
// Update region selectors
if (regionSelect && regionSelectCompact) {
regionSelect.value = newRegion;
regionSelectCompact.value = newRegion;
}
// Save to GM storage
GM_setValue("apiKey", newApiKey);
GM_setValue("preferredRegion", newRegion);
// Update global variables
window.ggDealsApiKey = newApiKey;
window.ggDealsRegion = newRegion;
// Clear cache to reflect updated region
document.querySelectorAll('.gg-deals-container').forEach(container => {
if (container.id) {
const match = container.id.match(/gg-deals-(app|sub|bundle)-(\d+)/);
if (match) {
const [, containerType, containerId] = match;
priceCache.get(`${containerType}_${containerId}`, true);
}
}
});
// Update status in settings
document.querySelectorAll('.gg-api-status:not(#gg-api-indicator)').forEach(status => {
const isActive = toggleStates.useApi && newApiKey;
status.classList.toggle('active', isActive);
status.classList.toggle('inactive', !isActive);
status.textContent = `API: ${isActive ? "Active" : "Inactive"}`;
});
// Update API indicator in attribution
const apiIndicator = document.getElementById('gg-api-indicator');
if (apiIndicator) {
const isActive = toggleStates.useApi && newApiKey;
apiIndicator.classList.toggle('active', isActive);
apiIndicator.classList.toggle('inactive', !isActive);
apiIndicator.textContent = isActive ? "· API Active" : "";
}
// Refresh prices with new region
if (toggleStates.useApi && newApiKey) {
document.querySelectorAll('.gg-refresh').forEach(refreshBtn => {
refreshBtn.click();
});
}
});
}
});
// Add color setting event listeners
const saveColorsBtn = container.querySelector("#gg-save-colors");
const saveColorsBtnCompact = container.querySelector("#gg-save-colors-compact");
const resetColorsBtn = container.querySelector("#gg-reset-colors");
const resetColorsBtnCompact = container.querySelector("#gg-reset-colors-compact");
// Function to get all color inputs
function getAllColorInputs(compact = false) {
const suffix = compact ? "-compact" : "";
return {
background: container.querySelector(`#gg-color-background${suffix}`),
headerBackground: container.querySelector(`#gg-color-header-bg${suffix}`),
officialText: container.querySelector(`#gg-color-official-text${suffix}`),
officialPrice: container.querySelector(`#gg-color-official-price${suffix}`),
keyshopText: container.querySelector(`#gg-color-keyshop-text${suffix}`),
keyshopPrice: container.querySelector(`#gg-color-keyshop-price${suffix}`), // Added keyshopPrice
bestPrice: container.querySelector(`#gg-color-best-price${suffix}`),
buttonBackground: container.querySelector(`#gg-color-button-bg${suffix}`), // Added buttonBackground
buttonText: container.querySelector(`#gg-color-button-text${suffix}`),
borderColor: container.querySelector(`#gg-color-border${suffix}`)
};
}
// Function to save colors from a set of inputs
function saveColorsFromInputs(compact = false) {
const inputs = getAllColorInputs(compact);
const nonCompactInputs = getAllColorInputs(false);
const compactInputs = getAllColorInputs(true);
// Get the values from the inputs
Object.keys(inputs).forEach(key => {
if (inputs[key]) {
let value = inputs[key].value;
// Special handling for border color (add transparency)
if (key === 'borderColor') {
// Convert hex to hex with alpha
value = value.replace('#', '#') + '30';
}
// Save to storage
savedColors[key] = value;
GM_setValue(`color_${key}`, value);
// Update both sets of inputs
if (nonCompactInputs[key]) {
nonCompactInputs[key].value = inputs[key].value;
}
if (compactInputs[key]) {
compactInputs[key].value = inputs[key].value;
}
}
});
// Apply the new colors
applyCustomColors();
}
// Function to reset colors to default
function resetColorsToDefault() {
// Reset all colors to default
Object.keys(defaultColors).forEach(key => {
savedColors[key] = defaultColors[key];
GM_setValue(`color_${key}`, defaultColors[key]);
});
// Update all inputs
const nonCompactInputs = getAllColorInputs(false);
const compactInputs = getAllColorInputs(true);
Object.keys(defaultColors).forEach(key => {
let displayValue = defaultColors[key];
// Special handling for border color (remove transparency for display)
// For button background, which is a text input, just use the value directly.
if (key === 'borderColor') {
displayValue = defaultColors[key].replace('30', '');
}
if (nonCompactInputs[key]) {
nonCompactInputs[key].value = displayValue;
}
if (compactInputs[key]) {
compactInputs[key].value = displayValue;
}
});
// Apply the default colors
applyCustomColors();
}
// Add save colors button listeners
[saveColorsBtn, saveColorsBtnCompact].forEach(btn => {
if (btn) {
btn.addEventListener("click", () => {
const isCompact = btn.id.includes('compact');
saveColorsFromInputs(isCompact);
});
}
});
// Add reset colors button listeners
[resetColorsBtn, resetColorsBtnCompact].forEach(btn => {
if (btn) {
btn.addEventListener("click", resetColorsToDefault);
}
});
// Add refresh button listeners to both compact and full view buttons
container.querySelectorAll(".gg-refresh").forEach(refreshButton => {
const refreshText = refreshButton.querySelector(".gg-tooltip-text");
refreshButton.addEventListener("click", async function () {
refreshButton.classList.add("loading");
refreshButton.disabled = true;
try {
// First check if this container has its own ID/type information
let type, id;
if (container.id) {
const containerMatch = container.id.match(/gg-deals-(app|sub|bundle)-(\d+)/);
if (containerMatch) {
[, type, id] = containerMatch;
}
}
// If no container-specific info, use the URL
if (!type || !id) {
const urlMatch = window.location.pathname.match(/\/(app|sub|bundle)\/(\d+)/);
if (urlMatch) {
[, type, id] = urlMatch;
} else {
throw new Error("Could not determine game ID");
}
}
// Force a refresh by clearing the cache first
const cacheKey = `${type}_${id}`;
priceCache.get(cacheKey, true);
// Fetch fresh data
await fetchGamePrices(null, container.id, true, { type, id });
// Check the data source after update
const dataSource = priceCache.getSource(`${type}_${id}`) || "web";
const sourceText = dataSource === "api" ? "API" : "Web";
refreshText.textContent = `Updated just now (via ${sourceText})`;
setTimeout(() => {
refreshText.textContent = "";
}, 3000);
} catch (error) {
console.error("Failed to refresh prices:", error);
refreshText.textContent = "Refresh failed";
setTimeout(() => {
refreshText.textContent = "";
}, 3000);
} finally {
refreshButton.classList.remove("loading");
refreshButton.disabled = false;
}
});
});
// Add settings dropdown toggle
const settingsIcon = container.querySelector(".gg-settings-icon");
const settingsContent = container.querySelector(".gg-settings-content");
settingsIcon.addEventListener("click", (e) => {
e.stopPropagation();
settingsContent.classList.toggle("show");
});
// Close settings dropdown when clicking outside
document.addEventListener("click", (e) => {
if (!settingsContent.contains(e.target) && !settingsIcon.contains(e.target)) {
settingsContent.classList.remove("show");
}
});
// Update last refresh time if cached data exists
const urlMatch = window.location.pathname.match(/\/(app|sub|bundle)\/(\d+)/);
if (urlMatch) {
const [, type, id] = urlMatch;
const timestamp = priceCache.getTimestamp(`${type}_${id}`);
if (timestamp) {
// Update all refresh tooltips with the timestamp and source
container.querySelectorAll('.gg-refresh').forEach(refreshButton => {
const tooltipSpan = refreshButton.querySelector('.gg-tooltip-text');
if (tooltipSpan) {
const timeAgo = Math.floor((Date.now() - timestamp) / 60000); // minutes
// Get source of data (API or web scraping)
const source = priceCache.getSource(`${type}_${id}`) || "web";
const sourceText = source === "api" ? "API" : "Web";
// Format time ago text
let timeText;
if (timeAgo < 60) {
timeText = `${timeAgo}m ago`;
} else {
const hoursAgo = Math.floor(timeAgo / 60);
timeText = `${hoursAgo}h ago`;
}
tooltipSpan.textContent = `Updated ${timeText} (via ${sourceText})`;
}
});
}
}
return container;
}
// Improved error handling and retries
async function fetchWithRetry(url, retries = MAX_RETRIES) {
try {
const response = await rateLimitedRequest(url);
if (response.status === 200) {
return response;
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (retries > 0) {
await new Promise((resolve) => setTimeout(resolve, 1000));
return fetchWithRetry(url, retries - 1);
}
throw error;
}
}
async function fetchGamePrices(gameTitle, containerId, forceRefresh = false, idInfo = null) {
let type, id;
if (idInfo) {
type = idInfo.type;
id = idInfo.id;
} else {
// First try to get ID from the container itself
const container = document.getElementById(containerId);
if (container) {
const purchaseGame = container.closest('.game_area_purchase_game');
if (purchaseGame) {
const bundleInput = purchaseGame.querySelector('input[name="bundleid"]');
const subInput = purchaseGame.querySelector('input[name="subid"]');
if (bundleInput) {
type = 'bundle';
id = bundleInput.value;
} else if (subInput) {
type = 'sub';
id = subInput.value;
}
}
}
// If no ID found from container, try URL
if (!type || !id) {
const urlMatch = window.location.pathname.match(/\/(app|sub|bundle)\/(\d+)/);
if (!urlMatch) {
console.warn("GG.deals: Could not find Steam ID");
return;
}
[, type, id] = urlMatch;
}
}
const cacheKey = `${type}_${id}`;
// Track active requests to prevent duplicate requests for the same game
const pendingRequestKey = `pending_${cacheKey}`;
if (window[pendingRequestKey] && !forceRefresh) {
console.log(`GG.deals: Request for ${type} ${id} already in progress, waiting...`);
try {
await window[pendingRequestKey];
// After the pending request completes, get the cached data
const cachedAfterWait = priceCache.get(cacheKey);
if (cachedAfterWait) {
updatePriceDisplay(cachedAfterWait, containerId);
return;
}
} catch (error) {
console.warn(`GG.deals: Error waiting for pending request: ${error}`);
// Continue with a new request if the pending one failed
}
}
// Check cache before making any requests
const cachedData = priceCache.get(cacheKey, forceRefresh);
if (cachedData) {
updatePriceDisplay(cachedData, containerId);
return;
}
// Create a promise for this request that other potential duplicate requests can await
const requestPromise = (async () => {
try {
// If forcing refresh, clear cache for all containers on the page
if (forceRefresh) {
document.querySelectorAll('.gg-deals-container').forEach(container => {
if (container.id && container.id !== containerId) {
const match = container.id.match(/gg-deals-(app|sub|bundle)-(\d+)/);
if (match) {
const [, containerType, containerId] = match;
priceCache.get(`${containerType}_${containerId}`, true);
}
}
});
}
// Check if API should be used
const useApi = GM_getValue("useApi", false);
const apiKey = GM_getValue("apiKey", "");
const enableScraping = GM_getValue("enableScraping", true);
// Try to use API if enabled and key is available
// Note: API works for app IDs and sub IDs, not bundle IDs
// Bundle pages always use web scraping regardless of API settings
if (useApi && apiKey && (type === 'app' || type === 'sub')) {
try {
// Use the ID directly, whether it's an app or sub ID
const itemId = id;
console.log(`GG.deals: Using API to fetch prices for ${type} ${id}`);
const data = await fetchGamePricesApi(itemId, type);
if (data) {
priceCache.set(cacheKey, data, "api");
updatePriceDisplay(data, containerId);
return;
}
} catch (error) {
console.warn("GG.deals API fetch failed:", error);
// If API fails and scraping is disabled, hide the container
if (!enableScraping) {
const noDataResult = {
officialPrice: "No data",
keyshopPrice: "No data",
historicalData: [],
lowestPriceType: null,
url: `https://gg.deals/steam/${type}/${id}/`,
isCorrectGame: true,
noData: true // Flag to indicate there's no data
};
priceCache.set(cacheKey, noDataResult, "api");
// Hide the container instead of updating with "No data"
const container = document.getElementById(containerId);
if (container) {
container.style.display = "none";
}
return;
}
// Otherwise fall back to web scraping if enabled
}
}
// If scraping is disabled, hide the container entirely
if (!enableScraping) {
const noDataResult = {
officialPrice: "No data",
keyshopPrice: "No data",
historicalData: [],
lowestPriceType: null,
url: `https://gg.deals/steam/${type}/${id}/`,
isCorrectGame: true,
noData: true // Flag to indicate there's no data
};
priceCache.set(cacheKey, noDataResult, "web");
// Hide the container instead of updating with "No data"
const container = document.getElementById(containerId);
if (container) {
container.style.display = "none";
}
return;
}
// If we get here, we're either:
// 1. Using web scraping for any type (scraping enabled)
// 2. Not using API at all
// Batch requests for performance
await new Promise(resolve => setTimeout(resolve, Math.random() * 100));
// Function to convert game name to URL slug
const toUrlSlug = (name) => {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
};
// Define base URL formats - only try one format unless necessary
let urlFormats = [];
if (type === 'bundle') {
// For bundles, only try the bundle format
urlFormats = [{ type: 'bundle', id: id }];
} else if (type === 'app') {
// For apps, only try the app format
urlFormats = [{ type: 'app', id: id }];
} else if (type === 'sub') {
// For subs, try both sub and app formats since GG.deals sometimes has subs under app URLs
urlFormats = [
{ type: 'sub', id: id },
{ type: 'app', id: id } // Try app format as fallback
];
}
// Try each URL format
for (const format of urlFormats) {
try {
const steamUrl = `https://gg.deals/steam/${format.type}/${format.id}/`;
const response = await fetchWithRetry(steamUrl);
const data = extractPriceData(response.responseText);
if (data && data.officialPrice !== "No data") {
priceCache.set(cacheKey, data, "web");
updatePriceDisplay(data, containerId);
return;
}
} catch (error) {
console.warn(`GG.deals ${format.type} URL fetch failed:`, error);
}
}
// If the direct Steam URL didn't work, hide the container
const noDataResult = {
officialPrice: "No data",
keyshopPrice: "No data",
historicalData: [],
lowestPriceType: null,
url: `https://gg.deals/steam/${type}/${id}/`,
isCorrectGame: true,
noData: true // Flag to indicate there's no data
};
priceCache.set(cacheKey, noDataResult, "web");
// Hide the container
const container = document.getElementById(containerId);
if (container) {
container.style.display = "none";
}
} finally {
// Clear the pending request marker when done
window[pendingRequestKey] = null;
}
})();
// Store the promise so other requests for the same item can wait for it
window[pendingRequestKey] = requestPromise;
// Execute the promise
await requestPromise;
}
function extractPriceData(html, expectedGameName) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Get the actual game name from the page
const pageGameName = doc.querySelector('.game-info-title')?.textContent?.trim() ||
doc.querySelector('.game-header-title')?.textContent?.trim();
// Check if we got the correct game
const isCorrectGame = !expectedGameName || !pageGameName ||
pageGameName.toLowerCase().includes(expectedGameName.toLowerCase()) ||
expectedGameName.toLowerCase().includes(pageGameName.toLowerCase());
// Check if it's a valid game page
if (!doc.querySelector('.game-info-price-col')) {
return {
officialPrice: "No data",
keyshopPrice: "No data",
historicalData: [],
lowestPriceType: null,
url: doc.querySelector('link[rel="canonical"]')?.href || "https://gg.deals",
isCorrectGame
};
}
// Find current prices (non-historical)
let officialPrice = "No data";
let keyshopPrice = "No data";
// Look for current prices in the main price sections (not historical)
const currentPriceSections = Array.from(doc.querySelectorAll('.game-info-price-col')).filter(
section => !section.classList.contains('historical')
);
currentPriceSections.forEach(section => {
const label = section.querySelector('.game-info-price-label')?.textContent.trim();
const price = section.querySelector('.price-inner.numeric')?.textContent.trim();
if (label?.includes('Official Stores')) {
officialPrice = price || "No data";
} else if (label?.includes('Keyshops')) {
keyshopPrice = price || "No data";
}
});
// Historical lows (separate section)
const historicalPrices = doc.querySelectorAll(
".game-info-price-col.historical.game-header-price-box"
);
const historicalData = [];
historicalPrices.forEach((priceBox) => {
const label = priceBox
.querySelector(".game-info-price-label")
?.textContent.trim();
const price = priceBox
.querySelector(".price-inner.numeric")
?.textContent.trim();
let date = priceBox
.querySelector(".game-price-active-label")
?.textContent.trim();
date = date?.replace("Expired", "").trim();
if (!price || !date) return;
const historicalText = `Historical Low: ${price} (${date})`;
if (label?.includes("Official Stores Low")) {
historicalData.push({
type: "official",
price: price,
historical: historicalText,
});
} else if (label?.includes("Keyshops Low") && keyshopPrice !== "No data") {
historicalData.push({
type: "keyshop",
price: price,
historical: historicalText,
});
}
});
// Compare current prices (not historical) to determine the lowest
const officialPriceNum = parseFloat(
officialPrice.replace(/[^0-9,.]/g, "").replace(",", ".")
);
const keyshopPriceNum = parseFloat(
keyshopPrice.replace(/[^0-9,.]/g, "").replace(",", ".")
);
let lowestPriceType = null;
if (!isNaN(officialPriceNum) && !isNaN(keyshopPriceNum)) {
lowestPriceType =
officialPriceNum <= keyshopPriceNum ? "official" : "keyshop";
} else if (!isNaN(officialPriceNum)) {
lowestPriceType = "official";
} else if (!isNaN(keyshopPriceNum)) {
lowestPriceType = "keyshop";
}
// Get the current URL for the "View Offers" link
const currentUrl = doc.querySelector('link[rel="canonical"]')?.href || "https://gg.deals";
return {
officialPrice: officialPrice,
keyshopPrice: keyshopPrice,
historicalData: historicalData,
lowestPriceType: lowestPriceType,
url: currentUrl,
isCorrectGame
};
}
function updatePriceDisplay(data, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
// If data has the noData flag, hide the container and return
if (data && data.noData) {
container.style.display = "none";
return;
}
// Update all View Offers links in the container
const links = container.querySelectorAll(".gg-view-offers");
if (data) {
// Update prices based on container type
if (container.classList.contains('bundle-sub-display')) {
// Update compact display
const officialPrice = container.querySelector('.gg-compact-official-price');
const keyshopPrice = container.querySelector('.gg-compact-keyshop-price');
const officialHistorical = container.querySelector('.gg-compact-official-historical');
const keyshopHistorical = container.querySelector('.gg-compact-keyshop-historical');
if (officialPrice) officialPrice.textContent = data.officialPrice;
if (keyshopPrice) keyshopPrice.textContent = data.keyshopPrice;
// Show historical data regardless of current price status
if (officialHistorical) {
const officialHistData = data.historicalData.find(h => h.type === 'official');
officialHistorical.textContent = officialHistData?.historical || '';
}
if (keyshopHistorical) {
const keyshopHistData = data.historicalData.find(h => h.type === 'keyshop');
keyshopHistorical.textContent = keyshopHistData?.historical || '';
}
// Update best price indicators
if (officialPrice) officialPrice.classList.remove('best-price');
if (keyshopPrice) keyshopPrice.classList.remove('best-price');
if (data.lowestPriceType === 'official' && officialPrice) {
officialPrice.classList.add('best-price');
} else if (data.lowestPriceType === 'keyshop' && keyshopPrice) {
keyshopPrice.classList.add('best-price');
}
} else {
// Update full display
const elements = {
official: {
price: container.querySelector("#gg-official-price"),
historical: container.querySelector("#gg-official-historical"),
compactPrice: container.querySelector("#gg-compact-official-price"),
compactHistorical: container.querySelector("#gg-compact-official-historical")
},
keyshop: {
price: container.querySelector("#gg-keyshop-price"),
historical: container.querySelector("#gg-keyshop-historical"),
compactPrice: container.querySelector("#gg-compact-keyshop-price"),
compactHistorical: container.querySelector("#gg-compact-keyshop-historical")
}
};
// Update prices
if (elements.official.price) elements.official.price.textContent = data.officialPrice;
if (elements.keyshop.price) elements.keyshop.price.textContent = data.keyshopPrice;
if (elements.official.compactPrice) elements.official.compactPrice.textContent = data.officialPrice;
if (elements.keyshop.compactPrice) elements.keyshop.compactPrice.textContent = data.keyshopPrice;
// Update historical data regardless of current price status
const officialHistData = data.historicalData.find(h => h.type === 'official');
const keyshopHistData = data.historicalData.find(h => h.type === 'keyshop');
if (elements.official.historical) {
elements.official.historical.textContent = officialHistData?.historical || '';
}
if (elements.keyshop.historical) {
elements.keyshop.historical.textContent = keyshopHistData?.historical || '';
}
if (elements.official.compactHistorical) {
elements.official.compactHistorical.textContent = officialHistData?.historical || '';
}
if (elements.keyshop.compactHistorical) {
elements.keyshop.compactHistorical.textContent = keyshopHistData?.historical || '';
}
// Update best price indicators
[elements.official.price, elements.official.compactPrice, elements.keyshop.price, elements.keyshop.compactPrice].forEach(el => {
if (el) el.classList.remove('best-price');
});
if (data.lowestPriceType === 'official') {
[elements.official.price, elements.official.compactPrice].forEach(el => {
if (el) el.classList.add('best-price');
});
} else if (data.lowestPriceType === 'keyshop') {
[elements.keyshop.price, elements.keyshop.compactPrice].forEach(el => {
if (el) el.classList.add('best-price');
});
}
}
// Update all View Offers links
if (data.url) {
links.forEach(link => {
link.href = data.url;
});
}
} else {
// Handle error state
const priceElements = container.querySelectorAll('.gg-price-value:not(.historical)');
priceElements.forEach(el => {
el.textContent = 'Not found';
});
const historicalElements = container.querySelectorAll('.gg-historical-tooltip-text, .gg-price-value.historical');
historicalElements.forEach(el => {
el.textContent = '';
});
// Set default URL for all View Offers links
links.forEach(link => {
link.href = `https://gg.deals/steam/${type}/${id}/`;
});
}
}
function createCompactPriceDisplay(containerId) {
const container = document.createElement('div');
container.className = 'gg-deals-container compact bundle-sub-display';
container.id = containerId;
container.style.display = toggleStates.subDisplay ? "" : "none";
container.innerHTML = `
<div class="gg-compact-row">
<img src="https://gg.deals/favicon.ico" alt="GG.deals" class="gg-icon">
<div class="gg-compact-prices">
<div class="gg-compact-price-item gg-compact-official" style="${!toggleStates.official ? "display:none" : ""}">
<span>Official:</span>
<span class="gg-historical-tooltip">
<span class="gg-price-value gg-compact-official-price">Loading...</span>
<span class="gg-historical-tooltip-text gg-compact-official-historical"></span>
</span>
</div>
<div class="gg-compact-price-item gg-compact-keyshop" style="${!toggleStates.keyshop ? "display:none" : ""}">
<span>Keyshop:</span>
<span class="gg-historical-tooltip">
<span class="gg-price-value gg-compact-keyshop-price">Loading...</span>
<span class="gg-historical-tooltip-text gg-compact-keyshop-historical"></span>
</span>
</div>
</div>
<div class="gg-compact-controls">
<a href="#" target="_blank" class="gg-view-offers">View Offers</a>
</div>
</div>
`;
return container;
}
// Wait for Steam page to fully load (including age gate) and handle tab visibility
let isInitialized = false;
// Queue for batching price requests
const requestQueue = {
items: [],
processing: false,
add: function(item) {
this.items.push(item);
if (!this.processing) {
this.processQueue();
}
},
processQueue: async function() {
if (this.items.length === 0) {
this.processing = false;
return;
}
this.processing = true;
// Process items in batches of 3 with a small delay between batches
const BATCH_SIZE = 3;
const BATCH_DELAY = 300; // ms
// Process a batch
const batch = this.items.splice(0, BATCH_SIZE);
const promises = batch.map(item => {
return fetchGamePrices(
item.gameTitle,
item.containerId,
item.forceRefresh,
item.idInfo
).catch(err => {
console.warn(`GG.deals: Error processing queue item:`, err);
// Update the display to show error
const container = document.getElementById(item.containerId);
if (container) {
const priceElements = container.querySelectorAll('.gg-price-value:not(.historical)');
priceElements.forEach(el => {
el.textContent = 'Error';
});
}
});
});
// Wait for batch to complete
await Promise.all(promises);
// Small delay before next batch to avoid overwhelming browser
if (this.items.length > 0) {
setTimeout(() => this.processQueue(), BATCH_DELAY);
} else {
this.processing = false;
}
}
};
function initializeWhenVisible() {
if (document.visibilityState === "visible" && !isInitialized) {
const urlMatch = window.location.pathname.match(/\/(app|sub|bundle)\/(\d+)/);
if (!urlMatch) return;
const [, pageType, pageId] = urlMatch;
isInitialized = true;
// For app pages, show the full container at the top
if (pageType === 'app') {
const purchaseSection = document.querySelector("#game_area_purchase");
if (purchaseSection) {
const mainContainer = createPriceContainer();
mainContainer.id = 'gg-deals-main';
purchaseSection.parentNode.insertBefore(mainContainer, purchaseSection);
// Prioritize the main container request
requestQueue.add({
gameTitle: null,
containerId: 'gg-deals-main',
forceRefresh: false,
idInfo: { type: pageType, id: pageId }
});
}
}
// For sub/bundle pages, show only one display at the top
if (pageType === 'sub' || pageType === 'bundle') {
// Try to find the first purchase game section
const firstPurchaseGame = document.querySelector('.game_area_purchase_game');
if (firstPurchaseGame) {
const mainContainer = createPriceContainer();
mainContainer.id = `gg-deals-${pageType}-${pageId}`;
firstPurchaseGame.parentNode.insertBefore(mainContainer, firstPurchaseGame);
requestQueue.add({
gameTitle: null,
containerId: mainContainer.id,
forceRefresh: false,
idInfo: { type: pageType, id: pageId }
});
}
return; // Exit early to prevent additional displays
}
// Only process additional items if we're on an app page
if (pageType === 'app') {
// Get all purchase sections up front
const purchaseSections = document.querySelectorAll('.game_area_purchase_game');
let delayIndex = 0;
// Function to process sections with delay
const processSections = () => {
// Process each purchase game with the bundle/sub displays
purchaseSections.forEach((element, index) => {
// Skip if this is a demo section
if (element.closest('.demo_above_purchase')) {
return;
}
// Get the ID and type from the inputs
const bundleInput = element.querySelector('input[name="bundleid"]');
const subInput = element.querySelector('input[name="subid"]');
if (!bundleInput && !subInput) {
// If no inputs found, try to get ID from the element ID
const elementId = element.id.match(/\d+$/)?.[0];
// Skip main app on app pages (main app already handled above)
if (pageType === 'app' && elementId === pageId) {
return;
}
}
let itemType, itemId;
if (bundleInput) {
itemType = 'bundle';
itemId = bundleInput.value;
} else if (subInput) {
itemType = 'sub';
itemId = subInput.value;
} else {
// Skip this item if we can't identify it
return;
}
const containerId = `gg-deals-${itemType}-${itemId}`;
// Check if a display already exists for this ID
if (document.getElementById(containerId)) {
return; // Skip if already exists
}
const compactDisplay = createCompactPriceDisplay(containerId);
// Insert before game_purchase_action
const purchaseAction = element.querySelector('.game_purchase_action');
if (purchaseAction) {
purchaseAction.parentNode.insertBefore(compactDisplay, purchaseAction);
// Add to request queue with lower priority
requestQueue.add({
gameTitle: null,
containerId: containerId,
forceRefresh: false,
idInfo: { type: itemType, id: itemId }
});
}
});
};
// Use requestIdleCallback if available, otherwise use setTimeout
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(() => processSections(), { timeout: 1000 });
} else {
// Small delay to let the main page render first
setTimeout(processSections, 200);
}
}
}
}
// Utility function to debounce function calls - useful for event handlers
function debounce(func, wait) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
// Check for visibility changes with debounce
document.addEventListener("visibilitychange", debounce(initializeWhenVisible, 100));
// Initial check (in case the tab is already visible)
// Use setTimeout to ensure the page is fully loaded
setTimeout(() => {
if (document.visibilityState === "visible") {
initializeWhenVisible();
}
}, 500);
// Cleanup interval check after a reasonable time
const checkTitle = setInterval(() => {
if (document.visibilityState === "visible") {
initializeWhenVisible();
if (isInitialized) {
clearInterval(checkTitle);
}
}
}, 1000);
// Clear the interval after 10 seconds regardless to avoid resource waste
setTimeout(() => {
clearInterval(checkTitle);
}, 10000);
})();