// ==UserScript==
// @name SteamDB - Sales; Ultimate Enhancer
// @namespace https://steamdb.info/
// @version 1.1
// @description Комплексное улучшение для SteamDB: фильтры по языкам, спискам, дате, рангам цен (РФ), конвертация валют, расширенная информация об играх
// @author 0wn3df1x
// @license MIT
// @include https://steamdb.info/sales/*
// @include https://steamdb.info/stats/mostfollowed/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect api.steampowered.com
// @connect gist.githubusercontent.com
// ==/UserScript==
(function() {
'use strict';
const VIGODA_DATA_URL = "https://gist.githubusercontent.com/0wn3dg0d/bc3494cbb487091495081e95c4b15fc9/raw/ru_vigoda.json";
const VIGODA_DATA_CACHE_DURATION_MINUTES = 60 * 730;
const scriptsConfig = {
toggleEnglishLangInfo: false
};
const API_URL = "https://api.steampowered.com/IStoreBrowseService/GetItems/v1";
const BATCH_SIZE = 100;
const HOVER_DELAY = 300;
const REQUEST_DELAY = 200;
const DEFAULT_EXCHANGE_RATE = 0.19;
let collectedAppIds = new Set();
let tooltip = null;
let hoverTimer = null;
let gameData = {};
let vigodaDataStore = {};
let activeLanguageFilter = null;
let totalGames = 0;
let processedGames = 0;
let progressContainer = null;
let requestQueue = [];
let isProcessingQueue = false;
let currentExchangeRate = DEFAULT_EXCHANGE_RATE;
let activeListFilter = false;
let activeDateFilterTimestamp = null;
let isProcessingStarted = false;
let processButton = null;
let activeMinRank = null;
let activeMaxRank = null;
let vigodaStatusIndicator = null;
const PROCESS_BUTTON_TEXT = {
idle: "Обработать игры",
processing: "Обработка...",
done: "Обработка завершена"
};
const VIGODA_STATUS = {
idle: "Данные рангов не загружены",
loading: "Загрузка данных рангов...",
loaded: (count) => `Данные рангов загружены (${count} игр)`,
error: "Ошибка загрузки данных рангов"
};
const styles = `
.steamdb-enhancer * { box-sizing: border-box; margin: 0; padding: 0; }
.steamdb-enhancer { background: #16202d; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.25); padding: 12px; width: auto; margin-top: 5px; margin-bottom: 15px; max-width: 900px; }
.enhancer-header { display: flex; align-items: center; gap: 12px; margin-bottom: 15px; flex-wrap: wrap; }
.row-layout { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; margin-bottom: 12px; }
.row-layout.compact { gap: 8px; margin-bottom: 0; }
.control-group { background: #1a2635; border-radius: 6px; padding: 10px; margin: 6px 0; }
.group-title { color: #66c0f4; font-size: 12px; font-weight: 600; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.5px; }
.btn-group { display: flex; flex-wrap: wrap; gap: 5px; }
.btn { background: #2a3a4d; border: 1px solid #354658; border-radius: 4px; color: #c6d4df; cursor: pointer; font-size: 12px; padding: 5px 10px; transition: all 0.2s ease; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.btn:hover { background: #31455b; border-color: #3d526b; }
.btn.active { background: #66c0f4 !important; border-color: #66c0f4 !important; color: #1b2838 !important; }
.btn-icon { width: 12px; height: 12px; fill: currentColor; }
.progress-container { background: #1a2635; border-radius: 4px; height: 6px; overflow: hidden; margin: 10px 0 5px; }
.progress-text { display: flex; justify-content: space-between; color: #8f98a0; font-size: 11px; margin: 4px 2px 0; }
.progress-count { flex: 1; text-align: left; }
.progress-percent { flex: 1; text-align: right; }
.progress-bar { height: 100%; background: linear-gradient(90deg, #66c0f4 0%, #4d9cff 100%); transition: width 0.3s ease; }
.converter-group { display: flex; gap: 6px; flex: 1; }
.input-field { background: #1a2635; border: 1px solid #2a3a4d; border-radius: 4px; color: #c6d4df; font-size: 12px; padding: 5px 8px; min-width: 60px; width: 80px; }
.date-picker { background: #1a2635; border: 1px solid #2a3a4d; border-radius: 4px; color: #c6d4df; font-size: 12px; padding: 5px; width: 120px; }
.status-indicator { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 5px 8px; border-radius: 4px; color: #8f98a0;}
.status-indicator.status-active { color: #66c0f4; }
.vigoda-status-indicator { font-size: 11px; margin-left: 10px; padding: 3px 6px; border-radius: 3px; background: #2a3a4d; color: #8f98a0; }
.vigoda-status-indicator.loading { color: #e6cf5a; }
.vigoda-status-indicator.loaded { color: #66c0f4; }
.vigoda-status-indicator.error { color: #a74343; background: #4d2a2a; }
.steamdb-tooltip { position: absolute; background: #1b2838; color: #c6d4df; padding: 15px; border-radius: 3px; width: 320px; font-size: 14px; line-height: 1.5; box-shadow: 0 0 12px rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s; pointer-events: none; z-index: 9999; display: none; }
.tooltip-arrow { position: absolute; width: 0; height: 0; border-top: 10px solid transparent; border-bottom: 10px solid transparent; }
.group-top { margin-bottom: 8px; }
.group-middle { margin-bottom: 12px; }
.group-bottom { margin-bottom: 15px; }
.tooltip-row { margin-bottom: 4px; }
.tooltip-row.compact { margin-bottom: 2px; }
.tooltip-row.spaced { margin-bottom: 10px; }
.tooltip-row.language { margin-bottom: 8px; }
.tooltip-row.description { margin-top: 15px; padding-top: 10px; border-top: 1px solid #2a3a4d; color: #8f98a0; font-style: italic; }
.positive { color: #66c0f4; }
.mixed { color: #997a00; }
.negative { color: #a74343; }
.no-reviews { color: #929396; }
.language-yes { color: #66c0f4; }
.language-no { color: #a74343; }
.early-access-yes { color: #66c0f4; }
.early-access-no { color: #929396; }
.no-data { color: #929396; }
.vigoda-display-container { position: absolute; left: -235px; top: 50%; transform: translateY(-50%); background: rgba(15, 23, 36, 0.85); border: 1px solid #2a3a4d; border-radius: 4px; padding: 3px 6px; display: flex; align-items: center; gap: 8px; font-size: 11px; color: #c6d4df; white-space: nowrap; z-index: 5; pointer-events: none; }
.vigoda-rank-box { display: inline-block; padding: 1px 5px; border-radius: 3px; font-weight: bold; min-width: 20px; text-align: center; }
.vigoda-details { color: #a7bacc; }
.vigoda-no-data { color: #8f98a0; font-style: italic; }
tr.app td:first-child { position: relative; }
`;
function getRankBoxStyle(rank) {
if (rank === 1) {
return 'background: linear-gradient(145deg, #fceabb 0%, #f8b500 100%); color: #332a00; border: 1px solid #e6a400;';
} else if (rank >= 2 && rank <= 39) {
const normalized = (rank - 2) / (39 - 2);
const hue = 120 * (1 - normalized);
const saturation = 75;
const lightness = 45;
return `background: hsl(${hue}, ${saturation}%, ${lightness}%); color: white; border: 1px solid hsl(${hue}, ${saturation}%, 35%);`;
} else {
return 'background: #2a3a4d; color: #8f98a0; border: 1px solid #354658;';
}
}
function createVigodaDisplayElement(appId) {
const container = document.createElement('div');
container.className = 'vigoda-display-container';
const vigodaInfo = vigodaDataStore[appId];
if (vigodaInfo) {
const rank = vigodaInfo.Ранг_цены;
const rankStyle = getRankBoxStyle(rank);
const formatValue = (val) => (val === null ? 'MAX' : (typeof val === 'number' ? val.toFixed(2) : val));
const perVperd = formatValue(vigodaInfo['%-раз-вперд']);
const rubVperd = formatValue(vigodaInfo['руб-раз-вперд']);
const perSred = formatValue(vigodaInfo['%-раз-сред']);
const rubSred = formatValue(vigodaInfo['руб-раз-сред']);
container.innerHTML = `
<span class="vigoda-rank-box" style="${rankStyle}">${rank !== undefined && rank !== null ? rank : '?'}</span>
<span class="vigoda-details">${perVperd}% | ${rubVperd} | ${perSred}% | ${rubSred}</span>`;
} else {
container.innerHTML = `<span class="vigoda-no-data">Нет данных</span>`;
}
return container;
}
function injectVigodaDisplay(row) {
const appId = row.dataset.appid;
if (!appId) return;
const targetCell = row.querySelector('td:first-child');
if (!targetCell) return;
const existingDisplay = targetCell.querySelector('.vigoda-display-container');
if (existingDisplay) {
existingDisplay.remove();
}
const displayElement = createVigodaDisplayElement(appId);
targetCell.prepend(displayElement);
}
function injectAllVigodaDisplays() {
console.log("Injecting Vigoda displays...");
document.querySelectorAll('tr.app[data-appid]').forEach(row => {
injectVigodaDisplay(row);
});
console.log("Vigoda displays injected.");
}
function createFiltersContainer() {
const container = document.createElement('div');
container.className = 'steamdb-enhancer';
container.innerHTML = `
<div class="enhancer-header">
<button class="btn" id="process-btn">
<svg class="btn-icon" viewBox="0 0 24 24"><path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.8.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z"/></svg>
${PROCESS_BUTTON_TEXT.idle}
</button>
<div class="status-indicator status-inactive">Выберите 'All (slow)' entries per page и нажмите "${PROCESS_BUTTON_TEXT.idle}".</div>
<div class="vigoda-status-indicator">${VIGODA_STATUS.idle}</div>
</div>
<div class="progress-container"><div class="progress-bar"></div></div>
<div class="progress-text">
<span class="progress-count">0/0</span>
<span class="progress-percent">(0%)</span>
</div>
<div class="row-layout">
<div class="control-group">
<div class="group-title">Русский перевод</div>
<div class="btn-group">
<button class="btn" data-filter="russian-any">Только текст</button>
<button class="btn" data-filter="russian-audio">Озвучка</button>
<button class="btn" data-filter="no-russian">Без перевода</button>
</div>
</div>
<div class="control-group">
<div class="group-title">Списки</div>
<div class="btn-group">
<button class="btn" data-action="list1">Список 1</button>
<button class="btn" data-action="list2">Список 2</button>
<button class="btn" data-action="list-filter">Фильтр списков</button>
</div>
</div>
<div class="control-group">
<div class="group-title">Ранги цен (RU)</div>
<div class="btn-group">
<input type="number" class="input-field" id="min-rank-input" placeholder="Мин ранг" min="1" max="39" step="1">
<input type="number" class="input-field" id="max-rank-input" placeholder="Макс ранг" min="1" max="39" step="1">
<button class="btn" data-action="rank-filter">Ранжировать</button>
<button class="btn" data-action="rank-reset" title="Сбросить фильтр рангов">✕</button>
</div>
</div>
</div>
<div class="control-group">
<div class="group-title">Дополнительные инструменты</div>
<div class="row-layout compact">
<div class="converter-group">
<input type="number" class="input-field" id="exchange-rate-input" value="${DEFAULT_EXCHANGE_RATE}" step="0.01">
<button class="btn" data-action="convert">Конвертировать</button>
</div>
<div class="btn-group">
<input type="date" class="date-picker">
<button class="btn" data-action="date-filter">Фильтр по дате</button>
</div>
</div>
</div>`;
return container;
}
function handleFilterClick(event) {
const btn = event.target.closest('[data-filter]');
if (!btn) return;
const filterType = btn.dataset.filter;
if (filterType.startsWith('russian-') || filterType === 'no-russian') {
const wasActive = btn.classList.contains('active');
document.querySelectorAll('[data-filter^="russian-"], [data-filter="no-russian"]').forEach(b => b.classList.remove('active'));
if (!wasActive) {
btn.classList.add('active');
activeLanguageFilter = filterType;
} else {
activeLanguageFilter = null;
}
}
applyAllFilters();
}
function handleControlClick(event) {
const btn = event.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
switch (action) {
case 'list1':
saveList('list1');
break;
case 'list2':
saveList('list2');
break;
case 'list-filter':
activeListFilter = !activeListFilter;
btn.classList.toggle('active', activeListFilter);
applyAllFilters();
break;
case 'convert':
currentExchangeRate = parseFloat(document.getElementById('exchange-rate-input').value) || DEFAULT_EXCHANGE_RATE;
convertPrices();
break;
case 'date-filter': {
const dateInput = btn.previousElementSibling;
if (btn.classList.contains('active')) {
btn.classList.remove('active');
dateInput.value = '';
activeDateFilterTimestamp = null;
} else {
const selectedDate = dateInput.value;
if (selectedDate) {
const dateObj = new Date(selectedDate + 'T00:00:00Z');
activeDateFilterTimestamp = dateObj.getTime() / 1000;
if (!isNaN(activeDateFilterTimestamp)) {
btn.classList.add('active');
} else {
console.error("Invalid date selected");
activeDateFilterTimestamp = null;
}
} else {
activeDateFilterTimestamp = null;
}
}
applyAllFilters();
break;
}
case 'rank-filter': {
const minRankInput = document.getElementById('min-rank-input');
const maxRankInput = document.getElementById('max-rank-input');
activeMinRank = minRankInput.value ? parseInt(minRankInput.value, 10) : null;
activeMaxRank = maxRankInput.value ? parseInt(maxRankInput.value, 10) : null;
if (activeMinRank !== null && isNaN(activeMinRank)) activeMinRank = null;
if (activeMaxRank !== null && isNaN(activeMaxRank)) activeMaxRank = null;
if (activeMinRank !== null && activeMaxRank !== null && activeMinRank > activeMaxRank) {
[activeMinRank, activeMaxRank] = [activeMaxRank, activeMinRank];
minRankInput.value = activeMinRank;
maxRankInput.value = activeMaxRank;
}
minRankInput.classList.toggle('active', activeMinRank !== null);
maxRankInput.classList.toggle('active', activeMaxRank !== null);
btn.classList.toggle('active', activeMinRank !== null || activeMaxRank !== null);
applyAllFilters();
break;
}
case 'rank-reset': {
document.getElementById('min-rank-input').value = '';
document.getElementById('max-rank-input').value = '';
activeMinRank = null;
activeMaxRank = null;
document.getElementById('min-rank-input').classList.remove('active');
document.getElementById('max-rank-input').classList.remove('active');
document.querySelector('[data-action="rank-filter"]').classList.remove('active');
applyAllFilters();
break;
}
}
}
function saveList(listName) {
const appIds = Array.from(collectedAppIds);
localStorage.setItem(listName, JSON.stringify(appIds));
alert(`Список ${listName} сохранён (${appIds.length} игр)`);
}
function convertPrices() {
document.querySelectorAll('tr.app').forEach(row => {
const priceCells = row.querySelectorAll('td.dt-type-numeric');
if (priceCells.length < 3) return;
const priceElement = priceCells[2];
if (!priceElement.dataset.originalPrice) {
priceElement.dataset.originalPrice = priceElement.textContent.trim();
}
const originalPriceText = priceElement.dataset.originalPrice;
let priceValue = NaN;
if (originalPriceText.includes('S/.')) {
const priceMatch = originalPriceText.match(/S\/\.\s*([0-9,.]+)/);
priceValue = priceMatch ? parseFloat(priceMatch[1].replace(/\s/g, '').replace(',', '.')) : NaN;
} else if (originalPriceText.includes('₽')) {
const priceMatch = originalPriceText.match(/([0-9,\s]+)\s*₽/);
priceValue = priceMatch ? parseFloat(priceMatch[1].replace(/\s/g, '').replace(',', '.')) : NaN;
} else if (originalPriceText.toLowerCase().includes('free')) {
priceValue = 0;
} else {
const priceMatch = originalPriceText.replace(',', '.').match(/([0-9.]+)/);
priceValue = priceMatch ? parseFloat(priceMatch[1]) : NaN;
}
if (!isNaN(priceValue)) {
if (priceValue === 0) {
priceElement.textContent = priceElement.dataset.originalPrice;
} else {
const converted = (priceValue * currentExchangeRate).toFixed(2);
priceElement.textContent = converted;
}
} else {
priceElement.textContent = originalPriceText;
}
});
}
function applyAllFilters() {
console.log("Applying filters (v1.0 logic for Date/List)...");
const rows = document.querySelectorAll('tr.app');
const list1 = JSON.parse(localStorage.getItem('list1') || '[]');
const list2 = JSON.parse(localStorage.getItem('list2') || '[]');
const commonIds = new Set(list1.filter(id => list2.includes(id)));
rows.forEach(row => {
const appId = row.dataset.appid;
const data = gameData[appId];
const vigodaInfo = vigodaDataStore[appId];
let visible = true;
if (activeListFilter) {
visible = !commonIds.has(appId);
}
if (visible && activeDateFilterTimestamp !== null) {
const cells = row.querySelectorAll('.timeago');
const timeToCheck = parseInt(cells[1]?.dataset.sort || cells[0]?.dataset.sort || '0');
if (!timeToCheck || isNaN(timeToCheck)) {
console.warn(`Invalid timeToCheck for AppID ${appId}:`, cells[1]?.dataset.sort, cells[0]?.dataset.sort);
visible = false;
} else {
visible = timeToCheck >= activeDateFilterTimestamp;
}
if(appId === rows[0]?.dataset.appid || appId === rows[1]?.dataset.appid) {
console.log(`Date Filter v1.0 Logic Check (AppID: ${appId}): timeToCheck=${timeToCheck}, activeDateFilterTimestamp=${activeDateFilterTimestamp}, Visible=${visible}`);
}
}
if (visible && activeLanguageFilter && data) {
const lang = data.language_support_russian || {};
switch (activeLanguageFilter) {
case 'russian-any': visible = (lang.supported || lang.subtitles) && !lang.full_audio; break;
case 'russian-audio': visible = lang.full_audio; break;
case 'no-russian': visible = !lang.supported && !lang.full_audio && !lang.subtitles; break;
}
} else if (visible && activeLanguageFilter && !data && isProcessingStarted) {
visible = false;
}
if (visible && (activeMinRank !== null || activeMaxRank !== null)) {
const rank = vigodaInfo?.Ранг_цены;
if (rank === undefined || rank === null) {
visible = false;
} else {
if (activeMinRank !== null && rank < activeMinRank) visible = false;
if (visible && activeMaxRank !== null && rank > activeMaxRank) visible = false;
}
}
row.style.display = visible ? '' : 'none';
});
console.log("Filters applied.");
}
function processGameData(items) {
items.forEach(item => {
if (!item?.id) return;
gameData[item.id] = {
franchises: item.basic_info?.franchises?.map(f => f.name).join(', '),
percent_positive: item.reviews?.summary_filtered?.percent_positive,
review_count: item.reviews?.summary_filtered?.review_count,
is_early_access: item.is_early_access,
short_description: item.basic_info?.short_description,
language_support_russian: item.supported_languages?.find(l => l.elanguage === 8),
language_support_english: item.supported_languages?.find(l => l.elanguage === 0)
};
processedGames++;
});
updateProgress();
applyAllFilters();
}
async function processRequestQueue() {
if (isProcessingQueue || !requestQueue.length) return;
isProcessingQueue = true;
console.log(`Starting queue processing. Batches: ${requestQueue.length}`);
while (requestQueue.length) {
const batch = requestQueue.shift();
console.log(`Processing batch of ${batch.length} appids...`);
try {
await fetchGameData(batch);
await new Promise(r => setTimeout(r, REQUEST_DELAY));
} catch (error) {
console.error('Error processing batch:', error);
processedGames += batch.length;
updateProgress();
}
}
console.log("Queue processing finished.");
isProcessingQueue = false;
await applyAllFilters();
injectAllVigodaDisplays();
updateProgress();
}
function fetchGameData(appIds) {
return new Promise((resolve, reject) => {
if (!appIds || appIds.length === 0) {
console.warn("fetchGameData called with empty appIds");
resolve();
return;
}
const input = {
ids: appIds.map(appid => ({ appid: parseInt(appid, 10) })),
context: { language: "russian", country_code: "US", steam_realm: 1 },
data_request: {
include_assets: false,
include_release: true,
include_platforms: false,
include_all_purchase_options: false,
include_screenshots: false,
include_trailers: false,
include_ratings: true,
include_tag_count: false,
include_reviews: true,
include_basic_info: true,
include_supported_languages: true,
include_full_description: false,
include_included_items: false
}
};
const url = `${API_URL}?input_json=${encodeURIComponent(JSON.stringify(input))}`;
console.log("Requesting Steam API for appids:", appIds.join(', '));
GM_xmlhttpRequest({
method: "GET",
url: url,
timeout: 15000,
onload: function(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
if (data && data.response && data.response.store_items) {
console.log(`Received data for ${data.response.store_items.length} items.`);
processGameData(data.response.store_items);
resolve();
} else {
console.error('Unexpected API response structure or no store_items:', data);
processedGames += appIds.length;
updateProgress();
resolve();
}
} catch (e) {
console.error('Error parsing JSON:', e, response.responseText);
processedGames += appIds.length;
updateProgress();
resolve();
}
} else {
console.error(`API request failed: ${response.status} ${response.statusText}`, response);
processedGames += appIds.length;
updateProgress();
resolve();
}
},
onerror: function(error) {
console.error('API request network error:', error);
processedGames += appIds.length;
updateProgress();
resolve();
},
ontimeout: function() {
console.error('API request timed out for appids:', appIds.join(', '));
processedGames += appIds.length;
updateProgress();
resolve();
}
});
});
}
function collectAppIds() {
console.log("Collecting AppIDs...");
const rows = document.querySelectorAll('tr.app[data-appid]');
const currentAppIdsOnPage = new Set(Array.from(rows).map(r => r.dataset.appid));
totalGames = currentAppIdsOnPage.size;
const newIds = new Set([...currentAppIdsOnPage].filter(id => !collectedAppIds.has(id)));
if (newIds.size > 0) {
console.log(`Found ${newIds.size} new AppIDs.`);
collectedAppIds = new Set([...collectedAppIds, ...newIds]);
const batches = [];
const arr = Array.from(newIds);
while (arr.length) batches.push(arr.splice(0, BATCH_SIZE));
requestQueue.push(...batches);
console.log(`Added ${batches.length} batches to the queue.`);
processRequestQueue();
} else {
console.log("No new AppIDs found on this page update.");
}
processedGames = [...currentAppIdsOnPage].filter(id => gameData.hasOwnProperty(id)).length;
updateProgress();
applyAllFilters();
injectAllVigodaDisplays();
}
function updateProgress() {
const progressBar = document.querySelector('.progress-bar');
const progressCount = document.querySelector('.progress-count');
const progressPercent = document.querySelector('.progress-percent');
const processBtn = document.getElementById('process-btn');
if (!progressBar || !progressCount || !progressPercent || !processBtn) return;
const percent = totalGames > 0 ? (processedGames / totalGames) * 100 : 0;
progressBar.style.width = `${Math.min(percent, 100)}%`;
progressCount.textContent = `${processedGames}/${totalGames}`;
progressPercent.textContent = `(${Math.round(Math.min(percent, 100))}%)`;
if (isProcessingStarted) {
if (processedGames >= totalGames && requestQueue.length === 0 && !isProcessingQueue) {
processBtn.textContent = PROCESS_BUTTON_TEXT.done;
processBtn.disabled = true;
document.querySelector('.status-indicator').classList.add('status-active');
document.querySelector('.status-indicator').textContent = "Обработка завершена.";
injectAllVigodaDisplays();
} else {
processBtn.textContent = PROCESS_BUTTON_TEXT.processing;
document.querySelector('.status-indicator').classList.remove('status-active');
document.querySelector('.status-indicator').textContent = "Идет обработка...";
}
}
}
function handleHover(event) {
const row = event.target.closest('tr.app');
if (!row || tooltip?.style?.opacity === '1') return;
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
const appId = row.dataset.appid;
if (gameData[appId]) {
showTooltip(row, gameData[appId]);
}
}, HOVER_DELAY);
row.addEventListener('mouseleave', hideTooltip, { once: true });
}
function hideTooltip() {
clearTimeout(hoverTimer);
if (tooltip) {
tooltip.style.opacity = '0';
setTimeout(() => {
if (tooltip && tooltip.style.opacity === '0') {
tooltip.style.display = 'none';
}
}, 250);
}
}
function showTooltip(element, data) {
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.className = 'steamdb-tooltip';
tooltip.addEventListener('mouseenter', () => clearTimeout(hoverTimer));
tooltip.addEventListener('mouseleave', hideTooltip);
document.body.appendChild(tooltip);
}
tooltip.innerHTML = `
<div class="tooltip-arrow"></div>
<div class="tooltip-content">${buildTooltipContent(data)}</div>`;
const rect = element.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
let left = rect.right + window.scrollX + 10;
let top = rect.top + window.scrollY + (rect.height / 2) - (tooltipRect.height / 2);
top = Math.max(window.scrollY + 5, top);
top = Math.min(window.scrollY + window.innerHeight - tooltipRect.height - 5, top);
const arrow = tooltip.querySelector('.tooltip-arrow');
if (left + tooltipRect.width > window.scrollX + window.innerWidth - 10) {
left = rect.left + window.scrollX - tooltipRect.width - 10;
arrow.style.left = 'auto';
arrow.style.right = '-10px';
arrow.style.borderRight = 'none';
arrow.style.borderLeft = '10px solid #1b2838';
} else {
arrow.style.left = '-10px';
arrow.style.right = 'auto';
arrow.style.borderLeft = 'none';
arrow.style.borderRight = '10px solid #1b2838';
}
arrow.style.top = `${Math.max(10, Math.min(tooltipRect.height - 10, (rect.height / 2))) }px`; // Center arrow vertically relative to element
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
tooltip.style.display = 'block';
requestAnimationFrame(() => { tooltip.style.opacity = '1'; });
}
function buildTooltipContent(data) {
const reviewClass = getReviewClass(data.percent_positive, data.review_count);
const earlyAccessClass = data.is_early_access ? 'early-access-yes' : 'early-access-no';
let languageSupportRussianText = "Отсутствует";
let languageSupportRussianClass = 'language-no';
if (data.language_support_russian) {
let content = [];
if (data.language_support_russian.supported) content.push("Интерфейс");
if (data.language_support_russian.subtitles) content.push("Субтитры");
if (data.language_support_russian.full_audio) content.push("<u>Озвучка</u>");
languageSupportRussianText = content.join(', ') || "Нет данных";
languageSupportRussianClass = content.length > 0 ? 'language-yes' : 'language-no';
}
let languageSupportEnglishText = "Отсутствует";
let languageSupportEnglishClass = 'language-no';
if (data.language_support_english) {
let content = [];
if (data.language_support_english.supported) content.push("Интерфейс");
if (data.language_support_english.subtitles) content.push("Субтитры");
if (data.language_support_english.full_audio) content.push("<u>Озвучка</u>");
languageSupportEnglishText = content.join(', ') || "Нет данных";
languageSupportEnglishClass = content.length > 0 ? 'language-yes' : 'language-no';
}
return `
<div class="group-top"><div class="tooltip-row compact"><strong>Серия игр:</strong> <span class="${!data.franchises ? 'no-data' : ''}">${data.franchises || "Нет данных"}</span></div></div>
<div class="group-middle">
<div class="tooltip-row spaced"><strong>Отзывы:</strong> <span class="${reviewClass}">${data.percent_positive !== undefined ? data.percent_positive + '%' : "Нет данных"}</span> (${data.review_count || "0"})</div>
<div class="tooltip-row spaced"><strong>Ранний доступ:</strong> <span class="${earlyAccessClass}">${data.is_early_access ? "Да" : "Нет"}</span></div>
</div>
<div class="group-bottom">
<div class="tooltip-row language"><strong>Русский язык:</strong> <span class="${languageSupportRussianClass}">${languageSupportRussianText}</span></div>
${scriptsConfig.toggleEnglishLangInfo ? `<div class="tooltip-row language"><strong>Английский язык:</strong> <span class="${languageSupportEnglishClass}">${languageSupportEnglishText}</span></div>` : ''}
</div>
<div class="tooltip-row description"><strong>Описание:</strong> <span class="${!data.short_description ? 'no-data' : ''}">${data.short_description || "Нет данных"}</span></div>`;
}
function getReviewClass(percent, totalReviews) {
if (totalReviews === undefined || totalReviews === null || totalReviews === 0) return 'no-reviews';
if (percent === undefined || percent === null) return 'no-reviews';
if (percent >= 70) return 'positive';
if (percent >= 40) return 'mixed';
return 'negative';
}
function updateVigodaStatus(status, count = 0) {
if (!vigodaStatusIndicator) {
vigodaStatusIndicator = document.querySelector('.vigoda-status-indicator');
}
if (vigodaStatusIndicator) {
let message = '';
let className = 'vigoda-status-indicator';
switch (status) {
case 'loading': message = VIGODA_STATUS.loading; className += ' loading'; break;
case 'loaded': message = VIGODA_STATUS.loaded(count); className += ' loaded'; break;
case 'error': message = VIGODA_STATUS.error; className += ' error'; break;
default: message = VIGODA_STATUS.idle;
}
vigodaStatusIndicator.textContent = message;
vigodaStatusIndicator.className = className;
}
}
function fetchVigodaData() {
return new Promise((resolve, reject) => {
if (!VIGODA_DATA_URL || VIGODA_DATA_URL.includes("ВАШ_RAW_GIST_URL_СЮДА")) {
console.error("Vigoda Data URL не установлен!");
updateVigodaStatus('error');
reject("URL не установлен");
return;
}
console.log("Fetching Vigoda data from:", VIGODA_DATA_URL);
updateVigodaStatus('loading');
GM_xmlhttpRequest({
method: "GET",
url: VIGODA_DATA_URL,
headers: { 'Cache-Control': 'no-cache' },
timeout: 20000,
onload: async function(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
const dataCount = Object.keys(data).length;
console.log(`Vigoda data loaded successfully. ${dataCount} entries.`);
const cacheData = { data: data, timestamp: Date.now() };
await GM_setValue('vigodaCache', JSON.stringify(cacheData));
vigodaDataStore = data;
updateVigodaStatus('loaded', dataCount);
injectAllVigodaDisplays();
applyAllFilters();
resolve(data);
} catch (e) {
console.error('Error parsing Vigoda JSON:', e, response.responseText);
updateVigodaStatus('error');
reject(e);
}
} else {
console.error(`Failed to fetch Vigoda data: ${response.status} ${response.statusText}`);
updateVigodaStatus('error');
reject(response.statusText);
}
},
onerror: function(error) {
console.error('Vigoda data fetch network error:', error);
updateVigodaStatus('error');
reject(error);
},
ontimeout: function() {
console.error('Vigoda data fetch timed out.');
updateVigodaStatus('error');
reject('Timeout');
}
});
});
}
async function loadVigodaData() {
const cachedDataString = await GM_getValue('vigodaCache', null);
let shouldFetch = true;
if (cachedDataString) {
try {
const cache = JSON.parse(cachedDataString);
const cacheAgeMinutes = (Date.now() - cache.timestamp) / (1000 * 60);
if (cache.data && cacheAgeMinutes < VIGODA_DATA_CACHE_DURATION_MINUTES) {
console.log(`Loading Vigoda data from cache (age: ${cacheAgeMinutes.toFixed(1)} mins).`);
vigodaDataStore = cache.data;
updateVigodaStatus('loaded', Object.keys(vigodaDataStore).length);
shouldFetch = false;
injectAllVigodaDisplays();
applyAllFilters();
} else {
console.log("Vigoda cache is old or invalid, fetching new data.");
}
} catch (e) {
console.error("Error parsing Vigoda cache:", e);
await GM_setValue('vigodaCache', null);
}
} else {
console.log("No Vigoda cache found, fetching new data.");
}
if (shouldFetch) {
try { await fetchVigodaData(); }
catch (error) { console.error("Failed to fetch Vigoda data on load:", error); }
}
return Promise.resolve();
}
async function init() {
console.log("Initializing SteamDB Enhancer...");
const style = document.createElement('style');
style.textContent = styles;
document.head.append(style);
const header = document.querySelector('.header-title');
if (header) {
const filtersContainer = createFiltersContainer();
header.parentNode.insertBefore(filtersContainer, header.nextElementSibling);
vigodaStatusIndicator = filtersContainer.querySelector('.vigoda-status-indicator');
} else {
console.error("Could not find header to insert controls.");
return;
}
document.addEventListener('click', (e) => {
const enhancerContainer = e.target.closest('.steamdb-enhancer');
if (enhancerContainer && !e.target.closest('#process-btn')) {
handleFilterClick(e);
handleControlClick(e);
}
});
document.getElementById('process-btn').addEventListener('click', async () => {
if (!isProcessingStarted) {
isProcessingStarted = true;
const processBtn = document.getElementById('process-btn');
const statusIndicator = document.querySelector('.status-indicator');
processBtn.textContent = PROCESS_BUTTON_TEXT.processing;
processBtn.disabled = true;
statusIndicator.textContent = VIGODA_STATUS.loading;
statusIndicator.classList.remove('status-active', 'status-inactive');
try {
await loadVigodaData();
injectAllVigodaDisplays();
statusIndicator.textContent = "Идет сбор AppID...";
collectAppIds();
} catch (error) {
console.error("Ошибка при загрузке данных рангов:", error);
statusIndicator.textContent = "Ошибка загрузки данных рангов.";
updateVigodaStatus('error');
processBtn.textContent = PROCESS_BUTTON_TEXT.idle;
processBtn.disabled = false;
isProcessingStarted = false;
}
}
});
document.querySelector('#DataTables_Table_0 tbody')?.addEventListener('mouseover', handleHover);
console.log("SteamDB Enhancer Initialized.");
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();