Dodaje dodatkowe informacje z gamestatus.info na kartach i stronach Steam
// ==UserScript==
// @name Steam GameStatus
// @name:ru Steam GameStatus
// @name:zh-CN Steam GameStatus
// @name:es Steam GameStatus
// @name:pt-BR Steam GameStatus
// @name:de Steam GameStatus
// @name:fr Steam GameStatus
// @name:ja Steam GameStatus
// @name:ko Steam GameStatus
// @name:pl Steam GameStatus
// @namespace https://github.com/NemoKing1210/steam-gamestatus
// @version 1.4.7
// @description Adds extra game info from gamestatus.info on Steam store cards and game pages
// @description:ru Добавляет доп. информацию с gamestatus.info на карточки Steam и страницы игр
// @description:zh-CN 在 Steam 商店卡片和游戏页面显示来自 gamestatus.info 的额外游戏信息
// @description:es Añade información extra de gamestatus.info en tarjetas y páginas de Steam
// @description:pt-BR Adiciona informações extras do gamestatus.info nos cards e páginas da Steam
// @description:de Zeigt zusätzliche Spieldaten von gamestatus.info auf Steam-Karten und Spielseiten
// @description:fr Ajoute des infos supplémentaires de gamestatus.info sur les cartes et pages Steam
// @description:ja Steamのカードとゲームページに gamestatus.info の追加ゲーム情報を表示
// @description:ko Steam 카드 및 게임 페이지에 gamestatus.info 추가 게임 정보를 표시
// @description:pl Dodaje dodatkowe informacje z gamestatus.info na kartach i stronach Steam
// @author NemoKing1210
// @tag steam
// @tag games
// @homepageURL https://github.com/NemoKing1210/steam-gamestatus
// @supportURL https://github.com/NemoKing1210/steam-gamestatus/issues
// @license MIT
// @icon https://gamestatus.info/favicon.ico
// @match https://store.steampowered.com/*
// @match https://steamcommunity.com/app/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @connect gamestatus.info
// @connect cdn.gamestatus.info
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
'use strict';
const API_BASE = 'https://gamestatus.info/back/api/gameinfo/game';
const SITE_BASE = 'https://gamestatus.info';
const DONATE_URL = `${SITE_BASE}/#donate`;
const REPO_URL = 'https://github.com/NemoKing1210/steam-gamestatus';
const CACHE_KEY = 'gs_steam_cache_v5';
const SETTINGS_KEY = 'gs_steam_settings';
const CACHE_HOURS_MAX = 168;
const DEFAULT_SETTINGS = {
cacheHours: 6,
badgeVertical: 'top',
badgeHorizontal: 'left',
badgeSize: 'medium',
showMissingBadge: true,
};
const MAX_CONCURRENT = 2;
const CARD_ROOT_MARGIN = '80px 0px';
const SCAN_DEBOUNCE_MS = 450;
const SCROLL_IDLE_MS = 150;
const CACHE_PERSIST_MS = 1000;
const REQUEST_DELAY_MS = 75;
const HYDRATE_BATCH_SIZE = 3;
const MAX_SLUG_ATTEMPTS = 2;
const BADGE_CLASS = 'gs-steam-badge';
const STEAM_APPDETAILS_URL = 'https://store.steampowered.com/api/appdetails';
const SUPPORTED_LOCALES = ['en', 'ru', 'zh', 'es', 'pt', 'de', 'fr', 'ja', 'ko', 'pl'];
const TRANSLATIONS = {
en: {
notInDatabase: 'Not in database',
cracked: 'Ready',
notCracked: 'Pending',
protectionBypass: 'Protection bypass',
releaseToday: 'Release today',
unknownStatus: 'Unknown status',
checkedUrls: 'Checked URLs',
gameNotFound: 'Game not found in the gamestatus.info database',
status: 'Status',
protection: 'Protection',
group: 'Group',
release: 'Release',
crack: 'Updated',
steamId: 'Steam ID',
score: 'Score',
metacritic: 'Metacritic',
type: 'Type',
subscriptions: 'Subscriptions',
hardware: 'Hardware',
supportProject: 'Support project',
loadError: 'Load error',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — Settings',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: 'Badges · cache · display options',
close: 'Close',
cancel: 'Cancel',
save: 'Save',
saveReload: 'Save & Reload page',
sectionBadge: 'Badge',
sectionCache: 'Cache',
badgePosition: 'Position on cards',
badgeVertical: 'Vertical',
badgeHorizontal: 'Horizontal',
posTop: 'Top',
posBottom: 'Bottom',
posLeft: 'Left',
posRight: 'Right',
badgePositionHint: 'Applies to badges on store cards. Game page chips stay in the header.',
badgeSize: 'Size on cards',
sizeSmall: 'Small',
sizeMedium: 'Medium',
sizeLarge: 'Large',
sizeXLarge: 'Very large',
badgeSizeHint: 'Applies to badges on store cards. Game page chips stay the same size.',
showMissing: 'Show “not in database” badges',
showMissingHint: 'When off, cards without a GameStatus match stay unmarked.',
on: 'ON',
off: 'OFF',
cacheHours: 'Cache duration (hours)',
cacheHoursHint:
'How long to reuse API results (including not-found). 0 disables cache. Clear cache or reload to refetch sooner.',
clearCache: 'Clear cache',
cacheCleared: 'Cache cleared ({count})',
cacheEmpty: 'Cache is empty',
cacheClearHint: 'Removes all stored GameStatus lookups from this browser profile.',
repoLink: 'GitHub',
repoAbout: 'Source code, updates, and issue reports',
},
ru: {
notInDatabase: 'Нет в базе',
cracked: 'Готово',
notCracked: 'Ожидание',
protectionBypass: 'Обход защиты',
releaseToday: 'Релиз сегодня',
unknownStatus: 'Статус неизвестен',
checkedUrls: 'Проверенные URL',
gameNotFound: 'Игра не найдена в базе gamestatus.info',
status: 'Статус',
protection: 'Защита',
group: 'Группа',
release: 'Релиз',
crack: 'Обновлено',
steamId: 'Steam ID',
score: 'Оценка',
metacritic: 'Metacritic',
type: 'Тип',
subscriptions: 'Подписки',
hardware: 'Железо',
supportProject: 'Поддержать проект',
loadError: 'Ошибка загрузки',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — Настройки',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: 'Бейджи · кеш · отображение',
close: 'Закрыть',
cancel: 'Отмена',
save: 'Сохранить',
saveReload: 'Сохранить и обновить',
sectionBadge: 'Бейдж',
sectionCache: 'Кеш',
badgePosition: 'Положение на карточках',
badgeVertical: 'По вертикали',
badgeHorizontal: 'По горизонтали',
posTop: 'Сверху',
posBottom: 'Снизу',
posLeft: 'Слева',
posRight: 'Справа',
badgePositionHint: 'Для бейджей на карточках магазина. На странице игры чип остаётся в шапке.',
badgeSize: 'Размер на карточках',
sizeSmall: 'Маленький',
sizeMedium: 'Средний',
sizeLarge: 'Большой',
sizeXLarge: 'Очень большой',
badgeSizeHint: 'Для бейджей на карточках магазина. На странице игры размер чипа не меняется.',
showMissing: 'Показывать «нет в базе»',
showMissingHint: 'Если выключено, карточки без записи в GameStatus остаются без бейджа.',
on: 'ВКЛ',
off: 'ВЫКЛ',
cacheHours: 'Время кеширования (часы)',
cacheHoursHint:
'Как долго хранить ответы API (включая «не найдено»). 0 отключает кеш. Сброс кеша или обновление страницы — для немедленного запроса.',
clearCache: 'Сбросить кеш',
cacheCleared: 'Кеш очищен ({count})',
cacheEmpty: 'Кеш пуст',
cacheClearHint: 'Удаляет все сохранённые ответы GameStatus в этом профиле браузера.',
repoLink: 'GitHub',
repoAbout: 'Исходный код, обновления и сообщения об ошибках',
},
zh: {
notInDatabase: '未收录',
cracked: '就绪',
notCracked: '待定',
protectionBypass: '保护绕过',
releaseToday: '今日发售',
unknownStatus: '状态未知',
checkedUrls: '已检查的 URL',
gameNotFound: '在 gamestatus.info 数据库中未找到该游戏',
status: '状态',
protection: '保护',
group: '组织',
release: '发售',
crack: '更新',
steamId: 'Steam ID',
score: '评分',
metacritic: 'Metacritic',
type: '类型',
subscriptions: '订阅数',
hardware: '硬件',
supportProject: '支持项目',
loadError: '加载失败',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — 设置',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: '徽章 · 缓存 · 显示选项',
close: '关闭',
cancel: '取消',
save: '保存',
saveReload: '保存并刷新页面',
sectionBadge: '徽章',
sectionCache: '缓存',
badgePosition: '卡片上的位置',
badgeVertical: '垂直',
badgeHorizontal: '水平',
posTop: '顶部',
posBottom: '底部',
posLeft: '左侧',
posRight: '右侧',
badgePositionHint: '适用于商店卡片上的徽章。游戏页徽章仍在页头。',
badgeSize: '卡片上的大小',
sizeSmall: '小',
sizeMedium: '中',
sizeLarge: '大',
sizeXLarge: '特大',
badgeSizeHint: '适用于商店卡片上的徽章。游戏页徽章大小不变。',
showMissing: '显示“未收录”徽章',
showMissingHint: '关闭后,未在 GameStatus 找到的游戏不显示徽章。',
on: '开',
off: '关',
cacheHours: '缓存时长(小时)',
cacheHoursHint: '复用 API 结果(含未找到)的时间。0 禁用缓存。清空缓存或刷新可立即重新请求。',
clearCache: '清空缓存',
cacheCleared: '已清空缓存({count})',
cacheEmpty: '缓存为空',
cacheClearHint: '删除此浏览器配置中保存的全部 GameStatus 查询结果。',
repoLink: 'GitHub',
repoAbout: '源代码、更新与问题反馈',
},
es: {
notInDatabase: 'No está en la base de datos',
cracked: 'Listo',
notCracked: 'Pendiente',
protectionBypass: 'Bypass de protección',
releaseToday: 'Lanzamiento hoy',
unknownStatus: 'Estado desconocido',
checkedUrls: 'URLs comprobadas',
gameNotFound: 'Juego no encontrado en la base de datos de gamestatus.info',
status: 'Estado',
protection: 'Protección',
group: 'Grupo',
release: 'Lanzamiento',
crack: 'Actualizado',
steamId: 'Steam ID',
score: 'Puntuación',
metacritic: 'Metacritic',
type: 'Tipo',
subscriptions: 'Suscripciones',
hardware: 'Hardware',
supportProject: 'Apoyar el proyecto',
loadError: 'Error de carga',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — Ajustes',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: 'Insignias · caché · visualización',
close: 'Cerrar',
cancel: 'Cancelar',
save: 'Guardar',
saveReload: 'Guardar y recargar',
sectionBadge: 'Insignia',
sectionCache: 'Caché',
badgePosition: 'Posición en las cartas',
badgeVertical: 'Vertical',
badgeHorizontal: 'Horizontal',
posTop: 'Arriba',
posBottom: 'Abajo',
posLeft: 'Izquierda',
posRight: 'Derecha',
badgePositionHint: 'Aplica a insignias en cartas de la tienda. En la página del juego permanece en la cabecera.',
badgeSize: 'Tamaño en las tarjetas',
sizeSmall: 'Pequeño',
sizeMedium: 'Mediano',
sizeLarge: 'Grande',
sizeXLarge: 'Muy grande',
badgeSizeHint: 'Aplica a las insignias en las tarjetas de la tienda. En la página del juego el tamaño no cambia.',
showMissing: 'Mostrar “no está en la base”',
showMissingHint: 'Si está desactivado, las cartas sin coincidencia en GameStatus quedan sin insignia.',
on: 'ON',
off: 'OFF',
cacheHours: 'Duración de caché (horas)',
cacheHoursHint:
'Cuánto reutilizar resultados de la API (incluido no encontrado). 0 desactiva la caché. Vaciar o recargar para pedir de nuevo.',
clearCache: 'Vaciar caché',
cacheCleared: 'Caché vaciada ({count})',
cacheEmpty: 'La caché está vacía',
cacheClearHint: 'Elimina todas las consultas de GameStatus guardadas en este perfil del navegador.',
repoLink: 'GitHub',
repoAbout: 'Código fuente, actualizaciones e informes de errores',
},
pt: {
notInDatabase: 'Não está no banco de dados',
cracked: 'Pronto',
notCracked: 'Pendente',
protectionBypass: 'Bypass de proteção',
releaseToday: 'Lançamento hoje',
unknownStatus: 'Status desconhecido',
checkedUrls: 'URLs verificadas',
gameNotFound: 'Jogo não encontrado no banco de dados do gamestatus.info',
status: 'Status',
protection: 'Proteção',
group: 'Grupo',
release: 'Lançamento',
crack: 'Atualizado',
steamId: 'Steam ID',
score: 'Pontuação',
metacritic: 'Metacritic',
type: 'Tipo',
subscriptions: 'Inscrições',
hardware: 'Hardware',
supportProject: 'Apoiar o projeto',
loadError: 'Erro ao carregar',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — Configurações',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: 'Emblemas · cache · exibição',
close: 'Fechar',
cancel: 'Cancelar',
save: 'Salvar',
saveReload: 'Salvar e recarregar',
sectionBadge: 'Emblema',
sectionCache: 'Cache',
badgePosition: 'Posição nos cards',
badgeVertical: 'Vertical',
badgeHorizontal: 'Horizontal',
posTop: 'Topo',
posBottom: 'Base',
posLeft: 'Esquerda',
posRight: 'Direita',
badgePositionHint: 'Aplica-se a emblemas nos cards da loja. Na página do jogo permanece no cabeçalho.',
badgeSize: 'Tamanho nos cards',
sizeSmall: 'Pequeno',
sizeMedium: 'Médio',
sizeLarge: 'Grande',
sizeXLarge: 'Muito grande',
badgeSizeHint: 'Aplica-se às badges nos cards da loja. Na página do jogo o tamanho não muda.',
showMissing: 'Mostrar “não está no banco”',
showMissingHint: 'Se desativado, cards sem correspondência no GameStatus ficam sem emblema.',
on: 'ON',
off: 'OFF',
cacheHours: 'Duração do cache (horas)',
cacheHoursHint:
'Por quanto tempo reutilizar resultados da API (incluindo não encontrado). 0 desativa o cache. Limpe ou recarregue para buscar de novo.',
clearCache: 'Limpar cache',
cacheCleared: 'Cache limpo ({count})',
cacheEmpty: 'O cache está vazio',
cacheClearHint: 'Remove todas as consultas do GameStatus salvas neste perfil do navegador.',
repoLink: 'GitHub',
repoAbout: 'Código-fonte, atualizações e relatos de problemas',
},
de: {
notInDatabase: 'Nicht in der Datenbank',
cracked: 'Bereit',
notCracked: 'Ausstehend',
protectionBypass: 'Schutz-Umgehung',
releaseToday: 'Release heute',
unknownStatus: 'Status unbekannt',
checkedUrls: 'Geprüfte URLs',
gameNotFound: 'Spiel nicht in der gamestatus.info-Datenbank gefunden',
status: 'Status',
protection: 'Schutz',
group: 'Gruppe',
release: 'Release',
crack: 'Aktualisiert',
steamId: 'Steam ID',
score: 'Bewertung',
metacritic: 'Metacritic',
type: 'Typ',
subscriptions: 'Abonnements',
hardware: 'Hardware',
supportProject: 'Projekt unterstützen',
loadError: 'Ladefehler',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — Einstellungen',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: 'Badges · Cache · Anzeige',
close: 'Schließen',
cancel: 'Abbrechen',
save: 'Speichern',
saveReload: 'Speichern & neu laden',
sectionBadge: 'Badge',
sectionCache: 'Cache',
badgePosition: 'Position auf Karten',
badgeVertical: 'Vertikal',
badgeHorizontal: 'Horizontal',
posTop: 'Oben',
posBottom: 'Unten',
posLeft: 'Links',
posRight: 'Rechts',
badgePositionHint: 'Gilt für Badges auf Store-Karten. Auf der Spielseite bleibt der Chip in der Kopfzeile.',
badgeSize: 'Größe auf Karten',
sizeSmall: 'Klein',
sizeMedium: 'Mittel',
sizeLarge: 'Groß',
sizeXLarge: 'Sehr groß',
badgeSizeHint: 'Gilt für Badges auf Store-Karten. Auf der Spieleseite bleibt die Größe unverändert.',
showMissing: '„Nicht in der Datenbank“ anzeigen',
showMissingHint: 'Wenn aus, bleiben Karten ohne GameStatus-Treffer ohne Badge.',
on: 'AN',
off: 'AUS',
cacheHours: 'Cache-Dauer (Stunden)',
cacheHoursHint:
'Wie lange API-Ergebnisse wiederverwendet werden (inkl. nicht gefunden). 0 deaktiviert den Cache. Leeren oder neu laden zum Sofortabruf.',
clearCache: 'Cache leeren',
cacheCleared: 'Cache geleert ({count})',
cacheEmpty: 'Cache ist leer',
cacheClearHint: 'Entfernt alle gespeicherten GameStatus-Abfragen in diesem Browserprofil.',
repoLink: 'GitHub',
repoAbout: 'Quellcode, Updates und Fehlerberichte',
},
fr: {
notInDatabase: 'Absent de la base',
cracked: 'Prêt',
notCracked: 'En attente',
protectionBypass: 'Contournement',
releaseToday: 'Sortie aujourd\'hui',
unknownStatus: 'Statut inconnu',
checkedUrls: 'URLs vérifiées',
gameNotFound: 'Jeu introuvable dans la base gamestatus.info',
status: 'Statut',
protection: 'Protection',
group: 'Groupe',
release: 'Sortie',
crack: 'Mis à jour',
steamId: 'Steam ID',
score: 'Note',
metacritic: 'Metacritic',
type: 'Type',
subscriptions: 'Abonnements',
hardware: 'Configuration',
supportProject: 'Soutenir le projet',
loadError: 'Erreur de chargement',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — Paramètres',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: 'Badges · cache · affichage',
close: 'Fermer',
cancel: 'Annuler',
save: 'Enregistrer',
saveReload: 'Enregistrer et recharger',
sectionBadge: 'Badge',
sectionCache: 'Cache',
badgePosition: 'Position sur les cartes',
badgeVertical: 'Vertical',
badgeHorizontal: 'Horizontal',
posTop: 'Haut',
posBottom: 'Bas',
posLeft: 'Gauche',
posRight: 'Droite',
badgePositionHint: 'S’applique aux badges des cartes boutique. Sur la page jeu, le chip reste dans l’en-tête.',
badgeSize: 'Taille sur les cartes',
sizeSmall: 'Petit',
sizeMedium: 'Moyen',
sizeLarge: 'Grand',
sizeXLarge: 'Très grand',
badgeSizeHint: 'S’applique aux badges des cartes du magasin. Sur la page du jeu, la taille ne change pas.',
showMissing: 'Afficher « absent de la base »',
showMissingHint: 'Si désactivé, les cartes sans correspondance GameStatus restent sans badge.',
on: 'ON',
off: 'OFF',
cacheHours: 'Durée du cache (heures)',
cacheHoursHint:
'Combien de temps réutiliser les résultats API (y compris introuvable). 0 désactive le cache. Vider ou recharger pour refetch.',
clearCache: 'Vider le cache',
cacheCleared: 'Cache vidé ({count})',
cacheEmpty: 'Le cache est vide',
cacheClearHint: 'Supprime toutes les requêtes GameStatus enregistrées dans ce profil navigateur.',
repoLink: 'GitHub',
repoAbout: 'Code source, mises à jour et signalements',
},
ja: {
notInDatabase: 'データベースに未登録',
cracked: '準備完了',
notCracked: '保留中',
protectionBypass: '保護回避',
releaseToday: '本日リリース',
unknownStatus: 'ステータス不明',
checkedUrls: '確認した URL',
gameNotFound: 'gamestatus.info のデータベースにゲームが見つかりません',
status: 'ステータス',
protection: '保護',
group: 'グループ',
release: 'リリース',
crack: '更新',
steamId: 'Steam ID',
score: 'スコア',
metacritic: 'Metacritic',
type: 'タイプ',
subscriptions: '購読数',
hardware: 'スペック',
supportProject: 'プロジェクトを支援',
loadError: '読み込みエラー',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — 設定',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: 'バッジ · キャッシュ · 表示',
close: '閉じる',
cancel: 'キャンセル',
save: '保存',
saveReload: '保存して再読み込み',
sectionBadge: 'バッジ',
sectionCache: 'キャッシュ',
badgePosition: 'カード上の位置',
badgeVertical: '上下',
badgeHorizontal: '左右',
posTop: '上',
posBottom: '下',
posLeft: '左',
posRight: '右',
badgePositionHint: 'ストアカードのバッジに適用。ゲームページのチップはヘッダーのままです。',
badgeSize: 'カード上のサイズ',
sizeSmall: '小',
sizeMedium: '中',
sizeLarge: '大',
sizeXLarge: '特大',
badgeSizeHint: 'ストアカードのバッジに適用されます。ゲームページのチップサイズは変わりません。',
showMissing: '「未登録」バッジを表示',
showMissingHint: 'オフにすると、GameStatus にないゲームはバッジなしのままになります。',
on: 'ON',
off: 'OFF',
cacheHours: 'キャッシュ期間(時間)',
cacheHoursHint:
'API 結果(未検出含む)を再利用する時間。0 でキャッシュ無効。クリアまたは再読み込みで即再取得。',
clearCache: 'キャッシュをクリア',
cacheCleared: 'キャッシュをクリアしました({count})',
cacheEmpty: 'キャッシュは空です',
cacheClearHint: 'このブラウザプロファイルに保存された GameStatus の結果をすべて削除します。',
repoLink: 'GitHub',
repoAbout: 'ソースコード・更新・不具合報告',
},
ko: {
notInDatabase: '데이터베이스에 없음',
cracked: '준비됨',
notCracked: '대기 중',
protectionBypass: '보호 우회',
releaseToday: '오늘 출시',
unknownStatus: '상태 불명',
checkedUrls: '확인한 URL',
gameNotFound: 'gamestatus.info 데이터베이스에서 게임을 찾을 수 없습니다',
status: '상태',
protection: '보호',
group: '그룹',
release: '출시',
crack: '업데이트',
steamId: 'Steam ID',
score: '점수',
metacritic: 'Metacritic',
type: '유형',
subscriptions: '구독 수',
hardware: '사양',
supportProject: '프로젝트 후원',
loadError: '로드 오류',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — 설정',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: '배지 · 캐시 · 표시',
close: '닫기',
cancel: '취소',
save: '저장',
saveReload: '저장 후 새로고침',
sectionBadge: '배지',
sectionCache: '캐시',
badgePosition: '카드 위치',
badgeVertical: '세로',
badgeHorizontal: '가로',
posTop: '위',
posBottom: '아래',
posLeft: '왼쪽',
posRight: '오른쪽',
badgePositionHint: '스토어 카드 배지에 적용됩니다. 게임 페이지 칩은 헤더에 유지됩니다.',
badgeSize: '카드 크기',
sizeSmall: '작게',
sizeMedium: '보통',
sizeLarge: '크게',
sizeXLarge: '매우 크게',
badgeSizeHint: '스토어 카드 배지에 적용됩니다. 게임 페이지 칩 크기는 그대로입니다.',
showMissing: '“데이터베이스에 없음” 표시',
showMissingHint: '끄면 GameStatus에 없는 게임은 배지 없이 남습니다.',
on: 'ON',
off: 'OFF',
cacheHours: '캐시 기간(시간)',
cacheHoursHint:
'API 결과(미발견 포함)를 재사용하는 시간. 0은 캐시 비활성화. 비우거나 새로고침하면 즉시 다시 요청.',
clearCache: '캐시 비우기',
cacheCleared: '캐시를 비웠습니다 ({count})',
cacheEmpty: '캐시가 비어 있습니다',
cacheClearHint: '이 브라우저 프로필에 저장된 모든 GameStatus 조회를 삭제합니다.',
repoLink: 'GitHub',
repoAbout: '소스 코드, 업데이트 및 문제 제보',
},
pl: {
notInDatabase: 'Brak w bazie',
cracked: 'Gotowe',
notCracked: 'Oczekujące',
protectionBypass: 'Obejście zabezpieczeń',
releaseToday: 'Premiera dziś',
unknownStatus: 'Nieznany status',
checkedUrls: 'Sprawdzone URL',
gameNotFound: 'Gry nie znaleziono w bazie gamestatus.info',
status: 'Status',
protection: 'Zabezpieczenie',
group: 'Grupa',
release: 'Premiera',
crack: 'Zaktualizowano',
steamId: 'Steam ID',
score: 'Ocena',
metacritic: 'Metacritic',
type: 'Typ',
subscriptions: 'Subskrypcje',
hardware: 'Sprzęt',
supportProject: 'Wspieraj projekt',
loadError: 'Błąd ładowania',
loading: 'GameStatus…',
menuSettings: 'Steam GameStatus — Ustawienia',
btnTitle: 'Steam GameStatus',
btnText: 'GameStatus',
panelTitle: 'GameStatus',
panelSubtitle: 'Odznaki · cache · wyświetlanie',
close: 'Zamknij',
cancel: 'Anuluj',
save: 'Zapisz',
saveReload: 'Zapisz i odśwież',
sectionBadge: 'Odznaka',
sectionCache: 'Cache',
badgePosition: 'Pozycja na kartach',
badgeVertical: 'Pionowo',
badgeHorizontal: 'Poziomo',
posTop: 'Góra',
posBottom: 'Dół',
posLeft: 'Lewo',
posRight: 'Prawo',
badgePositionHint: 'Dotyczy odznak na kartach sklepu. Na stronie gry chip zostaje w nagłówku.',
badgeSize: 'Rozmiar na kartach',
sizeSmall: 'Mały',
sizeMedium: 'Średni',
sizeLarge: 'Duży',
sizeXLarge: 'Bardzo duży',
badgeSizeHint: 'Dotyczy odznak na kartach sklepu. Na stronie gry rozmiar chipa się nie zmienia.',
showMissing: 'Pokazuj „brak w bazie”',
showMissingHint: 'Gdy wyłączone, karty bez dopasowania w GameStatus pozostają bez odznaki.',
on: 'WŁ',
off: 'WYŁ',
cacheHours: 'Czas cache (godziny)',
cacheHoursHint:
'Jak długo ponownie używać wyników API (w tym nie znaleziono). 0 wyłącza cache. Wyczyść lub odśwież, by pobrać od razu.',
clearCache: 'Wyczyść cache',
cacheCleared: 'Cache wyczyszczony ({count})',
cacheEmpty: 'Cache jest pusty',
cacheClearHint: 'Usuwa wszystkie zapisane zapytania GameStatus w tym profilu przeglądarki.',
repoLink: 'GitHub',
repoAbout: 'Kod źródłowy, aktualizacje i zgłoszenia błędów',
},
};
function resolveLocale() {
const candidates = [navigator.language, ...(navigator.languages || [])].filter(Boolean);
for (const candidate of candidates) {
const raw = String(candidate).toLowerCase();
const primary = raw.split('-')[0];
if (SUPPORTED_LOCALES.includes(primary)) return primary;
if (raw.startsWith('zh')) return 'zh';
if (raw.startsWith('pt')) return 'pt';
}
return 'en';
}
const LOCALE = resolveLocale();
/** @type {typeof DEFAULT_SETTINGS} */
let settings = loadSettings();
let panelOpen = false;
function t(key, vars) {
let text = TRANSLATIONS[LOCALE]?.[key] ?? TRANSLATIONS.en[key] ?? key;
if (vars && typeof vars === 'object') {
for (const [name, value] of Object.entries(vars)) {
text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value));
}
}
return text;
}
function normalizeCacheHours(value) {
const n = Math.round(Number(value));
if (!Number.isFinite(n) || n < 0) return DEFAULT_SETTINGS.cacheHours;
return Math.min(n, CACHE_HOURS_MAX);
}
function normalizeBadgeVertical(value) {
return value === 'bottom' ? 'bottom' : 'top';
}
function normalizeBadgeHorizontal(value) {
return value === 'right' ? 'right' : 'left';
}
function normalizeBadgeSize(value) {
if (value === 'small' || value === 'large' || value === 'xlarge') return value;
return 'medium';
}
function loadSettings() {
const raw = GM_getValue(SETTINGS_KEY, null);
if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS };
return {
...DEFAULT_SETTINGS,
...raw,
cacheHours: normalizeCacheHours(raw.cacheHours),
badgeVertical: normalizeBadgeVertical(raw.badgeVertical),
badgeHorizontal: normalizeBadgeHorizontal(raw.badgeHorizontal),
badgeSize: normalizeBadgeSize(raw.badgeSize),
showMissingBadge: raw.showMissingBadge !== false,
};
}
function saveSettings(next) {
settings = {
...settings,
...next,
cacheHours: normalizeCacheHours(next.cacheHours ?? settings.cacheHours),
badgeVertical: normalizeBadgeVertical(next.badgeVertical ?? settings.badgeVertical),
badgeHorizontal: normalizeBadgeHorizontal(next.badgeHorizontal ?? settings.badgeHorizontal),
badgeSize: normalizeBadgeSize(next.badgeSize ?? settings.badgeSize),
showMissingBadge:
typeof next.showMissingBadge === 'boolean'
? next.showMissingBadge
: settings.showMissingBadge !== false,
};
GM_setValue(SETTINGS_KEY, settings);
applyBadgeAppearance();
updateSettingsButtonState();
}
function getCacheTtlMs() {
const hours = normalizeCacheHours(settings.cacheHours);
return hours > 0 ? hours * 60 * 60 * 1000 : 0;
}
function applyBadgeAppearance() {
const root = document.documentElement;
root.classList.remove(
'gs-badge-v-top',
'gs-badge-v-bottom',
'gs-badge-h-left',
'gs-badge-h-right',
'gs-badge-size-sm',
'gs-badge-size-md',
'gs-badge-size-lg',
'gs-badge-size-xl'
);
const sizeClass =
settings.badgeSize === 'small'
? 'gs-badge-size-sm'
: settings.badgeSize === 'large'
? 'gs-badge-size-lg'
: settings.badgeSize === 'xlarge'
? 'gs-badge-size-xl'
: 'gs-badge-size-md';
root.classList.add(
settings.badgeVertical === 'bottom' ? 'gs-badge-v-bottom' : 'gs-badge-v-top',
settings.badgeHorizontal === 'right' ? 'gs-badge-h-right' : 'gs-badge-h-left',
sizeClass
);
}
function getApiLanguage() {
return navigator.language || 'en-US';
}
function getLocalizedGroup(game) {
if (!game) return null;
if (LOCALE === 'ru') return game.hacked_groups || game.hacked_groups_en;
return game.hacked_groups_en || game.hacked_groups;
}
function getLocalizedDescription(game) {
if (!game) return '';
if (LOCALE === 'ru') return game.description || game.description_en;
return game.description_en || game.description;
}
/** @type {Map<string, Promise<any|null>>} */
const inflight = new Map();
/** @type {Array<{ task: () => Promise<unknown>, resolve: (value: unknown) => void, reject: (reason?: unknown) => void }>} */
const queue = [];
let activeRequests = 0;
/** @type {Record<string, unknown>|null} */
let memoryCache = null;
let cacheDirty = false;
let cachePersistTimer = null;
let mutationObserver = null;
let isScrolling = false;
let scrollIdleTimer = null;
let hydrateFlushRaf = null;
let hasInitialScan = false;
/** @type {Element[]} */
const pendingScanNodes = [];
/** @type {Array<{ card: Element, appId: string, link: HTMLAnchorElement|{ href: string }, title: string }>} */
const hydrationQueue = [];
const MUTATION_OBSERVER_OPTIONS = {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-ds-appid', 'data-sub-id', 'class'],
};
GM_addStyle(`
html.gs-badge-size-sm {
--gs-card-badge-font: 11px;
--gs-card-badge-lh: 19px;
--gs-card-badge-gap: 5px;
--gs-card-badge-pad-x: 7px;
--gs-card-badge-dot: 4px;
--gs-card-badge-loading-dot: 10px;
--gs-chart-badge-font: 10px;
--gs-chart-badge-lh: 16px;
--gs-chart-badge-pad-x: 6px;
}
html.gs-badge-size-md {
--gs-card-badge-font: 13px;
--gs-card-badge-lh: 23px;
--gs-card-badge-gap: 6px;
--gs-card-badge-pad-x: 9px;
--gs-card-badge-dot: 5px;
--gs-card-badge-loading-dot: 12px;
--gs-chart-badge-font: 12px;
--gs-chart-badge-lh: 19px;
--gs-chart-badge-pad-x: 7px;
}
html.gs-badge-size-lg {
--gs-card-badge-font: 15px;
--gs-card-badge-lh: 26px;
--gs-card-badge-gap: 7px;
--gs-card-badge-pad-x: 10px;
--gs-card-badge-dot: 6px;
--gs-card-badge-loading-dot: 14px;
--gs-chart-badge-font: 14px;
--gs-chart-badge-lh: 22px;
--gs-chart-badge-pad-x: 8px;
}
html.gs-badge-size-xl {
--gs-card-badge-font: 17px;
--gs-card-badge-lh: 29px;
--gs-card-badge-gap: 8px;
--gs-card-badge-pad-x: 12px;
--gs-card-badge-dot: 7px;
--gs-card-badge-loading-dot: 16px;
--gs-chart-badge-font: 16px;
--gs-chart-badge-lh: 25px;
--gs-chart-badge-pad-x: 10px;
}
.${BADGE_CLASS} {
--gs-accent: #67c1f5;
--gs-bg: #384959;
--gs-text: #c7d5e0;
--gs-muted: #8f98a0;
position: absolute;
top: auto;
right: auto;
bottom: auto;
left: auto;
z-index: 100;
display: inline-flex;
align-items: center;
gap: var(--gs-card-badge-gap, 6px);
max-width: calc(100% - 16px);
padding: 0 var(--gs-card-badge-pad-x, 9px);
border-radius: 3px;
border: none;
background: var(--gs-bg);
color: var(--gs-text);
font: 500 var(--gs-card-badge-font, 13px)/var(--gs-card-badge-lh, 23px) "Motiva Sans", Arial, sans-serif;
text-decoration: none;
box-shadow: 1px 1px 0 0 #000000;
pointer-events: auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
html.gs-badge-v-top .${BADGE_CLASS}:not(.${BADGE_CLASS}--page):not(.${BADGE_CLASS}--chart) {
top: 8px;
}
html.gs-badge-v-bottom .${BADGE_CLASS}:not(.${BADGE_CLASS}--page):not(.${BADGE_CLASS}--chart) {
bottom: 8px;
}
html.gs-badge-h-left .${BADGE_CLASS}:not(.${BADGE_CLASS}--page):not(.${BADGE_CLASS}--chart) {
left: 8px;
}
html.gs-badge-h-right .${BADGE_CLASS}:not(.${BADGE_CLASS}--page):not(.${BADGE_CLASS}--chart) {
right: 8px;
}
.${BADGE_CLASS}:hover {
background: #4a5d70;
color: #e3eaef;
}
.${BADGE_CLASS}__dot {
width: var(--gs-card-badge-dot, 5px);
height: var(--gs-card-badge-dot, 5px);
border-radius: 50%;
flex: 0 0 auto;
background: var(--gs-accent);
}
.${BADGE_CLASS}__label {
overflow: hidden;
text-overflow: ellipsis;
}
.${BADGE_CLASS}--loading {
--gs-accent: #67c1f5;
cursor: wait;
pointer-events: none;
}
.${BADGE_CLASS}--loading .${BADGE_CLASS}__dot {
width: var(--gs-card-badge-loading-dot, 12px);
height: var(--gs-card-badge-loading-dot, 12px);
border: 2px solid rgba(103, 193, 245, 0.25);
border-top-color: #67c1f5;
background: transparent;
animation: gs-spin 0.7s linear infinite;
}
.${BADGE_CLASS}--cracked {
--gs-accent: #beee11;
--gs-bg: #4c6b22;
--gs-text: #beee11;
}
.${BADGE_CLASS}--cracked:hover {
background: #5a7f28;
color: #d4f54a;
}
.${BADGE_CLASS}--cracked .${BADGE_CLASS}__dot {
background: #2d4a14;
}
.${BADGE_CLASS}--bypass {
--gs-accent: #ffb321;
--gs-text: #e3eaef;
}
.${BADGE_CLASS}--not-cracked-recent {
--gs-accent: #ffb321;
--gs-text: #e3eaef;
}
.${BADGE_CLASS}--not-cracked-old {
--gs-accent: #f87171;
--gs-text: #fecaca;
}
.${BADGE_CLASS}--release-today {
--gs-accent: #67c1f5;
--gs-text: #c7d5e0;
}
.${BADGE_CLASS}--unknown,
.${BADGE_CLASS}--missing {
--gs-accent: #8f98a0;
--gs-text: #b0aeac;
}
.${BADGE_CLASS}--chart {
position: static;
top: auto;
left: auto;
z-index: auto;
display: inline-flex;
margin-top: 6px;
max-width: 100%;
white-space: normal;
box-shadow: none;
font-size: var(--gs-chart-badge-font, 12px);
line-height: var(--gs-chart-badge-lh, 19px);
padding: 0 var(--gs-chart-badge-pad-x, 7px);
}
/* Featured carousel: slides share the same coordinates — show badge only on focus */
.carousel_wide_mode .store_main_capsule:not(.focus) .${BADGE_CLASS} {
display: none !important;
pointer-events: none !important;
}
.carousel_wide_mode .store_main_capsule.focus .${BADGE_CLASS} {
z-index: 110;
}
[class*="StoreSalePriceWidgetContainer"] > .${BADGE_CLASS}--chart {
align-self: flex-start;
}
.${BADGE_CLASS}--page,
a.${BADGE_CLASS}.${BADGE_CLASS}--page,
span.${BADGE_CLASS}.${BADGE_CLASS}--page {
position: static;
top: auto;
left: auto;
z-index: auto;
display: inline-flex;
vertical-align: top;
align-items: center;
margin: 0;
max-width: none;
gap: 8px;
padding: 0 15px;
border-radius: 3px;
border: none;
background: var(--gs-bg);
color: var(--gs-text);
font: 500 15px/30px "Motiva Sans", Arial, sans-serif;
text-decoration: none;
box-shadow: 1px 1px 0 0 #000000;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
}
.${BADGE_CLASS}--page:hover,
a.${BADGE_CLASS}.${BADGE_CLASS}--page:hover,
span.${BADGE_CLASS}.${BADGE_CLASS}--page:hover {
background: #4a5d70;
color: #e3eaef;
text-decoration: none;
}
.${BADGE_CLASS}--page.${BADGE_CLASS}--cracked:hover,
a.${BADGE_CLASS}.${BADGE_CLASS}--page.${BADGE_CLASS}--cracked:hover {
background: #5a7f28;
color: #d4f54a;
}
.${BADGE_CLASS}--page .${BADGE_CLASS}__dot {
width: 8px;
height: 8px;
flex-shrink: 0;
}
.${BADGE_CLASS}--page .${BADGE_CLASS}__label {
overflow: hidden;
text-overflow: ellipsis;
}
.${BADGE_CLASS}--page.${BADGE_CLASS}--loading {
pointer-events: none;
cursor: wait;
}
.${BADGE_CLASS}--page.${BADGE_CLASS}--loading .${BADGE_CLASS}__dot {
width: 14px;
height: 14px;
}
.${BADGE_CLASS}__tooltip {
position: fixed;
z-index: 999999;
width: min(380px, calc(100vw - 24px));
max-width: 380px;
padding: 0;
border-radius: 3px;
border: 1px solid #000;
background: linear-gradient(180deg, #1b2838 0%, #16202d 100%);
color: #c7d5e0;
font: normal 12px/1.45 "Motiva Sans", Arial, Helvetica, sans-serif;
box-shadow:
0 0 12px rgba(0, 0, 0, 0.7),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
word-wrap: break-word;
white-space: normal;
overflow: hidden;
}
.${BADGE_CLASS}__tooltip--visible {
opacity: 1;
}
.${BADGE_CLASS}__tooltip--interactive {
pointer-events: auto;
}
.${BADGE_CLASS}__tooltip .gs-tip-header {
display: flex;
gap: 12px;
padding: 12px 12px 10px;
background:
radial-gradient(120% 90% at 0% 0%, rgba(102, 192, 244, 0.16), transparent 55%),
linear-gradient(90deg, #1a2332, #1b2838);
border-bottom: 1px solid #000;
}
.${BADGE_CLASS}__tooltip .gs-tip-cover {
flex: 0 0 92px;
width: 92px;
height: 43px;
border-radius: 2px;
object-fit: cover;
background: #0e1620;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55);
}
.${BADGE_CLASS}__tooltip .gs-tip-header-body {
min-width: 0;
flex: 1 1 auto;
}
.${BADGE_CLASS}__tooltip .gs-tip-title {
margin: 0 0 8px;
color: #ffffff;
font-size: 14px;
font-weight: 700;
line-height: 1.25;
}
.${BADGE_CLASS}__tooltip .gs-tip-chips {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.${BADGE_CLASS}__tooltip .gs-tip-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 0 7px;
border-radius: 2px;
border: none;
background: #384959;
color: #c7d5e0;
font: 500 11px/18px "Motiva Sans", Arial, sans-serif;
box-shadow: 1px 1px 0 0 #000;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${BADGE_CLASS}__tooltip .gs-tip-chip--cracked {
background: #4c6b22;
color: #beee11;
}
.${BADGE_CLASS}__tooltip .gs-tip-chip--bypass,
.${BADGE_CLASS}__tooltip .gs-tip-chip--not-cracked-recent {
background: #5a4630;
color: #ffb321;
}
.${BADGE_CLASS}__tooltip .gs-tip-chip--not-cracked-old {
background: #5a3030;
color: #fecaca;
}
.${BADGE_CLASS}__tooltip .gs-tip-chip--release-today {
background: #2a4a63;
color: #67c1f5;
}
.${BADGE_CLASS}__tooltip .gs-tip-chip--unknown,
.${BADGE_CLASS}__tooltip .gs-tip-chip--missing {
background: #3a4149;
color: #b0aeac;
}
.${BADGE_CLASS}__tooltip .gs-tip-chip--aaa {
background: linear-gradient(to bottom, #66c0f4 5%, #417a9b 95%);
color: #fff;
font-weight: 700;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
}
.${BADGE_CLASS}__tooltip .gs-tip-chip--protection {
background: #2a3644;
color: #acb2b8;
}
.${BADGE_CLASS}__tooltip .gs-tip-body {
padding: 10px 12px 12px;
}
.${BADGE_CLASS}__tooltip .gs-tip-muted {
margin: 0 0 8px;
color: #8f98a0;
font-size: 12px;
}
.${BADGE_CLASS}__tooltip .gs-tip-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.${BADGE_CLASS}__tooltip .gs-tip-section:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.${BADGE_CLASS}__tooltip .gs-tip-section-title {
margin: 0 0 6px;
color: #8f98a0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.${BADGE_CLASS}__tooltip .gs-tip-row {
display: grid;
grid-template-columns: 92px 1fr;
gap: 8px;
align-items: start;
margin-bottom: 5px;
}
.${BADGE_CLASS}__tooltip .gs-tip-row:last-child {
margin-bottom: 0;
}
.${BADGE_CLASS}__tooltip .gs-tip-label {
color: #8f98a0;
font-size: 11px;
font-weight: normal;
line-height: 18px;
}
.${BADGE_CLASS}__tooltip .gs-tip-value {
color: #dcdedf;
word-break: break-word;
line-height: 18px;
}
.${BADGE_CLASS}__tooltip .gs-tip-scores {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.${BADGE_CLASS}__tooltip .gs-tip-score {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 64px;
padding: 6px 10px;
border-radius: 2px;
border: 1px solid #000;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
text-align: center;
}
.${BADGE_CLASS}__tooltip .gs-tip-score__value {
font-size: 18px;
font-weight: 700;
line-height: 1.1;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
}
.${BADGE_CLASS}__tooltip .gs-tip-score__label {
margin-top: 2px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
opacity: 0.85;
}
.${BADGE_CLASS}__tooltip .gs-tip-score--high {
background: linear-gradient(180deg, #4c6b22 0%, #3a5219 100%);
color: #beee11;
}
.${BADGE_CLASS}__tooltip .gs-tip-score--mid {
background: linear-gradient(180deg, #8a6d1a 0%, #6a5214 100%);
color: #ffcc33;
}
.${BADGE_CLASS}__tooltip .gs-tip-score--low {
background: linear-gradient(180deg, #8a3030 0%, #6a2020 100%);
color: #ff8a8a;
}
.${BADGE_CLASS}__tooltip .gs-tip-score--unknown {
background: linear-gradient(180deg, #384959 0%, #2c3845 100%);
color: #c7d5e0;
}
.${BADGE_CLASS}__tooltip .gs-tip-specs {
display: flex;
flex-direction: column;
gap: 4px;
}
.${BADGE_CLASS}__tooltip .gs-tip-spec {
display: grid;
grid-template-columns: 36px 1fr;
gap: 8px;
align-items: start;
}
.${BADGE_CLASS}__tooltip .gs-tip-spec__key {
color: #66c0f4;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
line-height: 16px;
}
.${BADGE_CLASS}__tooltip .gs-tip-spec__val {
color: #acb2b8;
font-size: 11px;
line-height: 16px;
word-break: break-word;
}
.${BADGE_CLASS}__tooltip .gs-tip-desc {
margin: 0;
color: #acb2b8;
font-size: 11px;
line-height: 1.45;
}
.${BADGE_CLASS}__tooltip .gs-tip-links {
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.${BADGE_CLASS}__tooltip .gs-tip-links li + li {
margin-top: 4px;
}
.${BADGE_CLASS}__tooltip .gs-tip-links a,
.${BADGE_CLASS}__tooltip .gs-tip-footer-links a {
color: #66c0f4;
text-decoration: none;
word-break: break-all;
}
.${BADGE_CLASS}__tooltip .gs-tip-links a:hover,
.${BADGE_CLASS}__tooltip .gs-tip-footer-links a:hover {
color: #ffffff;
}
.${BADGE_CLASS}__tooltip .gs-tip-footer {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
padding: 10px 12px;
border-top: 1px solid #000;
background: rgba(0, 0, 0, 0.22);
font-size: 11px;
color: #8f98a0;
}
.${BADGE_CLASS}__tooltip .gs-tip-footer-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.${BADGE_CLASS}__tooltip .gs-tip-footer-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.${BADGE_CLASS}__tooltip .gs-tip-footer-sep {
color: #4b5561;
}
.${BADGE_CLASS}__tooltip .gs-tip-footer-divider {
height: 1px;
width: 100%;
background: linear-gradient(90deg, transparent, #000, transparent);
}
.${BADGE_CLASS}__tooltip .gs-tip-repo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
color: #8f98a0;
text-decoration: none;
word-break: normal;
}
.${BADGE_CLASS}__tooltip .gs-tip-repo:hover {
color: #c7d5e0;
}
.${BADGE_CLASS}__tooltip .gs-tip-repo:hover .gs-tip-repo-title {
color: #66c0f4;
}
.${BADGE_CLASS}__tooltip .gs-tip-repo-title {
color: #67c1f5;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.${BADGE_CLASS}__tooltip .gs-tip-repo-desc {
font-size: 11px;
line-height: 1.3;
}
.${BADGE_CLASS}__tooltip .gs-tip-donate-btn {
display: inline-block;
padding: 1px;
border-radius: 2px;
border: none;
background: linear-gradient(-60deg, #417a9b 5%, #67c1f5 95%);
color: #ffffff !important;
text-decoration: none !important;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.${BADGE_CLASS}__tooltip .gs-tip-donate-btn > span {
display: block;
padding: 0 12px;
font: normal 12px/26px "Motiva Sans", Arial, sans-serif;
color: #ffffff !important;
background: transparent;
border-radius: 2px;
text-align: center;
}
.${BADGE_CLASS}__tooltip .gs-tip-donate-btn:hover {
background: linear-gradient(-60deg, #4f8eb0 5%, #7ad0ff 95%);
color: #ffffff !important;
text-decoration: none !important;
}
@keyframes gs-spin {
to { transform: rotate(360deg); }
}
#gs-settings-btn.gs-header-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 8px;
height: 24px;
padding: 0 10px;
border: none;
border-radius: 2px;
background: linear-gradient(to bottom, #66c0f4 5%, #417a9b 95%);
color: #fff;
font: 700 11px/24px "Motiva Sans", Arial, Helvetica, sans-serif;
text-shadow: 0 1px 1px rgba(0,0,0,.3);
cursor: pointer;
position: relative;
white-space: nowrap;
box-shadow: 0 0 0 1px rgba(0,0,0,.35);
}
#gs-settings-btn.gs-header-btn:hover {
background: linear-gradient(to bottom, #7dcaFA 5%, #4d8bb0 95%);
}
#gs-settings-btn.gs-header-btn.is-open {
background: linear-gradient(to bottom, #a4d007 5%, #536904 95%);
}
.gs-header-btn__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255,255,255,.35);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.25);
}
.gs-header-btn__dot.is-on {
background: #beee11;
box-shadow: 0 0 6px rgba(190,238,17,.8);
}
.gs-panel {
position: fixed;
z-index: 999999;
width: 360px;
max-height: calc(100vh - 24px);
overflow: auto;
background: linear-gradient(180deg, #1b2838 0%, #16202d 100%);
border: 1px solid #000;
box-shadow: 0 0 12px rgba(0,0,0,.7), inset 0 1px 0 rgba(255,255,255,.04);
color: #c7d5e0;
font: 12px/1.4 "Motiva Sans", Arial, Helvetica, sans-serif;
border-radius: 3px;
}
.gs-panel__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 14px 14px 10px;
background: radial-gradient(120% 80% at 0% 0%, rgba(102,192,244,.18), transparent 55%),
linear-gradient(90deg, #1a2332, #1b2838);
border-bottom: 1px solid #000;
}
.gs-panel__title {
color: #fff;
font-size: 14px;
font-weight: 700;
}
.gs-panel__subtitle {
margin-top: 2px;
color: #8f98a0;
font-size: 11px;
}
.gs-panel__close {
border: 0;
background: transparent;
color: #8f98a0;
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.gs-panel__close:hover { color: #fff; }
.gs-panel__section {
padding: 10px 14px;
}
.gs-panel__section-title {
margin: 0 0 8px;
color: #66c0f4;
font-size: 11px;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
}
.gs-panel__section--row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.gs-panel__divider {
height: 1px;
background: linear-gradient(90deg, transparent, #000, transparent);
margin: 0 10px;
}
.gs-panel__footer {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px 14px;
border-top: 1px solid #000;
background: rgba(0,0,0,.2);
}
.gs-panel__footer-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.gs-panel__footer-divider {
height: 1px;
width: 100%;
background: linear-gradient(90deg, transparent, #000, transparent);
}
.gs-panel__repo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
color: #8f98a0;
text-decoration: none;
}
.gs-panel__repo:hover {
color: #c7d5e0;
}
.gs-panel__repo:hover .gs-panel__repo-title {
color: #66c0f4;
}
.gs-panel__repo-title {
color: #67c1f5;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.gs-panel__repo-desc {
font-size: 11px;
line-height: 1.3;
}
.gs-field {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 8px;
}
.gs-field:last-child { margin-bottom: 0; }
.gs-field__label {
color: #8f98a0;
text-transform: uppercase;
letter-spacing: .04em;
font-size: 10px;
font-weight: 700;
}
.gs-field input,
.gs-field select {
height: 30px;
padding: 0 8px;
border: 1px solid #000;
border-radius: 2px;
background: #316282;
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.05);
outline: none;
font: inherit;
}
.gs-field input::placeholder { color: rgba(255,255,255,.45); }
.gs-field input:focus,
.gs-field select:focus {
background: #3d7a9c;
box-shadow: 0 0 0 1px #66c0f4;
}
.gs-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.gs-hint {
margin: 6px 0 0;
color: #8f98a0;
font-size: 11px;
line-height: 1.35;
}
.gs-switch {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.gs-switch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.gs-switch__track {
width: 34px;
height: 18px;
border-radius: 9px;
background: #0e1620;
border: 1px solid #000;
position: relative;
box-shadow: inset 0 1px 2px rgba(0,0,0,.5);
flex: 0 0 auto;
}
.gs-switch__track::after {
content: '';
position: absolute;
top: 1px;
left: 1px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #8f98a0;
transition: transform .15s ease, background .15s ease;
}
.gs-switch input:checked + .gs-switch__track {
background: linear-gradient(to bottom, #66c0f4, #417a9b);
}
.gs-switch input:checked + .gs-switch__track::after {
transform: translateX(16px);
background: #fff;
}
.gs-switch__label { color: #c7d5e0; }
.gs-pill {
font-size: 10px;
font-weight: 700;
letter-spacing: .06em;
padding: 2px 7px;
border-radius: 2px;
background: #0e1620;
color: #8f98a0;
border: 1px solid #000;
flex: 0 0 auto;
}
.gs-pill.is-on {
color: #beee11;
background: #1b3708;
border-color: #53760d;
}
.gs-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 12px;
border: none;
border-radius: 2px;
cursor: pointer;
color: #fff;
font: 700 12px/28px "Motiva Sans", Arial, Helvetica, sans-serif;
background: linear-gradient(to bottom, #66c0f4 5%, #417a9b 95%);
text-shadow: 0 1px 1px rgba(0,0,0,.3);
box-shadow: 0 0 0 1px rgba(0,0,0,.4);
}
.gs-btn:hover { filter: brightness(1.08); }
.gs-btn--ghost {
background: linear-gradient(to bottom, #3d4450 5%, #2c313a 95%);
}
.gs-btn--green {
background: linear-gradient(to bottom, #a4d007 5%, #536904 95%);
}
.gs-btn--danger {
background: linear-gradient(to bottom, #c45c5c 5%, #8a3030 95%);
}
.gs-cache-status {
margin: 8px 0 0;
min-height: 14px;
color: #beee11;
font-size: 11px;
}
.gs-cache-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
@media (max-width: 900px) {
.gs-header-btn__text { display: none; }
}
`);
function loadMemoryCache() {
if (memoryCache) return memoryCache;
try {
memoryCache = JSON.parse(GM_getValue(CACHE_KEY, '{}')) || {};
} catch {
memoryCache = {};
}
return memoryCache;
}
function readCache() {
return loadMemoryCache();
}
function persistCacheNow() {
if (!cacheDirty || !memoryCache) return;
GM_setValue(CACHE_KEY, JSON.stringify(memoryCache));
cacheDirty = false;
}
function scheduleCachePersist() {
cacheDirty = true;
clearTimeout(cachePersistTimer);
cachePersistTimer = setTimeout(persistCacheNow, CACHE_PERSIST_MS);
}
function writeCache(cache) {
memoryCache = cache;
scheduleCachePersist();
}
function getCached(appId) {
const ttl = getCacheTtlMs();
if (ttl <= 0) return null;
const entry = readCache()[String(appId)];
if (!entry) return null;
if (Date.now() - entry.ts > ttl) return null;
return entry;
}
function setCached(appId, payload) {
if (getCacheTtlMs() <= 0) return;
const cache = readCache();
cache[String(appId)] = { ...payload, ts: Date.now() };
writeCache(cache);
}
function getCacheEntryCount() {
return Object.keys(readCache()).length;
}
function clearGameCache() {
const count = getCacheEntryCount();
memoryCache = {};
cacheDirty = false;
clearTimeout(cachePersistTimer);
cachePersistTimer = null;
GM_setValue(CACHE_KEY, '{}');
inflight.clear();
return count;
}
function slugify(text) {
return String(text || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[™®©'’":]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
function extractAppIdFromHref(href) {
const match = String(href || '').match(/\/app\/(\d+)(?:\/|$|\?)/i);
return match ? match[1] : null;
}
function extractSlugFromHref(href) {
const match = String(href || '').match(/\/app\/\d+\/([^/?#]+)/i);
if (!match) return null;
return slugify(match[1].replace(/_/g, '-'));
}
function hrefMatchesAppId(href, appId) {
if (!href || !appId) return false;
return extractAppIdFromHref(href) === String(appId);
}
function findAppAnchor(root, appId, { includeClosest = false } = {}) {
if (!root || !appId) return null;
const id = String(appId);
if (root instanceof HTMLAnchorElement && hrefMatchesAppId(root.href, id)) {
return root;
}
if (includeClosest && root.closest) {
const wrap = root.closest('a[href*="/app/"]');
if (wrap && hrefMatchesAppId(wrap.href, id)) return wrap;
}
if (!root.querySelectorAll) return null;
for (const anchor of root.querySelectorAll('a[href*="/app/"]')) {
if (hrefMatchesAppId(anchor.href, id)) return anchor;
}
return null;
}
function getPageAppId() {
const fromUrl = extractAppIdFromHref(location.pathname);
if (fromUrl) return fromUrl;
if (typeof window.g_rgAppData === 'object' && window.g_rgAppData?.appid) {
return String(window.g_rgAppData.appid);
}
const appDataNode = document.querySelector('[data-appid]');
return appDataNode?.getAttribute('data-appid') || null;
}
function getPageTitle() {
return (
document.querySelector('.apphub_AppName')?.textContent?.trim() ||
document.querySelector('#appHubAppName')?.textContent?.trim() ||
document.title.replace(/\s+on Steam.*$/i, '').trim()
);
}
const TITLE_SELECTORS = [
'.tab_item_title',
'.search_name .title',
'.search_result_row .title',
'.title',
'.StoreSaleWidgetTitle',
'[class*="item_title"]',
'[class*="ItemTitle"]',
'a[href*="/app/"] img[src*="/apps/"] + div',
];
const INVALID_SLUG_RE =
/^(https?-)?(store-)?steam(powered|static)?(-[a-z0-9]+)*(-com)?$|steampowered|steamstatic|akamaihd|^(on-)?wishlist$|^gamestatus$|^(soon-)?on-game-pass$/;
const UI_CHROME_TITLE_RE =
/^(in library|в библиотеке|owned|ignored|on wishlist|в вишлисте|в желаемом|wishlisted|on game pass|soon on game pass|скоро в game pass|gamestatus(?:…|\.{0,3})?|not in database|нет в базе|load error|ошибка загрузки|\+\d+\s*more)$/i;
function looksLikePriceText(text) {
const value = String(text || '').replace(/\s+/g, ' ').trim();
if (!value) return true;
if (value.length > 100) return true;
if (/^-\d+%/.test(value)) return true;
if (/^\d+%?\s*off\b/i.test(value)) return true;
if (/\b(руб|₽|usd|eur|uah|gbp|cad|aud|try|idr|vnd|kr)\b/i.test(value) && /\d/.test(value)) {
return true;
}
if (/^(released|выход|дата выхода|release date)/i.test(value)) return true;
if (/^free to play$/i.test(value)) return true;
const digits = (value.match(/\d/g) || []).length;
return digits > 0 && digits / value.length > 0.45 && /%|руб|₽|\$|€|£/.test(value);
}
function isUiChromeTitle(text) {
const value = String(text || '').replace(/\s+/g, ' ').trim();
if (!value) return true;
if (UI_CHROME_TITLE_RE.test(value)) return true;
if (/^game\s*status/i.test(value)) return true;
return false;
}
function isValidSlug(slug) {
if (!slug || slug.length < 2) return false;
if (!/[a-z]/.test(slug)) return false;
if (INVALID_SLUG_RE.test(slug)) return false;
if (/^\d+(-\d+)+$/.test(slug)) return false;
const segments = slug.split('-').filter(Boolean);
if (!segments.length) return false;
if (segments.every((part) => /^\d+$/.test(part))) return false;
const numericParts = segments.filter((part) => /^\d+$/.test(part)).length;
return numericParts / segments.length <= 0.5;
}
function normalizeImageAltTitle(alt) {
return String(alt || '')
.replace(/\s+/g, ' ')
.trim()
.replace(/\s+(banner|capsule|header|poster|logo)$/i, '')
.trim();
}
function getCardTitle(card, link) {
const sources = [];
const add = (value) => {
const text = String(value || '').replace(/\s+/g, ' ').trim();
if (!text || isUiChromeTitle(text) || looksLikePriceText(text) || sources.includes(text)) return;
sources.push(text);
};
for (const selector of TITLE_SELECTORS) {
const element = card.querySelector(selector);
if (element) add(element.textContent);
}
if (isDsAppCapsule(card)) {
const capsuleLink = link || getCardLink(card);
add(normalizeImageAltTitle(card.querySelector('img[alt]')?.alt));
const slugFromCapsule = extractSlugFromHref(capsuleLink?.href);
if (slugFromCapsule && isValidSlug(slugFromCapsule)) {
add(slugFromCapsule.replace(/-/g, ' '));
}
}
add(
normalizeImageAltTitle(
card.querySelector(
'img.sale_capsule_image[alt], img.tab_row_capsule[alt], img[class*="capsule"][alt], img[alt]'
)?.alt
)
);
const ariaLabel = link?.getAttribute('aria-label')?.trim();
if (ariaLabel && !/^\d+%\s*off\b/i.test(ariaLabel) && !looksLikePriceText(ariaLabel)) {
add(ariaLabel.replace(/\.\s*\d+%\s*off.*$/i, '').trim());
}
const slugFromHref = extractSlugFromHref(link?.href);
if (slugFromHref && isValidSlug(slugFromHref)) {
add(slugFromHref.replace(/-/g, ' '));
}
if (!sources.length && link) {
const clone = link.cloneNode(true);
clone
.querySelectorAll(
`.${BADGE_CLASS}, .ds_flag, .ds_options, .alike_cont, [class*="WishlistFlag"], [class*="wishlist_flag"]`
)
.forEach((el) => el.remove());
const firstLine = clone.textContent
.split('\n')
.map((line) => line.trim())
.find((line) => line && !looksLikePriceText(line) && !isUiChromeTitle(line));
add(firstLine);
}
return sources[0] || '';
}
function buildSlugCandidates(appId, link, title) {
const candidates = [];
const addSlug = (slug) => {
if (slug && isValidSlug(slug) && !candidates.includes(slug)) {
candidates.push(slug);
}
};
const addFromHref = (href) => addSlug(extractSlugFromHref(href));
const addFromText = (text) => {
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
if (!normalized || looksLikePriceText(normalized) || isUiChromeTitle(normalized)) return;
addSlug(slugify(normalized));
addSlug(slugify(normalized.replace(/\s*[-–—:|].*$/, '')));
};
addFromHref(link?.href);
// Only for the page's own game — not recommendation cards on the same page
if (isGamePage() && String(appId) === String(getPageAppId())) {
addFromHref(location.pathname);
addFromHref(document.querySelector('link[rel="canonical"]')?.href);
addFromHref(document.querySelector('meta[property="og:url"]')?.content);
}
addFromText(title);
return candidates;
}
function isGamePage() {
return /\/app\/\d+/i.test(location.pathname);
}
function requestJson(url) {
const language = getApiLanguage();
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
Accept: 'application/json',
'Accept-Language': language,
},
timeout: 15000,
onload(response) {
if (response.status === 404) {
resolve(null);
return;
}
if (response.status < 200 || response.status >= 300) {
reject(new Error(`HTTP ${response.status}`));
return;
}
try {
resolve(JSON.parse(response.responseText));
} catch (error) {
reject(error);
}
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout')),
});
});
}
function isMatchingGame(data, appId) {
if (!data) return false;
if (!data.steam_prod_id) return true;
return String(data.steam_prod_id) === String(appId);
}
function buildApiUrl(slug) {
return `${API_BASE}/${encodeURIComponent(slug)}/`;
}
function getTriedUrls(entry) {
if (entry?.triedUrls?.length) return entry.triedUrls;
if (entry?.slugs?.length) return entry.slugs.map(buildApiUrl);
if (entry?.slug) return [buildApiUrl(entry.slug)];
return [];
}
function truncateText(text, maxLength = 140) {
const value = String(text || '').trim();
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 1).trim()}…`;
}
function formatDate(value) {
if (!value) return null;
const date = parseGameDate(value);
if (!date) return value;
return date.toLocaleDateString(getApiLanguage());
}
function formatScore(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? `${number}` : String(value);
}
async function fetchBySlug(slug, appId) {
const data = await requestJson(buildApiUrl(slug));
return isMatchingGame(data, appId) ? data : null;
}
const steamTitleCache = new Map();
const steamTitleInflight = new Map();
async function fetchSteamAppTitle(appId) {
const key = String(appId);
if (steamTitleCache.has(key)) return steamTitleCache.get(key);
if (steamTitleInflight.has(key)) return steamTitleInflight.get(key);
const promise = (async () => {
try {
const onStore = location.hostname === 'store.steampowered.com';
const url = `${STEAM_APPDETAILS_URL}?appids=${encodeURIComponent(key)}&filters=basic`;
const response = await fetch(url, {
credentials: onStore ? 'same-origin' : 'omit',
});
if (!response.ok) {
steamTitleCache.set(key, null);
return null;
}
const json = await response.json();
const payload = json?.[key];
const name = payload?.success ? payload.data?.name : null;
const title = name ? String(name).replace(/\s+/g, ' ').trim() : null;
steamTitleCache.set(key, title || null);
return title || null;
} catch {
steamTitleCache.set(key, null);
return null;
} finally {
steamTitleInflight.delete(key);
}
})();
steamTitleInflight.set(key, promise);
return promise;
}
async function tryResolveBySlugs(appId, slugs, triedUrls) {
const slugsToTry = slugs.slice(0, MAX_SLUG_ATTEMPTS);
for (const slug of slugsToTry) {
try {
const data = await fetchBySlug(slug, appId);
if (data) {
return {
data,
slug,
slugs,
triedUrls,
apiUrl: buildApiUrl(slug),
missing: false,
};
}
} catch {
// try next slug
}
}
return null;
}
async function resolveGame(appId, link, title) {
const cached = getCached(appId);
if (cached) return cached;
const safeTitle = isUiChromeTitle(title) ? '' : title;
let resolvedTitle = safeTitle;
let slugs = buildSlugCandidates(appId, link, resolvedTitle);
let triedUrls = slugs.map(buildApiUrl);
let usedSteamTitle = false;
// Capsules like Personal Calendar often have /app/{id}?… with no real title
// (DOM may only expose Steam UI chrome such as "ON WISHLIST").
if (!slugs.length) {
const steamTitle = await fetchSteamAppTitle(appId);
usedSteamTitle = true;
if (steamTitle) {
resolvedTitle = steamTitle;
slugs = buildSlugCandidates(appId, link, resolvedTitle);
triedUrls = slugs.map(buildApiUrl);
}
}
let entry = await tryResolveBySlugs(appId, slugs, triedUrls);
if (entry) {
setCached(appId, entry);
return entry;
}
// Bad DOM titles (wishlist flags, badge text) can yield junk slugs — retry via Steam name.
if (!usedSteamTitle) {
const steamTitle = await fetchSteamAppTitle(appId);
if (steamTitle && steamTitle !== resolvedTitle) {
const steamSlugs = buildSlugCandidates(appId, link, steamTitle);
const steamTried = [...new Set([...triedUrls, ...steamSlugs.map(buildApiUrl)])];
entry = await tryResolveBySlugs(appId, steamSlugs, steamTried);
if (entry) {
setCached(appId, entry);
return entry;
}
slugs = steamSlugs.length ? steamSlugs : slugs;
triedUrls = steamTried;
resolvedTitle = steamTitle;
}
}
entry = {
data: null,
slug: slugs[0] || null,
slugs,
triedUrls,
missing: true,
};
setCached(appId, entry);
return entry;
}
function startQueueItem(item) {
activeRequests += 1;
item
.task()
.then(item.resolve)
.catch(item.reject)
.finally(() => {
activeRequests -= 1;
setTimeout(drainQueue, REQUEST_DELAY_MS);
});
}
function drainQueue() {
while (activeRequests < MAX_CONCURRENT && queue.length > 0) {
startQueueItem(queue.shift());
}
}
function enqueue(task) {
return new Promise((resolve, reject) => {
queue.push({ task, resolve, reject });
drainQueue();
});
}
function loadGame(appId, link, title) {
const key = String(appId);
const cached = getCached(key);
if (cached) return Promise.resolve(cached);
if (!inflight.has(key)) {
inflight.set(
key,
enqueue(() => resolveGame(appId, link, title)).finally(() => {
inflight.delete(key);
})
);
}
return inflight.get(key);
}
function parseGameDate(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function getNotCrackedVariant(game) {
const release = parseGameDate(game.release_date);
if (!release) return 'not-cracked-recent';
const monthAgo = new Date();
monthAgo.setMonth(monthAgo.getMonth() - 1);
return release > monthAgo ? 'not-cracked-recent' : 'not-cracked-old';
}
function getStatusType(game) {
if (!game) return 'missing';
const status = String(game.readable_status || '').toLowerCase();
const groups = String(game.hacked_groups_en || game.hacked_groups || '').toLowerCase();
if (/release today|релиз сегодня|выходит сегодня/.test(status)) {
return 'release-today';
}
if (/bypass|обход|hypervisor/.test(groups) || /bypass|обход/.test(status)) {
return 'bypass';
}
if (/not cracked|не взлом|не взломан|unbroken|unreleased crack/.test(status)) {
return getNotCrackedVariant(game);
}
if (game.crack_date || /cracked|взлом/.test(status)) {
return 'cracked';
}
return 'unknown';
}
function getStatusLabel(game, type) {
if (!game) return t('notInDatabase');
if (game.readable_status) return game.readable_status;
if (type === 'cracked') return t('cracked');
if (type === 'not-cracked-recent' || type === 'not-cracked-old') return t('notCracked');
if (type === 'bypass') return t('protectionBypass');
if (type === 'release-today') return t('releaseToday');
return t('unknownStatus');
}
function tooltipRow(label, valueHtml) {
if (!valueHtml) return '';
return `
<div class="gs-tip-row">
<span class="gs-tip-label">${escapeHtml(label)}</span>
<span class="gs-tip-value">${valueHtml}</span>
</div>
`;
}
function tipChip(text, variant = '') {
if (!text) return '';
const mod = variant ? ` gs-tip-chip--${variant}` : '';
return `<span class="gs-tip-chip${mod}">${escapeHtml(text)}</span>`;
}
function splitChipValues(value) {
return String(value || '')
.split(/[,;/|]+/)
.map((part) => part.trim())
.filter(Boolean);
}
function normalizeScorePercent(value) {
const number = Number(value);
if (!Number.isFinite(number)) return null;
if (number <= 10) return number * 10;
return Math.max(0, Math.min(100, number));
}
function getScoreTone(value) {
const percent = normalizeScorePercent(value);
if (percent === null) return 'unknown';
if (percent >= 75) return 'high';
if (percent >= 50) return 'mid';
return 'low';
}
function formatScoreBadge(label, value) {
const text = formatScore(value);
if (!text) return '';
const tone = getScoreTone(value);
return `
<div class="gs-tip-score gs-tip-score--${tone}">
<span class="gs-tip-score__value">${escapeHtml(text)}</span>
<span class="gs-tip-score__label">${escapeHtml(label)}</span>
</div>
`;
}
function formatTooltipFooter(entry, game) {
const pageUrl = game?.slug ? `${SITE_BASE}/${game.slug}` : SITE_BASE;
const apiUrl = entry?.apiUrl || (game?.slug ? buildApiUrl(game.slug) : null);
const links = [
apiUrl
? `<a href="${escapeAttr(apiUrl)}" target="_blank" rel="noopener noreferrer">API</a>`
: '',
`<a href="${escapeAttr(pageUrl)}" target="_blank" rel="noopener noreferrer">GameStatus</a>`,
].filter(Boolean);
return `
<div class="gs-tip-footer">
<div class="gs-tip-footer-top">
<a class="gs-tip-donate-btn" href="${escapeAttr(DONATE_URL)}" target="_blank" rel="noopener noreferrer"><span>${escapeHtml(t('supportProject'))}</span></a>
<div class="gs-tip-footer-links">${links.join('<span class="gs-tip-footer-sep">·</span>')}</div>
</div>
<div class="gs-tip-footer-divider" role="separator"></div>
<a class="gs-tip-repo" href="${escapeAttr(REPO_URL)}" target="_blank" rel="noopener noreferrer">
<span class="gs-tip-repo-title">${escapeHtml(t('repoLink'))}</span>
<span class="gs-tip-repo-desc">${escapeHtml(t('repoAbout'))}</span>
</a>
</div>
`;
}
function formatHardwareSection(specs) {
if (!specs || typeof specs !== 'object') return '';
const rows = [
['CPU', specs.cpu_info],
['RAM', specs.ram_info],
['GPU', specs.gpu_info],
['OS', specs.os_info],
].filter(([, value]) => value);
if (!rows.length) return '';
return `
<div class="gs-tip-section">
<div class="gs-tip-section-title">${escapeHtml(t('hardware'))}</div>
<div class="gs-tip-specs">
${rows
.map(
([key, value]) => `
<div class="gs-tip-spec">
<span class="gs-tip-spec__key">${escapeHtml(key)}</span>
<span class="gs-tip-spec__val">${escapeHtml(value)}</span>
</div>`
)
.join('')}
</div>
</div>
`;
}
function formatTooltip(entry) {
const game = entry?.data || null;
if (!game) {
const urls = getTriedUrls(entry);
const links = urls.length
? `<div class="gs-tip-section">
<div class="gs-tip-section-title">${escapeHtml(t('checkedUrls'))}</div>
<ul class="gs-tip-links">
${urls
.map(
(url) =>
`<li><a href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a></li>`
)
.join('')}
</ul>
</div>`
: '';
return `
<div class="gs-tip-header">
<div class="gs-tip-header-body">
<div class="gs-tip-title">GameStatus</div>
<div class="gs-tip-chips">${tipChip(t('notInDatabase'), 'missing')}</div>
</div>
</div>
<div class="gs-tip-body">
<p class="gs-tip-muted">${escapeHtml(t('gameNotFound'))}</p>
${links}
</div>
${formatTooltipFooter(entry, null)}
`;
}
const statusType = getStatusType(game);
const statusLabel = getStatusLabel(game, statusType);
const protectionChips = splitChipValues(game.protections)
.map((item) => tipChip(item, 'protection'))
.join('');
const group = getLocalizedGroup(game);
const headerChips = [
tipChip(statusLabel, statusType),
game.is_AAA ? tipChip('AAA', 'aaa') : '',
protectionChips,
group ? tipChip(group) : '',
]
.filter(Boolean)
.join('');
const cover = game.short_image
? `<img class="gs-tip-cover" src="${escapeAttr(game.short_image)}" alt="" loading="lazy" referrerpolicy="no-referrer" />`
: '';
const infoRows = [
tooltipRow(t('release'), formatDate(game.release_date) ? escapeHtml(formatDate(game.release_date)) : ''),
tooltipRow(t('crack'), formatDate(game.crack_date) ? escapeHtml(formatDate(game.crack_date)) : ''),
tooltipRow(t('steamId'), game.steam_prod_id ? escapeHtml(String(game.steam_prod_id)) : ''),
tooltipRow(
t('subscriptions'),
game.count_subscribe > 0 ? escapeHtml(String(game.count_subscribe)) : ''
),
].join('');
const scores = [formatScoreBadge(t('score'), game.user_score), formatScoreBadge(t('metacritic'), game.mata_score)]
.filter(Boolean)
.join('');
const scoresBlock = scores ? `<div class="gs-tip-section"><div class="gs-tip-scores">${scores}</div></div>` : '';
const description = truncateText(getLocalizedDescription(game), 180);
const descriptionBlock = description
? `<div class="gs-tip-section"><p class="gs-tip-desc">${escapeHtml(description)}</p></div>`
: '';
return `
<div class="gs-tip-header">
${cover}
<div class="gs-tip-header-body">
<div class="gs-tip-title">${escapeHtml(game.title || 'GameStatus')}</div>
<div class="gs-tip-chips">${headerChips}</div>
</div>
</div>
<div class="gs-tip-body">
${infoRows ? `<div class="gs-tip-section">${infoRows}</div>` : ''}
${scoresBlock}
${formatHardwareSection(game.specs_info)}
${descriptionBlock}
</div>
${formatTooltipFooter(entry, game)}
`;
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, '`');
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
let tooltipEl = null;
function ensureTooltip() {
if (!tooltipEl) {
tooltipEl = document.createElement('div');
tooltipEl.className = `${BADGE_CLASS}__tooltip`;
document.documentElement.appendChild(tooltipEl);
}
return tooltipEl;
}
function bindTooltip(badge, entry) {
const activate = () => {
badge.removeEventListener('mouseenter', activate);
badge.removeEventListener('focus', activate);
const tip = ensureTooltip();
let hideTimer = null;
const show = () => {
clearTimeout(hideTimer);
tip.innerHTML = formatTooltip(entry);
tip.classList.add(`${BADGE_CLASS}__tooltip--visible`, `${BADGE_CLASS}__tooltip--interactive`);
const rect = badge.getBoundingClientRect();
const tipRect = tip.getBoundingClientRect();
const pad = 15;
let left = rect.left;
let top = rect.top - tipRect.height - 7;
if (top < pad) {
top = rect.bottom + 7;
}
if (top + tipRect.height > window.innerHeight - pad) {
top = Math.max(pad, rect.top - tipRect.height - 7);
}
if (left + tipRect.width > window.innerWidth - pad) {
left = window.innerWidth - tipRect.width - pad;
}
tip.style.left = `${Math.max(pad, left)}px`;
tip.style.top = `${Math.max(pad, top)}px`;
};
const scheduleHide = () => {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
tip.classList.remove(`${BADGE_CLASS}__tooltip--visible`, `${BADGE_CLASS}__tooltip--interactive`);
}, 120);
};
badge.addEventListener('mouseenter', show);
badge.addEventListener('focus', show);
badge.addEventListener('mouseleave', scheduleHide);
badge.addEventListener('blur', scheduleHide);
tip.addEventListener('mouseenter', () => clearTimeout(hideTimer));
tip.addEventListener('mouseleave', scheduleHide);
show();
};
badge.addEventListener('mouseenter', activate);
badge.addEventListener('focus', activate);
}
function createLoaderBadge(isPage = false, isChart = false) {
const badge = document.createElement('span');
const pageClass = isPage ? ` ${BADGE_CLASS}--page` : '';
const chartClass = isChart ? ` ${BADGE_CLASS}--chart` : '';
badge.className = `${BADGE_CLASS} ${BADGE_CLASS}--loading${pageClass}${chartClass}`;
badge.innerHTML = `<span class="${BADGE_CLASS}__dot"></span><span class="${BADGE_CLASS}__label">${escapeHtml(t('loading'))}</span>`;
return badge;
}
function renderBadge(entry, options = {}) {
const { isPage = false, isChart = false, appId = null } = options;
const game = entry?.data || null;
const type = getStatusType(game);
const label = getStatusLabel(game, type);
const href = game?.slug ? `${SITE_BASE}/${game.slug}` : SITE_BASE;
const badge = document.createElement('a');
const pageClass = isPage ? ` ${BADGE_CLASS}--page` : '';
const chartClass = isChart ? ` ${BADGE_CLASS}--chart` : '';
badge.className = `${BADGE_CLASS} ${BADGE_CLASS}--${type}${pageClass}${chartClass}`;
badge.href = href;
badge.target = '_blank';
badge.rel = 'noopener noreferrer';
badge.setAttribute('data-gs-processed', '1');
badge.dataset.gsAppId = String(appId || game?.steam_prod_id || '');
badge.innerHTML = `<span class="${BADGE_CLASS}__dot"></span><span class="${BADGE_CLASS}__label">${escapeHtml(label)}</span>`;
bindTooltip(badge, entry);
return badge;
}
function ensureRelativePosition(element) {
const inlinePosition = element.style.position;
if (inlinePosition && inlinePosition !== 'static') return;
if (!inlinePosition) {
const computed = getComputedStyle(element).position;
if (computed !== 'static') return;
}
element.style.position = 'relative';
}
function pauseMutationObserver() {
mutationObserver?.disconnect();
}
function resumeMutationObserver() {
if (mutationObserver && document.body) {
mutationObserver.observe(document.body, MUTATION_OBSERVER_OPTIONS);
}
}
function isRelevantAddedNode(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const el = /** @type {Element} */ (node);
if (el.id === 'gs-panel' || el.id === 'gs-settings-btn') return false;
if (el.closest?.('#gs-panel')) return false;
if (el.classList?.contains(BADGE_CLASS)) return false;
if (el.closest?.(`.${BADGE_CLASS}__tooltip`)) return false;
if (el.closest?.('[data-gs-observed="1"]')) {
const observed = el.closest('[data-gs-observed="1"]');
if (observed && !hasMultipleGames(observed)) return false;
}
if (el.matches?.('a[href*="/app/"]')) return true;
return Boolean(el.querySelector?.('a[href*="/app/"]'));
}
function isFeaturedWideCarouselCard(card) {
return (
card?.classList?.contains('store_main_capsule') &&
Boolean(card.closest('.carousel_wide_mode'))
);
}
function isCarouselBadgeVisible(card) {
if (!isFeaturedWideCarouselCard(card)) return true;
return card.classList.contains('focus');
}
function verifyBadgeAppId(card, anchor) {
const expectedAppId = getCardAppId(card);
if (!expectedAppId || !anchor) return false;
const badge = anchor.querySelector(`.${BADGE_CLASS}:not(.${BADGE_CLASS}--loading)`);
if (!badge) return false;
if (badge.dataset.gsAppId && badge.dataset.gsAppId !== expectedAppId) {
badge.remove();
resetBadgeAnchor(anchor);
if (card !== anchor && card.dataset.gsObserved === '1') {
delete card.dataset.gsObserved;
delete card.dataset.gsAppId;
delete card.dataset.gsTitle;
}
return true;
}
return false;
}
function collectRelevantMutations(mutations) {
const nodes = [];
let needsRescan = false;
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
const target = mutation.target;
if (!(target instanceof Element)) continue;
if (mutation.attributeName === 'class' && isFeaturedWideCarouselCard(target)) {
if (target.classList.contains('focus')) {
const anchor = getBadgeAnchor(target);
if (verifyBadgeAppId(target, anchor)) {
needsRescan = true;
}
nodes.push(target);
}
continue;
}
if (!target.hasAttribute('data-ds-appid')) continue;
if (resetStaleCapsuleBadge(target)) {
needsRescan = true;
}
nodes.push(target);
continue;
}
for (const node of mutation.addedNodes) {
if (isRelevantAddedNode(node)) {
nodes.push(/** @type {Element} */ (node));
}
}
}
if (needsRescan && !nodes.length) {
nodes.push(document.body);
}
return nodes;
}
function getChartRowLink(row) {
const titleLink = row.querySelector('td a[href*="/app/"] img[src*="/apps/"]')?.closest('a');
return titleLink || row.querySelector('a[href*="/app/"]');
}
function getCardAppId(card) {
if (!card) return null;
// Prefer href on <a /app/{id}/> — SteamPeek (and similar) put the page app id in data-ds-appid
if (card instanceof HTMLAnchorElement) {
const fromHref = extractAppIdFromHref(card.href);
if (fromHref) return fromHref;
}
const fromAttr = card.getAttribute('data-ds-appid') || card.dataset.dsAppid;
if (fromAttr) return fromAttr;
if (isChartTableRow(card)) {
const link = getChartRowLink(card);
return link ? extractAppIdFromHref(link.href) : null;
}
return null;
}
function resolveCardAppId(card, fallbackAppId) {
return getCardAppId(card) || String(fallbackAppId || '');
}
function countDistinctAppIds(element) {
if (!element) return 0;
return new Set(
[...element.querySelectorAll('[data-ds-appid]')]
.map((el) => el.getAttribute('data-ds-appid'))
.filter(Boolean)
).size;
}
function hasMultipleGames(element) {
return countDistinctAppIds(element) > 1;
}
function findDsAppCard(link) {
const appId = extractAppIdFromHref(link.href);
if (!appId) return null;
// Link itself is the capsule; href wins when data-ds-appid mismatches (SteamPeek)
if (link.hasAttribute('data-ds-appid')) {
return link;
}
const inner =
link.querySelector(`[data-ds-appid="${appId}"]`) || link.querySelector('[data-ds-appid]');
if (inner) {
const innerId = inner.getAttribute('data-ds-appid');
if (!innerId || innerId === appId) return inner;
}
const ancestor = link.closest('[data-ds-appid]');
if (ancestor?.getAttribute('data-ds-appid') === appId) return ancestor;
return null;
}
function isDsAppCapsule(card) {
return Boolean(card?.hasAttribute?.('data-ds-appid'));
}
function tryAddCardFromLink(link, cards) {
if (link.classList.contains(BADGE_CLASS)) return;
if (link.closest('.home_content_reason')) return;
const appId = extractAppIdFromHref(link.href);
if (!appId) return;
// Game page header badge is handled separately — skip same-app capsules
if (isGamePage() && appId === getPageAppId()) return;
const card = findCardContainer(link);
if (!card || card.closest('.apphub_AppName, .game_area_purchase, #game_highlights')) return;
if (!isCarouselBadgeVisible(card)) return;
const cardAppId = getCardAppId(card);
if (cardAppId && cardAppId !== appId) return;
const anchor = getBadgeAnchor(card);
if (!anchor) return;
if (anchor.querySelector(`.${BADGE_CLASS}:not(.${BADGE_CLASS}--loading)`)) return;
if (anchor.dataset.gsObserved === '1' && anchor.querySelector(`.${BADGE_CLASS}--loading`)) return;
const cardLink = getCardLink(card) || link;
const title = getCardTitle(card, cardLink);
const resolvedAppId = cardAppId || appId;
const existing = cards.get(resolvedAppId);
if (!existing || shouldPreferCard(card, existing.card)) {
cards.set(resolvedAppId, { card, link: cardLink, title });
}
}
function collectCardsFromRoot(root, cards = new Map()) {
if (root instanceof HTMLAnchorElement && root.href.includes('/app/')) {
tryAddCardFromLink(root, cards);
}
root.querySelectorAll('a[href*="/app/"]').forEach((link) => tryAddCardFromLink(link, cards));
return cards;
}
function resolveCardLink(card) {
if (card instanceof HTMLAnchorElement && /\/app\/\d+/i.test(card.href)) {
return card;
}
return getCardLink(card);
}
function isChartTableRow(element) {
return Boolean(element?.matches?.('tr') && element.querySelector('[class*="StoreSalePriceWidgetContainer"]'));
}
function findChartTableRow(link) {
const row = link.closest('tr');
if (!row) return null;
const priceAnchor = row.querySelector('[class*="StoreSalePriceWidgetContainer"]');
if (!priceAnchor) return null;
const rowAppIds = new Set(
[...row.querySelectorAll('a[href*="/app/"]')]
.map((anchor) => extractAppIdFromHref(anchor.href))
.filter(Boolean)
);
if (rowAppIds.size !== 1) return null;
const linkAppId = extractAppIdFromHref(link.href);
if (linkAppId && !rowAppIds.has(linkAppId)) return null;
return row;
}
function getChartPriceAnchor(row) {
return row.querySelector('[class*="StoreSalePriceWidgetContainer"]');
}
function findHomeContentGamelink(link) {
const homeRoot = link.closest('.home_content.single, .home_content_single_ctn');
if (!homeRoot) return null;
const gamelink = homeRoot.querySelector('.home_content_items[data-ds-appid], .gamelink[data-ds-appid]');
if (!gamelink) return null;
const linkAppId = extractAppIdFromHref(link.href);
const cardAppId = getCardAppId(gamelink);
if (linkAppId && cardAppId && linkAppId !== cardAppId) return null;
return gamelink;
}
function getCapsuleImageAnchor(card) {
if (!card) return null;
return (
card.querySelector('.microtrailer_wrapper') ||
card.querySelector('.capsule.capsule_image_ctn.main_capsule') ||
card.querySelector('.capsule_image_ctn.main_capsule') ||
card.querySelector('.capsule.capsule_image_ctn') ||
card.querySelector('.capsule_image_ctn') ||
card.querySelector('a.capsule_image_ctn')
);
}
function getBadgeAnchor(card) {
if (!card) return card;
if (isChartTableRow(card)) {
const priceAnchor = getChartPriceAnchor(card);
if (priceAnchor) return priceAnchor;
}
const imageAnchor = getCapsuleImageAnchor(card);
if (imageAnchor) return imageAnchor;
const homeRoot = card.closest('.home_content.single, .home_content_single_ctn');
if (homeRoot) {
const gamelink = homeRoot.querySelector('.home_content_items[data-ds-appid], .gamelink[data-ds-appid]');
return (
getCapsuleImageAnchor(gamelink) ||
gamelink ||
card
);
}
return card;
}
function cleanupMisplacedHomeBadge(card) {
const homeRoot = card.closest('.home_content.single, .home_content_single_ctn');
if (!homeRoot) return;
homeRoot.querySelectorAll('.single_buttonbar .' + BADGE_CLASS).forEach((badge) => badge.remove());
}
function cleanupMisplacedGroupBadge(card, anchor) {
if (!card || !hasMultipleGames(card)) return;
cleanupMisplacedCapsuleBadges(card, anchor);
if (card.dataset.gsObserved === '1' && card !== anchor) {
delete card.dataset.gsObserved;
delete card.dataset.gsAppId;
delete card.dataset.gsTitle;
}
}
function cleanupMisplacedCapsuleBadges(card, anchor) {
if (!card || !anchor) return;
card.querySelectorAll('.' + BADGE_CLASS).forEach((badge) => {
if (!anchor.contains(badge)) badge.remove();
});
const anchorBadges = [...anchor.querySelectorAll('.' + BADGE_CLASS)];
anchorBadges.slice(1).forEach((badge) => badge.remove());
}
function resetStaleCapsuleBadge(card) {
if (!card?.hasAttribute?.('data-ds-appid')) return false;
const appId = getCardAppId(card);
if (!appId) return false;
const anchor = getBadgeAnchor(card);
if (anchor.dataset.gsObserved !== '1' || anchor.dataset.gsAppId === appId) return false;
resetBadgeAnchor(anchor);
if (card !== anchor && card.dataset.gsObserved === '1') {
delete card.dataset.gsObserved;
delete card.dataset.gsAppId;
delete card.dataset.gsTitle;
}
return true;
}
function shouldPreferCard(candidate, current) {
if (!current) return true;
if (current.contains(candidate)) return false;
if (candidate.contains(current)) return true;
if (isDsAppCapsule(candidate) && !isDsAppCapsule(current)) return true;
if (isDsAppCapsule(current) && !isDsAppCapsule(candidate)) return false;
if (isChartTableRow(candidate) && !isChartTableRow(current)) return true;
if (isChartTableRow(current) && !isChartTableRow(candidate)) return false;
const candidateAnchor = getBadgeAnchor(candidate);
const currentAnchor = getBadgeAnchor(current);
const candidateHasImage = candidateAnchor !== candidate;
const currentHasImage = currentAnchor !== current;
return candidateHasImage && !currentHasImage;
}
function findCardContainer(link) {
const chartRow = findChartTableRow(link);
if (chartRow) return chartRow;
const homeGamelink = findHomeContentGamelink(link);
if (homeGamelink) return homeGamelink;
const dsCard = findDsAppCard(link);
if (dsCard) return dsCard;
const appId = extractAppIdFromHref(link.href);
const container = link.closest(
[
'[data-ds-appid]',
'.sale_capsule',
'.tab_row_item',
'.search_result_row',
'.browse_item',
'.store_capsule',
'.capsule',
'.home_content_items',
'.gamelink',
'.Panel',
'div[class*="Capsule"]',
'div[class*="Item"]',
].join(', ')
);
if (container && hasMultipleGames(container) && appId) {
const specific = container.querySelector(`[data-ds-appid="${appId}"]`);
if (specific) return specific;
return null;
}
if (container?.classList.contains('single_buttonbar')) {
const gamelink = findHomeContentGamelink(link);
if (gamelink) return gamelink;
}
return container || link.parentElement;
}
function getCardLink(card) {
if (card instanceof HTMLAnchorElement && card.href.includes('/app/')) {
return card;
}
const cardAppId = getCardAppId(card);
if (cardAppId) {
const matched =
findAppAnchor(card, cardAppId, { includeClosest: isDsAppCapsule(card) }) ||
(isDsAppCapsule(card) ? findAppAnchor(card.parentElement, cardAppId, { includeClosest: true }) : null);
if (matched) return matched;
}
if (isChartTableRow(card)) {
const titleLink = getChartRowLink(card);
if (titleLink) return titleLink;
}
return card.querySelector('a[href*="/app/"]');
}
function collectCards() {
return collectCardsFromRoot(document);
}
function collectCardsFromNodes(nodes) {
const cards = new Map();
nodes.forEach((node) => collectCardsFromRoot(node, cards));
return cards;
}
function enqueueHydration(card, appId, link, title) {
const anchor = getBadgeAnchor(card);
visibilityObserver.unobserve(anchor);
hydrationQueue.push({ card, appId, link, title });
if (!isScrolling) {
flushHydrationQueue();
}
}
function flushHydrationQueue() {
if (hydrateFlushRaf || hydrationQueue.length === 0 || isScrolling) return;
hydrateFlushRaf = requestAnimationFrame(() => {
hydrateFlushRaf = null;
const batch = hydrationQueue.splice(0, HYDRATE_BATCH_SIZE);
batch.forEach(({ card, appId, link, title }) => {
hydrateCard(card, appId, link, title);
});
if (hydrationQueue.length > 0) {
flushHydrationQueue();
}
});
}
function setupScrollListener() {
window.addEventListener(
'scroll',
() => {
isScrolling = true;
clearTimeout(scrollIdleTimer);
scrollIdleTimer = setTimeout(() => {
isScrolling = false;
flushHydrationQueue();
}, SCROLL_IDLE_MS);
},
{ passive: true }
);
}
const visibilityObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const anchor = entry.target;
const card =
anchor.closest('[data-ds-appid]') ||
anchor.closest('tr') ||
anchor.closest('.home_content_items[data-ds-appid], .gamelink[data-ds-appid], [data-ds-appid]') ||
anchor;
if (!isCarouselBadgeVisible(card)) return;
const appId = resolveCardAppId(card, anchor.dataset.gsAppId);
if (!appId) return;
const link = getCardLink(card) || resolveCardLink(card);
const title = anchor.dataset.gsTitle || getCardTitle(card, link);
enqueueHydration(card, appId, link, title);
});
},
{ root: null, rootMargin: CARD_ROOT_MARGIN, threshold: 0.05 }
);
function resetBadgeAnchor(anchor) {
anchor.querySelectorAll(`.${BADGE_CLASS}`).forEach((badge) => badge.remove());
visibilityObserver.unobserve(anchor);
delete anchor.dataset.gsObserved;
delete anchor.dataset.gsAppId;
delete anchor.dataset.gsTitle;
}
function shouldRenderMissingBadge(entry) {
if (entry?.data) return true;
return settings.showMissingBadge !== false;
}
async function hydrateCard(card, appId, link, title) {
const anchor = getBadgeAnchor(card);
const isChart = isChartTableRow(card);
const resolvedAppId = resolveCardAppId(card, appId);
const resolvedLink = getCardLink(card) || link;
const resolvedTitle = title || getCardTitle(card, resolvedLink);
const loader = anchor.querySelector(`.${BADGE_CLASS}--loading`);
if (anchor.dataset.gsAppId && anchor.dataset.gsAppId !== resolvedAppId) {
loader?.remove();
return;
}
try {
const entry = await loadGame(resolvedAppId, resolvedLink, resolvedTitle);
if (anchor.dataset.gsAppId && anchor.dataset.gsAppId !== resolvedAppId) {
loader?.remove();
return;
}
if (!shouldRenderMissingBadge(entry)) {
requestAnimationFrame(() => {
if (anchor.dataset.gsAppId && anchor.dataset.gsAppId !== resolvedAppId) {
loader?.remove();
return;
}
loader?.remove();
anchor.dataset.gsMissingHidden = '1';
});
return;
}
const badge = renderBadge(entry, { isChart, appId: resolvedAppId });
requestAnimationFrame(() => {
if (anchor.dataset.gsAppId && anchor.dataset.gsAppId !== resolvedAppId) {
loader?.remove();
return;
}
loader?.replaceWith(badge);
});
} catch {
if (anchor.dataset.gsAppId && anchor.dataset.gsAppId !== resolvedAppId) {
loader?.remove();
return;
}
if (!settings.showMissingBadge) {
loader?.remove();
return;
}
const badge = renderBadge({ data: null, missing: true, triedUrls: [] }, { isChart, appId: resolvedAppId });
badge.querySelector(`.${BADGE_CLASS}__label`).textContent = t('loadError');
requestAnimationFrame(() => {
if (anchor.dataset.gsAppId && anchor.dataset.gsAppId !== resolvedAppId) {
loader?.remove();
return;
}
loader?.replaceWith(badge);
});
}
}
function observeCardsMap(cards) {
if (!cards.size) return;
pauseMutationObserver();
requestAnimationFrame(() => {
try {
cards.forEach(({ card, link, title }, appId) => {
const resolvedAppId = resolveCardAppId(card, appId);
if (getCardAppId(card) && String(appId) !== resolvedAppId) return;
if (!isCarouselBadgeVisible(card)) return;
const anchor = getBadgeAnchor(card);
const isChart = isChartTableRow(card);
if (verifyBadgeAppId(card, anchor)) {
// stale badge removed — continue to re-observe
} else if (anchor.dataset.gsObserved === '1') {
if (anchor.dataset.gsAppId === resolvedAppId) return;
resetBadgeAnchor(anchor);
}
cleanupMisplacedHomeBadge(card);
cleanupMisplacedGroupBadge(card, anchor);
cleanupMisplacedCapsuleBadges(card, anchor);
if (!isChart) {
ensureRelativePosition(anchor);
}
anchor.dataset.gsObserved = '1';
anchor.dataset.gsAppId = resolvedAppId;
anchor.dataset.gsTitle = title;
if (!hasMultipleGames(card) || card === anchor) {
card.dataset.gsObserved = '1';
}
if (!anchor.querySelector(`.${BADGE_CLASS}`)) {
anchor.appendChild(createLoaderBadge(false, isChart));
}
visibilityObserver.observe(anchor);
});
} finally {
resumeMutationObserver();
}
});
}
function observeCards() {
observeCardsMap(collectCards());
}
function observeCardsFromNodes(nodes) {
observeCardsMap(collectCardsFromNodes(nodes));
}
async function renderGamePage() {
const appId = getPageAppId();
if (!appId) return;
const anchor = document.querySelector('.apphub_OtherSiteInfo');
if (!anchor || anchor.dataset.gsPageProcessed === '1') return;
if (anchor.querySelector(`.${BADGE_CLASS}`)) return;
anchor.dataset.gsPageProcessed = '1';
const loader = createLoaderBadge(true);
anchor.prepend(loader);
const title = getPageTitle();
const link =
findAppAnchor(document, appId) ||
document.querySelector(`a[href*="/app/${appId}"]`) ||
{ href: location.pathname };
try {
const entry = await loadGame(appId, link, title);
if (!shouldRenderMissingBadge(entry)) {
loader.remove();
return;
}
loader.replaceWith(renderBadge(entry, { isPage: true }));
} catch {
if (!settings.showMissingBadge) {
loader.remove();
return;
}
const badge = renderBadge({ data: null, missing: true, triedUrls: [] }, { isPage: true });
badge.querySelector(`.${BADGE_CLASS}__label`).textContent = t('loadError');
loader.replaceWith(badge);
}
}
function debounce(fn, delay) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function runScan() {
if (isGamePage()) {
renderGamePage();
}
if (!hasInitialScan) {
hasInitialScan = true;
pendingScanNodes.length = 0;
observeCards();
return;
}
if (pendingScanNodes.length > 0) {
const nodes = pendingScanNodes.splice(0, pendingScanNodes.length);
observeCardsFromNodes(nodes);
} else {
observeCards();
}
}
const scheduleScan = debounce(runScan, SCAN_DEBOUNCE_MS);
/* ─── Header button + settings panel ─── */
function observeHeader() {
const observer = new MutationObserver(() => {
if (!document.getElementById('gs-settings-btn')) {
ensureSettingsButton();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
function waitForElement(selector, timeout = 15000) {
return new Promise((resolve) => {
const existing = document.querySelector(selector);
if (existing) {
resolve(existing);
return;
}
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
resolve(el);
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
observer.disconnect();
resolve(document.querySelector(selector));
}, timeout);
});
}
async function ensureSettingsButton() {
const host = await waitForElement('#global_actions', 20000);
if (!host || document.getElementById('gs-settings-btn')) {
updateSettingsButtonState();
return document.getElementById('gs-settings-btn');
}
const btn = document.createElement('button');
btn.type = 'button';
btn.id = 'gs-settings-btn';
btn.className = 'gs-header-btn';
btn.title = t('btnTitle');
btn.innerHTML = `
<span class="gs-header-btn__text">${escapeHtml(t('btnText'))}</span>
<span class="gs-header-btn__dot" id="gs-settings-dot"></span>
`;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
togglePanel();
});
const srbbBtn = host.querySelector('#srbb-settings-btn');
const sihBtn = host.querySelector('.sih-features-button');
if (srbbBtn) host.insertBefore(btn, srbbBtn.nextSibling);
else if (sihBtn) host.insertBefore(btn, sihBtn);
else host.insertBefore(btn, host.firstChild);
ensurePanel();
updateSettingsButtonState();
return btn;
}
function updateSettingsButtonState() {
const dot = document.getElementById('gs-settings-dot');
const customized =
settings.cacheHours !== DEFAULT_SETTINGS.cacheHours ||
settings.badgeVertical !== DEFAULT_SETTINGS.badgeVertical ||
settings.badgeHorizontal !== DEFAULT_SETTINGS.badgeHorizontal ||
settings.badgeSize !== DEFAULT_SETTINGS.badgeSize ||
settings.showMissingBadge !== DEFAULT_SETTINGS.showMissingBadge;
if (dot) {
dot.classList.toggle('is-on', customized);
dot.title = customized ? t('on') : t('off');
}
}
function ensurePanel() {
if (document.getElementById('gs-panel')) return;
const panel = document.createElement('div');
panel.id = 'gs-panel';
panel.className = 'gs-panel';
panel.hidden = true;
panel.innerHTML = `
<div class="gs-panel__header">
<div>
<div class="gs-panel__title">${escapeHtml(t('panelTitle'))}</div>
<div class="gs-panel__subtitle">${escapeHtml(t('panelSubtitle'))}</div>
</div>
<button type="button" class="gs-panel__close" data-gs="close" aria-label="${escapeHtml(t('close'))}">×</button>
</div>
<div class="gs-panel__section">
<div class="gs-panel__section-title">${escapeHtml(t('sectionBadge'))}</div>
<div class="gs-field">
<span class="gs-field__label">${escapeHtml(t('badgePosition'))}</span>
<div class="gs-grid">
<label class="gs-field">
<span class="gs-field__label">${escapeHtml(t('badgeVertical'))}</span>
<select id="gs-badge-vertical">
<option value="top">${escapeHtml(t('posTop'))}</option>
<option value="bottom">${escapeHtml(t('posBottom'))}</option>
</select>
</label>
<label class="gs-field">
<span class="gs-field__label">${escapeHtml(t('badgeHorizontal'))}</span>
<select id="gs-badge-horizontal">
<option value="left">${escapeHtml(t('posLeft'))}</option>
<option value="right">${escapeHtml(t('posRight'))}</option>
</select>
</label>
</div>
</div>
<p class="gs-hint">${escapeHtml(t('badgePositionHint'))}</p>
<label class="gs-field">
<span class="gs-field__label">${escapeHtml(t('badgeSize'))}</span>
<select id="gs-badge-size">
<option value="small">${escapeHtml(t('sizeSmall'))}</option>
<option value="medium">${escapeHtml(t('sizeMedium'))}</option>
<option value="large">${escapeHtml(t('sizeLarge'))}</option>
<option value="xlarge">${escapeHtml(t('sizeXLarge'))}</option>
</select>
</label>
<p class="gs-hint">${escapeHtml(t('badgeSizeHint'))}</p>
</div>
<div class="gs-panel__section gs-panel__section--row">
<label class="gs-switch">
<input type="checkbox" id="gs-show-missing" />
<span class="gs-switch__track"></span>
<span class="gs-switch__label">${escapeHtml(t('showMissing'))}</span>
</label>
<span class="gs-pill" id="gs-show-missing-pill">${escapeHtml(t('off'))}</span>
</div>
<div class="gs-panel__section" style="padding-top:0">
<p class="gs-hint" style="margin-top:0">${escapeHtml(t('showMissingHint'))}</p>
</div>
<div class="gs-panel__divider"></div>
<div class="gs-panel__section">
<div class="gs-panel__section-title">${escapeHtml(t('sectionCache'))}</div>
<label class="gs-field">
<span class="gs-field__label">${escapeHtml(t('cacheHours'))}</span>
<input type="number" id="gs-cache-hours" min="0" max="${CACHE_HOURS_MAX}" step="1" placeholder="6" inputmode="numeric" />
</label>
<p class="gs-hint">${escapeHtml(t('cacheHoursHint'))}</p>
<div class="gs-cache-row">
<button type="button" class="gs-btn gs-btn--danger" data-gs="clear-cache">${escapeHtml(t('clearCache'))}</button>
</div>
<p class="gs-hint">${escapeHtml(t('cacheClearHint'))}</p>
<p class="gs-cache-status" id="gs-cache-status" aria-live="polite"></p>
</div>
<div class="gs-panel__footer">
<div class="gs-panel__footer-actions">
<button type="button" class="gs-btn gs-btn--ghost" data-gs="close">${escapeHtml(t('cancel'))}</button>
<button type="button" class="gs-btn" data-gs="save">${escapeHtml(t('save'))}</button>
<button type="button" class="gs-btn gs-btn--green" data-gs="save-run">${escapeHtml(t('saveReload'))}</button>
</div>
<div class="gs-panel__footer-divider" role="separator"></div>
<a class="gs-panel__repo" href="${REPO_URL}" target="_blank" rel="noopener noreferrer">
<span class="gs-panel__repo-title">${escapeHtml(t('repoLink'))}</span>
<span class="gs-panel__repo-desc">${escapeHtml(t('repoAbout'))}</span>
</a>
</div>
`;
document.body.appendChild(panel);
panel.addEventListener('click', (e) => e.stopPropagation());
panel.querySelectorAll('[data-gs="close"]').forEach((el) =>
el.addEventListener('click', () => togglePanel(false))
);
panel.querySelector('[data-gs="save"]').addEventListener('click', () => {
persistPanelForm();
togglePanel(false);
});
panel.querySelector('[data-gs="save-run"]').addEventListener('click', () => {
persistPanelForm();
togglePanel(false);
location.reload();
});
panel.querySelector('[data-gs="clear-cache"]').addEventListener('click', () => {
const count = clearGameCache();
const status = panel.querySelector('#gs-cache-status');
if (status) {
status.textContent =
count > 0 ? t('cacheCleared', { count }) : t('cacheEmpty');
}
});
const missingToggle = panel.querySelector('#gs-show-missing');
missingToggle.addEventListener('change', () => syncMissingPill());
document.addEventListener('click', (e) => {
if (!panelOpen) return;
const btn = document.getElementById('gs-settings-btn');
if (panel.contains(e.target) || btn?.contains(e.target)) return;
togglePanel(false);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panelOpen) togglePanel(false);
});
}
function syncMissingPill() {
const panel = document.getElementById('gs-panel');
if (!panel) return;
const on = panel.querySelector('#gs-show-missing').checked;
const pill = panel.querySelector('#gs-show-missing-pill');
pill.textContent = on ? t('on') : t('off');
pill.classList.toggle('is-on', on);
}
function fillPanelForm() {
const panel = document.getElementById('gs-panel');
if (!panel) return;
panel.querySelector('#gs-badge-vertical').value = normalizeBadgeVertical(settings.badgeVertical);
panel.querySelector('#gs-badge-horizontal').value = normalizeBadgeHorizontal(settings.badgeHorizontal);
panel.querySelector('#gs-badge-size').value = normalizeBadgeSize(settings.badgeSize);
panel.querySelector('#gs-show-missing').checked = settings.showMissingBadge !== false;
panel.querySelector('#gs-cache-hours').value = String(normalizeCacheHours(settings.cacheHours));
const status = panel.querySelector('#gs-cache-status');
if (status) status.textContent = '';
syncMissingPill();
}
function persistPanelForm() {
const panel = document.getElementById('gs-panel');
if (!panel) return;
saveSettings({
badgeVertical: panel.querySelector('#gs-badge-vertical').value,
badgeHorizontal: panel.querySelector('#gs-badge-horizontal').value,
badgeSize: panel.querySelector('#gs-badge-size').value,
showMissingBadge: panel.querySelector('#gs-show-missing').checked,
cacheHours: normalizeCacheHours(panel.querySelector('#gs-cache-hours').value),
});
}
function togglePanel(force) {
ensurePanel();
const panel = document.getElementById('gs-panel');
const btn = document.getElementById('gs-settings-btn');
if (!panel) return;
panelOpen = typeof force === 'boolean' ? force : !panelOpen;
panel.hidden = !panelOpen;
btn?.classList.toggle('is-open', panelOpen);
if (panelOpen) {
fillPanelForm();
positionPanel();
}
}
function positionPanel() {
const panel = document.getElementById('gs-panel');
const btn = document.getElementById('gs-settings-btn');
if (!panel || !btn) return;
const rect = btn.getBoundingClientRect();
const width = 360;
let left = rect.right - width;
if (left < 8) left = 8;
if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
panel.style.top = `${Math.round(rect.bottom + 8)}px`;
panel.style.left = `${Math.round(left)}px`;
}
function init() {
applyBadgeAppearance();
loadMemoryCache();
setupScrollListener();
window.addEventListener('pagehide', persistCacheNow);
ensureSettingsButton();
observeHeader();
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand(t('menuSettings'), () => {
ensureSettingsButton().then(() => togglePanel(true));
});
}
scheduleScan();
mutationObserver = new MutationObserver((mutations) => {
const nodes = collectRelevantMutations(mutations);
if (!nodes.length) return;
pendingScanNodes.push(...nodes);
scheduleScan();
});
mutationObserver.observe(document.body, MUTATION_OBSERVER_OPTIONS);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();