您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Комплексное улучшение для 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或关注我们的公众号极客氢云获取最新地址