// ==UserScript==
// @name GG.deals Steam Companion
// @namespace http://tampermonkey.net/
// @version 1.6.4
// @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
// @downloadURL https://raw.githubusercontent.com/Crimsab/ggdeals-steam-companion/main/userscript.user.js
// @updateURL https://raw.githubusercontent.com/Crimsab/ggdeals-steam-companion/main/userscript.user.js
// ==/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.
// Steam sub IDs are no longer supported by the API - all requests use app IDs.
// "Enable Scraping" toggle controls whether web scraping is used when API is disabled or fails.
// GG.deals website now uses Cloudflare protection which blocks automated requests (HTTP 403 errors). So most of the resolvers are disabled.
(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;
}
// Function to extract app ID from Steam page URL
function extractAppIdFromSteamPage() {
try {
// Look for app ID in the current URL
const urlMatch = window.location.pathname.match(/\/app\/(\d+)/);
if (urlMatch) {
return urlMatch[1];
}
// Look for app ID in page elements (fallback)
const appIdElement = document.querySelector('input[name="appid"]') ||
document.querySelector('[data-appid]') ||
document.querySelector('.game_area_purchase_game input[name="appid"]');
if (appIdElement) {
return appIdElement.value || appIdElement.getAttribute('data-appid');
}
return null;
} catch (error) {
console.warn("GG.deals: Error extracting app ID from Steam page:", error);
return null;
}
}
// 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");
// For subs, try to extract the actual app ID from the Steam page
let effectiveSteamId = steamId;
if (steamType === 'sub') {
const extractedAppId = extractAppIdFromSteamPage();
if (extractedAppId && extractedAppId !== steamId) {
console.log(`GG.deals: Using extracted app ID ${extractedAppId} instead of sub ID ${steamId}`);
effectiveSteamId = extractedAppId;
}
}
// Steam sub IDs are no longer supported, use app ID for both
const apiUrl = `https://api.gg.deals/v1/prices/by-steam-app-id/?ids=${effectiveSteamId}&key=${apiKey}®ion=${region}`;
try {
const response = await rateLimitedRequest(apiUrl);
// Check for successful response
if (response.status !== 200) {
console.error(`GG.deals API Error Details:`, {
status: response.status,
statusText: response.statusText,
url: apiUrl,
originalSteamId: steamId,
effectiveSteamId: effectiveSteamId,
steamType: steamType,
region: region,
responseText: response.responseText?.substring(0, 500) || 'No response text',
timestamp: new Date().toISOString()
});
throw new Error(`API returned status ${response.status} (${response.statusText})`);
}
// 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[effectiveSteamId]) {
throw new Error("No data found for this game");
}
const gameData = jsonData.data[effectiveSteamId];
// 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: error.message,
originalSteamId: steamId,
effectiveSteamId: effectiveSteamId,
steamType: steamType,
region: region,
apiUrl: apiUrl,
stack: error.stack,
timestamp: new Date().toISOString()
});
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 = `
Official:
Loading...
Keyshop:
Loading...
Click to refresh prices
Color Settings
Save Colors
Reset to Default
View Offers
Official Stores
Loading...
Keyshops
Loading...
View Offers
Click to refresh prices
Color Settings
Save Colors
Reset to Default
Extension by
Crimsab
·
Data by
gg.deals
${toggleStates.useApi && apiKey ? "· API Active" : ""}
`;
// 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 = ' ';
} else {
// Show the "eye-off" icon to indicate the user can click to hide the password
eyeIcon.innerHTML = ' ';
}
}
}
});
}
});
// 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} (${response.statusText || 'Unknown'})`);
} catch (error) {
if (retries > 0) {
console.warn(`GG.deals fetchWithRetry: Retrying ${url} (${retries} retries left)`, {
error: error.message,
url: url,
status: error.status || 'Unknown',
statusText: error.statusText || 'Unknown',
retriesLeft: retries,
timestamp: new Date().toISOString()
});
await new Promise((resolve) => setTimeout(resolve, 1000));
return fetchWithRetry(url, retries - 1);
}
console.error(`GG.deals fetchWithRetry: All retries failed for ${url}`, {
error: error.message,
url: url,
status: error.status || 'Unknown',
statusText: error.statusText || 'Unknown',
responseText: error.responseText?.substring(0, 500) || 'No response text',
stack: error.stack,
timestamp: new Date().toISOString()
});
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: error.message,
steamId: id,
steamType: type,
region: GM_getValue("preferredRegion", "us"),
apiKey: apiKey ? "Present" : "Missing",
stack: error.stack,
timestamp: new Date().toISOString()
});
// 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 app format first since GG.deals now uses app IDs for subs
urlFormats = [
{ type: 'app', id: id }, // Try app format first (new standard)
{ type: 'sub', id: id } // Try sub format as fallback (legacy if for whatever reason they will add it back)
];
}
// 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) {
const isCloudflareBlock = error.message?.includes('403') || error.status === 403;
const errorDetails = {
error: error.message,
url: `https://gg.deals/steam/${format.type}/${format.id}/`,
steamId: format.id,
steamType: format.type,
status: error.status || 'Unknown',
statusText: error.statusText || 'Unknown',
responseText: error.responseText?.substring(0, 500) || 'No response text',
stack: error.stack,
timestamp: new Date().toISOString(),
isCloudflareBlock: isCloudflareBlock
};
if (isCloudflareBlock) {
console.error(`GG.deals ${format.type} URL blocked by Cloudflare:`, errorDetails);
console.warn(`GG.deals: Cloudflare protection detected. Can't do anything about it.`);
} else {
console.warn(`GG.deals ${format.type} URL fetch failed:`, errorDetails);
}
}
}
// If the direct Steam URL didn't work, try to construct a fallback URL
// This is useful when Cloudflare blocks web scraping but we can still show a link
const fallbackUrl = `https://gg.deals/steam/${type}/${id}/`;
// Create a minimal data structure with the fallback URL
const noDataResult = {
officialPrice: "No data",
keyshopPrice: "No data",
historicalData: [],
lowestPriceType: null,
url: fallbackUrl,
isCorrectGame: true,
noData: true, // Flag to indicate there's no data
cloudflareBlocked: true // Flag to indicate Cloudflare blocked the request
};
priceCache.set(cacheKey, noDataResult, "web");
// Instead of hiding the container, show it with a message about Cloudflare
const container = document.getElementById(containerId);
if (container) {
// Update the display to show the fallback data
updatePriceDisplay(noDataResult, containerId);
// Add a small notice about Cloudflare if this is the first time
if (!container.querySelector('.gg-cloudflare-notice')) {
const notice = document.createElement('div');
notice.className = 'gg-cloudflare-notice';
notice.style.cssText = 'font-size: 11px; color: #ff7b7b; margin-top: 8px; font-style: italic; text-align: center;';
notice.textContent = '⚠️ Cloudflare protection detected. Use API for full functionality.';
container.appendChild(notice);
}
}
} 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 = `
Official:
Loading...
Keyshop:
Loading...
`;
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:`, {
error: err.message,
containerId: item.containerId,
gameTitle: item.gameTitle,
forceRefresh: item.forceRefresh,
idInfo: item.idInfo,
stack: err.stack,
timestamp: new Date().toISOString()
});
// 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);
})();