SteamDB - Sales; Ultimate Enhancer

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

当前为 2025-02-07 提交的版本,查看 最新版本

// ==UserScript==
// @name         SteamDB - Sales; Ultimate Enhancer
// @namespace    https://steamdb.info/
// @version      1.0
// @description  Комплексное улучшение для SteamDB: фильтры по языкам, спискам и дате, конвертация валют, расширенная информация об играх
// @author       0wn3df1x
// @license      MIT
// @include      https://steamdb.info/sales/*
// @grant        GM_xmlhttpRequest
// @connect      api.steampowered.com
// ==/UserScript==

(function() {
    'use strict';

    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 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;

    const PROCESS_BUTTON_TEXT = {
        idle: "Обработать игры",
        processing: "Обработка...",
        done: "Обработка завершена"
    };

    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: 50%;
        margin-top: 5px;
        margin-bottom: 15px;
    }

    .enhancer-header {
        display: flex;
        align-items: center;
        gap: 12px;
        margin-bottom: 15px;
    }

    .row-layout {
        display: grid;
        grid-template-columns: 1fr 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;
    }

    .steamdb-tooltip {
        background: #1a2635;
        border: 1px solid #2a3a4d;
        border-radius: 6px;
        box-shadow: 0 4px 12px rgba(0,0,0,0.3);
        padding: 12px;
        max-width: 320px;
        font-size: 13px;
        line-height: 1.5;
        position: absolute;
        z-index: 10000;
        opacity: 0;
        transition: opacity 0.2s;
        pointer-events: none;
    }

    .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;
    }

    .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;
    }

    .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;
    }

    .tooltip-arrow {
        position: absolute;
        left: -10px;
        top: 20px;
        width: 0;
        height: 0;
        border-top: 10px solid transparent;
        border-bottom: 10px solid transparent;
        border-right: 10px solid #1b2838;
    }

    .group-top { margin-bottom: 8px; }
    .group-middle { margin-bottom: 12px; }
    .group-bottom { margin-bottom: 15px; }
    .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; }
`;

    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 и нажмите на кнопку "Обработать игры".</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>

        <div class="control-group">
            <div class="group-title">Дополнительные инструменты</div>
            <div class="row-layout compact">
                <div class="converter-group">
                    <input type="number" class="input-field" 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;
        const wasActive = btn.classList.contains('active');

        document.querySelectorAll('[data-filter]').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.querySelector('.input-field').value) || DEFAULT_EXCHANGE_RATE;
                convertPrices();
                break;
            case 'date-filter': {
                const dateInput = btn.previousElementSibling;
                if (btn.classList.contains('active')) {
                    btn.classList.remove('active');
                    activeDateFilterTimestamp = null;
                } else {
                    activeDateFilterTimestamp = new Date(dateInput.value).getTime() / 1000;
                    btn.classList.add('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 priceElements = row.querySelectorAll('td.dt-type-numeric');
            if (priceElements.length < 3) return;

            const priceElement = priceElements[2];
            const priceText = priceElement.textContent.trim();

            let priceValue;
            if (priceText.includes('S/.')) {
                const priceMatch = priceText.match(/S\/\.([0-9,.]+)/);
                priceValue = priceMatch ? parseFloat(priceMatch[1].replace(',', '.')) : 0;
            } else {
                const priceMatch = priceText.match(/([0-9,.]+)/);
                priceValue = priceMatch ? parseFloat(priceMatch[1].replace(',', '.')) : 0;
            }

            if (!isNaN(priceValue)) {
                const converted = (priceValue * currentExchangeRate).toFixed(2);
                priceElement.textContent = `${converted}`;
            }
        });
    }

    function applyAllFilters() {
        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];
            let visible = true;

            if (activeListFilter) visible = !commonIds.has(appId);
            if (visible && activeDateFilterTimestamp !== null) {
                const cells = row.querySelectorAll('.timeago');
                const startTime = parseInt(cells[1]?.dataset.sort || cells[0]?.dataset.sort || '0');
                visible = startTime >= activeDateFilterTimestamp;
            }
            if (visible && activeLanguageFilter) {
                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;
                }
            }

            row.style.display = visible ? '' : 'none';
        });
    }

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

    async function processRequestQueue() {
        if (isProcessingQueue || !requestQueue.length) return;
        isProcessingQueue = true;

        while (requestQueue.length) {
            const batch = requestQueue.shift();
            try {
                await fetchGameData(batch);
                await new Promise(r => setTimeout(r, REQUEST_DELAY));
            } catch (error) {
                console.error('Batch error:', error);
            }
        }

        isProcessingQueue = false;
    }

    function fetchGameData(appIds) {
        return new Promise((resolve, reject) => {
            const input = {
                ids: Array.from(appIds).map(appid => ({
                    appid
                })),
                context: {
                    language: "russian",
                    country_code: "US",
                    steam_realm: 1
                },
                data_request: {
                    include_assets: true,
                    include_release: true,
                    include_platforms: true,
                    include_all_purchase_options: true,
                    include_screenshots: true,
                    include_trailers: true,
                    include_ratings: true,
                    include_tag_count: true,
                    include_reviews: true,
                    include_basic_info: true,
                    include_supported_languages: true,
                    include_full_description: true,
                    include_included_items: true
                }
            };

            GM_xmlhttpRequest({
                method: "GET",
                url: `${API_URL}?input_json=${encodeURIComponent(JSON.stringify(input))}`,
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            processGameData(data.response.store_items);
                            resolve();
                        } catch (e) {
                            console.error('Error parsing JSON:', e);
                            processedGames += appIds.length;
                            updateProgress();
                            resolve();
                        }
                    } else {
                        console.error('API request failed:', response.statusText);
                        processedGames += appIds.length;
                        updateProgress();
                        resolve();
                    }
                },
                onerror: function(error) {
                    console.error('API request error:', error);
                    processedGames += appIds.length;
                    updateProgress();
                    resolve();
                }
            });
        });
    }

    function collectAppIds() {
        const rows = document.querySelectorAll('tr.app[data-appid]');
        totalGames = rows.length;

        const newIds = new Set(
            Array.from(rows)
            .map(r => r.dataset.appid)
            .filter(id => !collectedAppIds.has(id))
        );

        if (newIds.size) {
            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);
            processRequestQueue();
        }

        updateProgress();
    }

    function updateProgress() {
        const progressBar = document.querySelector('.progress-bar');
        const progressCount = document.querySelector('.progress-count');
        const progressPercent = document.querySelector('.progress-percent');

        if (!progressBar || !progressCount || !progressPercent) return;

        const percent = (processedGames / totalGames) * 100;
        progressBar.style.width = `${percent}%`;

        progressCount.textContent = `${processedGames}/${totalGames}`;
        progressPercent.textContent = `(${Math.round(percent)}%)`;

        if (processedGames === totalGames) {
            document.querySelector('#process-btn').textContent = PROCESS_BUTTON_TEXT.done;
            document.querySelector('.status-indicator').classList.add('status-active');
        }
    }

    function handleHover(event) {
        const row = event.target.closest('tr.app');
        if (!row) return;

        clearTimeout(hoverTimer);
        hoverTimer = setTimeout(() => {
            const appId = row.dataset.appid;
            if (gameData[appId]) showTooltip(row, gameData[appId]);
        }, HOVER_DELAY);

        row.addEventListener('mouseleave', () => {
            clearTimeout(hoverTimer);
            if (tooltip) tooltip.style.opacity = '0';
        }, {
            once: true
        });
    }

    function showTooltip(element, data) {
        if (!tooltip) {
            tooltip = document.createElement('div');
            tooltip.className = 'steamdb-tooltip';
            tooltip.innerHTML = `
                <div class="tooltip-arrow"></div>
                <div class="tooltip-content">
                    ${buildTooltipContent(data)}
                </div>
            `;
            document.body.appendChild(tooltip);
        } else {
            tooltip.querySelector('.tooltip-content').innerHTML = buildTooltipContent(data);
        }

        const rect = element.getBoundingClientRect();
        tooltip.style.left = `${rect.right + window.scrollX}px`;
        tooltip.style.top = `${rect.top + window.scrollY - 8}px`;
        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) {
            languageSupportRussianText = "";
            if (data.language_support_russian.supported) languageSupportRussianText += "<br>Интерфейс: ✔ ";
            if (data.language_support_russian.full_audio) languageSupportRussianText += "<br>Озвучка: ✔ ";
            if (data.language_support_russian.subtitles) languageSupportRussianText += "<br>Субтитры: ✔";
            languageSupportRussianClass = languageSupportRussianText ? 'language-yes' : 'language-no';
        }

        let languageSupportEnglishText = "Отсутствует";
        let languageSupportEnglishClass = 'language-no';
        if (data.language_support_english) {
            languageSupportEnglishText = "";
            if (data.language_support_english.supported) languageSupportEnglishText += "<br>Интерфейс: ✔ ";
            if (data.language_support_english.full_audio) languageSupportEnglishText += "<br>Озвучка: ✔ ";
            if (data.language_support_english.subtitles) languageSupportEnglishText += "<br>Субтитры: ✔";
            languageSupportEnglishClass = languageSupportEnglishText ? '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 || "0"}%</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 === 0) return 'no-reviews';
        if (percent >= 70) return 'positive';
        if (percent >= 40) return 'mixed';
        return 'negative';
    }


    function init() {
        const style = document.createElement('style');
        style.textContent = styles;
        document.head.append(style);

        const header = document.querySelector('.header-title');
        if (header) {
            header.parentNode.insertBefore(createFiltersContainer(), header.nextElementSibling);
        }

        document.addEventListener('click', (e) => {
            if (e.target.closest('.steamdb-enhancer')) {
                handleFilterClick(e);
                handleControlClick(e);
            }
        });

        document.querySelector('#process-btn').addEventListener('click', () => {
            if (!isProcessingStarted) {
                isProcessingStarted = true;
                document.querySelector('#process-btn').textContent = PROCESS_BUTTON_TEXT.processing;
                new MutationObserver(collectAppIds).observe(document.body, {
                    childList: true,
                    subtree: true
                });
                collectAppIds();
            }
        });

        document.addEventListener('mouseover', handleHover);
    }

    init();
})();