SteamDB - Sales; Ultimate Enhancer

Комплексное улучшение для SteamDB: фильтры по языкам, спискам, дате, рангам цен (РФ), конвертация валют, расширенная информация об играх

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

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址