// ==UserScript==
// @name Ultimate Steam Enhancer
// @namespace https://store.steampowered.com/
// @version 1.9.5
// @description Добавляет множество функций для улучшения взаимодействия с магазином и сообществом (Полный список на странице скрипта)
// @author 0wn3df1x
// @license MIT
// @require https://code.jquery.com/jquery-3.6.0.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.js
// @match https://store.steampowered.com/*
// @match *://*steamcommunity.com/*
// @grant GM_xmlhttpRequest
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_deleteValue
// @connect zoneofgames.ru
// @connect raw.githubusercontent.com
// @connect gist.githubusercontent.com
// @connect store.steampowered.com
// @connect api.steampowered.com
// @connect steamcommunity.com
// @connect shared.cloudflare.steamstatic.com
// @connect umadb.ro
// @connect api.github.com
// @connect howlongtobeat.com
// @connect vgtimes.ru
// @connect api.digiseller.com
// @connect plati.market
// @connect digiseller.mycdn.ink
// ==/UserScript==
(function() {
'use strict';
const scriptsConfig = {
// Основные скрипты
gamePage: true, // Скрипт для страницы игры (индикаторы о наличии русского перевода; получение дополнительных обзоров) | https://store.steampowered.com/app/*
hltbData: true, // Скрипт для страницы игры (HLTB; получение сведений о времени прохождения) | https://store.steampowered.com/app/*
friendsPlaytime: true, // Скрипт для страницы игры (Время друзей & Достижения) | https://store.steampowered.com/app/*
earlyaccdata: true, // Скрипт для страницы игры (Ранний доступ) | https://store.steampowered.com/app/*
zogInfo: true, // Скрипт для страницы игры (ZOG; получение сведение о наличии русификаторов) | https://store.steampowered.com/app/*
vgtSales: true, // Скрипт для страницы игры (VGT; отображения цен из агрегатора VGTimes) | https://store.steampowered.com/app/*
platiSales: true, // Скрипт для страницы игры (Plati; отображение цен с Plati.Market) | https://store.steampowered.com/app/*
catalogInfo: true, // Скрипт для получения дополнительной информации об игре при наведении на неё на странице поиска по каталогу | https://store.steampowered.com/search/
catalogHider: false, // Скрипт скрытия игр на странице поиска по каталогу | https://store.steampowered.com/search/
newsFilter: true, // Скрипт для скрытия новостей в новостном центре: | https://store.steampowered.com/news/
Kaznachei: true, // Скрипт для показа годовых и исторических продаж предмета на торговой площадке Steam | https://steamcommunity.com/market/listings/*
homeInfo: true, // Скрипт для получения дополнительной информации об игре при наведении на неё на странице вашей активности Steam | https://steamcommunity.com/my/
wishlistTracker: true, // Скрипт для получения уведомлений об изменении дат выхода игр из вашего списка желаемого Steam и показа календаря с датами | https://steamcommunity.com/my/wishlist/
// Дополнительные настройки
autoExpandHltb: false, // Автоматически раскрывать спойлер HLTB
autoLoadReviews: false, // Автоматически загружать дополнительные обзоры
toggleEnglishLangInfo: false // Отображает данные об английском языке в дополнительной информации при поиске по каталогу и в активности (функция для переводчиков)
};
// Скрипт для страницы игры (индикаторы о наличии русского перевода; получение дополнительных обзоров) | https://store.steampowered.com/app/*
if (scriptsConfig.gamePage && window.location.pathname.includes('/app/')) {
(function() {
'use strict';
function createFruitIndicator(apple, hasSupport, orange) {
const banana = document.createElement('div');
banana.style.position = 'relative';
banana.style.cursor = 'pointer';
const grape = document.createElement('div');
grape.style.width = '60px';
grape.style.height = '60px';
grape.style.borderRadius = '4px';
grape.style.display = 'flex';
grape.style.alignItems = 'center';
grape.style.justifyContent = 'center';
grape.style.background = hasSupport ? 'rgba(66, 135, 245, 0.2)' : 'rgba(0, 0, 0, 0.1)';
grape.style.border = `1px solid ${hasSupport ? '#2A5891' : '#3c3c3c'}`;
grape.style.opacity = '0.95';
grape.style.transition = 'transform 0.3s ease, box-shadow 0.3s ease';
grape.style.overflow = 'hidden';
grape.style.position = 'relative';
grape.style.transform = 'translateZ(0)';
const kiwi = document.createElement('div');
kiwi.innerHTML = apple;
kiwi.style.width = '30px';
kiwi.style.height = '30px';
kiwi.style.display = 'block';
kiwi.style.margin = '0 auto';
kiwi.style.transition = 'fill 0.3s ease';
grape.appendChild(kiwi);
const svgElement = kiwi.querySelector('svg');
function setColor(hasSupport) {
const borderColor = hasSupport ? '#2A5891' : '#3c3c3c';
const svgFill = hasSupport ? '#FFFFFF' : '#0E1C25';
grape.style.border = `1px solid ${borderColor}`;
svgElement.style.fill = svgFill;
}
setColor(hasSupport);
const pineapple = document.createElement('div');
const hasLabel = hasSupport ? orange : getGenitiveCase(orange);
pineapple.textContent = hasSupport ? `Есть ${orange}` : `Нет ${hasLabel}`;
pineapple.style.position = 'absolute';
pineapple.style.top = '50%';
pineapple.style.left = '100%';
pineapple.style.transform = 'translateY(-50%) translateX(10px)';
pineapple.style.background = 'rgba(0, 0, 0, 0.8)';
pineapple.style.color = '#fff';
pineapple.style.padding = '8px 12px';
pineapple.style.borderRadius = '8px';
pineapple.style.fontSize = '14px';
pineapple.style.whiteSpace = 'nowrap';
pineapple.style.opacity = '0';
pineapple.style.transition = 'opacity 0.3s ease';
pineapple.style.zIndex = '10000';
pineapple.style.pointerEvents = 'none';
banana.appendChild(pineapple);
banana.addEventListener('mouseenter', () => {
grape.style.transform = 'scale(1.1) translateZ(0)';
pineapple.style.opacity = '1';
});
banana.addEventListener('mouseleave', () => {
grape.style.transform = 'scale(1) translateZ(0)';
pineapple.style.opacity = '0';
});
banana.appendChild(grape);
return banana;
}
function getGenitiveCase(orange) {
switch (orange) {
case 'интерфейс':
return 'интерфейса';
case 'озвучка':
return 'озвучки';
case 'субтитры':
return 'субтитров';
default:
return orange;
}
}
function checkRussianSupport() {
const mango = document.querySelector('#languageTable table.game_language_options');
if (!mango) return {
interface: false,
voice: false,
subtitles: false
};
const strawberry = mango.querySelectorAll('tr');
for (let blueberry of strawberry) {
const watermelon = blueberry.querySelector('td.ellipsis');
if (watermelon && /русский|Russian/i.test(watermelon.textContent.trim())) {
const cherry = blueberry.querySelector('td.checkcol:nth-child(2) span');
const raspberry = blueberry.querySelector('td.checkcol:nth-child(3) span');
const blackberry = blueberry.querySelector('td.checkcol:nth-child(4) span');
return {
interface: cherry !== null,
voice: raspberry !== null,
subtitles: blackberry !== null
};
}
}
return {
interface: false,
voice: false,
subtitles: false
};
}
function addRussianIndicators() {
const russianSupport = checkRussianSupport();
if (!russianSupport) return;
let lemon = document.querySelector('#gameHeaderImageCtn');
if (!lemon) return;
const lime = document.createElement('div');
lime.style.position = 'absolute';
lime.style.top = '-10px';
lime.style.left = 'calc(100% + 10px)';
lime.style.display = 'flex';
lime.style.flexDirection = 'column';
lime.style.gap = '15px';
lime.style.alignItems = 'flex-start';
lime.style.zIndex = '2';
lime.style.marginTop = '10px';
const peach = createFruitIndicator(`<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12,0C5.38,0,0,5.38,0,12s5.38,12,12,12s12-5.38,12-12S18.62,0,12,0z M12,22C6.49,22,2,17.51,2,12S6.49,2,12,2 s10,4.49,10,10S17.51,22,12,22z M10.5,10h3v8h-3V10z M10.5,5h3v3h-3V5z" /></svg>`, russianSupport.interface, 'интерфейс');
const plum = createFruitIndicator(`<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M15,21v-2c3.86,0,7-3.14,7-7s-3.14-7-7-7V3c4.96,0,9,4.04,9,9S19.96,21,15,21z M15,17v-2c1.65,0,3-1.35,3-3s-1.35-3-3-3V7 c2.76,0,5,2.24,5,5S17.76,17,15,17z M1,12v4h5l6,5V3L6,8H1V12" /></svg>`, russianSupport.voice, 'озвучка');
const apricot = createFruitIndicator(`<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g><path d="M11,24l-4.4-5H0V0h23v19h-7.6L11,24z M2,17h5.4l3.6,4l3.6-4H21V2H2V17z" /></g><g><rect x="5" y="8" width="3" height="3" /></g><g><rect x="10" y="8" width="3" height="3" /></g><g><rect x="15" y="8" width="3" height="3" /></g></svg>`, russianSupport.subtitles, 'субтитры');
lime.appendChild(peach);
lime.appendChild(plum);
lime.appendChild(apricot);
lemon.style.position = 'relative';
lemon.appendChild(lime);
const appName = document.querySelector('#appHubAppName.apphub_AppName');
if (appName) {
appName.style.maxWidth = '530px';
appName.style.overflow = 'hidden';
appName.style.textOverflow = 'ellipsis';
appName.style.whiteSpace = 'nowrap';
appName.title = appName.textContent;
}
}
const settings = {
showTotalReviews: true,
showNonChineseReviews: true,
showRussianReviews: true
};
function fetchReviews(appid, language, callback) {
let url = `https://store.steampowered.com/appreviews/${appid}?json=1&language=${language}&purchase_type=all`;
GM_xmlhttpRequest({
method: "GET",
url: url,
onload: function(response) {
let data = JSON.parse(response.responseText);
callback(data);
}
});
}
function fetchRussianReviewsHTML(appid, filter, callback) {
let url = `https://store.steampowered.com/appreviews/${appid}?language=russian&purchase_type=all&filter=${filter}&day_range=365`;
GM_xmlhttpRequest({
method: "GET",
url: url,
onload: function(response) {
let data = JSON.parse(response.responseText);
callback(data.html);
}
});
}
function addStyles() {
GM_addStyle(`
.additional-reviews {
margin-top: 10px;
}
.additional-reviews .user_reviews_summary_row {
display: flex;
line-height: 16px;
cursor: pointer;
margin-bottom: 5px;
}
.additional-reviews .subtitle {
flex: 1;
color: #556772;
font-size: 12px;
}
.additional-reviews .summary {
flex: 3;
color: #c6d4df;
font-size: 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.additional-reviews .game_review_summary {
font-weight: normal;
}
.additional-reviews .positive {
color: #66c0f4;
}
.additional-reviews .mixed {
color: #B9A074;
}
.additional-reviews .negative {
color: #a34c25;
}
.additional-reviews .no_reviews {
color: #929396;
}
.additional-reviews .responsive_hidden {
color: #556772;
margin-left: 5px;
}
.ofxmodal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.8);
}
.ofxmodal-content {
background-color: #1b2838;
margin: 10% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 800px;
color: #c6d4df;
position: relative;
max-height: 80vh;
overflow-y: auto;
}
.ofxclose {
color: #aaa;
position: sticky;
top: 0;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
background: rgba(0,0,0,0.8);
padding: 5px 10px;
border-radius: 5px;
transition: color 0.2s ease, background 0.2s ease, transform 0.2s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.ofxclose:hover {
color: #fff;
background: #e64a4a;
transform: scale(1.1);
}
.ofxclose:active {
background: #c43c3c;
transform: scale(0.95);
}
.refresh-button {
position: left;
top: 10px;
right: 50px;
background: #66c0f4;
color: #1b2838;
padding: 10px 20px;
border: none;
cursor: pointer;
z-index: 1001;
border-radius: 2px;
transition: background 0.2s ease, color 0.2s ease;
}
.refresh-button:hover {
background: #45b0e6;
color: #fff;
}
.refresh-button:active {
background: #329cd4;
transform: translateY(1px);
}
`);
}
function formatNumber(number) {
return number.toLocaleString('en-US');
}
function getReviewClass(percent, totalReviews) {
if (totalReviews === 0) return 'no_reviews';
if (percent >= 70) return 'positive';
if (percent >= 40) return 'mixed';
if (percent >= 1) return 'negative';
return 'negative';
}
function addLoadButton() {
let reviewsContainer = document.querySelector('.user_reviews');
if (reviewsContainer) {
let additionalReviews = document.createElement('div');
additionalReviews.className = 'additional-reviews';
additionalReviews.innerHTML = `
<div class="user_reviews_summary_row" id="load-reviews-button">
<div class="subtitle column all">Доп. обзоры:</div>
<div class="summary column">
<span class="game_review_summary no_reviews">Загрузить</span>
</div>
</div>
`;
reviewsContainer.appendChild(additionalReviews);
document.getElementById('load-reviews-button').addEventListener('click', function() {
loadAdditionalReviews();
});
if (scriptsConfig.autoLoadReviews) {
loadAdditionalReviews();
}
}
}
function loadAdditionalReviews() {
let appid = window.location.pathname.match(/\/app\/(\d+)/)[1];
let languages = [];
let data = {};
let loadButton = document.getElementById('load-reviews-button');
if (loadButton) {
loadButton.querySelector('.game_review_summary').textContent = 'Загрузка...';
}
if (settings.showTotalReviews || settings.showNonChineseReviews) {
languages.push('all');
}
if (settings.showNonChineseReviews) {
languages.push('schinese');
}
if (settings.showRussianReviews) {
languages.push('russian');
}
languages.forEach(language => {
fetchReviews(appid, language, (response) => {
data[language] = response;
if (Object.keys(data).length === languages.length) {
displayAdditionalReviews(data['all'], data['schinese'], data['russian']);
if (loadButton) {
loadButton.querySelector('.game_review_summary').textContent = 'Загрузить';
}
}
});
});
}
function displayAdditionalReviews(allData, schineseData, russianData) {
let allReviews = allData ? allData.query_summary : null;
let schineseReviews = schineseData ? schineseData.query_summary : null;
let russianReviews = russianData ? russianData.query_summary : null;
let additionalReviews = document.querySelector('.additional-reviews');
if (additionalReviews) {
additionalReviews.innerHTML = '';
if (settings.showTotalReviews && allReviews) {
let allPercent = allReviews.total_reviews > 0 ? Math.round((allReviews.total_positive / allReviews.total_reviews) * 100) : 0;
let allClass = getReviewClass(allPercent, allReviews.total_reviews);
additionalReviews.innerHTML += `
<div class="user_reviews_summary_row">
<div class="subtitle column all">Тотальные:</div>
<div class="summary column">
<span class="game_review_summary ${allClass}">${allPercent}% из ${formatNumber(allReviews.total_reviews)} положительные</span>
</div>
</div>
`;
}
if (settings.showNonChineseReviews && allReviews && schineseReviews) {
let schintotalrev = allReviews.total_reviews - schineseReviews.total_reviews;
let schintotapos = allReviews.total_positive - schineseReviews.total_positive;
let schinpercent = schintotalrev > 0 ? Math.round((schintotapos / schintotalrev) * 100) : 0;
let schinClass = getReviewClass(schinpercent, schintotalrev);
additionalReviews.innerHTML += `
<div class="user_reviews_summary_row">
<div class="subtitle column all">Безкитайские:</div>
<div class="summary column">
<span class="game_review_summary ${schinClass}">${schinpercent}% из ${formatNumber(schintotalrev)} положительные</span>
</div>
</div>
`;
}
if (settings.showRussianReviews && russianReviews) {
let rustotalrev = russianReviews.total_reviews;
let ruspositive = russianReviews.total_positive;
let ruspercent = rustotalrev > 0 ? Math.round((ruspositive / rustotalrev) * 100) : 0;
let rusClass = getReviewClass(ruspercent, rustotalrev);
additionalReviews.innerHTML += `
<div class="user_reviews_summary_row" id="russian-reviews-row">
<div class="subtitle column all">Русские:</div>
<div class="summary column">
<span class="game_review_summary ${rusClass}">${ruspercent}% из ${formatNumber(rustotalrev)} положительные</span>
</div>
</div>
`;
document.getElementById('russian-reviews-row').addEventListener('click', function() {
openModal();
});
}
}
}
function openModal() {
let ofxmodal = document.createElement('div');
ofxmodal.className = 'ofxmodal';
ofxmodal.innerHTML = `
<div class="ofxmodal-content">
<span class="ofxclose">×</span>
<button class="refresh-button" id="refresh-reviews">Загрузить актуальные</button>
<div id="reviews-container"></div>
</div>
`;
document.body.appendChild(ofxmodal);
ofxmodal.querySelector('.ofxclose').addEventListener('click', function() {
ofxmodal.style.display = 'none';
});
ofxmodal.querySelector('#refresh-reviews').addEventListener('click', function() {
refreshReviews(ofxmodal);
});
ofxmodal.style.display = 'block';
loadReviews(ofxmodal, 'all');
}
function refreshReviews(ofxmodal) {
ofxmodal.querySelector('#reviews-container').innerHTML = '';
loadReviews(ofxmodal, 'recent');
}
function loadReviews(ofxmodal, filter) {
fetchRussianReviewsHTML(window.location.pathname.match(/\/app\/(\d+)/)[1], filter, function(html) {
ofxmodal.querySelector('#reviews-container').innerHTML = html;
ofxmodal.querySelector('#LoadMoreReviewsall')?.remove();
ofxmodal.querySelector('#LoadMoreReviewsrecent')?.remove();
});
}
function main() {
addStyles();
addRussianIndicators();
addLoadButton();
}
main();
})();
}
// Скрипт для страницы игры (HLTB; получение сведений о времени прохождения) | https://store.steampowered.com/app/*
if (window.location.pathname.includes('/app/') && scriptsConfig.hltbData) {
(async function() {
let hltbBlock = document.createElement('div');
hltbBlock.style.position = 'absolute';
hltbBlock.style.top = '232px';
hltbBlock.style.left = '334px';
hltbBlock.style.width = '30px';
hltbBlock.style.height = '30px';
hltbBlock.style.background = 'rgba(27, 40, 56, 0.95)';
hltbBlock.style.padding = '15px';
hltbBlock.style.borderRadius = '4px';
hltbBlock.style.border = '1px solid #3c3c3c';
hltbBlock.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
hltbBlock.style.zIndex = '2';
hltbBlock.style.fontFamily = 'Arial, sans-serif';
hltbBlock.style.overflow = 'hidden';
hltbBlock.style.transition = 'all 0.3s ease';
let triangle = document.createElement('div');
triangle.className = 'triangle-down';
triangle.style.position = 'absolute';
triangle.style.bottom = '5px';
triangle.style.left = '50%';
triangle.style.transform = 'translateX(-50%)';
triangle.style.width = '0';
triangle.style.height = '0';
triangle.style.borderLeft = '5px solid transparent';
triangle.style.borderRight = '5px solid transparent';
triangle.style.borderTop = '5px solid #67c1f5';
triangle.style.cursor = 'pointer';
hltbBlock.appendChild(triangle);
let title = document.createElement('div');
title.style.fontSize = '12px';
title.style.fontWeight = 'bold';
title.style.color = '#67c1f5';
title.style.marginBottom = '10px';
title.textContent = 'HLTB';
title.style.cursor = 'pointer';
hltbBlock.appendChild(title);
let content = document.createElement('div');
content.style.fontSize = '14px';
content.style.color = '#c6d4df';
content.style.display = 'none';
content.style.whiteSpace = 'auto';
content.style.padding = '0 0';
hltbBlock.appendChild(content);
const updateHltbPosition = () => {
const russianIndicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
if (scriptsConfig.gamePage && russianIndicators) {
hltbBlock.style.top = `${russianIndicators.offsetTop + russianIndicators.offsetHeight + 16}px`;
} else {
hltbBlock.style.top = '0px';
}
hltbBlock.style.left = '334px';
};
const initHltbObservers = () => {
if (scriptsConfig.gamePage) {
const indicatorsObserver = new MutationObserver(() => {
updateHltbPosition();
});
const indicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
if (indicators) {
indicatorsObserver.observe(indicators, {
attributes: true,
childList: true,
subtree: true
});
}
}
const generalObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
updateHltbPosition();
}
});
});
generalObserver.observe(document.querySelector('#gameHeaderImageCtn'), {
childList: true,
subtree: true
});
};
document.querySelector('#gameHeaderImageCtn').appendChild(hltbBlock);
initHltbObservers();
updateHltbPosition();
const handleClick = async function() {
if (content.style.display === 'none') {
hltbBlock.style.transition = 'width 0.3s ease, height 0.3s ease';
updateHltbPosition();
await new Promise(resolve => setTimeout(resolve, 50));
hltbBlock.style.width = '200px';
hltbBlock.style.height = '40px';
await new Promise(resolve => setTimeout(resolve, 300));
content.textContent = 'Ищем в базе...';
content.style.display = 'block';
triangle.classList.remove('triangle-down');
triangle.classList.add('triangle-up');
triangle.style.borderTop = 'none';
triangle.style.borderBottom = '5px solid #67c1f5';
let gameName = getGameName();
let gameNameNormalized = normalizeGameName(gameName);
let orangutanFetchUrl = 'https://umadb.ro/hltb/fetch.php';
let orangutanHltbUrl = "https://howlongtobeat.com";
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: orangutanFetchUrl,
onload: resolve,
onerror: reject
});
});
if (response.status === 200) {
const key = response.responseText.trim();
orangutanHltbUrl = "https://howlongtobeat.com" + key;
} else {
throw new Error('Failed to fetch key. Status: ' + response.status);
}
} catch (error) {
content.textContent = 'Ошибка при получении ключа.';
return;
}
let chimpQuery = '{"searchType":"games","searchTerms":[' + gameNameNormalized + '],"searchPage":1,"size":20,"searchOptions":{"games":{"userId":0,"platform":"","sortCategory":"popular","rangeCategory":"main","rangeTime":{"min":null,"max":null},"gameplay":{"perspective":"","flow":"","genre":"","difficulty":""},"rangeYear":{"min":"","max":""},"modifier":""},"users":{"sortCategory":"postcount"},"lists":{"sortCategory":"follows"},"filter":"","sort":0,"randomizer":0},"useCache":true}';
GM_xmlhttpRequest({
method: "POST",
url: orangutanHltbUrl,
data: chimpQuery,
headers: {
"Content-Type": "application/json",
"origin": "https://howlongtobeat.com",
"referer": "https://howlongtobeat.com/"
},
onload: async function(response) {
let baboonData = {
count: 0,
data: []
};
if (!response.responseText.includes("<title>HowLongToBeat - 404</title>")) {
try {
baboonData = JSON.parse(response.responseText);
} catch (e) {
content.textContent = 'Ошибка при обработке данных.';
return;
}
}
if (baboonData.count === 0 && /[а-яё]/i.test(gameName)) {
const appId = window.location.pathname.split('/')[2];
const steamApiUrl = `https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json={"ids": [{"appid": ${appId}}], "context": {"language": "english", "country_code": "US", "steam_realm": 1}, "data_request": {"include_assets": true}}`;
try {
const steamResponse = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: steamApiUrl,
onload: resolve,
onerror: reject
});
});
if (steamResponse.status === 200) {
const steamData = JSON.parse(steamResponse.responseText);
const englishName = steamData.response.store_items[0].name;
if (englishName) {
gameName = englishName;
gameNameNormalized = normalizeGameName(gameName);
chimpQuery = '{"searchType":"games","searchTerms":[' + gameNameNormalized + '],"searchPage":1,"size":20,"searchOptions":{"games":{"userId":0,"platform":"","sortCategory":"popular","rangeCategory":"main","rangeTime":{"min":null,"max":null},"gameplay":{"perspective":"","flow":"","genre":"","difficulty":""},"rangeYear":{"min":"","max":""},"modifier":""},"users":{"sortCategory":"postcount"},"lists":{"sortCategory":"follows"},"filter":"","sort":0,"randomizer":0},"useCache":true}';
const secondResponse = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: orangutanHltbUrl,
data: chimpQuery,
headers: {
"Content-Type": "application/json",
"origin": "https://howlongtobeat.com",
"referer": "https://howlongtobeat.com/"
},
onload: resolve,
onerror: reject
});
});
if (secondResponse.status === 200) {
baboonData = JSON.parse(secondResponse.responseText);
}
}
}
} catch (error) {
console.error('Ошибка при запросе к Steam API:', error);
}
}
if (baboonData.count > 0) {
const matches = findPossibleMatches(gameName, baboonData.data);
if (matches.length > 0) {
renderPossibleMatches(matches);
hltbBlock.style.height = `${content.scrollHeight + 30}px`;
return;
}
}
renderContent(baboonData.data[0]);
hltbBlock.style.height = `${content.scrollHeight + 30}px`;
},
onerror: function(error) {
content.textContent = 'Ошибка при запросе к HLTB.';
},
ontimeout: function() {
content.textContent = 'Тайм-аут при запросе к HLTB.';
},
timeout: 10000
});
} else {
content.style.display = 'none';
hltbBlock.style.height = '30px';
hltbBlock.style.width = '30px';
triangle.classList.remove('triangle-up');
triangle.classList.add('triangle-down');
triangle.style.borderBottom = 'none';
triangle.style.borderTop = '5px solid #67c1f5';
}
};
title.onclick = handleClick;
triangle.onclick = handleClick;
window.addEventListener('resize', updateHltbPosition);
function normalizeGameName(name) {
return name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
.toLowerCase()
.split(/\s+/)
.map(word => `"${word}"`)
.join(",");
}
function findPossibleMatches(gameName, data) {
const cleanGameName = gameName
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
.toLowerCase();
return data
.map(item => {
const cleanItemName = item.game_name
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
.toLowerCase();
const similarity = calculateSimilarity(cleanGameName, cleanItemName);
const startsWith = cleanItemName.startsWith(cleanGameName);
return {
...item,
percentage: similarity,
startsWith: startsWith
};
})
.filter(item => item.percentage > 50 || item.startsWith)
.sort((a, b) => {
if (a.startsWith && !b.startsWith) return -1;
if (!a.startsWith && b.startsWith) return 1;
return b.percentage - a.percentage;
})
.slice(0, 5);
}
function calculateSimilarity(str1, str2) {
const len = Math.max(str1.length, str2.length);
if (len === 0) return 100;
const distance = levenshteinDistance(str1, str2);
return Math.round(((len - distance) / len) * 100);
}
function levenshteinDistance(str1, str2) {
const m = str1.length;
const n = str2.length;
const dp = Array.from({
length: m + 1
}, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) {
for (let j = 0; j <= n; j++) {
if (i === 0) {
dp[i][j] = j;
} else if (j === 0) {
dp[i][j] = i;
} else {
dp[i][j] = Math.min(
dp[i - 1][j - 1] + (str1[i - 1] === str2[j - 1] ? 0 : 1),
dp[i - 1][j] + 1,
dp[i][j - 1] + 1
);
}
}
}
return dp[m][n];
}
function getTextWidth(text, font) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
context.font = font;
const metrics = context.measureText(text);
return metrics.width;
}
function renderPossibleMatches(matches) {
content.innerHTML = '';
const title = document.createElement('div');
title.textContent = 'Возможные совпадения:';
title.style.color = '#67c1f5';
title.style.marginBottom = '10px';
content.appendChild(title);
const list = document.createElement('ul');
list.style.paddingLeft = '15px';
list.style.marginTop = '5px';
list.style.marginBottom = '0';
matches.forEach(match => {
const li = document.createElement('li');
li.style.marginBottom = '8px';
const link = document.createElement('a');
link.href = '#';
link.textContent = `${match.game_name} (${match.percentage}%)`;
link.style.color = '#c6d4df';
link.style.wordBreak = 'break-word';
link.style.textDecoration = 'none';
link.onclick = () => {
renderContent(match);
hltbBlock.style.height = `${content.scrollHeight + 30}px`;
return false;
};
li.appendChild(link);
list.appendChild(li);
});
const noMatch = document.createElement('li');
noMatch.style.marginBottom = '8px';
const noMatchLink = document.createElement('a');
noMatchLink.href = '#';
noMatchLink.textContent = 'Ничего не подходит';
noMatchLink.style.color = '#c6d4df';
noMatchLink.style.wordBreak = 'break-word';
noMatchLink.style.textDecoration = 'none';
noMatchLink.onclick = () => {
renderContent(null);
hltbBlock.style.height = `${content.scrollHeight + 30}px`;
return false;
};
noMatch.appendChild(noMatchLink);
list.appendChild(noMatch);
content.appendChild(list);
let maxWidth = 0;
content.querySelectorAll('a').forEach(link => {
const text = link.textContent;
const font = window.getComputedStyle(link).font;
const width = getTextWidth(text, font);
if (width > maxWidth) maxWidth = width;
});
hltbBlock.style.width = `${Math.max(maxWidth + 40, 250)}px`;
}
function renderContent(entry) {
content.innerHTML = '';
if (!entry) {
content.textContent = 'Игра не найдена в базе HLTB';
return;
}
const titleLink = document.createElement('a');
titleLink.href = `https://howlongtobeat.com/game/${entry.game_id}`;
titleLink.target = '_blank';
titleLink.textContent = entry.game_name || 'Без названия';
titleLink.style.color = '#67c1f5';
titleLink.style.wordBreak = 'break-word';
content.appendChild(titleLink);
const list = document.createElement('ul');
list.style.paddingLeft = '15px';
list.style.marginTop = '5px';
list.style.marginBottom = '0';
const times = [{
label: 'Только сюжет',
time: entry.comp_main,
count: entry.comp_main_count
},
{
label: 'Сюжет + доп.',
time: entry.comp_plus,
count: entry.comp_plus_count
},
{
label: 'Комплеционист',
time: entry.comp_100,
count: entry.comp_100_count
},
{
label: 'Все стили',
time: entry.comp_all,
count: entry.comp_all_count
}
];
times.forEach(time => {
const li = document.createElement('li');
li.style.marginBottom = '8px';
const timeText = time.time ? formatTime(time.time) : "X";
li.innerHTML = `${time.label}: <span style="color: #fff;">${timeText}</span> (${time.count} чел.)`;
list.appendChild(li);
});
content.appendChild(list);
let maxWidth = 0;
content.querySelectorAll('li').forEach(child => {
const text = child.textContent;
const font = window.getComputedStyle(child).font;
const width = getTextWidth(text, font);
if (width > maxWidth) maxWidth = width;
});
hltbBlock.style.width = `${Math.max(maxWidth + 30, 200)}px`;
hltbBlock.style.whiteSpace = 'nowrap';
}
function formatTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.round((seconds % 3600) / 60);
if (hours === 0) {
return `${minutes} м.`;
} else if (hours + (minutes / 60) >= hours + 0.5) {
return `${hours + 1} ч.`;
} else {
return `${hours} ч.`;
}
}
function getGameName() {
return document.querySelector('.apphub_AppName').textContent
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[’]/g, "'")
.replace(/[^a-zA-Zа-яёА-ЯЁ0-9 _'\-!]/g, '')
.trim()
.toLowerCase();
}
if (scriptsConfig.autoExpandHltb) {
handleClick();
}
})();
}
// Скрипт для страницы игры (Время друзей & Достижения) | https://store.steampowered.com/app/*
if (window.location.pathname.includes('/app/') && scriptsConfig.friendsPlaytime) {
(async function() {
const statsBlock = document.createElement('div');
statsBlock.style.position = 'absolute';
statsBlock.style.top = '0px';
statsBlock.style.left = '406px';
statsBlock.style.width = '30px';
statsBlock.style.height = '30px';
statsBlock.style.background = 'rgba(27, 40, 56, 0.95)';
statsBlock.style.padding = '15px';
statsBlock.style.borderRadius = '4px';
statsBlock.style.border = '1px solid #3c3c3c';
statsBlock.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
statsBlock.style.zIndex = '1';
statsBlock.style.fontFamily = 'Arial, sans-serif';
statsBlock.style.overflow = 'hidden';
statsBlock.style.transition = 'all 0.3s ease';
const triangle = document.createElement('div');
triangle.style.position = 'absolute';
triangle.style.bottom = '5px';
triangle.style.left = '50%';
triangle.style.transform = 'translateX(-50%)';
triangle.style.width = '0';
triangle.style.height = '0';
triangle.style.borderLeft = '5px solid transparent';
triangle.style.borderRight = '5px solid transparent';
triangle.style.borderTop = '5px solid #67c1f5';
triangle.style.cursor = 'pointer';
statsBlock.appendChild(triangle);
const title = document.createElement('div');
title.style.display = 'flex';
title.style.alignItems = 'center';
title.style.marginBottom = '7px';
title.style.cursor = 'pointer';
const combinedImg = document.createElement('div');
combinedImg.style.width = '29px';
combinedImg.style.height = '29px';
combinedImg.style.backgroundImage = 'url(https://gist.githubusercontent.com/0wn3dg0d/9c259eebc40a1e97397ccf3da7ee7bd6/raw/SUEftach.png)';
combinedImg.style.backgroundSize = 'contain';
combinedImg.style.backgroundPosition = 'center';
title.appendChild(combinedImg);
statsBlock.appendChild(title);
const content = document.createElement('div');
content.style.fontSize = '14px';
content.style.color = '#c6d4df';
content.style.display = 'none';
content.style.padding = '0';
statsBlock.appendChild(content);
const toggleBlock = async () => {
if (content.style.display === 'none') {
statsBlock.style.width = '250px';
statsBlock.style.height = '60px';
content.style.display = 'block';
content.textContent = 'Загрузка...';
triangle.style.borderTop = 'none';
triangle.style.borderBottom = '5px solid #67c1f5';
try {
const friendsData = await loadFriendsData();
const achievementsData = await loadAchievementsData();
content.innerHTML = '';
const friendsTitle = document.createElement('div');
friendsTitle.style.fontSize = '12px';
friendsTitle.style.fontWeight = 'bold';
friendsTitle.style.color = '#67c1f5';
friendsTitle.style.marginBottom = '5px';
friendsTitle.textContent = 'ВРЕМЯ ДРУЗЕЙ';
content.appendChild(friendsTitle);
if (friendsData.length > 0) {
const maxHours = Math.max(...friendsData.map(f => f.hours));
const minHours = Math.min(...friendsData.map(f => f.hours));
const avgHours = friendsData.reduce((sum, f) => sum + f.hours, 0) / friendsData.length;
const maxPlayers = friendsData.filter(f => f.hours === maxHours);
const maxEl = document.createElement('div');
maxEl.style.marginBottom = '4px';
maxEl.innerHTML = `<span style="color: #67c1f5;">Макс:</span> ${maxHours.toFixed(1)} ч.`;
if (maxPlayers.length > 0) {
maxEl.innerHTML += ` (${maxPlayers.map(p =>
`<a href="${p.profile}" target="_blank" style="color: #c6d4df; text-decoration: none;">${p.name}</a>`
).join(', ')})`;
}
const avgEl = document.createElement('div');
avgEl.style.marginBottom = '4px';
avgEl.innerHTML = `<span style="color: #67c1f5;">Среднее:</span> ${avgHours.toFixed(1)} ч. (${friendsData.length} чел.)`;
const minEl = document.createElement('div');
minEl.innerHTML = `<span style="color: #67c1f5;">Минимальное:</span> ${minHours.toFixed(1)} ч.`;
content.append(maxEl, avgEl, minEl);
} else {
const noData = document.createElement('div');
noData.textContent = 'Друзья не играли';
noData.style.marginBottom = '12px';
content.appendChild(noData);
}
const achTitle = document.createElement('div');
achTitle.style.fontSize = '12px';
achTitle.style.fontWeight = 'bold';
achTitle.style.color = '#67c1f5';
achTitle.style.margin = '16px 0 5px 0';
achTitle.textContent = 'ГЛОБАЛЬНЫЕ ДОСТИЖЕНИЯ';
content.appendChild(achTitle);
if (achievementsData.hasAchievements) {
const platinumEl = document.createElement('div');
platinumEl.style.marginBottom = '4px';
platinumEl.innerHTML = `<span style="color: #67c1f5;">Платина:</span> ${achievementsData.platinumPercent}%`;
const averageEl = document.createElement('div');
averageEl.innerHTML = `<span style="color: #67c1f5;">Средний прогресс:</span> ${achievementsData.averageAdjustedPercent}%`;
content.append(platinumEl, averageEl);
} else {
const noAch = document.createElement('div');
noAch.textContent = achievementsData.error === 'Нет достижений' ?
'Достижений нет' :
achievementsData.error;
noAch.style.marginBottom = '12px';
content.appendChild(noAch);
}
statsBlock.style.height = `${content.scrollHeight + 38}px`;
} catch (error) {
content.textContent = 'Ошибка загрузки';
statsBlock.style.height = '60px';
}
} else {
content.style.display = 'none';
statsBlock.style.height = '30px';
statsBlock.style.width = '30px';
triangle.style.borderBottom = 'none';
triangle.style.borderTop = '5px solid #67c1f5';
}
};
async function loadFriendsData() {
try {
const friendsLink = document.querySelector('.recommendation_reasons a[href*="friendsthatplay"]');
if (!friendsLink) return [];
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: friendsLink.href,
onload: resolve,
onerror: reject,
timeout: 5000
});
});
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, 'text/html');
return Array.from(doc.querySelectorAll('.friendBlockContent'))
.map(block => {
const timeText = block.querySelector('.friendSmallText')?.textContent;
const hoursMatch = timeText?.match(/(\d+[,.]?\d*)\s*ч/);
return {
name: block.firstChild.textContent.trim(),
hours: hoursMatch ? parseFloat(hoursMatch[1].replace(',', '.')) : 0,
profile: block.closest('.friendBlock').querySelector('a').href
};
})
.filter(f => f.hours > 0);
} catch (error) {
return [];
}
}
async function loadAchievementsData() {
try {
const appIdMatch = window.location.pathname.match(/\/app\/(\d+)/);
if (!appIdMatch) return {
hasAchievements: false,
error: 'Не найден App ID'
};
const appId = appIdMatch[1];
const achievementsUrl = `https://steamcommunity.com/stats/${appId}/achievements/`;
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: achievementsUrl,
onload: resolve,
onerror: reject,
timeout: 8000
});
});
if (response.status !== 200) return {
hasAchievements: false,
error: 'Ошибка загрузки страницы'
};
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, 'text/html');
if (doc.querySelector('.no_achievements_message')) {
return {
hasAchievements: false,
error: 'Достижения отсутствуют'
};
}
const percentElements = doc.querySelectorAll('.achievePercent');
if (percentElements.length === 0) return {
hasAchievements: false,
error: 'Достижения отсутствуют'
};
const percents = Array.from(percentElements)
.map(el => {
const text = el.textContent.trim();
return parseFloat(text.replace('%', '')) || 0;
})
.filter(p => p > 0);
if (percents.length === 0) return {
hasAchievements: false,
error: 'Нет данных'
};
const maxPercent = Math.max(...percents);
const minPercent = Math.min(...percents);
const adjustment = 100 - maxPercent;
const adjustedPercents = percents.map(p => p + adjustment);
const averageAdjusted = adjustedPercents.reduce((sum, p) => sum + p, 0) / adjustedPercents.length;
return {
hasAchievements: true,
platinumPercent: (minPercent + adjustment).toFixed(1),
averageAdjustedPercent: averageAdjusted.toFixed(1),
};
} catch (error) {
return {
hasAchievements: false,
error: 'Ошибка соединения'
};
}
}
title.addEventListener('click', toggleBlock);
triangle.addEventListener('click', toggleBlock);
document.querySelector('#gameHeaderImageCtn').appendChild(statsBlock);
if (scriptsConfig.autoExpandFriends) {
toggleBlock();
}
})();
}
// Скрипт для страницы игры (Ранний доступ) | https://store.steampowered.com/app/*
if (window.location.pathname.includes('/app/') && scriptsConfig.earlyaccdata) {
(function() {
'use strict';
const EAORDATE_STORAGE_KEY = 'USE_EarlyAccess_ordateData';
const EAORDATE_URL = 'https://gist.githubusercontent.com/0wn3dg0d/58a8e35f3d34014ea749a22d02f7e203/raw/eaordate.json';
const CACHE_DURATION = 180 * 24 * 60 * 60 * 1000; // 6 месяцев
const getYearForm = (n) => {
n = Math.abs(n) % 100;
const n1 = n % 10;
if (n > 10 && n < 20) return 'лет';
if (n1 === 1) return 'год';
if (n1 >= 2 && n1 <= 4) return 'года';
return 'лет';
};
const getMonthForm = (n) => {
n = Math.abs(n) % 100;
const n1 = n % 10;
if (n > 10 && n < 20) return 'месяцев';
if (n1 === 1) return 'месяц';
if (n1 >= 2 && n1 <= 4) return 'месяца';
return 'месяцев';
};
const parseSteamDate = (dateStr) => {
const numericParts = dateStr.split('.');
if (numericParts.length === 3) {
const day = parseInt(numericParts[0], 10);
const month = parseInt(numericParts[1], 10) - 1;
const year = parseInt(numericParts[2], 10);
return new Date(year, month, day);
}
const monthsMap = {
'янв': 0,
'фев': 1,
'мар': 2,
'апр': 3,
'мая': 4,
'июн': 5,
'июл': 6,
'авг': 7,
'сен': 8,
'окт': 9,
'ноя': 10,
'дек': 11
};
const cleanedStr = dateStr.replace(/\./g, '');
const [day, monthNameRaw, year] = cleanedStr.split(' ');
const monthName = monthNameRaw.substring(0, 3);
return new Date(parseInt(year), monthsMap[monthName], parseInt(day));
};
const fetchOrdateData = async () => {
const cachedData = GM_getValue(EAORDATE_STORAGE_KEY, null);
if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) {
return cachedData.data;
}
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: EAORDATE_URL,
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
GM_setValue(EAORDATE_STORAGE_KEY, {
timestamp: Date.now(),
data: data
});
resolve(data);
} catch (e) {
console.error('Error parsing EAOrdate data:', e);
resolve(cachedData?.data || []);
}
},
onerror: () => resolve(cachedData?.data || [])
});
});
};
const getAppId = () => {
const match = window.location.pathname.match(/\/app\/(\d+)/);
return match ? parseInt(match[1]) : null;
};
const createInfoBox = (duration, isReleased) => {
const infoBox = document.createElement('div');
Object.assign(infoBox.style, {
position: 'absolute',
top: '-46px',
left: '334px',
background: isReleased ? 'rgba(103, 193, 245, 0.15)' : 'rgba(245, 166, 35, 0.15)',
padding: '6.5px',
borderRadius: '3px',
border: `1px solid ${isReleased ? '#2A568E' : '#f5a623'}`,
fontSize: '12px',
color: '#c6d4df',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
fontFamily: '"Motiva Sans", Arial, sans-serif',
zIndex: 3,
display: 'inline-block',
whiteSpace: 'nowrap'
});
let message;
if (isReleased) {
message = duration ?
`Вышла спустя ${duration} раннего доступа` :
'Игра вышла из раннего доступа (срок неизвестен)';
} else {
message = `В раннем доступе уже ${duration}`;
}
infoBox.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="color: ${isReleased ? '#67c1f5' : '#f5a623'}; font-weight: bold;">
${isReleased ? '➡️' : '⏳'}
</span>
<span>${message}</span>
</div>
`;
return infoBox;
};
const calculateDuration = (startDate, endDate) => {
let diffMonths = (endDate.getFullYear() - startDate.getFullYear()) * 12 +
(endDate.getMonth() - startDate.getMonth());
if (endDate.getDate() < startDate.getDate()) diffMonths--;
const years = Math.floor(diffMonths / 12);
const months = diffMonths % 12;
const parts = [];
if (years > 0) parts.push(`${years} ${getYearForm(years)}`);
if (months > 0) parts.push(`${months} ${getMonthForm(months)}`);
return parts.length > 0 ? parts.join(' и ') : 'менее месяца';
};
const main = async () => {
const detailsBlock = document.querySelector('#genresAndManufacturer');
const isStillEarlyAccess = !!document.querySelector('#earlyAccessHeader');
if (!detailsBlock) return;
const parseDates = () => {
const fullText = detailsBlock.textContent;
const dates = {
earlyDate: null,
releaseDate: null
};
const earlyMatch = fullText.match(/Дата выпуска в раннем доступе:\s*(\d+\s\S+\s\d{4})/);
const releaseMatch = fullText.match(/Дата выхода:\s*(\d+\s\S+\s\d{4})/);
if (isStillEarlyAccess && !earlyMatch && releaseMatch) {
dates.earlyDate = releaseMatch[1];
} else {
if (earlyMatch) dates.earlyDate = earlyMatch[1];
if (releaseMatch) dates.releaseDate = releaseMatch[1];
}
return dates;
};
const {
earlyDate: earlyDateStr,
releaseDate: releaseDateStr
} = parseDates();
const appid = getAppId();
if (!earlyDateStr && appid) {
const ordateData = await fetchOrdateData();
const gameData = ordateData.find(item => item.appid === appid);
if (gameData) {
try {
const ordate = parseSteamDate(gameData.ordate);
const releaseDate = releaseDateStr ? parseSteamDate(releaseDateStr) : new Date();
if (ordate >= releaseDate) throw new Error('Invalid date order');
const duration = calculateDuration(ordate, releaseDate);
const infoBox = createInfoBox(duration, true);
document.querySelector('.game_header_image_ctn')?.appendChild(infoBox);
} catch (e) {
const infoBox = createInfoBox(null, true);
document.querySelector('.game_header_image_ctn')?.appendChild(infoBox);
}
}
return;
}
const earlyDate = earlyDateStr ? parseSteamDate(earlyDateStr) :
isStillEarlyAccess ? parseSteamDate(releaseDateStr) : null;
const releaseDate = releaseDateStr ? parseSteamDate(releaseDateStr) : null;
if (!earlyDate) return;
const endDate = isStillEarlyAccess ? new Date() : releaseDate;
if (!endDate) return;
try {
const duration = calculateDuration(earlyDate, endDate);
const infoBox = createInfoBox(duration, !isStillEarlyAccess);
document.querySelector('.game_header_image_ctn')?.appendChild(infoBox);
} catch (e) {
console.error('Early access date calculation error:', e);
}
};
main();
})();
}
// Скрипт для получения дополнительной информации об игре при наведении на неё на странице поиска по каталогу | https://store.steampowered.com/search/
if (scriptsConfig.catalogInfo && window.location.pathname.includes('/search')) {
(function() {
'use strict';
const ALEXANDER_API_URL = "https://api.steampowered.com/IStoreBrowseService/GetItems/v1";
const HANNIBAL_WAIT_TIME = 2000;
const CAESAR_VISIBLE_ELEMENTS_SELECTOR = "a.search_result_row[data-ds-appid]";
const NAPOLEON_HOVER_ELEMENT_SELECTOR = "a.search_result_row";
let GENghis_collectedAppIds = new Set();
let ATTILA_tooltip = null;
let SALADIN_hoverTimer = null;
let TAMERLAN_hideTimer = null;
let RUSSIAN_TRANSLATION_CHECKBOX = null;
let RUSSIAN_VOICE_CHECKBOX = null;
let NO_RUSSIAN_CHECKBOX = null;
const STEAM_TAGS_CACHE_KEY = 'SteamEnhancer_TagsCache_v2';
const STEAM_TAGS_URL = "https://gist.githubusercontent.com/0wn3dg0d/22a351ff4c65e50a9a8af6da360defad/raw/steamrutagsownd.json";
const OWNED_APPS_CACHE_KEY = 'SteamEnhancer_OwnedApps';
const USERDATA_URL = 'https://store.steampowered.com/dynamicstore/userdata/';
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 часа
async function loadSteamTags() {
const cached = GM_getValue(STEAM_TAGS_CACHE_KEY, {
data: null,
timestamp: 0
});
const now = Date.now();
const CACHE_DURATION = 744 * 60 * 60 * 1000;
if (cached.data && (now - cached.timestamp) < CACHE_DURATION) {
return cached.data;
}
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: STEAM_TAGS_URL,
onload: resolve,
onerror: reject
});
});
if (response.status === 200) {
const data = JSON.parse(response.responseText);
GM_setValue(STEAM_TAGS_CACHE_KEY, {
data: data,
timestamp: now
});
return data;
}
} catch (e) {
console.error('Ошибка загрузки тегов:', e);
return cached.data || {};
}
return {};
}
async function fetchOwnedApps() {
const cached = GM_getValue(OWNED_APPS_CACHE_KEY, {
data: null,
timestamp: 0
});
const now = Date.now();
if (cached.data && (now - cached.timestamp) < CACHE_DURATION) {
return cached.data;
}
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: USERDATA_URL,
onload: resolve,
onerror: reject
});
});
if (response.status === 200) {
const data = JSON.parse(response.responseText);
const ownedApps = data.rgOwnedApps || [];
GM_setValue(OWNED_APPS_CACHE_KEY, {
data: ownedApps,
timestamp: now
});
return ownedApps;
}
} catch (e) {
console.error('Ошибка загрузки списка игр:', e);
return cached.data || [];
}
return [];
}
function fetchGameData(appIds) {
const inputJson = {
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,
included_item_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,
include_assets_without_overrides: true,
apply_user_filters: false,
include_links: true
},
include_assets_without_overrides: true,
apply_user_filters: false,
include_links: true
}
};
GM_xmlhttpRequest({
method: "GET",
url: `${ALEXANDER_API_URL}?input_json=${encodeURIComponent(JSON.stringify(inputJson))}`,
onload: function(response) {
const data = JSON.parse(response.responseText);
processGameData(data);
}
});
}
async function processGameData(data) {
const ownedApps = await fetchOwnedApps();
const items = data.response.store_items;
const dlcFilterActive = document.querySelector('[data-param="your_dlc"] .tab_filter_control')?.classList.contains('checked');
items.forEach(item => {
const appId = item.id;
const gameElement = document.querySelector(`a.search_result_row[data-ds-appid="${appId}"]`);
if (gameElement) {
const gameData = {
is_early_access: item.is_early_access,
review_count: item.reviews?.summary_filtered?.review_count,
percent_positive: item.reviews?.summary_filtered?.percent_positive,
short_description: item.basic_info?.short_description,
publishers: item.basic_info?.publishers?.map(p => p.name).join(", "),
developers: item.basic_info?.developers?.map(d => d.name).join(", "),
franchises: item.basic_info?.franchises?.map(f => f.name).join(", "),
tagids: item.tagids || [],
language_support_russian: item.supported_languages?.find(lang => lang.elanguage === 8),
language_support_english: item.supported_languages?.find(lang => lang.elanguage === 0),
type: item.type,
parent_appid: item.related_items?.parent_appid
};
gameElement.dataset.gameInfo = JSON.stringify(gameData);
applyRussianLanguageFilter(gameElement);
if (item.type === 4 && item.related_items?.parent_appid && ownedApps.includes(item.related_items.parent_appid)) {
gameElement.classList.add('es_highlighted_dlcforya');
}
if (dlcFilterActive) {
applyDlcFilter(gameElement, true);
}
}
});
}
function collectAndFetchAppIds() {
const visibleElements = document.querySelectorAll(CAESAR_VISIBLE_ELEMENTS_SELECTOR);
const newAppIds = new Set();
visibleElements.forEach(element => {
const appId = element.dataset.dsAppid;
if (!GENghis_collectedAppIds.has(appId)) {
newAppIds.add(parseInt(appId, 10));
GENghis_collectedAppIds.add(appId);
}
});
if (newAppIds.size > 0) {
fetchGameData(newAppIds);
}
}
function handleHover(event) {
const gameElement = event.target.closest(NAPOLEON_HOVER_ELEMENT_SELECTOR);
if (gameElement && gameElement.dataset.gameInfo) {
clearTimeout(SALADIN_hoverTimer);
clearTimeout(TAMERLAN_hideTimer);
SALADIN_hoverTimer = setTimeout(() => {
const gameData = JSON.parse(gameElement.dataset.gameInfo);
displayGameInfo(gameElement, gameData);
}, 300);
} else {
clearTimeout(SALADIN_hoverTimer);
clearTimeout(TAMERLAN_hideTimer);
if (ATTILA_tooltip) {
ATTILA_tooltip.style.opacity = 0;
setTimeout(() => {
ATTILA_tooltip.style.display = 'none';
}, 300);
}
}
}
function getReviewClassCatalog(percent, totalReviews) {
if (totalReviews === 0) return 'catalog-no-reviews';
if (percent >= 70) return 'catalog-positive';
if (percent >= 40) return 'catalog-mixed';
if (percent >= 1) return 'catalog-negative';
return 'catalog-negative';
}
async function getTagNames(tagIds) {
const tagsData = await loadSteamTags();
return tagIds.slice(0, 5).map(tagId =>
tagsData[tagId] || `Тег #${tagId}`
);
}
async function displayGameInfo(element, data) {
if (!ATTILA_tooltip) {
ATTILA_tooltip = document.createElement('div');
ATTILA_tooltip.className = 'custom-tooltip';
ATTILA_tooltip.innerHTML = '<div class="tooltip-arrow"></div><div class="tooltip-content"></div>';
document.body.appendChild(ATTILA_tooltip);
}
const tooltipContent = ATTILA_tooltip.querySelector('.tooltip-content');
let languageSupportRussianText = "Отсутствует";
let languageSupportRussianClass = 'catalog-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>Субтитры: ✔";
if (languageSupportRussianText === "") languageSupportRussianText = "Отсутствует";
else languageSupportRussianClass = 'catalog-language-yes';
}
let languageSupportEnglishText = "Отсутствует";
let languageSupportEnglishClass = 'catalog-language-no';
if (scriptsConfig.toggleEnglishLangInfo && 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>Субтитры: ✔";
if (languageSupportEnglishText === "") languageSupportEnglishText = "Отсутствует";
else languageSupportEnglishClass = 'catalog-language-yes';
}
const reviewClass = getReviewClassCatalog(data.percent_positive, data.review_count);
const earlyAccessClass = data.is_early_access ? 'catalog-early-access-yes' : 'catalog-early-access-no';
const tags = await getTagNames(data.tagids || []);
const tagsHtml = tags.map(tag =>
`<div class="custom-tag">${tag}</div>`
).join('');
tooltipContent.innerHTML = `
<div style="margin-bottom: 0px;"><strong>Издатели:</strong> <span class="${!data.publishers ? 'catalog-no-reviews' : ''}">${data.publishers || "Нет данных"}</span></div>
<div style="margin-bottom: 0px;"><strong>Разработчики:</strong> <span class="${!data.developers ? 'catalog-no-reviews' : ''}">${data.developers || "Нет данных"}</span></div>
<div style="margin-bottom: 10px;"><strong>Серия игр:</strong> <span class="${!data.franchises ? 'catalog-no-reviews' : ''}">${data.franchises || "Нет данных"}</span></div>
<div style="margin-bottom: 10px;"><strong>Отзывы: </strong><span id="reviewCount">${data.review_count || "0"} </span><span class="${reviewClass}">(${data.percent_positive || "0"}% положительных)</span></div>
<div style="margin-bottom: 10px;"><strong>Ранний доступ:</strong> <span class="${earlyAccessClass}">${data.is_early_access ? "Да" : "Нет"}</span></div>
<div style="margin-bottom: 10px;"><strong>Русский язык:</strong> <span class="${languageSupportRussianClass}">${languageSupportRussianText}</span></div>
${scriptsConfig.toggleEnglishLangInfo ? `<div style="margin-bottom: 10px;"><strong>Английский язык:</strong> <span class="${languageSupportEnglishClass}">${languageSupportEnglishText}</span></div>` : ''}
<div style="margin-bottom: 10px;"><strong>Метки:</strong><br>
<div class="custom-tags-container">${tagsHtml}</div></div>
<div style="margin-bottom: 10px;"><strong>Описание:</strong> <span class="${!data.short_description ? 'catalog-no-reviews' : ''}">${data.short_description || "Нет данных"}</span></div>
`;
ATTILA_tooltip.style.display = 'block';
const rect = element.getBoundingClientRect();
const tooltipRect = ATTILA_tooltip.getBoundingClientRect();
ATTILA_tooltip.style.left = `${rect.left + window.scrollX - tooltipRect.width - 4}px`;
ATTILA_tooltip.style.top = `${rect.top + window.scrollY - 20}px`;
ATTILA_tooltip.style.opacity = 0;
ATTILA_tooltip.style.display = 'block';
setTimeout(() => {
ATTILA_tooltip.style.opacity = 1;
}, 10);
element.addEventListener('mouseleave', () => {
clearTimeout(TAMERLAN_hideTimer);
TAMERLAN_hideTimer = setTimeout(() => {
ATTILA_tooltip.style.opacity = 0;
setTimeout(() => {
ATTILA_tooltip.style.display = 'none';
}, 300);
}, 200);
}, {
once: true
});
element.addEventListener('mouseover', () => {
clearTimeout(TAMERLAN_hideTimer);
});
}
function createRussianLanguageFilterBlock() {
const filterBlock = document.createElement('div');
filterBlock.className = 'block search_collapse_block';
filterBlock.innerHTML = `
<div data-panel="{"focusable":true,"clickOnActivate":true}" class="block_header labs_block_header">
<div>Русский перевод</div>
</div>
<div class="block_content block_content_inner">
<div class="tab_filter_control_row" data-param="russian_translation" data-value="__toggle" data-loc="Только текст" data-clientside="0">
<span data-panel="{"focusable":true,"clickOnActivate":true}" class="tab_filter_control tab_filter_control_include" data-param="russian_translation" data-value="__toggle" data-loc="Только текст" data-clientside="0" data-gpfocus="item">
<span>
<span class="tab_filter_control_checkbox"></span>
<span class="tab_filter_control_label">Только текст</span>
<span class="tab_filter_control_count" style="display: none;"></span>
</span>
</span>
</div>
<div class="tab_filter_control_row" data-param="russian_voice" data-value="__toggle" data-loc="Озвучка" data-clientside="0">
<span data-panel="{"focusable":true,"clickOnActivate":true}" class="tab_filter_control tab_filter_control_include" data-param="russian_voice" data-value="__toggle" data-loc="Озвучка" data-clientside="0" data-gpfocus="item">
<span>
<span class="tab_filter_control_checkbox"></span>
<span class="tab_filter_control_label">Озвучка</span>
<span class="tab_filter_control_count" style="display: none;"></span>
</span>
</span>
</div>
<div class="tab_filter_control_row" data-param="no_russian" data-value="__toggle" data-loc="Без перевода" data-clientside="0">
<span data-panel="{"focusable":true,"clickOnActivate":true}" class="tab_filter_control tab_filter_control_include" data-param="no_russian" data-value="__toggle" data-loc="Без перевода" data-clientside="0" data-gpfocus="item">
<span>
<span class="tab_filter_control_checkbox"></span>
<span class="tab_filter_control_label">Без перевода</span>
<span class="tab_filter_control_count" style="display: none;"></span>
</span>
</span>
</div>
</div>
`;
const dlcFilterBlock = document.createElement('div');
dlcFilterBlock.className = 'block search_collapse_block';
dlcFilterBlock.innerHTML = `
<div data-panel="{"focusable":true,"clickOnActivate":true}" class="block_header labs_block_header">
<div>DLC</div>
</div>
<div class="block_content block_content_inner">
<div class="tab_filter_control_row" data-param="your_dlc" data-value="__toggle" data-loc="Только ваши DLC" data-clientside="0">
<span data-panel="{"focusable":true,"clickOnActivate":true}" class="tab_filter_control tab_filter_control_include" data-param="your_dlc" data-value="__toggle" data-loc="Только ваши DLC" data-clientside="0" data-gpfocus="item">
<span>
<span class="tab_filter_control_checkbox"></span>
<span class="tab_filter_control_label">Только ваши DLC</span>
<span class="tab_filter_control_count" style="display: none;"></span>
</span>
</span>
</div>
</div>
`;
const priceBlock = document.querySelector('.block.search_collapse_block[data-collapse-name="price"]');
priceBlock.parentNode.insertBefore(filterBlock, priceBlock.nextSibling);
priceBlock.parentNode.insertBefore(dlcFilterBlock, filterBlock.nextSibling);
const translationRow = filterBlock.querySelector('[data-param="russian_translation"]');
const voiceRow = filterBlock.querySelector('[data-param="russian_voice"]');
const noRussianRow = filterBlock.querySelector('[data-param="no_russian"]');
const dlcRow = dlcFilterBlock.querySelector('[data-param="your_dlc"]');
[translationRow, voiceRow, noRussianRow].forEach(row => {
row.addEventListener('click', () => {
const control = row.querySelector('.tab_filter_control');
const wasChecked = control.classList.contains('checked');
[translationRow, voiceRow, noRussianRow].forEach(r => {
r.querySelector('.tab_filter_control').classList.remove('checked');
r.classList.remove('checked');
});
if (!wasChecked) {
control.classList.add('checked');
row.classList.add('checked');
}
document.querySelectorAll(CAESAR_VISIBLE_ELEMENTS_SELECTOR).forEach(gameElement => {
applyRussianLanguageFilter(gameElement);
});
});
});
dlcRow.addEventListener('click', () => {
const control = dlcRow.querySelector('.tab_filter_control');
const isChecked = !control.classList.contains('checked');
control.classList.toggle('checked');
dlcRow.classList.toggle('checked');
document.querySelectorAll(CAESAR_VISIBLE_ELEMENTS_SELECTOR).forEach(gameElement => {
applyDlcFilter(gameElement, isChecked);
});
});
}
function applyDlcFilter(gameElement, showOnlyDlc) {
if (!gameElement.dataset.gameInfo) return;
const gameData = JSON.parse(gameElement.dataset.gameInfo);
const isDlcForOwnedGame = gameElement.classList.contains('es_highlighted_dlcforya');
if (showOnlyDlc) {
if (!isDlcForOwnedGame) {
animateDisappearance(gameElement);
} else {
animateAppearance(gameElement);
}
} else {
animateAppearance(gameElement);
}
}
function applyRussianLanguageFilter(gameElement) {
if (!gameElement.dataset.gameInfo) return;
const gameData = JSON.parse(gameElement.dataset.gameInfo);
const hasRussianText = gameData.language_support_russian?.supported || gameData.language_support_russian?.subtitles;
const hasRussianVoice = gameData.language_support_russian?.full_audio;
const hasAnyRussian = hasRussianText || hasRussianVoice;
const translationChecked = document.querySelector('[data-param="russian_translation"] .tab_filter_control').classList.contains('checked');
const voiceChecked = document.querySelector('[data-param="russian_voice"] .tab_filter_control').classList.contains('checked');
const noRussianChecked = document.querySelector('[data-param="no_russian"] .tab_filter_control').classList.contains('checked');
const dlcFilterActive = document.querySelector('[data-param="your_dlc"] .tab_filter_control')?.classList.contains('checked');
if (dlcFilterActive && !gameElement.classList.contains('es_highlighted_dlcforya')) {
animateDisappearance(gameElement);
return;
}
if (translationChecked) {
if (!hasRussianText || hasRussianVoice) animateDisappearance(gameElement);
else animateAppearance(gameElement);
} else if (voiceChecked) {
if (!hasRussianVoice) animateDisappearance(gameElement);
else animateAppearance(gameElement);
} else if (noRussianChecked) {
if (hasAnyRussian) animateDisappearance(gameElement);
else animateAppearance(gameElement);
} else {
animateAppearance(gameElement);
}
}
function animateDisappearance(element) {
element.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out';
element.style.opacity = '0';
element.style.transform = 'translateX(-100%)';
setTimeout(() => {
element.style.display = 'none';
}, 500);
}
function animateAppearance(element) {
element.style.display = 'block';
element.style.opacity = '0';
element.style.transform = 'translateX(-100%)';
element.style.transition = 'opacity 0.5s ease-in-out, transform 0.5s ease-in-out';
setTimeout(() => {
element.style.opacity = '1';
element.style.transform = 'translateX(0)';
}, 0);
setTimeout(() => {
element.style.transition = '';
}, 500);
}
function observeNewElements() {
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
collectAndFetchAppIds();
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
function initialize() {
setTimeout(() => {
collectAndFetchAppIds();
observeNewElements();
document.addEventListener('mouseover', handleHover);
createRussianLanguageFilterBlock();
}, HANNIBAL_WAIT_TIME);
}
initialize();
const style = document.createElement('style');
style.innerHTML = `
.custom-tooltip {
position: absolute;
background: linear-gradient(to bottom, #e3eaef, #c7d5e0);
color: #30455a;
padding: 12px;
border-radius: 0px;
box-shadow: 0 0 12px #000;
font-size: 12px;
max-width: 300px;
display: none;
z-index: 1000;
opacity: 0;
transition: opacity 0.4s ease-in-out;
}
.tooltip-arrow {
position: absolute;
right: -9px;
top: 32px;
width: 0;
height: 0;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-left: 10px solid #E1E8ED;
}
.catalog-positive {
color: #2B80E9;
}
.catalog-mixed {
color: #997a00;
}
.catalog-negative {
color: #E53E3E;
}
.catalog-no-reviews {
color: #929396;
}
.catalog-language-yes {
color: #2B80E9;
}
.catalog-language-no {
color: #E53E3E;
}
.catalog-early-access-yes {
color: #2B80E9;
}
.catalog-early-access-no {
color: #929396;
}
.search_result_row {
transition: opacity 0.5s ease-in-out, transform 0.5s ease-in-out;
}
.custom-tags-container {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 6px;
}
.custom-tag {
background-color: #96a3ae;
color: #e3eaef;
padding: 0 4px;
border-radius: 2px;
font-size: 11px;
line-height: 19px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
box-shadow: none;
margin-bottom: 3px;
}
.es_highlighted_dlcforya {
background: #822dbf linear-gradient(135deg, rgba(0, 0, 0, 0.70) 10%, rgba(0, 0, 0, 0) 100%) !important;
}
`;
document.head.appendChild(style);
})();
}
// Скрипт скрытия игр на странице поиска по каталогу | https://store.steampowered.com/search/
if (scriptsConfig.catalogHider && window.location.pathname.includes('/search')) {
(function() {
"use strict";
function addBeetles() {
const scarabLinks = document.querySelectorAll("a.search_result_row:not(.ds_ignored):not(.ds_excluded_by_preferences):not(.ds_wishlist):not(.ds_owned)");
scarabLinks.forEach(link => {
if (link.querySelector(".my-checkbox")) return;
const ladybug = document.createElement("input");
ladybug.type = "checkbox";
ladybug.className = "my-checkbox";
ladybug.dataset.aphid = link.dataset.dsAppid;
link.insertBefore(ladybug, link.firstChild);
ladybug.addEventListener("change", function() {
link.style.background = this.checked ? "linear-gradient(to bottom, #381616, #5d1414)" : "";
});
});
}
function hideSelectedCrickets() {
const checkedLadybugs = document.querySelectorAll(".my-checkbox:checked");
checkedLadybugs.forEach(ladybug => {
const link = document.querySelector(`a[data-ds-appid="${ladybug.dataset.aphid}"]`);
if (link) {
link.classList.add("ds_ignored", "ds_flagged");
ladybug.remove();
jQuery.ajax({
url: "https://store.steampowered.com/recommended/ignorerecommendation/",
type: "POST",
data: {
sessionid: g_sessionID,
appid: ladybug.dataset.aphid,
remove: 0,
snr: "1_account_notinterested_",
},
success: () => {
console.log(`Game with appid ${ladybug.dataset.aphid} added to the ignore list`);
GDynamicStore.InvalidateCache();
},
});
}
});
updateAntCounter();
}
function removeIgnoredDragonflies() {
const ignoredGames = document.querySelectorAll("a.search_result_row.ds_ignored, a.search_result_row.ds_excluded_by_preferences,a.search_result_row.ds_wishlist");
ignoredGames.forEach(game => game.remove());
updateAntCounter();
}
function updateAntCounter() {
const scarabLinks = document.querySelectorAll("a.search_result_row:not(.ds_ignored):not(.ds_excluded_by_preferences):not(.ds_wishlist):not(.ds_owned)");
const termiteElement = document.querySelector(".game-counter");
if (termiteElement) {
termiteElement.textContent = `Игр осталось: ${scarabLinks.length}`;
}
}
const grasshopperButton = document.createElement("button");
grasshopperButton.textContent = "Скрыть выбранное";
grasshopperButton.addEventListener("click", hideSelectedCrickets);
grasshopperButton.classList.add("my-button", "floating-button");
document.body.appendChild(grasshopperButton);
const cockroach = document.createElement("div");
cockroach.textContent = "Игр осталось: 0";
cockroach.classList.add("game-counter", "floating-button");
document.body.appendChild(cockroach);
GM_addStyle(`
input[type=checkbox] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: 6px inset rgba(255, 0, 0, 0.8);
border-radius: 50%;
width: 42px;
height: 42px;
outline: none;
transition: .15s ease-in-out;
vertical-align: middle;
position: absolute;
left: 0px;
top: 50%;
transform: translateY(-50%);
background-color: rgba(0, 0, 0, 0.0);
box-shadow: inset 0 0 0 0 rgba(255, 255, 255, 0.5);
cursor: pointer;
z-index: 9999;
}
input[type=checkbox]:checked {
background-color: rgba(0, 0, 0, 0.5);
border-color: #b71c1c;
box-shadow: inset 0 0 0 12px rgba(255, 0, 0, 0.5);
}
input[type=checkbox]:after {
content: "";
display: block;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0);
width: 25px;
height: 25px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.9);
opacity: 0.9;
box-shadow: 0 0 0 0 #b71c1c;
transition: transform .15s ease-in-out, box-shadow .15s ease-in-out;
}
input[type=checkbox]:checked:after {
transform: translate(-50%, -50%) scale(1);
box-shadow: 0 0 0 4px #b71c1c;
}
.my-button {
margin-right: 10px;
padding: 10px 20px;
border: none;
border-radius: 50px;
font-size: 16px;
font-weight: 700;
color: #fff;
background: linear-gradient(to right, #16202D, #1B2838);
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-family: "Roboto", sans-serif;
margin-top: 245px;
}
.my-button:hover {
background: linear-gradient(to right, #0072ff, #00c6ff);
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
}
.floating-button {
position: fixed;
top: -189px;
left: 240px;
z-index: 1000;
}
.game-counter {
margin-right: 10px;
padding: 10px 20px;
border: none;
border-radius: 50px;
font-size: 16px;
font-weight: 700;
color: #fff;
background: linear-gradient(to right, #16202D, #1B2838);
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
font-family: "Roboto", sans-serif;
margin-top: 195px;
}
`);
const butterflyObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === "childList" && mutation.addedNodes.length) {
addBeetles();
removeIgnoredDragonflies();
updateAntCounter();
}
});
});
butterflyObserver.observe(document.body, {
childList: true,
subtree: true
});
addBeetles();
removeIgnoredDragonflies();
updateAntCounter();
})();
}
// Скрипт для скрытия новостей в новостном центре: | https://store.steampowered.com/news/
if (scriptsConfig.newsFilter && window.location.pathname.includes('/news')) {
(function() {
'use strict';
const stromboliStyle = `
.etna-checkbox {
position: absolute;
top: 50%;
right: 10px;
width: 60px;
height: 60px;
border-radius: 50%;
border: 2px solid #66c0f4;
background-color: rgba(27, 40, 56, 0.7);
cursor: pointer;
z-index: 1000;
transform: translateY(-50%);
opacity: 0.5;
}
.etna-checkbox:checked {
background-color: rgba(102, 192, 244, 0.8);
}
.vesuvius-hide-button {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 30px;
background-color: #66c0f4;
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
z-index: 1000;
font-size: 18px;
transition: background-color 0.3s, box-shadow 0.3s;
}
.vesuvius-hide-button:hover {
background-color: #4a90e2;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
}
`;
const krakatoaStyleElement = document.createElement('style');
krakatoaStyleElement.innerHTML = stromboliStyle;
document.head.appendChild(krakatoaStyleElement);
function addEtnaCheckboxes(newsItems) {
newsItems.forEach(item => {
const fujiNewsLink = item.querySelector('a.Focusable[href^="/news/app/"]');
if (fujiNewsLink && !item.querySelector('.etna-checkbox')) {
const rainierCheckbox = document.createElement('input');
rainierCheckbox.type = 'checkbox';
rainierCheckbox.className = 'etna-checkbox';
rainierCheckbox.addEventListener('click', (event) => {
event.stopPropagation();
});
const kilimanjaroOverlayDiv = item.querySelector('._3HF9tOy_soo1B_odf1XArk');
if (kilimanjaroOverlayDiv) {
kilimanjaroOverlayDiv.style.position = 'relative';
kilimanjaroOverlayDiv.appendChild(rainierCheckbox);
}
}
});
}
function addVesuviusHideButton() {
const vesuviusHideButton = document.createElement('button');
vesuviusHideButton.className = 'vesuvius-hide-button';
vesuviusHideButton.textContent = 'Скрыть';
vesuviusHideButton.onclick = hideSelectedNews;
document.body.appendChild(vesuviusHideButton);
}
function hideSelectedNews() {
const maunaLoaCheckboxes = document.querySelectorAll('.etna-checkbox:checked');
maunaLoaCheckboxes.forEach(maunaLoaCheckbox => {
const newsItem = maunaLoaCheckbox.closest('._398u23KF15gxmeH741ZSyL');
const fujiNewsLink = newsItem.querySelector('a.Focusable[href^="/news/app/"]').getAttribute('href');
const shishaldinNewsTitle = newsItem.querySelector('._1M8-Pa3b3WboayCgd5VBJT').textContent;
const bakerNewsDate = new Date().toISOString();
const hiddenNews = JSON.parse(localStorage.getItem('hiddenNews') || '[]');
hiddenNews.push({
link: fujiNewsLink,
title: shishaldinNewsTitle,
date: bakerNewsDate
});
localStorage.setItem('hiddenNews', JSON.stringify(hiddenNews));
newsItem.remove();
});
}
function removeHiddenNews() {
const hiddenNews = JSON.parse(localStorage.getItem('hiddenNews') || '[]');
hiddenNews.forEach(news => {
const newsItem = document.querySelector(`a[href="${news.link}"]`)?.closest('._398u23KF15gxmeH741ZSyL');
if (newsItem) {
newsItem.remove();
}
});
}
function init() {
removeHiddenNews();
addEtnaCheckboxes(document.querySelectorAll('._398u23KF15gxmeH741ZSyL'));
addVesuviusHideButton();
}
setTimeout(init, 1000);
const erebusObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
const newNewsItems = document.querySelectorAll('._398u23KF15gxmeH741ZSyL');
addEtnaCheckboxes(newNewsItems);
removeHiddenNews();
}
});
});
erebusObserver.observe(document.body, {
childList: true,
subtree: true
});
})();
}
// Скрипт для показа годовых и исторических продаж предмета на торговой площадке Steam | https://steamcommunity.com/market/listings/*
if (scriptsConfig.Kaznachei && window.location.pathname.includes('/market/listings/')) {
async function fetchSalesInfo() {
const urlParts = window.location.pathname.split('/');
const appId = urlParts[3];
const marketHashName = decodeURIComponent(urlParts[4]);
const apiUrl = `https://steamcommunity.com/market/pricehistory/?appid=${appId}&market_hash_name=${marketHashName}`;
try {
const response = await fetch(apiUrl);
const data = await response.json();
if (data.success) {
const salesData = data.prices;
const yearlySales = {};
let totalSales = 0;
salesData.forEach(sale => {
const date = sale[0];
const price = parseFloat(sale[1]);
const quantity = parseInt(sale[2]);
const year = date.split(' ')[2];
const totalForDay = price * quantity;
if (!yearlySales[year]) {
yearlySales[year] = {
total: 0,
commission: 0,
developerShare: 0,
valveShare: 0
};
}
yearlySales[year].total += totalForDay;
totalSales += totalForDay;
});
for (const year in yearlySales) {
const commission = yearlySales[year].total * 0.13;
const developerShare = commission * 0.6667;
const valveShare = commission * 0.3333;
yearlySales[year].commission = commission;
yearlySales[year].developerShare = developerShare;
yearlySales[year].valveShare = valveShare;
}
displaySalesInfo(yearlySales, totalSales);
} else {
console.error('Не удалось получить информацию о продажах.');
}
} catch (error) {
console.error('Ошибка при получении данных:', error);
}
}
function displaySalesInfo(yearlySales, totalSales) {
const salesInfoContainer = document.createElement('div');
salesInfoContainer.style.marginTop = '20px';
salesInfoContainer.style.padding = '10px';
salesInfoContainer.style.border = '1px solid #4a4a4a';
salesInfoContainer.style.backgroundColor = '#1b2838';
salesInfoContainer.style.borderRadius = '4px';
salesInfoContainer.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.5)';
salesInfoContainer.style.color = '#c7d5e0';
const spoilerHeader = document.createElement('div');
spoilerHeader.style.cursor = 'pointer';
spoilerHeader.style.padding = '10px';
spoilerHeader.style.backgroundColor = '#171a21';
spoilerHeader.style.borderRadius = '4px 4px 0 0';
spoilerHeader.style.color = '#c7d5e0';
spoilerHeader.style.fontWeight = 'bold';
spoilerHeader.style.fontFamily = '"Motiva Sans", sans-serif';
spoilerHeader.style.fontSize = '16px';
spoilerHeader.style.display = 'flex';
spoilerHeader.style.alignItems = 'center';
spoilerHeader.style.justifyContent = 'space-between';
spoilerHeader.innerHTML = 'Информация о продажах <span style="font-size: 12px; transform: rotate(0deg); transition: transform 0.3s ease;">▼</span>';
spoilerHeader.addEventListener('click', () => {
const content = spoilerHeader.nextElementSibling;
content.style.display = content.style.display === 'none' ? 'block' : 'none';
const arrow = spoilerHeader.querySelector('span');
arrow.style.transform = content.style.display === 'none' ? 'rotate(0deg)' : 'rotate(180deg)';
});
const spoilerContent = document.createElement('div');
spoilerContent.style.display = 'none';
spoilerContent.style.padding = '10px';
spoilerContent.style.borderTop = '1px solid #4a4a4a';
const yearlySalesTable = document.createElement('table');
yearlySalesTable.style.width = '100%';
yearlySalesTable.style.borderCollapse = 'collapse';
yearlySalesTable.style.marginBottom = '20px';
yearlySalesTable.style.fontFamily = '"Motiva Sans", sans-serif';
yearlySalesTable.style.fontSize = '14px';
const yearlySalesHeader = document.createElement('tr');
yearlySalesHeader.innerHTML = '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #4a4a4a; background-color: #171a21; color: #c7d5e0;">Год</th><th style="padding: 8px; text-align: left; border-bottom: 2px solid #4a4a4a; background-color: #171a21; color: #c7d5e0;">Сумма продаж за год</th><th style="padding: 8px; text-align: left; border-bottom: 2px solid #4a4a4a; background-color: #171a21; color: #c7d5e0;">Ушло разработчику</th><th style="padding: 8px; text-align: left; border-bottom: 2px solid #4a4a4a; background-color: #171a21; color: #c7d5e0;">Ушло Valve</th>';
yearlySalesTable.appendChild(yearlySalesHeader);
for (const year in yearlySales) {
const row = document.createElement('tr');
row.innerHTML = `<td style="padding: 8px; border-bottom: 1px solid #4a4a4a; background-color: #1b2838; color: #c7d5e0;">${year}</td><td style="padding: 8px; border-bottom: 1px solid #4a4a4a; background-color: #1b2838; color: #c7d5e0;">${yearlySales[year].total.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.</td><td style="padding: 8px; border-bottom: 1px solid #4a4a4a; background-color: #1b2838; color: #c7d5e0;">${yearlySales[year].developerShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.</td><td style="padding: 8px; border-bottom: 1px solid #4a4a4a; background-color: #1b2838; color: #c7d5e0;">${yearlySales[year].valveShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.</td>`;
yearlySalesTable.appendChild(row);
}
const totalSalesParagraph = document.createElement('p');
totalSalesParagraph.textContent = `Сумма продаж за всё время: ${totalSales.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.`;
totalSalesParagraph.style.fontWeight = 'bold';
totalSalesParagraph.style.fontSize = '16px';
totalSalesParagraph.style.color = '#c7d5e0';
totalSalesParagraph.style.fontFamily = '"Motiva Sans", sans-serif';
const commission = totalSales * 0.13;
const developerShare = commission * 0.6667;
const valveShare = commission * 0.3333;
const developerShareParagraph = document.createElement('p');
developerShareParagraph.textContent = `Ушло разработчику: ${developerShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.`;
developerShareParagraph.style.fontSize = '14px';
developerShareParagraph.style.color = '#c7d5e0';
developerShareParagraph.style.fontFamily = '"Motiva Sans", sans-serif';
const valveShareParagraph = document.createElement('p');
valveShareParagraph.textContent = `Ушло Valve: ${valveShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.`;
valveShareParagraph.style.fontSize = '14px';
valveShareParagraph.style.color = '#c7d5e0';
valveShareParagraph.style.fontFamily = '"Motiva Sans", sans-serif';
spoilerContent.appendChild(yearlySalesTable);
spoilerContent.appendChild(totalSalesParagraph);
spoilerContent.appendChild(developerShareParagraph);
spoilerContent.appendChild(valveShareParagraph);
salesInfoContainer.appendChild(spoilerHeader);
salesInfoContainer.appendChild(spoilerContent);
const marketHeaderBg = document.querySelector('.market_header_bg');
if (marketHeaderBg) {
marketHeaderBg.parentNode.insertBefore(salesInfoContainer, marketHeaderBg.nextSibling);
}
}
setTimeout(fetchSalesInfo, 100);
}
// Скрипт для получения дополнительной информации об игре при наведении на неё на странице вашей активности Steam
if (scriptsConfig.homeInfo && window.location.href.includes('steamcommunity.com') && window.location.pathname.includes('/home')) {
(function() {
'use strict';
const MOREL_API_URL = "https://api.steampowered.com/IStoreBrowseService/GetItems/v1";
const CHANTERELLE_WAIT_TIME = 2000;
const PORCINI_VISIBLE_ELEMENTS_SELECTOR = "a[href*='/app/'], a[data-appid]";
const TRUFFLE_HOVER_ELEMENT_SELECTOR = "a[href*='/app/'], a[data-appid]";
let SHIITAKE_collectedAppIds = new Set();
let ENOKI_tooltip = null;
let MAITAKE_hoverTimer = null;
let HEN_OF_THE_WOODS_hideTimer = null;
const MUSHROOM_GAME_DATA = {};
const STEAM_TAGS_CACHE_KEY = 'SteamEnhancer_TagsCache_v2';
const STEAM_TAGS_URL = "https://gist.githubusercontent.com/0wn3dg0d/22a351ff4c65e50a9a8af6da360defad/raw/steamrutagsownd.json";
function fetchGameData(appIds) {
const inputJson = {
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,
included_item_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,
include_assets_without_overrides: true,
apply_user_filters: false,
include_links: true
},
include_assets_without_overrides: true,
apply_user_filters: false,
include_links: true
}
};
GM_xmlhttpRequest({
method: "GET",
url: `${MOREL_API_URL}?input_json=${encodeURIComponent(JSON.stringify(inputJson))}`,
onload: function(response) {
const data = JSON.parse(response.responseText);
processGameData(data);
}
});
}
function processGameData(data) {
const items = data.response.store_items;
items.forEach(item => {
const appId = item.id;
MUSHROOM_GAME_DATA[appId] = {
name: item.name,
is_early_access: item.is_early_access,
review_count: item.reviews?.summary_filtered?.review_count,
percent_positive: item.reviews?.summary_filtered?.percent_positive,
short_description: item.basic_info?.short_description,
publishers: item.basic_info?.publishers?.map(p => p.name).join(", "),
developers: item.basic_info?.developers?.map(d => d.name).join(", "),
franchises: item.basic_info?.franchises?.map(f => f.name).join(", "),
tagids: item.tagids || [],
language_support_russian: item.supported_languages?.find(lang => lang.elanguage === 8),
language_support_english: item.supported_languages?.find(lang => lang.elanguage === 0),
release_date: item.release?.steam_release_date ? new Date(item.release.steam_release_date * 1000).toLocaleDateString() : "Нет данных"
};
});
}
function collectAndFetchAppIds() {
const visibleElements = document.querySelectorAll(PORCINI_VISIBLE_ELEMENTS_SELECTOR);
const newAppIds = new Set();
visibleElements.forEach(element => {
const appId = element.dataset.appid || element.href.match(/app\/(\d+)/)?.[1];
if (appId && !SHIITAKE_collectedAppIds.has(appId)) {
newAppIds.add(parseInt(appId, 10));
SHIITAKE_collectedAppIds.add(appId);
}
});
if (newAppIds.size > 0) {
fetchGameData(newAppIds);
}
}
function handleHover(event) {
const gameElement = event.target.closest(TRUFFLE_HOVER_ELEMENT_SELECTOR);
if (gameElement) {
const appId = gameElement.dataset.appid || gameElement.href.match(/app\/(\d+)/)?.[1];
if (appId && MUSHROOM_GAME_DATA[appId]) {
clearTimeout(MAITAKE_hoverTimer);
clearTimeout(HEN_OF_THE_WOODS_hideTimer);
MAITAKE_hoverTimer = setTimeout(() => {
displayGameInfo(gameElement, MUSHROOM_GAME_DATA[appId], appId);
}, 300);
} else {
clearTimeout(MAITAKE_hoverTimer);
clearTimeout(HEN_OF_THE_WOODS_hideTimer);
if (ENOKI_tooltip) {
ENOKI_tooltip.style.opacity = 0;
setTimeout(() => {
ENOKI_tooltip.style.display = 'none';
}, 300);
}
}
}
}
function getReviewClassCatalog(percent, totalReviews) {
if (totalReviews === 0) return 'mushroom-no-reviews';
if (percent >= 70) return 'mushroom-positive';
if (percent >= 40) return 'mushroom-mixed';
if (percent >= 1) return 'mushroom-negative';
return 'mushroom-negative';
}
async function loadSteamTags() {
const cached = GM_getValue(STEAM_TAGS_CACHE_KEY, {
data: null,
timestamp: 0
});
const now = Date.now();
const CACHE_DURATION = 744 * 60 * 60 * 1000;
if (cached.data && (now - cached.timestamp) < CACHE_DURATION) {
return cached.data;
}
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: STEAM_TAGS_URL,
onload: resolve,
onerror: reject
});
});
if (response.status === 200) {
const data = JSON.parse(response.responseText);
GM_setValue(STEAM_TAGS_CACHE_KEY, {
data: data,
timestamp: now
});
return data;
}
} catch (e) {
console.error('Ошибка загрузки тегов:', e);
return cached.data || {};
}
return {};
}
async function displayGameInfo(element, data, appId) {
if (!ENOKI_tooltip) {
ENOKI_tooltip = document.createElement('div');
ENOKI_tooltip.className = 'mushroom-tooltip';
ENOKI_tooltip.innerHTML = '<div class="tooltip-arrow"></div><div class="tooltip-content"></div>';
document.body.appendChild(ENOKI_tooltip);
}
const tooltipContent = ENOKI_tooltip.querySelector('.tooltip-content');
let languageSupportRussianText = "Отсутствует";
let languageSupportRussianClass = 'mushroom-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>Субтитры: ✔";
if (languageSupportRussianText === "") languageSupportRussianText = "Отсутствует";
else languageSupportRussianClass = 'mushroom-language-yes';
}
let languageSupportEnglishText = "Отсутствует";
let languageSupportEnglishClass = 'mushroom-language-no';
if (scriptsConfig.toggleEnglishLangInfo && 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>Субтитры: ✔";
if (languageSupportEnglishText === "") languageSupportEnglishText = "Отсутствует";
else languageSupportEnglishClass = 'mushroom-language-yes';
}
const reviewClass = getReviewClassCatalog(data.percent_positive, data.review_count);
const earlyAccessClass = data.is_early_access ? 'mushroom-early-access-yes' : 'mushroom-early-access-no';
async function getTagNames(tagIds) {
const tagsData = await loadSteamTags();
return tagIds.slice(0, 5).map(tagId =>
tagsData[tagId] || `Тег #${tagId}`
);
}
const tags = await getTagNames(data.tagids || []);
const tagsHtml = tags.map(tag =>
`<div class="mushroom-tag">${tag}</div>`
).join('');
tooltipContent.innerHTML = `
<div style="margin-bottom: 10px;"><strong>Название:</strong> ${data.name || "Нет данных"}</div>
<div style="margin-bottom: 10px;"><img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appId}/header.jpg" alt="${data.name}" style="width: 50%; height: auto;"></div>
<div style="margin-bottom: 10px;"><strong>Дата выхода:</strong> ${data.release_date}</div>
<div style="margin-bottom: 0px;"><strong>Издатели:</strong> <span class="${!data.publishers ? 'mushroom-no-reviews' : ''}">${data.publishers || "Нет данных"}</span></div>
<div style="margin-bottom: 0px;"><strong>Разработчики:</strong> <span class="${!data.developers ? 'mushroom-no-reviews' : ''}">${data.developers || "Нет данных"}</span></div>
<div style="margin-bottom: 10px;"><strong>Серия игр:</strong> <span class="${!data.franchises ? 'mushroom-no-reviews' : ''}">${data.franchises || "Нет данных"}</span></div>
<div style="margin-bottom: 10px;"><strong>Отзывы: </strong><span id="reviewCount">${data.review_count || "0"} </span><span class="${reviewClass}">(${data.percent_positive || "0"}% положительных)</span></div>
<div style="margin-bottom: 10px;"><strong>Ранний доступ:</strong> <span class="${earlyAccessClass}">${data.is_early_access ? "Да" : "Нет"}</span></div>
<div style="margin-bottom: 10px;"><strong>Русский язык:</strong> <span class="${languageSupportRussianClass}">${languageSupportRussianText}</span></div>
${scriptsConfig.toggleEnglishLangInfo ? `<div style="margin-bottom: 10px;"><strong>Английский язык:</strong> <span class="${languageSupportEnglishClass}">${languageSupportEnglishText}</span></div>` : ''}
<div style="margin-bottom: 10px;"><strong>Метки:</strong><br>
<div class="mushroom-tags-container">${tagsHtml}</div></div>
<div style="margin-bottom: 10px;"><strong>Описание:</strong> <span class="${!data.short_description ? 'mushroom-no-reviews' : ''}">${data.short_description || "Нет данных"}</span></div>
`;
ENOKI_tooltip.style.display = 'block';
const blotterDayElement = document.querySelector('.blotter_day');
if (blotterDayElement) {
const blotterRect = blotterDayElement.getBoundingClientRect();
const tooltipRect = ENOKI_tooltip.getBoundingClientRect();
ENOKI_tooltip.style.left = `${blotterRect.left - tooltipRect.width - 5}px`;
ENOKI_tooltip.style.top = `${element.getBoundingClientRect().top + window.scrollY - 35}px`;
}
ENOKI_tooltip.style.opacity = 0;
ENOKI_tooltip.style.display = 'block';
setTimeout(() => {
ENOKI_tooltip.style.opacity = 1;
}, 10);
element.addEventListener('mouseleave', () => {
clearTimeout(HEN_OF_THE_WOODS_hideTimer);
HEN_OF_THE_WOODS_hideTimer = setTimeout(() => {
ENOKI_tooltip.style.opacity = 0;
setTimeout(() => {
ENOKI_tooltip.style.display = 'none';
}, 300);
}, 200);
}, {
once: true
});
element.addEventListener('mouseover', () => {
clearTimeout(HEN_OF_THE_WOODS_hideTimer);
});
}
function observeNewElements() {
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
collectAndFetchAppIds();
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
function initialize() {
setTimeout(() => {
collectAndFetchAppIds();
observeNewElements();
document.addEventListener('mouseover', handleHover);
}, CHANTERELLE_WAIT_TIME);
}
initialize();
const style = document.createElement('style');
style.innerHTML = `
.mushroom-tooltip {
position: absolute;
background: linear-gradient(to bottom, #e3eaef, #c7d5e0);
color: #30455a;
padding: 12px;
border-radius: 0px;
box-shadow: 0 0 12px #000;
font-size: 12px;
max-width: 300px;
display: none;
z-index: 1000;
opacity: 0;
transition: opacity 0.4s ease-in-out;
}
.tooltip-arrow {
position: absolute;
right: -9px;
top: 32px;
width: 0;
height: 0;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-left: 10px solid #E1E8ED;
}
.mushroom-positive {
color: #2B80E9;
}
.mushroom-mixed {
color: #997a00;
}
.mushroom-negative {
color: #E53E3E;
}
.mushroom-no-reviews {
color: #929396;
}
.mushroom-language-yes {
color: #2B80E9;
}
.mushroom-language-no {
color: #E53E3E;
}
.mushroom-early-access-yes {
color: #2B80E9;
}
.mushroom-early-access-no {
color: #929396;
}
.mushroom-tags-container {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 6px;
}
.mushroom-tag {
background-color: #96a3ae;
color: #e3eaef;
padding: 0 4px;
border-radius: 2px;
font-size: 11px;
line-height: 19px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
box-shadow: none;
margin-bottom: 3px;
}
`;
document.head.appendChild(style);
})();
}
// Скрипт для страницы игры (ZOG; получение сведений о наличии русификаторов) | https://store.steampowered.com/app/*
if (window.location.pathname.includes('/app/') && scriptsConfig.zogInfo) {
(async function() {
const ZOG_CACHE_KEY = 'ZoGRusekiEdrit';
const ZOG_DATA_URL = 'https://gist.githubusercontent.com/0wn3dg0d/7baa8d9f42b0304fe303e903d44d2ada/raw/zogrusbase.json';
const zogBlock = document.createElement('div');
Object.assign(zogBlock.style, {
position: 'absolute',
left: '334px',
width: '30px',
height: '30px',
background: 'rgba(27, 40, 56, 0.95)',
padding: '15px',
borderRadius: '4px',
border: '1px solid #3c3c3c',
boxShadow: '0 0 10px rgba(0, 0, 0, 0.5)',
zIndex: '2',
fontFamily: 'Arial, sans-serif',
overflow: 'hidden',
transition: 'all 0.3s ease'
});
let hltbBlock = null;
let hltbObserver = null;
let zogMap = null;
let zogNameMap = null;
const updatePosition = () => {
hltbBlock = document.querySelector('#gameHeaderImageCtn > div[style*="background: rgba(27, 40, 56, 0.95)"]');
const russianIndicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
if (hltbBlock && scriptsConfig.hltbData) {
zogBlock.style.top = `${hltbBlock.offsetTop + hltbBlock.offsetHeight + 16}px`;
} else if (russianIndicators && scriptsConfig.gamePage) {
zogBlock.style.top = `${russianIndicators.offsetTop + russianIndicators.offsetHeight + 16}px`;
} else {
const headerImage = document.querySelector('#gameHeaderImageCtn');
if (headerImage) {
zogBlock.style.top = `${0}px`;
}
}
zogBlock.style.left = '334px';
zogBlock.style.zIndex = '2';
};
const initObservers = () => {
if (scriptsConfig.hltbData) {
hltbBlock = document.querySelector('#gameHeaderImageCtn > div[style*="background: rgba(27, 40, 56, 0.95)"]');
if (hltbBlock && !hltbObserver) {
hltbObserver = new ResizeObserver(updatePosition);
hltbObserver.observe(hltbBlock);
hltbBlock.addEventListener('transitionend', updatePosition);
}
}
if (scriptsConfig.gamePage) {
const russianObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
updatePosition();
}
});
});
const indicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
if (indicators) {
russianObserver.observe(indicators, {
attributes: true,
attributeFilter: ['style']
});
}
}
const generalObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
updatePosition();
initObservers();
}
});
});
generalObserver.observe(document.querySelector('#gameHeaderImageCtn'), {
childList: true,
subtree: true
});
};
async function loadZogData() {
const cached = GM_getValue(ZOG_CACHE_KEY);
const lastUpdated = cached?.lastUpdated || '';
try {
const metaResponse = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://api.github.com/gists/7baa8d9f42b0304fe303e903d44d2ada',
onload: resolve,
onerror: reject
});
});
const metaData = JSON.parse(metaResponse.responseText);
const newLastUpdated = metaData.updated_at;
if (newLastUpdated === lastUpdated) {
return cached.data;
}
const dataResponse = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: ZOG_DATA_URL,
onload: resolve,
onerror: reject
});
});
const newData = JSON.parse(dataResponse.responseText);
GM_setValue(ZOG_CACHE_KEY, {
lastUpdated: newLastUpdated,
data: newData,
timestamp: Date.now()
});
return newData;
} catch (error) {
console.error('Ошибка загрузки данных ZOG:', error);
return cached?.data || [];
}
}
async function initZogData() {
try {
const data = await loadZogData();
zogMap = new Map(data.map(item => [item.app_id, item]));
zogNameMap = new Map(data.map(item => [
item.title
?.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
.toLowerCase(),
item
]));
} catch (e) {
console.error('Ошибка инициализации данных ZOG:', e);
content.textContent = 'Ошибка загрузки базы';
}
}
const title = document.createElement('div');
Object.assign(title.style, {
fontSize: '12px',
fontWeight: 'bold',
color: '#67c1f5',
marginBottom: '10px',
cursor: 'pointer'
});
title.textContent = 'ZOG';
const content = document.createElement('div');
Object.assign(content.style, {
display: 'none',
color: '#c6d4df',
fontSize: '14px',
maxWidth: '300px',
overflowY: 'auto',
whiteSpace: 'normal',
lineHeight: '1.4',
padding: '0 5px'
});
const arrow = createArrow();
zogBlock.append(arrow, title, content);
document.querySelector('#gameHeaderImageCtn').appendChild(zogBlock);
initObservers();
updatePosition();
await initZogData();
title.onclick = () => toggleBlock(arrow);
arrow.onclick = () => toggleBlock(arrow);
async function toggleBlock(arrowElement) {
if (content.style.display === 'none') {
await expandBlock(arrowElement);
} else {
collapseBlock(arrowElement);
}
}
async function expandBlock(arrowElement) {
if (!zogMap || !zogNameMap) {
console.error('Данные ZOG не инициализированы');
return;
}
zogBlock.style.transition = 'width 0.3s ease, height 0.3s ease';
zogBlock.style.width = '300px';
zogBlock.style.height = '40px';
arrowElement.style.transform = 'translateX(-50%) rotate(180deg)';
await new Promise(resolve => setTimeout(resolve, 300));
content.style.display = 'block';
content.textContent = 'Ищем в базе...';
await new Promise(resolve => requestAnimationFrame(resolve));
const appId = getAppId();
let entry = zogMap.get(appId);
if (!entry) {
const gameName = getGameName()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
.toLowerCase();
content.textContent = 'Ищем углубленно...';
await new Promise(resolve => requestAnimationFrame(resolve));
entry = zogNameMap.get(gameName);
if (!entry && /[а-яё]/i.test(gameName)) {
content.textContent = 'Запрашиваем англ. название...';
await new Promise(resolve => requestAnimationFrame(resolve));
const steamApiUrl = `https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json={"ids": [{"appid": ${appId}}], "context": {"language": "english", "country_code": "US", "steam_realm": 1}, "data_request": {"include_assets": true}}`;
try {
const steamResponse = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: steamApiUrl,
onload: resolve,
onerror: reject
});
});
if (steamResponse.status === 200) {
const steamData = JSON.parse(steamResponse.responseText);
const englishName = steamData.response.store_items[0]?.name;
if (englishName) {
const cleanEnglishName = englishName
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
.toLowerCase();
content.textContent = 'Проверяем англ. название...';
await new Promise(resolve => requestAnimationFrame(resolve));
entry = zogNameMap.get(cleanEnglishName);
if (!entry) {
content.textContent = 'Проверяем возможные совпадения...';
await new Promise(resolve => requestAnimationFrame(resolve));
const possibleMatches = findPossibleMatches(cleanEnglishName, Array.from(zogNameMap.values()));
if (possibleMatches.length > 0) {
renderPossibleMatches(possibleMatches);
zogBlock.style.height = `${content.scrollHeight + 30}px`;
updatePosition();
return;
}
}
}
}
} catch (error) {
console.error('Ошибка при запросе к Steam API:', error);
}
}
}
if (!entry) {
content.textContent = 'Проверяем возможные совпадения...';
await new Promise(resolve => requestAnimationFrame(resolve));
const possibleMatches = findPossibleMatches(getGameName(), Array.from(zogNameMap.values()));
if (possibleMatches.length > 0) {
renderPossibleMatches(possibleMatches);
zogBlock.style.height = `${content.scrollHeight + 30}px`;
updatePosition();
return;
}
}
renderContent(entry);
zogBlock.style.height = `${content.scrollHeight + 30}px`;
updatePosition();
}
function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
function collapseBlock(arrowElement) {
zogBlock.style.transition = 'width 0.3s ease, height 0.3s ease';
zogBlock.style.width = '30px';
zogBlock.style.height = '30px';
arrowElement.style.transform = 'translateX(-50%) rotate(0deg)';
content.style.display = 'none';
updatePosition();
}
function renderContent(entry) {
content.innerHTML = '';
if (!entry) {
content.textContent = 'Игра не найдена в базе ZOG';
return;
}
const titleLink = document.createElement('a');
titleLink.href = `https://www.zoneofgames.ru/games/${entry.id}.html`;
titleLink.target = '_blank';
titleLink.textContent = entry.title || 'Без названия';
titleLink.style.color = '#67c1f5';
titleLink.style.wordBreak = 'break-word';
content.appendChild(titleLink);
const list = document.createElement('ul');
list.style.paddingLeft = '15px';
list.style.marginTop = '5px';
list.style.marginBottom = '0';
if (entry.localizations?.length > 0) {
entry.localizations.forEach(loc => {
const li = document.createElement('li');
li.style.marginBottom = '8px';
const link = document.createElement('a');
link.href = loc.link;
link.target = '_blank';
link.textContent = `${loc.name} ${loc.size || ''}`;
link.style.color = '#c6d4df';
link.style.wordBreak = 'break-word';
link.style.textDecoration = 'none';
li.appendChild(link);
list.appendChild(li);
});
} else {
list.textContent = 'Русификаторы отсутствуют';
list.style.color = '#999';
}
content.appendChild(list);
}
function renderPossibleMatches(matches) {
content.innerHTML = '';
const title = document.createElement('div');
title.textContent = 'Возможные совпадения:';
title.style.color = '#67c1f5';
title.style.marginBottom = '10px';
content.appendChild(title);
const list = document.createElement('ul');
list.style.paddingLeft = '15px';
list.style.marginTop = '5px';
list.style.marginBottom = '0';
matches.forEach(match => {
const li = document.createElement('li');
li.style.marginBottom = '8px';
const link = document.createElement('a');
link.href = `https://www.zoneofgames.ru/games/${match.id}.html`;
link.target = '_blank';
link.textContent = `${match.title} (${match.percentage}%)`;
link.style.color = '#c6d4df';
link.style.wordBreak = 'break-word';
link.style.textDecoration = 'none';
link.onclick = () => {
renderContent(match);
zogBlock.style.height = `${content.scrollHeight + 30}px`;
updatePosition();
return false;
};
li.appendChild(link);
list.appendChild(li);
});
const noMatch = document.createElement('li');
noMatch.style.marginBottom = '8px';
const noMatchLink = document.createElement('a');
noMatchLink.href = '#';
noMatchLink.textContent = 'Ничего не подходит';
noMatchLink.style.color = '#c6d4df';
noMatchLink.style.wordBreak = 'break-word';
noMatchLink.style.textDecoration = 'none';
noMatchLink.onclick = () => {
renderContent(null);
zogBlock.style.height = `${content.scrollHeight + 30}px`;
updatePosition();
return false;
};
noMatch.appendChild(noMatchLink);
list.appendChild(noMatch);
content.appendChild(list);
}
function findPossibleMatches(gameName, data) {
const cleanGameName = gameName
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
.toLowerCase();
return data
.map(item => {
const cleanItemName = item.title
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
.toLowerCase();
const similarity = calculateSimilarity(cleanGameName, cleanItemName);
const startsWith = cleanItemName.startsWith(cleanGameName);
return {
...item,
percentage: similarity,
startsWith: startsWith
};
})
.filter(item => item.percentage > 50 || item.startsWith)
.sort((a, b) => {
if (a.startsWith && !b.startsWith) return -1;
if (!a.startsWith && b.startsWith) return 1;
return b.percentage - a.percentage;
})
.slice(0, 5);
}
function calculateSimilarity(str1, str2) {
const len = Math.max(str1.length, str2.length);
if (len === 0) return 100;
const distance = levenshteinDistance(str1, str2);
return Math.round(((len - distance) / len) * 100);
}
function levenshteinDistance(str1, str2) {
const m = str1.length;
const n = str2.length;
const dp = Array.from({
length: m + 1
}, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) {
for (let j = 0; j <= n; j++) {
if (i === 0) {
dp[i][j] = j;
} else if (j === 0) {
dp[i][j] = i;
} else {
dp[i][j] = Math.min(
dp[i - 1][j - 1] + (str1[i - 1] === str2[j - 1] ? 0 : 1),
dp[i - 1][j] + 1,
dp[i][j - 1] + 1
);
}
}
}
return dp[m][n];
}
function createArrow() {
const arrow = document.createElement('div');
Object.assign(arrow.style, {
position: 'absolute',
bottom: '5px',
left: '50%',
width: '0',
height: '0',
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: '5px solid #67c1f5',
cursor: 'pointer',
transition: 'transform 0.3s ease',
transform: 'translateX(-50%)'
});
return arrow;
}
function getAppId() {
return window.location.pathname.split('/')[2];
}
function getGameName() {
return document.querySelector('.apphub_AppName').textContent
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[’]/g, "'")
.replace(/[^a-zA-Zа-яёА-ЯЁ0-9 _'\-!]/g, '')
.trim()
.toLowerCase();
}
})();
}
// Скрипт для получения уведомлений об изменении дат выхода игр из вашего списка желаемого Steam и показа календаря с датами | https://steamcommunity.com/my/wishlist/
if (scriptsConfig.wishlistTracker) {
(function() {
'use strict';
const STORAGE_PREFIX = 'USE_Wishlist_';
const STORAGE_KEYS = {
NOTIFICATIONS: STORAGE_PREFIX + 'notifications',
GAME_DATA: STORAGE_PREFIX + 'gameData',
LAST_UPDATE: STORAGE_PREFIX + 'lastUpdate'
};
const calendarIcon = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM5 20V10h14v10H5zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2zm-8 4H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2z"/>
</svg>`;
const BATCH_SIZE = 200;
const MILLISECONDS_IN_HOUR = 60 * 60 * 1000;
let notifications = GM_getValue(STORAGE_KEYS.NOTIFICATIONS, []);
let isPanelOpen = false;
GM_addStyle(`
.wishlist-tracker-container {
position: absolute;
right: 180px;
top: 6px;
z-index: 999;
}
.wishlist-tracker-button {
color: #c6d4df;
background: rgba(103, 193, 245, 0.1);
padding: 7px 12px;
border-radius: 2px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 4px;
align-items: center;
transition: all 0.2s ease;
}
.wishlist-tracker-button:hover {
background: rgba(103, 193, 245, 0.2);
}
.notification-badge {
background: #67c1f5;
color: #1b2838;
border-radius: 3px;
padding: 3px 6px;
font-size: 14px;
font-weight: bold;
margin-left: 8px;
min-width: 20px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.status-indicator {
background: #4a5562;
color: #c6d4df;
border-radius: 3px;
padding: 3px 6px;
font-size: 12px;
font-weight: bold;
margin-left: 5px;
min-width: 30px;
text-align: center;
transition: all 0.3s ease;
cursor: help;
}
.status-ok { background: #4a5562; }
.status-warning { background: #4a5562; }
.status-alert1 { background: #665c3a; color: #ffd700; }
.status-alert2 { background: #804d4d; color: #ffb3b3; }
.status-critical { background: #e60000; color: #fff; }
.status-unknown { background: #1b2838; color: #8f98a0; }
.wishlist-tracker-panel {
position: fixed;
right: 132px;
top: 50px;
background: #1b2838;
border: 1px solid #67c1f5;
width: 500px;
max-height: 500px;
min-width: 460px;
overflow-y: auto;
z-index: 9999;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
display: none;
}
.wt-panel-header {
padding: 15px;
background: #171a21;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-title {
font-size: 17px;
font-weight: 500;
color: #67c1f5;
}
.panel-controls {
display: flex;
}
.panel-controls button {
background: rgba(30, 45, 60, 0.7);
border: none;
color: #c6d4df;
padding: 8px 14px;
cursor: pointer;
margin-left: 5px;
border-radius: 2px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.panel-controls button:hover {
background: rgba(40, 60, 80, 0.9);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
}
.panel-controls button:active {
background: rgba(30, 45, 60, 0.6);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.calendar-btn {
padding: 8px 10px !important;
display: flex;
align-items: center;
}
.wt-notification-item {
padding: 15px;
border-bottom: 1px solid #2a475e;
position: relative;
transition: opacity 0.3s;
}
.notification-content {
display: flex;
gap: 15px;
}
.notification-image {
width: 80px;
height: 45px;
object-fit: cover;
}
.notification-text {
flex-grow: 1;
padding-right: 25px;
}
.notification-game-title {
color: #66c0f4;
font-weight: bold;
text-decoration: none;
display: block;
margin-bottom: 5px;
}
.notification-date {
font-size: 12px;
color: #8f98a0;
}
.notification-dates {
color: #c6d4df;
font-size: 13px;
}
.wtunread {
background: rgba(102, 192, 244, 0.15);
}
.notification-controls {
position: absolute;
right: 10px;
top: 10px;
display: flex;
gap: 8px;
}
.notification-control {
cursor: pointer;
width: 18px;
height: 18px;
opacity: 0.7;
transition: opacity 0.2s;
}
.notification-control:hover {
opacity: 1;
}
.delete-btn {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #6C7781;
font-size: 16px;
font-weight: bold;
line-height: 1;
border: none;
cursor: pointer;
transition: color 0.2s ease, transform 0.1s ease;
}
.delete-btn:hover {
color: #8F98A0;
}
.delete-btn:active {
color: #800000;
transform: scale(0.9);
}
.loading-indicator {
color: #67c1f5;
text-align: center;
padding: 10px;
}
.calendar-wtmodal.active {
display: flex;
flex-direction: column;
}
.calendar-wtmodal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
height: 80vh;
background: #1b2838;
border: 1px solid #67c1f5;
box-shadow: 0 0 30px rgba(0,0,0,0.7);
z-index: 100000;
display: none;
padding: 20px;
overflow: hidden;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 15px;
border-bottom: 1px solid #2a475e;
margin-bottom: 15px;
}
.calendar-title {
color: #67c1f5;
font-size: 25px;
}
.calendar-close {
cursor: pointer;
color: #8f98a0;
font-size: 54px;
padding: 5px;
}
.calendar-close:hover {
color: #67c1f5;
}
.calendar-content {
flex-grow: 1;
overflow-y: auto;
padding-right: 10px;
}
.calendar-month {
margin-bottom: 30px;
}
.month-header {
color: #67c1f5;
font-size: 24px;
margin-bottom: 15px;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-size: 14px;
font-weight: 500;
}
.calendar-grid > div:not(.calendar-day) {
padding: 10px 0;
background: #1b2838;
color: #67c1f5;
border-bottom: 2px solid #67c1f5;
text-transform: uppercase;
text-align: center;
}
.calendar-day {
background: #2a475e;
min-height: 69px;
padding: 20px 0 16px 0;
position: relative;
display: flex;
flex-direction: column;
gap: 3px;
}
.day-number {
position: absolute;
top: 3px;
right: 5px;
color: #8f98a0;
font-size: 14px;
z-index: 100003
}
.calendar-game {
display: flex;
position: relative;
padding-bottom: 8px;
align-items: center;
margin: 5px 0;
padding: 5px;
background: rgba(42,71,94,0.5);
border-radius: 3px;
transition: background 0.2s;
text-decoration: none !important;
color: inherit;
}
.calendar-game:not(:last-child)::after {
content: "";
position: absolute;
bottom: -7px;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(103, 193, 245, 0.3) 20%,
rgba(103, 193, 245, 0.4) 50%,
rgba(103, 193, 245, 0.3) 80%,
transparent 100%
);
margin-top: 8px;
}
.calendar-game-approximate .calendar-game-title {
color: #FFD580 !important;
opacity: 0.9;
}
.calendar-game:hover {
background: rgba(67, 103, 133, 0.5);
}
.calendar-game-image {
width: 100px;
height: 45px;
object-fit: cover;
margin-right: 10px;
}
.calendar-game-title {
color: #c6d4df;
font-size: 13px;
}
.load-more-months {
text-align: center;
padding: 15px;
}
.load-more-btn {
background: rgba(103, 193, 245, 0.1);
color: #67c1f5;
border: none;
padding: 10px 20px;
cursor: pointer;
border-radius: 3px;
}
.load-more-btn:hover {
background: rgba(103, 193, 245, 0.2);
}
.wt-tooltip {
display: flex !important;
position: relative;
}
.wt-tooltip .wt-tooltiptext {
visibility: hidden;
width: 220px;
background-color: #171a21;
color: #c6d4df;
text-align: center;
border-radius: 3px;
padding: 12px;
position: absolute;
z-index: 1;
left: 100%;
margin-left: 2px;
opacity: 0;
transition: opacity 0.3s;
border: 1px solid #67c1f5;
}
.wt-tooltip:hover .wt-tooltiptext {
visibility: visible;
opacity: 1;
}
`);
const envelopeIcons = {
wtunread: `<svg width="20" height="16" viewBox="0 0 32 32" fill="#67c1f5" xmlns="http://www.w3.org/2000/svg">
<path d="M16.015 18.861l-4.072-3.343-8.862 10.463h25.876l-8.863-10.567-4.079 3.447zM29.926 6.019h-27.815l13.908 11.698 13.907-11.698zM20.705 14.887l9.291 11.084v-18.952l-9.291 7.868zM2.004 7.019v18.952l9.291-11.084-9.291-7.868z"/>
</svg>`,
wtread: `<svg width="20" height="16" viewBox="0 0 32 32" fill="#8f98a0" xmlns="http://www.w3.org/2000/svg">
<path d="M20.139 18.934l9.787-7.999-13.926-9.833-13.89 9.833 9.824 8.032 8.205-0.033zM12.36 19.936l-9.279 10.962h25.876l-9.363-10.9-7.234-0.062zM20.705 19.803l9.291 11.084v-18.952l-9.291 7.868zM2.004 11.935v18.952l9.291-11.084-9.291-7.868z"/>
</svg>`
};
function createNotificationUI() {
const container = $(`
<div class="wishlist-tracker-container">
<div class="wishlist-tracker-button">
<span>Отслеживание вишлиста</span>
<div class="status-indicator status-unknown">??</div>
<div class="notification-badge">${getUnreadCount()}</div>
</div>
<div class="wishlist-tracker-panel">
<div class="wt-panel-header">
<div class="panel-title">Уведомлений: (${notifications.length})</div>
<div class="panel-controls">
<button class="refresh-btn">⟳ Обновить</button>
<button class="clear-btn">× Очистить</button>
<button class="calendar-btn">${calendarIcon}</button>
</div>
</div>
</div>
</div>
`);
const panel = container.find('.wishlist-tracker-panel');
const button = container.find('.wishlist-tracker-button');
button.click(function(e) {
e.stopPropagation();
togglePanel();
});
container.find('.refresh-btn').click((e) => {
e.stopPropagation();
updateData();
});
container.find('.clear-btn').click(() => {
notifications = [];
GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
updateNotificationPanel();
updateBadge();
});
container.find('.calendar-btn').click((e) => {
e.stopPropagation();
showCalendarModal();
});
if (window.self === window.top) {
document.body.appendChild(container[0]);
}
updateNotificationPanel();
$(document).click(() => {
if (isPanelOpen) {
panel.hide();
isPanelOpen = false;
}
});
}
function showLoadingIndicator() {
const panel = $('.wishlist-tracker-panel');
panel.find('.loading-indicator').remove();
const loading = $(`<div class="loading-indicator">Обновление данных...</div>`);
panel.append(loading);
}
function togglePanel() {
updateStatusIndicator();
const panel = $('.wishlist-tracker-panel');
panel.toggle();
isPanelOpen = !isPanelOpen;
if (isPanelOpen) {
panel.css('display', 'block');
}
}
function updateNotificationPanel() {
const panel = $('.wishlist-tracker-panel');
panel.find('.wt-notification-item, .loading-indicator').remove();
panel.find('.panel-title').text(`Уведомлений: (${notifications.length})`);
notifications.slice(0, 5000).forEach((notification, index) => {
const item = $(`
<div class="wt-notification-item ${notification.wtread ? '' : 'wtunread'}">
<div class="notification-controls">
<div class="toggle-wtread-btn notification-control">
${notification.wtread ? envelopeIcons.wtread : envelopeIcons.wtunread}
</div>
<div class="delete-btn notification-control">X</div>
</div>
<div class="notification-content">
<a href="https://store.steampowered.com/app/${notification.appid}" target="_blank">
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${notification.appid}/header.jpg"
class="notification-image">
</a>
<div class="notification-text">
<a href="https://store.steampowered.com/app/${notification.appid}"
class="notification-game-title" target="_blank">
${notification.name}
</a>
<div class="notification-dates">
Дата выхода изменилась:<br>
<span class="old-date">${formatDate(notification.oldDate)}</span> →
<span class="new-date">${formatDate(notification.newDate)}</span>
</div>
<div class="notification-date">
Обнаружено: ${new Date(notification.timestamp).toLocaleString()}
</div>
</div>
</div>
</div>
`);
item.find('.delete-btn').click((e) => {
e.stopPropagation();
notifications = notifications.filter((_, i) => i !== index);
GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
item.fadeOut(300, () => {
updateNotificationPanel();
updateBadge();
});
});
item.find('.toggle-wtread-btn').click((e) => {
e.stopPropagation();
notifications[index].wtread = !notifications[index].wtread;
GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
item.toggleClass('wtunread', !notifications[index].wtread);
item.find('.toggle-wtread-btn').html(notifications[index].wtread ? envelopeIcons.wtread : envelopeIcons.wtunread);
updateBadge();
});
panel.append(item);
});
}
function formatDate(dateInfo) {
if (!dateInfo || dateInfo.value === 'Не указана') return 'Не указано';
const value = dateInfo.value;
const displayType = dateInfo.displayType;
if (typeof value === 'string' && isNaN(value)) {
return value;
}
const ts = formatTimestamp(value);
const date = new Date(ts * 1000);
const monthNames = ["январь", "февраль", "март", "апрель", "май", "июнь",
"июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"
];
const quarter = Math.floor(date.getMonth() / 3) + 1;
if (displayType) {
switch (displayType) {
case 'date_month':
return `${monthNames[date.getMonth()]} ${date.getFullYear()}`;
case 'date_quarter':
return `Q${quarter} ${date.getFullYear()}`;
case 'date_year':
return `${date.getFullYear()}`;
case 'date_full':
default:
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
}
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
function updateStatusIndicator() {
const lastUpdate = GM_getValue(STORAGE_KEYS.LAST_UPDATE, 0);
const hoursPassed = (Date.now() - lastUpdate) / MILLISECONDS_IN_HOUR;
const indicator = $('.status-indicator');
const days = Math.floor(hoursPassed / 24);
const hours = Math.floor(hoursPassed % 24);
indicator.attr('title', `Данные не обновлялись: ${days} д. и ${hours} ч.`);
if (!lastUpdate) {
indicator.text('-').removeClass().addClass('status-indicator status-unknown');
return;
}
if (hoursPassed < 12) {
indicator.text('OK').removeClass().addClass('status-indicator status-ok');
} else if (hoursPassed < 24) {
indicator.text('OK?').removeClass().addClass('status-indicator status-warning');
} else if (hoursPassed < 48) {
indicator.text('!').removeClass().addClass('status-indicator status-alert1');
} else if (hoursPassed < 72) {
indicator.text('!!').removeClass().addClass('status-indicator status-alert2');
} else if (hoursPassed < 96) {
indicator.text('!!!').removeClass().addClass('status-indicator status-critical');
} else {
indicator.text('???').removeClass().addClass('status-indicator status-critical');
}
}
function updateBadge() {
$('.notification-badge').text(getUnreadCount());
}
function getUnreadCount() {
return notifications.filter(n => !n.wtread).length;
}
async function fetchWishlistAppIds() {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://store.steampowered.com/dynamicstore/userdata/',
onload: function(response) {
const data = JSON.parse(response.responseText);
resolve(data.rgWishlist || []);
}
});
});
}
async function fetchGameDetails(appIds) {
const batches = [];
for (let i = 0; i < appIds.length; i += BATCH_SIZE) {
batches.push(appIds.slice(i, i + BATCH_SIZE));
}
const allDetails = [];
for (const batch of batches) {
const details = await fetchBatchDetails(batch);
allDetails.push(...details);
await new Promise(resolve => setTimeout(resolve, 1000));
}
return allDetails;
}
async function fetchBatchDetails(appIds) {
const requestData = {
ids: appIds.map(appid => ({
appid
})),
context: {
language: 'russian',
country_code: 'RU',
steam_realm: 1
},
data_request: {
include_release: true,
include_basic_info: true
}
};
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=${encodeURIComponent(JSON.stringify(requestData))}`,
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
resolve(data.response?.store_items || []);
} catch (e) {
console.error('Error parsing response:', e);
resolve([]);
}
}
});
});
}
function checkForChanges(currentData) {
const previousData = GM_getValue(STORAGE_KEYS.GAME_DATA, {});
const changes = [];
currentData.forEach(game => {
const prevGame = previousData[game.appid];
const currentRelease = getReleaseInfo(game.release);
const prevRelease = prevGame ? getReleaseInfo(prevGame.rawRelease) : null;
if (prevGame && (
currentRelease.date !== prevRelease?.date ||
currentRelease.type !== prevRelease?.type ||
currentRelease.displayType !== prevRelease?.displayType
)) {
changes.push({
appid: game.appid,
name: game.name,
oldDate: {
value: prevRelease?.date || 'Не указана',
displayType: prevRelease?.displayType
},
newDate: {
value: currentRelease.date,
displayType: currentRelease.displayType
},
timestamp: Date.now(),
wtread: false
});
}
});
const newGameData = currentData.reduce((acc, game) => {
acc[game.appid] = {
name: game.name,
rawRelease: game.release,
releaseInfo: getReleaseInfo(game.release)
};
return acc;
}, {});
GM_setValue(STORAGE_KEYS.GAME_DATA, {
...previousData,
...newGameData
});
if (changes.length > 0) {
notifications = [...changes, ...notifications];
GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
updateNotificationPanel();
updateBadge();
}
$('.wishlist-tracker-panel .loading-indicator').remove();
}
function getReleaseInfo(releaseData) {
if (!releaseData) return {
date: 'Не указана',
type: 'unknown',
displayType: null
};
const displayType = releaseData.coming_soon_display || null;
if (releaseData.steam_release_date) {
return {
date: releaseData.steam_release_date,
type: 'date',
displayType: displayType
};
}
if (releaseData.custom_release_date_message) {
return {
date: releaseData.custom_release_date_message,
type: 'custom',
displayType: null
};
}
return {
date: 'Не указана',
type: 'unknown',
displayType: null
};
}
function formatTimestamp(ts) {
if (!ts) return ts;
if (typeof ts === 'string') {
if (/^\d{4}-\d{2}-\d{2}$/.test(ts)) {
return Math.floor(new Date(ts).getTime() / 1000);
}
return ts;
}
return typeof ts === 'number' ? ts : parseInt(ts);
}
async function updateData() {
try {
showLoadingIndicator();
const indicator = $('.status-indicator');
indicator.text('...').removeClass().addClass('status-indicator status-unknown');
const appIds = await fetchWishlistAppIds();
const gameDetails = await fetchGameDetails(appIds);
checkForChanges(gameDetails);
GM_setValue(STORAGE_KEYS.LAST_UPDATE, Date.now());
updateStatusIndicator();
} catch (e) {
console.error('Update error:', e);
showErrorIndicator();
updateStatusIndicator();
} finally {
$('.wishlist-tracker-panel .loading-indicator').remove();
}
}
function showErrorIndicator() {
const panel = $('.wishlist-tracker-panel');
const error = $(`
<div class="wt-notification-item" style="color: #ff4747;">
Ошибка при обновлении данных
</div>
`);
panel.prepend(error);
setTimeout(() => error.remove(), 5000);
}
function showCalendarModal() {
const gameData = GM_getValue(STORAGE_KEYS.GAME_DATA, {});
const monthsData = getGamesByMonths(gameData);
const wtmodal = $(`
<div class="calendar-wtmodal">
<div class="calendar-header">
<div class="calendar-title">Календарь релизов (${monthsData.length} месяцев)</div>
<div class="calendar-close">×</div>
</div>
<div class="calendar-content"></div>
</div>
`);
const clickHandler = (e) => {
if (!$(e.target).closest('.calendar-wtmodal').length) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
wtmodal.remove();
$(document).off('click', clickHandler);
}
};
wtmodal.find('.calendar-close').click((e) => {
e.preventDefault();
e.stopPropagation();
wtmodal.remove();
$(document).off('click', clickHandler);
});
wtmodal.click(e => e.stopPropagation());
$(document).on('click', clickHandler);
$('body').append(wtmodal);
wtmodal.addClass('active');
let visibleMonths = 3;
const renderCalendar = () => {
const visibleData = monthsData.slice(0, visibleMonths);
const content = wtmodal.find('.calendar-content').empty();
visibleData.forEach(({
month,
year,
games
}) => {
const monthDate = new Date(year, month);
const monthName = monthDate.toLocaleString('ru-RU', {
month: 'long'
});
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDay = new Date(year, month, 1).getDay();
const adjustedFirstDay = firstDay === 0 ? 6 : firstDay - 1;
const monthBlock = $(`
<div class="calendar-month">
<div class="month-header">${monthName} ${year}</div>
<div class="calendar-grid"></div>
</div>
`);
const grid = monthBlock.find('.calendar-grid');
grid.append('<div>Пн</div><div>Вт</div><div>Ср</div><div>Чт</div><div>Пт</div><div>Сб</div><div>Вс</div>');
for (let i = 0; i < adjustedFirstDay; i++) {
grid.append('<div class="calendar-day"></div>');
}
for (let day = 1; day <= daysInMonth; day++) {
const dayGames = games.filter(g => {
const releaseDate = new Date(g.releaseInfo.date * 1000);
return releaseDate.getDate() === day &&
releaseDate.getMonth() === month &&
releaseDate.getFullYear() === year;
});
const dayElement = $(`
<div class="calendar-day">
<div class="day-number">${day}</div>
</div>
`);
dayGames.sort((a, b) => a.name.localeCompare(b.name)).forEach(game => {
const isApproximate = ['date_month', 'date_quarter', 'date_year']
.includes(game.releaseInfo.displayType);
const gameElement = $(`
<a href="https://store.steampowered.com/app/${game.appid}"
target="_blank"
class="calendar-game ${isApproximate ? 'calendar-game-approximate wt-tooltip' : ''}">
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.appid}/header.jpg"
class="calendar-game-image">
<div class="calendar-game-title">${game.name}</div>
${isApproximate ?
`<div class="wt-tooltiptext">Приблизительная дата: ${getApproximateDateText(game.releaseInfo)}</div>`
: ''}
</a>
`);
dayElement.append(gameElement);
});
grid.append(dayElement);
}
content.append(monthBlock);
});
if (visibleMonths < monthsData.length) {
content.append(`
<div class="load-more-months">
<button class="load-more-btn">Показать ещё 3 месяца</button>
</div>
`);
content.find('.load-more-btn').click(() => {
visibleMonths += 3;
renderCalendar();
});
}
};
wtmodal.addClass('active');
renderCalendar();
}
function getGamesByMonths(gameData) {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth();
const games = Object.entries(gameData)
.map(([appid, game]) => ({
appid: parseInt(appid),
...game,
releaseDate: game.releaseInfo.date && typeof game.releaseInfo.date === 'number' ?
new Date(game.releaseInfo.date * 1000) : null
}))
.filter(g => g.releaseDate)
.filter(g => {
const releaseYear = g.releaseDate.getFullYear();
const releaseMonth = g.releaseDate.getMonth();
return (releaseYear > currentYear) ||
(releaseYear === currentYear && releaseMonth >= currentMonth);
});
const monthMap = games.reduce((acc, game) => {
const year = game.releaseDate.getFullYear();
const month = game.releaseDate.getMonth();
const key = `${year}-${month}`;
if (!acc[key]) {
acc[key] = {
year,
month,
games: []
};
}
acc[key].games.push(game);
return acc;
}, {});
return Object.values(monthMap)
.sort((a, b) => a.year === b.year ? a.month - b.month : a.year - b.year);
}
function getApproximateDateText(releaseInfo) {
const date = new Date(releaseInfo.date * 1000);
const quarter = Math.floor(date.getMonth() / 3) + 1;
switch (releaseInfo.displayType) {
case 'date_month':
return date.toLocaleString('ru-RU', {
month: 'long',
year: 'numeric'
});
case 'date_quarter':
return `Q${quarter} ${date.getFullYear()}`;
case 'date_year':
return date.getFullYear().toString();
default:
return date.toLocaleDateString('ru-RU');
}
}
function initialize() {
createNotificationUI();
updateStatusIndicator();
}
$(document).ready(initialize);
})();
}
// Скрипт для страницы игры (Plati; отображение цен с Plati.Market) | https://store.steampowered.com/app/*
if (scriptsConfig.platiSales && window.location.pathname.includes('/app/')) {
(function() {
'use strict';
// --- Конфигурация PlatiSearch (PS) ---
const PS_API_BASE_URL = 'https://api.digiseller.com/api/products/search2';
const PS_SUGGEST_API_URL = 'https://plati.market/api/suggest.ashx';
const PS_IMAGE_DOMAIN = 'digiseller.mycdn.ink';
const PS_RESULTS_PER_PAGE_CHECK = 1;
const PS_DEFAULT_SORT_MODE = 2; // Relevance
const PS_SUGGEST_DEBOUNCE_MS = 300;
const PS_FILTER_DEBOUNCE_MS = 500;
const PS_FILTER_STORAGE_PREFIX = 'platiSalesFilter_v1_';
const PS_EXCLUSION_STORAGE_KEY = 'platiSalesExclusions_v1_';
const PS_LAST_SORT_STORAGE_KEY = 'platiSalesLastSort_v1_';
const PS_CURRENCY_STORAGE_KEY = 'platiSalesCurrency_v1_';
const PS_FILTER_PANEL_WIDTH = 230;
const PS_EXCLUSION_PANEL_WIDTH = 250;
const PS_SIDE_PANEL_HORIZONTAL_PADDING = 20;
const PS_CONTENT_PADDING_BUFFER = 15;
const PS_CONTENT_PADDING_LEFT = PS_FILTER_PANEL_WIDTH + PS_SIDE_PANEL_HORIZONTAL_PADDING + PS_CONTENT_PADDING_BUFFER;
const PS_CONTENT_PADDING_RIGHT = PS_EXCLUSION_PANEL_WIDTH + PS_SIDE_PANEL_HORIZONTAL_PADDING + PS_CONTENT_PADDING_BUFFER;
const PS_HEADER_APPROX_HEIGHT = 65;
const PS_TOP_OFFSET_FOR_SIDE_PANELS = PS_HEADER_APPROX_HEIGHT + 25;
const PS_BOTTOM_OFFSET_FOR_SIDE_PANELS = 20;
const PS_ADV_SORT_CONTAINER_WIDTH = 230;
const NEW_ITEM_THRESHOLD_DAYS = 7;
// --- Глобальные переменные ---
let ps_currentResults = [];
let ps_currentSort = GM_getValue(PS_LAST_SORT_STORAGE_KEY, { field: 'relevance', direction: 'asc' });
let ps_currentCurrency = GM_getValue(PS_CURRENCY_STORAGE_KEY, 'RUR');
let ps_firstSortClick = {};
['price', 'sales', 'relevance', 'name', 'date_create', 'discount', 'seller_rating', 'review_ratio', 'good_reviews', 'bad_reviews', 'returns'].forEach(field => {
ps_firstSortClick[field] = ps_currentSort.field !== field;
});
let ps_exclusionKeywords = GM_getValue(PS_EXCLUSION_STORAGE_KEY, []);
let ps_currentFilters = ps_loadFilters();
let ps_suggestDebounceTimeout;
let ps_filterDebounceTimeout;
let ps_advSortMenuTimeout;
// --- DOM Элементы ---
let ps_modal, ps_closeBtn, ps_searchInput, ps_searchBtn, ps_sortPriceBtn, ps_sortSalesBtn, ps_advSortBtnContainer, ps_advSortBtn, ps_advSortMenu, ps_currencySelect, ps_resetSortBtn;
let ps_resultsContainer, ps_resultsDiv, ps_statusDiv, ps_excludeInput, ps_addExcludeBtn, ps_exclusionTagsDiv;
let ps_suggestionsDiv;
let ps_filtersPanel;
let ps_filterPriceMin, ps_filterPriceMax, ps_filterSalesMin, ps_filterSalesMax, ps_filterRatingMin, ps_filterRatingMax;
let ps_filterHideBadReviews, ps_filterHideReturns, ps_filterOnlyDiscount;
let ps_filterDateSelect;
let ps_resetAllFiltersBtn;
let ps_exclusionTagsListDiv;
// --- Описания сортировок ---
const ps_advancedSorts = {
'price': { name: 'По цене', defaultDir: 'asc' },
'sales': { name: 'По продажам', defaultDir: 'desc'},
'relevance': { name: 'По релевантности', defaultDir: 'asc' },
'name': { name: 'По названию', defaultDir: 'asc' },
'date_create': { name: 'По дате добавления', defaultDir: 'desc' },
'discount': { name: 'По % в скид. системе', defaultDir: 'desc' },
'seller_rating':{ name: 'По рейтингу продавца', defaultDir: 'desc' },
'review_ratio': { name: 'По соотношению отзывов', defaultDir: 'desc' },
'good_reviews': { name: 'По кол-ву хор. отзывов', defaultDir: 'desc' },
'bad_reviews': { name: 'По кол-ву плох. отзывов', defaultDir: 'asc' },
'returns': { name: 'По кол-ву возвратов', defaultDir: 'asc' }
};
const ps_advSortOrder = ['name', 'date_create', 'discount', 'seller_rating', 'review_ratio', 'good_reviews', 'bad_reviews', 'returns'];
const ps_dateFilterOptions = {
'all': 'За все время', '1d': 'За сутки', '2d': 'За 2 дня', '1w': 'За неделю', '1m': 'За месяц', '6m': 'За полгода', '1y': 'За год', '5y': 'За 5 лет', '10y': 'За 10 лет',
};
// --- Вспомогательные функции ---
function formatPrice(priceStr) {
if (!priceStr) return 0;
return parseFloat(String(priceStr).replace(/[^\d,.]/g, '').replace(',', '.')) || 0;
}
function formatSales(salesStr) {
if (!salesStr) return 0;
return parseInt(String(salesStr).replace(/\D/g, ''), 10) || 0;
}
function parseSellerRating(ratingStr) {
if (!ratingStr) return 0;
return parseFloat(String(ratingStr).replace(',', '.')) || 0;
}
function calculateReviewRatio(item) {
const good = parseInt(item.cnt_good_responses || '0', 10);
const bad = parseInt(item.cnt_bad_responses || '0', 10);
const total = good + bad;
return total > 0 ? (good / total) : -1;
}
function parseDate(dateStr) {
if (!dateStr) return 0;
const parts = dateStr.split(' ');
if (parts.length !== 2) return 0;
const dateParts = parts[0].split('.');
const timeParts = parts[1].split(':');
if (dateParts.length !== 3 || timeParts.length !== 3) return 0;
try { return new Date(Date.UTC(dateParts[2], dateParts[1] - 1, dateParts[0], timeParts[0], timeParts[1], timeParts[2])).getTime(); }
catch (e) { return 0; }
}
function formatDateString(timestamp) {
if (!timestamp || timestamp === 0) return 'N/A';
try {
const date = new Date(timestamp);
const day = String(date.getUTCDate()).padStart(2, '0');
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const year = String(date.getUTCFullYear()).slice(-2);
return `${day}.${month}.${year}`;
} catch (e) { return 'N/A'; }
}
function getPriceInSelectedCurrency(item, currency) {
let price = 0;
switch (currency) {
case 'USD': price = formatPrice(item.price_usd); break;
case 'EUR': price = formatPrice(item.price_eur); break;
case 'UAH': price = formatPrice(item.price_uah); break;
case 'RUR': default: price = formatPrice(item.price_rur); break;
}
if (price <= 0 && currency !== 'RUR') price = formatPrice(item.price_rur);
if (price <= 0 && currency !== 'USD') price = formatPrice(item.price_usd);
if (price <= 0 && currency !== 'EUR') price = formatPrice(item.price_eur);
return price > 0 ? price : Infinity;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => { clearTimeout(timeout); func(...args); };
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function getSteamGameName() {
const appNameElement = document.querySelector('#appHubAppName');
return appNameElement ? appNameElement.textContent.trim() : '';
}
// --- Создание UI ---
function createPlatiModal() {
const existingModal = document.querySelector('#platiSearchModal');
if (existingModal) existingModal.remove();
ps_modal = document.createElement('div');
ps_modal.id = 'platiSearchModal';
const container = document.createElement('div');
container.id = 'platiSearchContainer';
const header = document.createElement('div');
header.id = 'platiSearchHeader';
const searchInputContainer = document.createElement('div');
searchInputContainer.className = 'platiSearchInputContainer';
ps_searchInput = document.createElement('input');
ps_searchInput.id = 'platiSearchInput';
ps_searchInput.type = 'text';
ps_searchInput.placeholder = 'Введите название игры или товара...';
ps_searchInput.autocomplete = 'off';
ps_searchInput.onkeydown = (e) => { if (e.key === 'Enter') ps_triggerSearch(); };
ps_searchInput.oninput = () => {
clearTimeout(ps_suggestDebounceTimeout);
ps_suggestDebounceTimeout = setTimeout(() => ps_fetchSuggestions(ps_searchInput.value), PS_SUGGEST_DEBOUNCE_MS);
};
ps_searchInput.onblur = () => { setTimeout(() => { if (ps_suggestionsDiv) ps_suggestionsDiv.style.display = 'none'; }, 150); };
ps_suggestionsDiv = document.createElement('div');
ps_suggestionsDiv.id = 'platiSearchSuggestions';
searchInputContainer.appendChild(ps_searchInput);
searchInputContainer.appendChild(ps_suggestionsDiv);
header.appendChild(searchInputContainer);
ps_searchBtn = document.createElement('button');
ps_searchBtn.textContent = 'Найти';
ps_searchBtn.id = 'platiSearchGoBtn';
ps_searchBtn.className = 'platiSearchBtn';
ps_searchBtn.onclick = ps_triggerSearch;
header.appendChild(ps_searchBtn);
ps_resetSortBtn = document.createElement('button');
ps_resetSortBtn.id = 'platiResetSortBtn';
ps_resetSortBtn.className = 'platiSearchBtn';
ps_resetSortBtn.title = 'Сбросить сортировку (Релевантность)';
ps_resetSortBtn.innerHTML = `<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6s-2.69 6-6 6s-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8s-3.58-8-8-8Z"/></svg>`;
ps_resetSortBtn.onclick = () => ps_resetSort(true);
header.appendChild(ps_resetSortBtn);
ps_sortPriceBtn = document.createElement('button');
ps_sortPriceBtn.className = 'platiSearchBtn sortBtn';
ps_sortPriceBtn.dataset.sort = 'price';
ps_sortPriceBtn.onclick = () => ps_handleSort('price');
header.appendChild(ps_sortPriceBtn);
ps_sortSalesBtn = document.createElement('button');
ps_sortSalesBtn.className = 'platiSearchBtn sortBtn';
ps_sortSalesBtn.dataset.sort = 'sales';
ps_sortSalesBtn.onclick = () => ps_handleSort('sales');
header.appendChild(ps_sortSalesBtn);
ps_advSortBtnContainer = document.createElement('div');
ps_advSortBtnContainer.id = 'platiSearchAdvSortBtnContainer';
ps_advSortBtn = document.createElement('button');
ps_advSortBtn.id = 'platiSearchAdvSortBtn';
ps_advSortBtn.className = 'platiSearchBtn sortBtn';
ps_advSortBtnContainer.appendChild(ps_advSortBtn);
ps_advSortMenu = document.createElement('div');
ps_advSortMenu.id = 'platiSearchAdvSortMenu';
ps_advSortOrder.forEach(key => {
const sortInfo = ps_advancedSorts[key];
const menuItem = document.createElement('div');
menuItem.className = 'platiSearchSortMenuItem';
menuItem.dataset.sort = key;
menuItem.innerHTML = `${sortInfo.name} <span class="sortArrow"></span>`; // Стрелка добавится в update
menuItem.onclick = () => ps_handleSort(key);
ps_advSortMenu.appendChild(menuItem);
});
ps_advSortBtnContainer.appendChild(ps_advSortMenu);
header.appendChild(ps_advSortBtnContainer);
ps_currencySelect = document.createElement('select');
ps_currencySelect.id = 'platiSearchCurrencySelect';
['RUR', 'USD', 'EUR', 'UAH'].forEach(curr => {
const option = document.createElement('option');
option.value = curr; option.textContent = curr;
if (curr === ps_currentCurrency) option.selected = true;
ps_currencySelect.appendChild(option);
});
ps_currencySelect.onchange = ps_handleCurrencyChange;
header.appendChild(ps_currencySelect);
container.appendChild(header);
ps_resultsContainer = document.createElement('div');
ps_resultsContainer.id = 'platiSearchResultsContainer';
ps_statusDiv = document.createElement('div');
ps_statusDiv.id = 'platiSearchResultsStatus';
ps_resultsDiv = document.createElement('div');
ps_resultsDiv.id = 'platiSearchResults';
ps_resultsContainer.appendChild(ps_statusDiv);
ps_resultsContainer.appendChild(ps_resultsDiv);
container.appendChild(ps_resultsContainer);
ps_modal.appendChild(container);
ps_filtersPanel = document.createElement('div');
ps_filtersPanel.id = 'platiSearchFiltersPanel';
ps_filtersPanel.innerHTML = `
<div class="filterGroup"> <h4>Цена (${ps_currentCurrency}) ${ps_createResetButtonHTML('price')}</h4> <div class="filterRangeInputs"> <input type="number" id="psFilterPriceMin" placeholder="от" min="0"> <input type="number" id="psFilterPriceMax" placeholder="до" min="0"> </div> </div>
<div class="filterGroup"> <h4>Продажи ${ps_createResetButtonHTML('sales')}</h4> <div class="filterRangeInputs"> <input type="number" id="psFilterSalesMin" placeholder="от" min="0"> <input type="number" id="psFilterSalesMax" placeholder="до" min="0"> </div> </div>
<div class="filterGroup"> <h4>Рейтинг продавца ${ps_createResetButtonHTML('rating')}</h4> <div class="filterRangeInputs"> <input type="number" id="psFilterRatingMin" placeholder="от" step="0.1" min="0"> <input type="number" id="psFilterRatingMax" placeholder="до" step="0.1" min="0"> </div> </div>
<div class="filterGroup"> <h4>Опции ${ps_createResetButtonHTML('options')}</h4> <div class="filterCheckbox"> <label><input type="checkbox" id="psFilterHideBadReviews"> Скрыть с плох. отзывами</label> </div> <div class="filterCheckbox"> <label><input type="checkbox" id="psFilterHideReturns"> Скрыть с возвратами</label> </div> <div class="filterCheckbox"> <label><input type="checkbox" id="psFilterOnlyDiscount"> Участие в скидках</label> </div> </div>
<div class="filterGroup"> <h4>Дата добавления ${ps_createResetButtonHTML('date')}</h4> <div class="filterSelect"> <select id="psFilterDateSelect"> ${Object.entries(ps_dateFilterOptions).map(([key, text]) => `<option value="${key}">${text}</option>`).join('')} </select> </div> </div>
<button id="psResetAllFiltersBtn" class="platiSearchBtn">Сбросить все фильтры</button>
`;
ps_modal.appendChild(ps_filtersPanel);
ps_exclusionTagsDiv = document.createElement('div');
ps_exclusionTagsDiv.id = 'platiSearchExclusionTags';
const exclusionInputGroup = document.createElement('div');
exclusionInputGroup.className = 'exclusionInputGroup';
ps_excludeInput = document.createElement('input');
ps_excludeInput.type = 'text';
ps_excludeInput.id = 'platiSearchExcludeInput';
ps_excludeInput.placeholder = 'Исключить слово';
ps_excludeInput.onkeydown = (e) => { if (e.key === 'Enter') ps_addFilterKeyword(); };
ps_addExcludeBtn = document.createElement('button');
ps_addExcludeBtn.id = 'platiSearchAddExcludeBtn';
ps_addExcludeBtn.innerHTML = `<svg viewBox="0 0 20 20"><path d="M10 2.5a.75.75 0 0 1 .75.75v6h6a.75.75 0 0 1 0 1.5h-6v6a.75.75 0 0 1-1.5 0v-6h-6a.75.75 0 0 1 0-1.5h6v-6a.75.75 0 0 1 .75-.75Z" /></svg>`;
ps_addExcludeBtn.onclick = ps_addFilterKeyword;
exclusionInputGroup.appendChild(ps_excludeInput);
exclusionInputGroup.appendChild(ps_addExcludeBtn);
ps_exclusionTagsDiv.appendChild(exclusionInputGroup);
ps_exclusionTagsListDiv = document.createElement('div');
ps_exclusionTagsListDiv.id = 'platiExclusionTagsList';
ps_exclusionTagsDiv.appendChild(ps_exclusionTagsListDiv);
ps_modal.appendChild(ps_exclusionTagsDiv);
ps_closeBtn = document.createElement('button');
ps_closeBtn.id = 'platiSearchCloseBtn';
ps_closeBtn.innerHTML = '×';
ps_closeBtn.onclick = hidePlatiModal;
ps_modal.appendChild(ps_closeBtn);
document.body.appendChild(ps_modal);
// Назначение переменных элементам UI
ps_filterPriceMin = document.getElementById('psFilterPriceMin');
ps_filterPriceMax = document.getElementById('psFilterPriceMax');
ps_filterSalesMin = document.getElementById('psFilterSalesMin');
ps_filterSalesMax = document.getElementById('psFilterSalesMax');
ps_filterRatingMin = document.getElementById('psFilterRatingMin');
ps_filterRatingMax = document.getElementById('psFilterRatingMax');
ps_filterHideBadReviews = document.getElementById('psFilterHideBadReviews');
ps_filterHideReturns = document.getElementById('psFilterHideReturns');
ps_filterOnlyDiscount = document.getElementById('psFilterOnlyDiscount');
ps_filterDateSelect = document.getElementById('psFilterDateSelect');
ps_resetAllFiltersBtn = document.getElementById('psResetAllFiltersBtn');
ps_addFilterEventListeners();
applyLoadedFiltersToUI();
ps_updateSortButtonsState(); // Устанавливаем начальное состояние кнопок сортировки
function handleEsc(event) { if (event.key === 'Escape') hidePlatiModal(); }
document.addEventListener('keydown', handleEsc);
ps_modal._escHandler = handleEsc;
}
function ps_createResetButtonHTML(filterKey) {
return `<button class="filterResetBtn" title="Сбросить фильтр" data-filter-key="${filterKey}"><svg viewBox="0 0 24 24"><path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"></path></svg></button>`;
}
// --- Управление Модальным Окном ---
function showPlatiModal() {
if (!ps_modal) createPlatiModal();
const gameName = getSteamGameName();
if (gameName && !ps_searchInput.value) { ps_searchInput.value = gameName; }
document.body.style.overflow = 'hidden';
ps_modal.style.display = 'block';
ps_modal.scrollTop = 0;
ps_renderExclusionTags();
applyLoadedFiltersToUI();
ps_updateFilterPlaceholders();
ps_updateSortButtonsState();
requestAnimationFrame(() => {
const header = document.getElementById('platiSearchHeader');
const headerRect = header ? header.getBoundingClientRect() : { bottom: PS_TOP_OFFSET_FOR_SIDE_PANELS };
const newTopOffset = headerRect.bottom + 5;
const availableHeight = `calc(100vh - ${newTopOffset}px - ${PS_BOTTOM_OFFSET_FOR_SIDE_PANELS}px)`;
if (ps_filtersPanel) { ps_filtersPanel.style.top = `${newTopOffset}px`; ps_filtersPanel.style.maxHeight = availableHeight;}
if (ps_exclusionTagsDiv) { ps_exclusionTagsDiv.style.top = `${newTopOffset}px`; ps_exclusionTagsDiv.style.maxHeight = availableHeight; }
});
if (ps_searchInput.value.trim()) { ps_triggerSearch(); }
else { ps_updateStatus('Введите запрос для поиска.'); }
}
function hidePlatiModal() {
if (ps_modal) {
ps_modal.style.display = 'none';
if (ps_suggestionsDiv) ps_suggestionsDiv.style.display = 'none';
if (ps_modal._escHandler) { document.removeEventListener('keydown', ps_modal._escHandler); delete ps_modal._escHandler; }
}
document.body.style.overflow = '';
}
// --- Обновление статуса ---
function ps_updateStatus(message, isLoading = false) {
if (ps_statusDiv) {
ps_statusDiv.innerHTML = message + (isLoading ? ' <span class="spinner"></span>' : '');
ps_statusDiv.style.display = 'block';
if(ps_currentResults.length === 0 && message && !isLoading) {
ps_resultsDiv.innerHTML = '';
}
}
}
// --- Запуск поиска ---
function ps_triggerSearch() {
const query = ps_searchInput.value.trim();
if (ps_suggestionsDiv) ps_suggestionsDiv.style.display = 'none';
if (!query) {
ps_updateStatus('Пожалуйста, введите запрос.');
ps_currentResults = []; ps_renderResults(); return;
}
ps_currentResults = [];
ps_resetSort(false); // Сброс на релевантность без рендера
applyLoadedFiltersToUI();
ps_renderResults();
ps_updateStatus('Получение общего количества товаров...', true);
ps_fetchTotalCount(query);
}
// --- Функции подсказок ---
function ps_fetchSuggestions(query) {
const trimmedQuery = query.trim();
if (trimmedQuery.length < 2) {
if (ps_suggestionsDiv) { ps_suggestionsDiv.innerHTML = ''; ps_suggestionsDiv.style.display = 'none'; } return;
}
const params = new URLSearchParams({ q: trimmedQuery, v: 2 });
try { if (typeof plang !== 'undefined') params.append('lang', plang); if (typeof clientgeo !== 'undefined') params.append('geo', clientgeo); }
catch (e) { console.warn("PlatiSearch: Could not get plang/clientgeo for suggestions."); }
GM_xmlhttpRequest({
method: "GET", url: `${PS_SUGGEST_API_URL}?${params.toString()}`, timeout: 5000,
onload: function(response) {
try { ps_renderSuggestions(JSON.parse(response.responseText)); }
catch (e) { if (ps_suggestionsDiv) { ps_suggestionsDiv.innerHTML = ''; ps_suggestionsDiv.style.display = 'none'; } }
},
onerror: function(error) { if (ps_suggestionsDiv) { ps_suggestionsDiv.innerHTML = ''; ps_suggestionsDiv.style.display = 'none'; } },
ontimeout: function() { if (ps_suggestionsDiv) { ps_suggestionsDiv.innerHTML = ''; ps_suggestionsDiv.style.display = 'none'; } }
});
}
function ps_renderSuggestions(suggestions) {
if (!ps_suggestionsDiv) return;
if (!suggestions || !Array.isArray(suggestions) || suggestions.length === 0) {
ps_suggestionsDiv.innerHTML = ''; ps_suggestionsDiv.style.display = 'none'; return;
}
ps_suggestionsDiv.innerHTML = '';
let addedSuggestions = 0;
suggestions.forEach(suggestion => {
if (suggestion && suggestion.name && (suggestion.type === "Товары" || suggestion.type === "Search" || suggestion.type === "Игры")) {
const item = document.createElement('div');
item.className = 'suggestionItem';
item.textContent = suggestion.name;
item.onmousedown = (e) => {
e.preventDefault(); ps_searchInput.value = suggestion.name;
ps_suggestionsDiv.style.display = 'none'; ps_triggerSearch();
};
ps_suggestionsDiv.appendChild(item);
addedSuggestions++;
}
});
ps_suggestionsDiv.style.display = addedSuggestions > 0 ? 'block' : 'none';
}
// --- Запросы API ---
function ps_fetchTotalCount(query) {
// При запросе количества всегда используем сортировку по релевантности (дефолт API)
const params = new URLSearchParams({
query: query, searchmode: 10, sortmode: PS_DEFAULT_SORT_MODE,
pagesize: PS_RESULTS_PER_PAGE_CHECK, pagenum: 1, owner: 1,
details: 1, checkhidesales: 1, host: 'plati.market'
});
GM_xmlhttpRequest({
method: "GET", url: `${PS_API_BASE_URL}?${params.toString()}`, timeout: 15000, responseType: 'json',
onload: function(response) {
if (response.status >= 200 && response.status < 400 && response.response) {
const data = response.response;
if (data?.result?.total > 0) {
const total = data.result.total;
ps_updateStatus(`Найдено ${total} товаров. Загрузка...`, true);
// Загружаем все результаты с дефолтной сортировкой API (релевантность)
ps_fetchAllResults(query, total, PS_DEFAULT_SORT_MODE);
} else {
ps_updateStatus(`По запросу "${query}" ничего не найдено.`);
ps_currentResults = []; ps_renderResults(); ps_updateFilterPlaceholders(); ps_applyFilters();
}
} else { ps_updateStatus(`Ошибка получения общего количества товаров (Статус: ${response.status})`); }
},
onerror: function(error) { ps_updateStatus('Ошибка сети при получении общего количества товаров.'); },
ontimeout: function() { ps_updateStatus('Время ожидания ответа от сервера (количество) истекло.'); }
});
}
function ps_fetchAllResults(query, total, sortMode) {
const MAX_PAGE_SIZE = 1000;
const effectivePageSize = Math.min(total, MAX_PAGE_SIZE);
if (total > MAX_PAGE_SIZE) ps_updateStatus(`Найдено ${total} товаров. Загрузка первых ${MAX_PAGE_SIZE}...`, true);
const params = new URLSearchParams({
query: query, searchmode: 10, sortmode: sortMode, pagesize: effectivePageSize,
pagenum: 1, owner: 1, details: 1, checkhidesales: 1, host: 'plati.market'
});
GM_xmlhttpRequest({
method: "GET", url: `${PS_API_BASE_URL}?${params.toString()}`, timeout: 90000, responseType: 'json',
onload: function(response) {
// Проверяем, открыто ли еще модальное окно
if (!document.body.contains(ps_modal)) return;
if (response.status >= 200 && response.status < 400 && response.response) {
const data = response.response;
if (data?.items?.item && Array.isArray(data.items.item)) {
ps_currentResults = data.items.item.map((item, index) => ({ ...item, originalIndex: index }));
const loadedCount = ps_currentResults.length;
ps_updateStatus(`Загружено ${loadedCount}${total > loadedCount ? ` из ${total}` : ''} товаров.`);
// После загрузки применяем ТЕКУЩУЮ выбранную пользователем сортировку
ps_applySort(ps_currentSort.field, ps_currentSort.direction);
ps_renderResults();
ps_updateFilterPlaceholders();
ps_applyFilters();
} else {
ps_updateStatus(`Ошибка загрузки товаров: неверный формат ответа API.`);
ps_currentResults = []; ps_renderResults(); ps_updateFilterPlaceholders(); ps_applyFilters();
}
} else { ps_updateStatus(`Ошибка загрузки товаров (Статус: ${response.status})`); }
},
onerror: function(error) { if (document.body.contains(ps_modal)) ps_updateStatus('Ошибка сети при загрузке товаров.'); },
ontimeout: function() { if (document.body.contains(ps_modal)) ps_updateStatus('Время ожидания ответа от сервера (товары) истекло.'); }
});
}
// --- Сортировка ---
function ps_handleSort(field) {
let newDirection;
const sortInfo = ps_advancedSorts[field];
if (!sortInfo) return; // Неизвестное поле сортировки
// Определяем текущее направление из сохраненного состояния
let currentDir = (ps_currentSort.field === field) ? ps_currentSort.direction : sortInfo.defaultDir;
// Определяем новое направление
if (ps_firstSortClick[field] || ps_currentSort.field !== field) {
// Если это первый клик по этому полю или клик по новому полю, используем дефолтное направление
newDirection = sortInfo.defaultDir;
} else {
// Иначе инвертируем текущее направление
newDirection = currentDir === 'desc' ? 'asc' : 'desc';
}
// Обновляем флаги первого клика
Object.keys(ps_firstSortClick).forEach(key => {
ps_firstSortClick[key] = (key !== field);
});
ps_firstSortClick[field] = false; // Устанавливаем, что это уже не первый клик
// Сохраняем новое состояние
ps_currentSort.field = field;
ps_currentSort.direction = newDirection;
GM_setValue(PS_LAST_SORT_STORAGE_KEY, ps_currentSort);
// Применяем сортировку КЛИЕНТСКИ к уже загруженным данным
ps_applySort(field, newDirection);
ps_renderResults(); // Перерисовываем с новой сортировкой
ps_updateSortButtonsState(); // Обновляем UI кнопок
}
function ps_updateSortButtonsState() {
const activeField = ps_currentSort.field;
const activeDirection = ps_currentSort.direction;
// Основные кнопки (Цена, Продажи)
$(ps_sortPriceBtn).add(ps_sortSalesBtn).each(function() {
const $btn = $(this);
const btnField = $btn.data('sort');
const baseText = (btnField === 'price') ? 'Цена' : 'Продажи';
if (btnField === activeField) {
const arrow = activeDirection === 'asc' ? ' ▲' : ' ▼';
$btn.addClass('active').text(baseText + arrow).attr('data-dir', activeDirection);
} else {
const defaultDir = ps_advancedSorts[btnField].defaultDir;
const defaultArrow = defaultDir === 'asc' ? ' ▲' : ' ▼';
$btn.removeClass('active').text(baseText + defaultArrow).attr('data-dir', defaultDir);
}
});
// Кнопка и меню доп. сортировки
let advBtnText = 'Доп. сорт.';
const $advButton = $(ps_advSortBtn);
const isAdvSortActive = ps_advancedSorts[activeField] && activeField !== 'price' && activeField !== 'sales' && activeField !== 'relevance';
if (isAdvSortActive) {
$advButton.addClass('active');
const arrow = activeDirection === 'asc' ? ' ▲' : ' ▼';
advBtnText = `${ps_advancedSorts[activeField].name}${arrow}`;
} else {
$advButton.removeClass('active');
}
$advButton.text(advBtnText);
// Пункты меню
$('#platiSearchAdvSortMenu .platiSearchSortMenuItem').each(function() {
const $item = $(this);
const itemField = $item.data('sort');
const baseText = ps_advancedSorts[itemField].name;
if (itemField === activeField) {
const arrow = activeDirection === 'asc' ? ' ▲' : ' ▼';
$item.addClass('active').html(`${baseText} <span class="sortArrow">${arrow}</span>`).attr('data-dir', activeDirection);
} else {
const defaultDir = ps_advancedSorts[itemField].defaultDir;
const defaultArrow = defaultDir === 'asc' ? ' ▲' : ' ▼';
$item.removeClass('active').html(`${baseText} <span class="sortArrow">${defaultArrow}</span>`).attr('data-dir', defaultDir);
}
});
// Кнопка сброса показывает релевантность
if (activeField === 'relevance') {
$(ps_resetSortBtn).addClass('active');
} else {
$(ps_resetSortBtn).removeClass('active');
}
}
function ps_resetSort(render = true) {
ps_currentSort = { field: 'relevance', direction: 'asc' }; // Релевантность - это исходный порядок API
ps_firstSortClick = {
price: true, sales: true, relevance: false, name: true, date_create: true, discount: true,
seller_rating: true, review_ratio: true, good_reviews: true, bad_reviews: true, returns: true
};
GM_setValue(PS_LAST_SORT_STORAGE_KEY, ps_currentSort);
ps_updateSortButtonsState();
if (render) {
ps_applySort(ps_currentSort.field, ps_currentSort.direction);
ps_renderResults();
}
}
function ps_applySort(field, direction) {
const dirMultiplier = direction === 'asc' ? 1 : -1;
const selectedCurrency = ps_currencySelect ? ps_currencySelect.value.toUpperCase() : 'RUR';
ps_currentResults.sort((a, b) => {
let valA, valB;
const nameA = (a.name || '').toLowerCase();
const nameB = (b.name || '').toLowerCase();
const finalPriceA = getPriceInSelectedCurrency(a, selectedCurrency);
const finalPriceB = getPriceInSelectedCurrency(b, selectedCurrency);
let comparisonResult = 0;
switch (field) {
case 'price': valA = finalPriceA; valB = finalPriceB; break;
case 'sales': valA = formatSales(a.cnt_sell); valB = formatSales(b.cnt_sell); break;
case 'name': comparisonResult = nameA.localeCompare(nameB) * dirMultiplier; break;
case 'date_create': valA = parseDate(a.date_create); valB = parseDate(b.date_create); break;
case 'discount': valA = parseInt(a.discount || '0', 10); valB = parseInt(b.discount || '0', 10); break;
case 'seller_rating': valA = parseSellerRating(a.seller_rating); valB = parseSellerRating(b.seller_rating); break;
case 'review_ratio': valA = calculateReviewRatio(a); valB = calculateReviewRatio(b); break;
case 'good_reviews': valA = parseInt(a.cnt_good_responses || '0', 10); valB = parseInt(b.cnt_good_responses || '0', 10); break;
case 'bad_reviews': valA = parseInt(a.cnt_bad_responses || '0', 10); valB = parseInt(b.cnt_bad_responses || '0', 10); break;
case 'returns': valA = parseInt(a.cnt_return || '0', 10); valB = parseInt(b.cnt_return || '0', 10); break;
case 'relevance': valA = a.originalIndex; valB = b.originalIndex; break;
default: return 0;
}
if (field !== 'name') {
const fallbackAsc = Infinity; const fallbackDesc = -Infinity;
if (valA === null || valA === undefined || isNaN(valA) || valA === Infinity || valA === -Infinity) valA = direction === 'asc' ? fallbackAsc : fallbackDesc;
if (valB === null || valB === undefined || isNaN(valB) || valB === Infinity || valB === -Infinity) valB = direction === 'asc' ? fallbackAsc : fallbackDesc;
if (valA < valB) comparisonResult = -1; else if (valA > valB) comparisonResult = 1; else comparisonResult = 0;
comparisonResult *= dirMultiplier;
}
// Вторичная сортировка для стабильности
if (comparisonResult === 0) {
if (field !== 'name') { let nameCompare = nameA.localeCompare(nameB); if (nameCompare !== 0) return nameCompare; }
if (field !== 'price') { if (finalPriceA < finalPriceB) return -1; if (finalPriceA > finalPriceB) return 1; }
if (field !== 'relevance') { return a.originalIndex - b.originalIndex; }
}
return comparisonResult;
});
}
// --- Управление Фильтрами ---
function ps_getFilterStorageKey(key) { return `${PS_FILTER_STORAGE_PREFIX}${key}`; }
function ps_loadFilters() {
const defaults = { priceMin: '', priceMax: '', salesMin: '', salesMax: '', ratingMin: '', ratingMax: '', hideBadReviews: false, hideReturns: false, onlyDiscount: false, date: 'all' };
let loaded = {};
for (const key in defaults) { loaded[key] = GM_getValue(ps_getFilterStorageKey(key), defaults[key]); }
return loaded;
}
function ps_saveFilter(key, value) { ps_currentFilters[key] = value; GM_setValue(ps_getFilterStorageKey(key), value); }
function applyLoadedFiltersToUI() {
if (!ps_filtersPanel) return;
ps_filterPriceMin.value = ps_currentFilters.priceMin; ps_filterPriceMax.value = ps_currentFilters.priceMax;
ps_filterSalesMin.value = ps_currentFilters.salesMin; ps_filterSalesMax.value = ps_currentFilters.salesMax;
ps_filterRatingMin.value = ps_currentFilters.ratingMin; ps_filterRatingMax.value = ps_currentFilters.ratingMax;
ps_filterHideBadReviews.checked = ps_currentFilters.hideBadReviews; ps_filterHideReturns.checked = ps_currentFilters.hideReturns;
ps_filterOnlyDiscount.checked = ps_currentFilters.onlyDiscount; ps_filterDateSelect.value = ps_currentFilters.date;
const priceHeader = ps_filtersPanel.querySelector('.filterGroup h4');
if (priceHeader && priceHeader.textContent.includes('Цена')) {
priceHeader.innerHTML = `Цена (${ps_currentCurrency}) ${ps_createResetButtonHTML('price')}`;
const resetButton = priceHeader.querySelector('.filterResetBtn');
if (resetButton) resetButton.onclick = ps_handleFilterReset;
}
}
function ps_addFilterEventListeners() {
if (!ps_filtersPanel) return;
const debouncedApply = debounce(ps_applyFilters, PS_FILTER_DEBOUNCE_MS);
ps_filterPriceMin.addEventListener('input', (e) => { ps_saveFilter('priceMin', e.target.value); debouncedApply(); });
ps_filterPriceMax.addEventListener('input', (e) => { ps_saveFilter('priceMax', e.target.value); debouncedApply(); });
ps_filterSalesMin.addEventListener('input', (e) => { ps_saveFilter('salesMin', e.target.value); debouncedApply(); });
ps_filterSalesMax.addEventListener('input', (e) => { ps_saveFilter('salesMax', e.target.value); debouncedApply(); });
ps_filterRatingMin.addEventListener('input', (e) => { ps_saveFilter('ratingMin', e.target.value); debouncedApply(); });
ps_filterRatingMax.addEventListener('input', (e) => { ps_saveFilter('ratingMax', e.target.value); debouncedApply(); });
ps_filterHideBadReviews.addEventListener('change', (e) => { ps_saveFilter('hideBadReviews', e.target.checked); ps_applyFilters(); });
ps_filterHideReturns.addEventListener('change', (e) => { ps_saveFilter('hideReturns', e.target.checked); ps_applyFilters(); });
ps_filterOnlyDiscount.addEventListener('change', (e) => { ps_saveFilter('onlyDiscount', e.target.checked); ps_applyFilters(); });
ps_filterDateSelect.addEventListener('change', (e) => { ps_saveFilter('date', e.target.value); ps_applyFilters(); });
ps_resetAllFiltersBtn.addEventListener('click', () => ps_resetAllFilters(true));
ps_filtersPanel.querySelectorAll('.filterResetBtn').forEach(btn => { btn.onclick = ps_handleFilterReset; });
}
function ps_handleFilterReset(event) { ps_resetFilterByKey(event.currentTarget.dataset.filterKey, true); }
function ps_resetFilterByKey(key, apply = true) {
switch (key) {
case 'price': ps_saveFilter('priceMin', ''); if (ps_filterPriceMin) ps_filterPriceMin.value = ''; ps_saveFilter('priceMax', ''); if (ps_filterPriceMax) ps_filterPriceMax.value = ''; break;
case 'sales': ps_saveFilter('salesMin', ''); if (ps_filterSalesMin) ps_filterSalesMin.value = ''; ps_saveFilter('salesMax', ''); if (ps_filterSalesMax) ps_filterSalesMax.value = ''; break;
case 'rating': ps_saveFilter('ratingMin', ''); if (ps_filterRatingMin) ps_filterRatingMin.value = ''; ps_saveFilter('ratingMax', ''); if (ps_filterRatingMax) ps_filterRatingMax.value = ''; break;
case 'options': ps_saveFilter('hideBadReviews', false); if (ps_filterHideBadReviews) ps_filterHideBadReviews.checked = false; ps_saveFilter('hideReturns', false); if (ps_filterHideReturns) ps_filterHideReturns.checked = false; ps_saveFilter('onlyDiscount', false); if (ps_filterOnlyDiscount) ps_filterOnlyDiscount.checked = false; break;
case 'date': ps_saveFilter('date', 'all'); if (ps_filterDateSelect) ps_filterDateSelect.value = 'all'; break;
}
if (apply) ps_applyFilters();
}
function ps_resetAllFilters(apply = true) {
const filterKeys = ['price', 'sales', 'rating', 'options', 'date'];
filterKeys.forEach(key => ps_resetFilterByKey(key, false));
if (apply) ps_applyFilters();
}
function ps_updateFilterPlaceholders() {
if (!ps_filtersPanel || !ps_currentResults || ps_currentResults.length === 0) {
$('#psFilterPriceMin, #psFilterPriceMax, #psFilterSalesMin, #psFilterSalesMax, #psFilterRatingMin, #psFilterRatingMax').attr('placeholder', '-'); return;
}
let minPrice = Infinity, maxPrice = -Infinity, minSales = Infinity, maxSales = -Infinity, minRating = Infinity, maxRating = -Infinity;
const selectedCurrency = ps_currencySelect ? ps_currencySelect.value.toUpperCase() : 'RUR';
ps_currentResults.forEach(item => {
const price = getPriceInSelectedCurrency(item, selectedCurrency); const sales = formatSales(item.cnt_sell); const rating = parseSellerRating(item.seller_rating);
if (price !== Infinity && price < minPrice) minPrice = price; if (price !== Infinity && price > maxPrice) maxPrice = price;
if (sales < minSales) minSales = sales; if (sales > maxSales) maxSales = sales;
if (rating > 0 && rating < minRating) minRating = rating; if (rating > maxRating) maxRating = rating;
});
if (minRating === Infinity) minRating = 0;
if (ps_filterPriceMin) ps_filterPriceMin.placeholder = minPrice === Infinity ? '-' : `от ${Math.floor(minPrice)}`; if (ps_filterPriceMax) ps_filterPriceMax.placeholder = maxPrice === -Infinity ? '-' : `до ${Math.ceil(maxPrice)}`;
if (ps_filterSalesMin) ps_filterSalesMin.placeholder = minSales === Infinity ? '-' : `от ${minSales}`; if (ps_filterSalesMax) ps_filterSalesMax.placeholder = maxSales === -Infinity ? '-' : `до ${maxSales}`;
if (ps_filterRatingMin) ps_filterRatingMin.placeholder = minRating === Infinity ? '-' : `от ${minRating.toFixed(1)}`; if (ps_filterRatingMax) ps_filterRatingMax.placeholder = maxRating === -Infinity ? '-' : `до ${maxRating.toFixed(1)}`;
}
function ps_getDateThreshold(periodKey) {
const now = Date.now(); let threshold = 0; const dayMs = 86400000;
switch (periodKey) {
case '1d': threshold = now - 1 * dayMs; break; case '2d': threshold = now - 2 * dayMs; break; case '1w': threshold = now - 7 * dayMs; break;
case '1m': threshold = now - 30 * dayMs; break; case '6m': threshold = now - 182 * dayMs; break; case '1y': threshold = now - 365 * dayMs; break;
case '5y': threshold = now - 5 * 365 * dayMs; break; case '10y': threshold = now - 10 * 365 * dayMs; break; default: threshold = 0; break;
} return threshold;
}
function ps_applyFilters() {
if (!ps_resultsDiv || !ps_currentResults) return;
const keywords = ps_exclusionKeywords.map(k => k.toLowerCase());
const pMin = parseFloat(ps_currentFilters.priceMin) || 0; const pMax = parseFloat(ps_currentFilters.priceMax) || Infinity;
const sMin = parseInt(ps_currentFilters.salesMin, 10) || 0; const sMax = parseInt(ps_currentFilters.salesMax, 10) || Infinity;
const rMin = parseFloat(ps_currentFilters.ratingMin) || 0; const rMax = parseFloat(ps_currentFilters.ratingMax) || Infinity;
const hideBad = ps_currentFilters.hideBadReviews; const hideRet = ps_currentFilters.hideReturns; const onlyDisc = ps_currentFilters.onlyDiscount;
const datePeriod = ps_currentFilters.date; const dateThreshold = ps_getDateThreshold(datePeriod);
const selectedCurrency = ps_currencySelect ? ps_currencySelect.value.toUpperCase() : 'RUR';
let visibleCount = 0;
const items = ps_resultsDiv.querySelectorAll('.platiSearchItem');
items.forEach(itemElement => {
const itemId = itemElement.dataset.id; const itemData = ps_currentResults.find(r => r.id === itemId);
if (!itemData) { itemElement.classList.add('hidden-by-filter'); return; }
let shouldHide = false;
if (!shouldHide && keywords.length > 0) { const title = (itemData.name || '').toLowerCase(); const seller = (itemData.seller_name || '').toLowerCase(); if (keywords.some(keyword => (title + ' ' + seller).includes(keyword))) { shouldHide = true; } }
if (!shouldHide) { const price = getPriceInSelectedCurrency(itemData, selectedCurrency); if (price < pMin || price > pMax) { shouldHide = true; } }
if (!shouldHide) { const sales = formatSales(itemData.cnt_sell); if (sales < sMin || sales > sMax) { shouldHide = true; } }
if (!shouldHide) { const rating = parseSellerRating(itemData.seller_rating); if ((rating === 0 && (rMin > 0 || rMax < Infinity)) || rating < rMin || rating > rMax) { shouldHide = true; } }
if (!shouldHide && hideBad) { if (parseInt(itemData.cnt_bad_responses || '0', 10) > 0) { shouldHide = true; } }
if (!shouldHide && hideRet) { if (parseInt(itemData.cnt_return || '0', 10) > 0) { shouldHide = true; } }
if (!shouldHide && onlyDisc) { if (parseInt(itemData.discount || '0', 10) <= 0) { shouldHide = true; } }
if (!shouldHide && dateThreshold > 0) { const itemDate = parseDate(itemData.date_create); if (!itemDate || itemDate < dateThreshold) { shouldHide = true; } }
if (shouldHide) { itemElement.classList.add('hidden-by-filter'); } else { itemElement.classList.remove('hidden-by-filter'); visibleCount++; }
});
const totalLoadedCount = ps_currentResults.length;
const anyFilterActive = pMin > 0 || pMax < Infinity || sMin > 0 || sMax < Infinity || rMin > 0 || rMax < Infinity || hideBad || hideRet || onlyDisc || datePeriod !== 'all' || keywords.length > 0;
if (totalLoadedCount > 0) {
if (anyFilterActive) { ps_updateStatus(`Показано ${visibleCount} из ${totalLoadedCount} товаров (фильтры/исключения применены).`); }
else { ps_updateStatus(`Загружено ${totalLoadedCount} товаров.`); }
} else if (ps_searchInput && ps_searchInput.value.trim()){ /* Статус уже должен быть установлен */ }
else { ps_updateStatus(`Введите запрос для поиска.`); }
if (visibleCount === 0 && totalLoadedCount > 0 && anyFilterActive) { ps_statusDiv.textContent += ' Нет товаров, соответствующих критериям.'; ps_statusDiv.style.display = 'block'; }
else if (totalLoadedCount === 0 && ps_searchInput && ps_searchInput.value.trim()) { ps_statusDiv.style.display = 'block'; }
}
// --- Фильтрация Исключений ---
function ps_addFilterKeyword() {
const keyword = ps_excludeInput.value.trim().toLowerCase();
if (keyword && !ps_exclusionKeywords.includes(keyword)) {
ps_exclusionKeywords.push(keyword); GM_setValue(PS_EXCLUSION_STORAGE_KEY, ps_exclusionKeywords);
ps_excludeInput.value = ''; ps_renderExclusionTags(); ps_applyFilters();
}
}
function ps_removeFilterKeyword(keywordToRemove) {
ps_exclusionKeywords = ps_exclusionKeywords.filter(k => k !== keywordToRemove);
GM_setValue(PS_EXCLUSION_STORAGE_KEY, ps_exclusionKeywords); ps_renderExclusionTags(); ps_applyFilters();
}
function ps_renderExclusionTags() {
if (!ps_exclusionTagsListDiv) return;
ps_exclusionTagsListDiv.innerHTML = '';
ps_exclusionKeywords.forEach(keyword => {
const tag = document.createElement('span'); tag.className = 'exclusionTag';
tag.textContent = keyword; tag.title = `Удалить "${keyword}"`;
tag.onclick = () => ps_removeFilterKeyword(keyword); ps_exclusionTagsListDiv.appendChild(tag);
});
}
// --- Рендеринг Результатов ---
function ps_renderResults() {
if (!ps_resultsDiv) return;
ps_resultsDiv.innerHTML = '';
if (ps_currentResults.length === 0) { ps_applyFilters(); return; }
const fragment = document.createDocumentFragment();
const now = Date.now();
const thresholdTime = now - NEW_ITEM_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
const selectedCurrency = ps_currencySelect ? ps_currencySelect.value.toUpperCase() : 'RUR';
ps_currentResults.forEach(item => {
const itemDiv = document.createElement('div'); itemDiv.className = 'platiSearchItem'; itemDiv.dataset.id = item.id;
const link = document.createElement('a'); link.href = item.url || `https://plati.market/itm/${item.id}`; link.target = '_blank'; link.rel = 'noopener noreferrer nofollow';
const imageWrapper = document.createElement('div'); imageWrapper.className = 'card-image-wrapper';
const img = document.createElement('img'); const imgSrc = `https://${PS_IMAGE_DOMAIN}/imgwebp.ashx?id_d=${item.id}&w=164&h=164&dc=${item.ticks_last_change || Date.now()}`;
img.src = imgSrc; img.alt = item.name || 'Изображение товара'; img.loading = 'lazy';
img.onerror = function() { this.onerror = null; this.src = 'https://plati.market/images/logo-plati.png'; this.style.objectFit = 'contain'; };
imageWrapper.appendChild(img);
const itemDate = parseDate(item.date_create);
if (itemDate && itemDate > thresholdTime) { const newBadge = document.createElement('span'); newBadge.className = 'newItemBadge'; newBadge.textContent = 'New'; imageWrapper.appendChild(newBadge); }
link.appendChild(imageWrapper);
const priceDiv = document.createElement('div'); priceDiv.className = 'price'; let displayPrice = getPriceInSelectedCurrency(item, selectedCurrency); let currencySymbol;
switch (selectedCurrency) { case 'USD': currencySymbol = '$'; break; case 'EUR': currencySymbol = '€'; break; case 'UAH': currencySymbol = '₴'; break; default: currencySymbol = '₽'; break; }
priceDiv.textContent = displayPrice !== Infinity ? `${displayPrice.toLocaleString('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 2})} ${currencySymbol}` : 'Нет цены'; priceDiv.title = `Цена в ${selectedCurrency}`; link.appendChild(priceDiv);
const titleDiv = document.createElement('div'); titleDiv.className = 'title'; titleDiv.textContent = item.name || 'Без названия'; titleDiv.title = item.name || 'Без названия'; link.appendChild(titleDiv);
const infoContainer = document.createElement('div'); infoContainer.className = 'cardInfoContainer';
const infoRow1 = document.createElement('div'); infoRow1.className = 'cardInfoRow1'; const infoRow2 = document.createElement('div'); infoRow2.className = 'cardInfoRow2';
const ratingVal = parseSellerRating(item.seller_rating); const goodRev = parseInt(item.cnt_good_responses || '0'); const badRev = parseInt(item.cnt_bad_responses || '0'); const returns = parseInt(item.cnt_return || '0'); let salesCount = formatSales(item.cnt_sell);
infoRow1.innerHTML = `<span title="Рейтинг продавца">Рейт: ${ratingVal > 0 ? ratingVal.toLocaleString('ru-RU', {maximumFractionDigits: 0}) : 'N/A'}</span><span title="Отзывы (Хорошие/Плохие)">Отз: <span class="reviewsGood">${goodRev}</span>${badRev > 0 ? '/<span class="reviewsBad">' + badRev + '</span>' : ''}</span><span title="Возвраты">Возв: ${returns}</span>`;
infoRow2.innerHTML = `<span class="sales" title="Продажи">Прод: ${salesCount > 0 ? salesCount.toLocaleString('ru-RU') : '0'}</span><span class="dateAdded" title="Дата добавления">Доб: ${formatDateString(itemDate)}</span>`;
infoContainer.appendChild(infoRow1); infoContainer.appendChild(infoRow2);
const sellerLink = document.createElement('a'); sellerLink.className = 'sellerLink'; sellerLink.textContent = `Продавец: ${item.seller_name || 'N/A'}`; sellerLink.title = `Перейти к продавцу: ${item.seller_name || 'N/A'}`;
if (item.seller_id && item.seller_name) { const safeSellerName = encodeURIComponent(item.seller_name.replace(/[^a-zA-Z0-9_\-.~]/g, '-')).replace(/%2F/g, '/'); sellerLink.href = `https://plati.market/seller/${safeSellerName}/${item.seller_id}/`; sellerLink.target = '_blank'; sellerLink.rel = 'noopener noreferrer nofollow'; sellerLink.onclick = (e) => { e.stopPropagation(); }; }
else { sellerLink.style.pointerEvents = 'none'; }
infoContainer.appendChild(sellerLink); link.appendChild(infoContainer);
const buyButtonDiv = document.createElement('div'); buyButtonDiv.className = 'buyButton'; buyButtonDiv.textContent = 'Перейти'; link.appendChild(buyButtonDiv);
itemDiv.appendChild(link); fragment.appendChild(itemDiv);
});
ps_resultsDiv.appendChild(fragment);
ps_applyFilters();
}
// --- Обработчики UI ---
function ps_handleCurrencyChange() {
ps_currentCurrency = ps_currencySelect.value.toUpperCase(); GM_setValue(PS_CURRENCY_STORAGE_KEY, ps_currentCurrency);
applyLoadedFiltersToUI(); ps_updateFilterPlaceholders();
if (ps_currentSort.field === 'price') { ps_applySort(ps_currentSort.field, ps_currentSort.direction); }
ps_renderResults();
}
// --- Добавление кнопки Plati ---
function addPlatiButton() {
const actionsContainer = document.querySelector('#queueActionsCtn');
const ignoreButtonContainer = actionsContainer?.querySelector('#ignoreBtn');
if (!actionsContainer || !ignoreButtonContainer || actionsContainer.querySelector('.plati_price_button')) return;
const platiContainer = document.createElement('div');
platiContainer.className = 'plati_price_button queue_control_button';
platiContainer.style.marginLeft = '3px';
platiContainer.innerHTML = `<div class="btnv6_blue_hoverfade btn_medium" style="height: 32px;" title="Найти на Plati.Market"><span>Plati</span></div>`;
platiContainer.querySelector('div').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); showPlatiModal(); });
ignoreButtonContainer.insertAdjacentElement('afterend', platiContainer);
}
// --- Стили ---
function addPlatiStyles() {
GM_addStyle(`
/* Стили Спиннера */
@keyframes platiSpin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.spinner { border: 3px solid rgba(255, 255, 255, 0.3); border-radius: 50%; border-top-color: #fff; width: 1em; height: 1em; animation: platiSpin 1s linear infinite; display: inline-block; vertical-align: middle; margin-left: 5px; }
.platiSearchBtn .spinner { width: 0.8em; height: 0.8em; border-width: 2px; }
/* Стили Модального окна */
#platiSearchModal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(20, 20, 25, 0.98); z-index: 9999; display: none; color: #eee; font-family: "Motiva Sans", Sans-serif, Arial; overflow-y: auto; scrollbar-color: #67c1f5 #17202d; scrollbar-width: thin; }
#platiSearchModal::-webkit-scrollbar { width: 8px; } #platiSearchModal::-webkit-scrollbar-track { background: #17202d; border-radius: 4px; } #platiSearchModal::-webkit-scrollbar-thumb { background-color: #4b6f9c; border-radius: 4px; border: 2px solid #17202d; } #platiSearchModal::-webkit-scrollbar-thumb:hover { background-color: #67c1f5; }
#platiSearchModal * { box-sizing: border-box; }
#platiSearchContainer { max-width: 1350px; margin: 0 auto; padding: 15px ${PS_SIDE_PANEL_HORIZONTAL_PADDING}px; position: relative; min-height: 100%; }
#platiSearchCloseBtn { position: fixed; top: 15px; right: 20px; font-size: 35px; color: #aaa; background: none; border: none; cursor: pointer; line-height: 1; z-index: 10002; padding: 5px; transition: color 0.2s, transform 0.2s; } #platiSearchCloseBtn:hover { color: #fff; transform: scale(1.1); }
/* Шапка */
#platiSearchHeader { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; position: relative; z-index: 5; border-bottom: 1px solid #444; padding-bottom: 15px; padding-left: ${PS_CONTENT_PADDING_LEFT}px; padding-right: ${PS_CONTENT_PADDING_RIGHT}px; margin-left: -${PS_CONTENT_PADDING_LEFT}px; margin-right: -${PS_CONTENT_PADDING_RIGHT}px; flex-shrink: 0; }
.platiSearchInputContainer { position: relative; flex-grow: 0.7; min-width: 200px; flex-basis: 350px; }
#platiSearchInput { width: 100%; padding: 10px 15px; font-size: 16px; background-color: #333; border: 1px solid #555; color: #eee; border-radius: 4px; height: 40px; outline: none; } #platiSearchInput:focus { border-color: #67c1f5; }
#platiSearchSuggestions { position: absolute; top: 100%; left: 0; right: 0; background-color: #3a3a40; border: 1px solid #555; border-top: none; border-radius: 0 0 4px 4px; max-height: 300px; overflow-y: auto; z-index: 10000; display: none; }
.suggestionItem { padding: 8px 15px; cursor: pointer; color: #eee; font-size: 14px; border-bottom: 1px solid #4a4a50; } .suggestionItem:last-child { border-bottom: none; } .suggestionItem:hover { background-color: #4a4a55; }
/* Кнопки в шапке */
.platiSearchBtn { padding: 10px 15px; font-size: 14px; color: white; border: none; border-radius: 4px; cursor: pointer; white-space: nowrap; height: 40px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; background-color: #555; transition: background-color 0.2s; } .platiSearchBtn:hover:not(:disabled) { background-color: #666; } .platiSearchBtn:disabled { opacity: 0.6; cursor: default; }
#platiSearchGoBtn { background-color: #4D88FF; } #platiSearchGoBtn:hover { background-color: #3366CC; }
.platiSearchBtn.sortBtn.active { background-color: #007bff; } .platiSearchBtn.sortBtn.active:hover { background-color: #0056b3; }
#platiResetSortBtn { background-color: #777; margin-right: 5px; padding: 0 10px; } #platiResetSortBtn:hover { background-color: #888; } #platiResetSortBtn svg { width: 16px; height: 16px; fill: currentColor; } #platiResetSortBtn.active { background-color: #007bff; }
#platiSearchAdvSortBtnContainer { position: relative; flex-shrink: 0; width: ${PS_ADV_SORT_CONTAINER_WIDTH}px; display: flex; justify-content: center; }
#platiSearchAdvSortBtn { width: 100%; justify-content: center; overflow: hidden; text-overflow: ellipsis; }
#platiSearchCurrencySelect { margin-left: 10px; background-color: #333; color: #eee; border: 1px solid #555; border-radius: 4px; height: 40px; padding: 0 8px; font-size: 14px; cursor: pointer; flex-shrink: 0; outline: none; } #platiSearchCurrencySelect:focus { border-color: #67c1f5; }
/* Меню доп сортировки */
#platiSearchAdvSortMenu { display: none; position: absolute; top: 100%; left: 0; background-color: #3a3a40; border: 1px solid #555; border-radius: 4px; min-width: 100%; z-index: 10001; padding: 5px 0; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); }
#platiSearchAdvSortBtnContainer:hover #platiSearchAdvSortMenu { display: block; }
.platiSearchSortMenuItem { display: block; padding: 8px 15px; color: #eee; font-size: 14px; cursor: pointer; white-space: nowrap; transition: background-color 0.1s; } .platiSearchSortMenuItem:hover { background-color: #4a4a55; } .platiSearchSortMenuItem.active { background-color: #007bff; color: white; } .platiSearchSortMenuItem .sortArrow { display: inline-block; margin-left: 5px; font-size: 12px; }
/* Боковые панели */
#platiSearchFiltersPanel, #platiSearchExclusionTags { position: fixed; top: ${PS_TOP_OFFSET_FOR_SIDE_PANELS}px; max-height: calc(100vh - ${PS_TOP_OFFSET_FOR_SIDE_PANELS}px - ${PS_BOTTOM_OFFSET_FOR_SIDE_PANELS}px); overflow-y: auto; z-index: 1000; padding: 10px; padding-right: 15px; scrollbar-width: thin; scrollbar-color: #555 #2a2a30; background-color: transparent; transition: top 0.2s ease-in-out; }
#platiSearchFiltersPanel::-webkit-scrollbar, #platiSearchExclusionTags::-webkit-scrollbar { width: 5px; } #platiSearchFiltersPanel::-webkit-scrollbar-track, #platiSearchExclusionTags::-webkit-scrollbar-track { background: rgba(42, 42, 48, 0.5); border-radius: 3px; } #platiSearchFiltersPanel::-webkit-scrollbar-thumb, #platiSearchExclusionTags::-webkit-scrollbar-thumb { background-color: rgba(85, 85, 85, 0.7); border-radius: 3px; }
#platiSearchFiltersPanel { left: ${PS_SIDE_PANEL_HORIZONTAL_PADDING}px; width: ${PS_FILTER_PANEL_WIDTH}px; }
#platiSearchExclusionTags { right: ${PS_SIDE_PANEL_HORIZONTAL_PADDING}px; width: ${PS_EXCLUSION_PANEL_WIDTH}px; display: flex; flex-direction: column; gap: 10px; }
/* Фильтры */
.filterGroup { margin-bottom: 18px; } .filterGroup h4 { font-size: 15px; color: #ddd; margin-bottom: 8px; padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center; text-shadow: 1px 1px 2px rgba(0,0,0,0.7); font-weight: 500; }
.filterResetBtn { font-size: 12px; color: #aaa; background: none; border: none; cursor: pointer; padding: 0 3px; line-height: 1; } .filterResetBtn:hover { color: #fff; } .filterResetBtn svg { width: 14px; height: 14px; vertical-align: middle; fill: currentColor; }
.filterRangeInputs { display: flex; gap: 8px; align-items: center; } .filterRangeInputs input[type="number"] { width: calc(50% - 4px); padding: 6px 8px; font-size: 13px; background-color: rgba(51,51,51,0.85); border: 1px solid #666; color: #eee; border-radius: 3px; height: 30px; text-align: center; -moz-appearance: textfield; box-shadow: inset 0 1px 3px rgba(0,0,0,0.3); outline: none; } .filterRangeInputs input[type="number"]:focus { border-color: #67c1f5; } .filterRangeInputs input[type="number"]::-webkit-outer-spin-button, .filterRangeInputs input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .filterRangeInputs input[type="number"]::placeholder { color: #999; font-size: 11px; text-align: center; }
.filterCheckbox { margin-bottom: 8px; } .filterCheckbox label { display: flex; align-items: center; font-size: 14px; cursor: pointer; color: #ccc; text-shadow: 1px 1px 2px rgba(0,0,0,0.7); } .filterCheckbox input[type="checkbox"] { margin-right: 8px; width: 16px; height: 16px; accent-color: #007bff; cursor: pointer; flex-shrink: 0; }
.filterSelect select { width: 100%; padding: 6px 8px; font-size: 13px; background-color: rgba(51,51,51,0.85); border: 1px solid #666; color: #eee; border-radius: 3px; height: 30px; box-shadow: inset 0 1px 3px rgba(0,0,0,0.3); outline: none; } .filterSelect select:focus { border-color: #67c1f5; }
#psResetAllFiltersBtn { width: 100%; margin-top: 10px; padding: 8px 10px; height: auto; background-color: rgba(108, 117, 125, 0.8); border: 1px solid #888; text-shadow: 1px 1px 1px rgba(0,0,0,0.4); } #psResetAllFiltersBtn:hover { background-color: rgba(90, 98, 104, 0.9); }
/* Исключения */
.exclusionInputGroup { display: flex; align-items: stretch; border: 1px solid #555; border-radius: 4px; background-color: rgba(51,51,51,0.85); overflow: hidden; height: 34px; flex-shrink: 0; box-shadow: inset 0 1px 3px rgba(0,0,0,0.3); }
.exclusionInputGroup #platiSearchExcludeInput { padding: 6px 10px; font-size: 13px; background-color: transparent; border: none; color: #eee; outline: none; border-radius: 0; flex-grow: 1; width: auto; height: auto; } .exclusionInputGroup #platiSearchExcludeInput:focus { box-shadow: none; }
.exclusionInputGroup #platiSearchAddExcludeBtn { display: flex; align-items: center; justify-content: center; padding: 0 10px; background-color: #555; border: none; border-left: 1px solid #555; cursor: pointer; border-radius: 0; color: #eee; height: auto; } .exclusionInputGroup #platiSearchAddExcludeBtn:hover { background-color: #666; } .exclusionInputGroup #platiSearchAddExcludeBtn svg { width: 16px; height: 16px; fill: currentColor; }
#platiExclusionTagsList { display: flex; flex-direction: row; flex-wrap: wrap; align-content: flex-start; gap: 8px; overflow-y: auto; flex-grow: 1; }
.exclusionTag { display: inline-block; background-color: rgba(70,70,80,0.9); color: #ddd; padding: 5px 10px; border-radius: 15px; font-size: 13px; cursor: pointer; transition: background-color 0.2s; border: 1px solid rgba(100,100,110,0.9); white-space: nowrap; text-shadow: 1px 1px 1px rgba(0,0,0,0.5); } .exclusionTag:hover { background-color: rgba(220,53,69,0.9); border-color: rgba(200,40,50,0.95); color: #fff; } .exclusionTag::after { content: ' ×'; font-weight: bold; margin-left: 4px; }
/* Результаты */
#platiSearchResultsContainer { position: relative; padding-left: ${PS_CONTENT_PADDING_LEFT}px; padding-right: ${PS_CONTENT_PADDING_RIGHT}px; margin-left: -${PS_CONTENT_PADDING_LEFT}px; margin-right: -${PS_CONTENT_PADDING_RIGHT}px; }
#platiSearchResultsStatus { width: 100%; text-align: center; font-size: 18px; color: #aaa; padding: 50px 0; display: none; min-height: 100px; display: flex; align-items: center; justify-content: center; flex-direction: column;}
#platiSearchResults { display: flex; flex-wrap: wrap; gap: 15px; justify-content: flex-start; padding-top: 10px; }
/* Карточка товара */
.platiSearchItem { background-color: #2a2a30; border-radius: 8px; padding: 10px; width: calc(20% - 12px); min-width: 170px; display: flex; flex-direction: column; transition: transform 0.2s ease, box-shadow 0.2s ease; box-shadow: 0 2px 5px rgba(0,0,0,0.2); position: relative; color: #ccc; font-size: 13px; min-height: 340px; border: 1px solid transparent; }
.platiSearchItem:hover { transform: translateY(-3px); box-shadow: 0 4px 10px rgba(0,0,0,0.4); border-color: #4b6f9c; }
.platiSearchItem.hidden-by-filter { display: none !important; }
.platiSearchItem a { text-decoration: none; color: inherit; display: flex; flex-direction: column; height: 100%; }
.platiSearchItem .card-image-wrapper { position: relative; width: 100%; aspect-ratio: 1 / 1; margin-bottom: 8px; background-color: #444; border-radius: 6px; overflow: hidden; }
.platiSearchItem img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; border-radius: 6px; }
.newItemBadge { position: absolute; top: 4px; left: 4px; background-color: #f54848; color: white; padding: 1px 5px; font-size: 10px; border-radius: 3px; font-weight: bold; z-index: 1; text-shadow: 1px 1px 1px rgba(0,0,0,0.3); }
.platiSearchItem .price { font-size: 16px; font-weight: 700; color: #a4d007; margin-bottom: 5px; }
.platiSearchItem .title { font-size: 13px; font-weight: 500; line-height: 1.3; height: 3.9em; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; color: #eee; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
.cardInfoContainer { margin-top: auto; padding-top: 6px; }
.cardInfoRow1, .cardInfoRow2 { display: flex; justify-content: space-between; flex-wrap: nowrap; gap: 8px; font-size: 12px; color: #bbb; margin-bottom: 4px; }
.cardInfoRow1 span, .cardInfoRow2 span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; }
.cardInfoRow1 span:first-child, .cardInfoRow2 span:first-child { flex-shrink: 0; margin-right: auto; }
.reviewsGood { color: #6cff5c; font-weight: bold; } .reviewsBad { color: #f54848; margin-left: 2px; font-weight: bold;} .sales { font-weight: bold; color: #eee; }
.sellerLink { display: block; font-size: 12px; color: #bbb; text-decoration: none; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color 0.2s; } .sellerLink:hover { color: #ddd; text-decoration: underline; }
.platiSearchItem .buyButton { display: block; text-align: center; padding: 8px; margin-top: 8px; background-color: #007bff; color: white; border-radius: 4px; font-size: 13px; font-weight: 600; transition: background-color 0.2s; } .platiSearchItem .buyButton:hover { background-color: #0056b3; }
/* Адаптивность */
@media (max-width: 1650px) { .platiSearchItem { width: calc(20% - 12px); } }
@media (max-width: 1400px) { .platiSearchItem { width: calc(25% - 12px); } }
@media (max-width: 1100px) { .platiSearchItem { width: calc(33.33% - 10px); } }
@media (max-width: 850px) { #platiSearchFiltersPanel, #platiSearchExclusionTags { display: none; } #platiSearchHeader, #platiSearchResultsContainer { padding-left: 15px; padding-right: 15px; margin-left: 0; margin-right: 0; } .platiSearchItem { width: calc(50% - 8px); } #platiSearchHeader { justify-content: center; } }
@media (max-width: 600px) { .platiSearchItem { width: 100%; min-height: auto; } #platiSearchHeader { gap: 5px; } .platiSearchInputContainer { flex-basis: 100%; order: -1; } .platiSearchBtn, #platiSearchCurrencySelect, #platiSearchAdvSortBtnContainer { width: calc(33.3% - 4px); font-size: 13px; padding: 8px 5px; height: 36px; } #platiSearchAdvSortBtnContainer { width: calc(33.3% - 4px); } #platiSearchAdvSortBtn { width: 100%; } #platiSearchAdvSortMenu { min-width: 200px; left: 50%; transform: translateX(-50%); } #platiResetSortBtn { width: auto; padding: 0 8px; } }
/* Стили для кнопки Plati на странице Steam */
.plati_price_button .btnv6_blue_hoverfade { margin: 0; padding: 0 15px; font-size: 15px; display: flex; align-items: center; transition: filter 0.2s; }
.plati_price_button .btnv6_blue_hoverfade:hover { filter: brightness(1.1); }
`);
}
// --- Инициализация модуля ---
addPlatiStyles();
const steamAppIdCheck = window.location.pathname.match(/\/app\/(\d+)/);
if (steamAppIdCheck && steamAppIdCheck[1]) {
addPlatiButton();
}
})();
}
// Скрипт для страницы игры (VGT; отображения цен из агрегатора VGTimes) | https://store.steampowered.com/app/*
if (scriptsConfig.vgtSales && window.location.pathname.includes('/app/')) {
(function() {
'use strict';
const VGT_DATA_URL = 'https://gist.githubusercontent.com/0wn3dg0d/2644d328cca76b74c57804c7303b8606/raw/vgtstulex.json';
const VGT_API_URL = 'https://vgtimes.ru/engine/modules/games/shops_table.php';
const ITEMS_PER_PAGE = 40;
let vgtDataMap = new Map();
let vgtSteamMap = new Map();
let vgtNameMap = new Map();
function addVGTButton() {
const actionsContainer = document.querySelector('#queueActionsCtn');
// Находим элемент кнопки "Скрыть" (Ignore) по его ID
const ignoreButtonContainer = actionsContainer?.querySelector('#ignoreBtn');
// Проверяем, что оба элемента найдены
if (!actionsContainer || !ignoreButtonContainer) {
console.warn('VGT Button: Could not find actions container or ignore button container.');
return; // Выходим, если не нашли нужные элементы
}
const vgtContainer = document.createElement('div');
// Добавляем класс для стилизации и идентификации
vgtContainer.className = 'vgt_price_button queue_control_button'; // Добавляем queue_control_button для выравнивания
// Убираем правый отступ и добавляем левый отступ в 3px
vgtContainer.style.marginLeft = '3px';
// vgtContainer.style.marginRight = '4px'; // Убираем этот стиль
vgtContainer.innerHTML = `
<div class="btnv6_blue_hoverfade btn_medium" style="height: 32px;"> <span>Цены (VGT)</span>
</div>
`;
vgtContainer.querySelector('div').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openVGTModal();
});
// Вставляем кнопку VGT *после* контейнера кнопки "Скрыть"
ignoreButtonContainer.insertAdjacentElement('afterend', vgtContainer);
}
async function openVGTModal() {
const vgtModal = createModal();
document.body.appendChild(vgtModal);
showLoading(vgtModal);
setupModalEvents(vgtModal);
try {
await loadVGTData();
const appId = getAppId();
const gameData = await findGameData(appId);
if (!gameData) {
showError(vgtModal, 'Игра не найдена в базе VGTimes');
return;
}
initModalContent(vgtModal, gameData);
await loadPrices(gameData['data-id'], vgtModal, 0);
} catch (error) {
showError(vgtModal, 'Ошибка загрузки данных');
console.error('VGT Error:', error);
}
}
async function loadVGTData() {
const data = await fetchJson(VGT_DATA_URL);
vgtDataMap = new Map(Object.entries(data));
vgtSteamMap = new Map();
vgtNameMap = new Map();
for (const [key, value] of Object.entries(data)) {
if (value.steam !== null) {
vgtSteamMap.set(String(value.steam), value);
}
const normalized = normalizeName(value.title);
vgtNameMap.set(normalized, value);
}
}
async function findGameData(appId) {
if (vgtSteamMap.has(appId)) {
return vgtSteamMap.get(appId);
}
const gameName = getGameName();
const normalized = normalizeName(gameName);
if (vgtNameMap.has(normalized)) return vgtNameMap.get(normalized);
const possibleMatches = findPossibleMatches(gameName, Array.from(vgtDataMap.values()));
if (possibleMatches.length === 0) return null;
const selectedGame = await showGameSelection(possibleMatches);
return selectedGame;
}
function normalizeName(name) {
return name
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[’]/g, "'")
.replace(/[^a-zA-Zа-яёА-ЯЁ0-9 _'\-!]/g, '')
.trim()
.toLowerCase();
}
function findPossibleMatches(gameName, games) {
const cleanGameName = normalizeName(gameName);
return games
.map(game => {
const cleanTitle = normalizeName(game.title);
const similarity = calculateSimilarity(cleanGameName, cleanTitle);
return {
...game,
similarity
};
})
.filter(game => game.similarity > 50)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 5);
}
function calculateSimilarity(a, b) {
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 0;
const distance = levenshteinDistance(a, b);
return Math.round((1 - distance / maxLen) * 100);
}
function levenshteinDistance(a, b) {
const matrix = Array.from({
length: a.length + 1
}, (_, i) =>
Array.from({
length: b.length + 1
}, (_, j) =>
i === 0 ? j : j === 0 ? i : 0));
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost
);
}
}
return matrix[a.length][b.length];
}
async function showGameSelection(games) {
return new Promise(resolve => {
const modal = document.querySelector('.vgt_modal');
const content = modal.querySelector('.vgt_content');
content.innerHTML = `
<div class="vgt_selection">
<h3>Выберите игру:</h3>
<div class="vgt_games_list">
${games.map(game => `
<div class="vgt_game_item" data-id="${game['data-id']}">
<div class="vgt_game_title">${game.title}</div>
<div class="vgt_game_similar">Совпадение: ${game.similarity}%</div>
</div>
`).join('')}
</div>
<div class="vgt_selection_buttons">
<button class="vgt_cancel_btn">Ничего не подходит</button>
</div>
</div>
`;
modal.querySelectorAll('.vgt_game_item').forEach(item => {
item.addEventListener('click', () => {
const selectedId = item.dataset.id;
const selectedGame = vgtDataMap.get(selectedId);
content.innerHTML = '';
resolve(selectedGame);
});
});
modal.querySelector('.vgt_cancel_btn').addEventListener('click', () => {
content.innerHTML = '';
resolve(null);
});
});
}
function createModal() {
const modal = document.createElement('div');
modal.className = 'vgt_modal';
modal.innerHTML = `
<div class="vgt_modal-overlay"></div>
<div class="vgt_modal-content">
<span class="vgt_close">×</span>
<div class="vgt_header"></div>
<div class="vgt_content"></div>
</div>
`;
return modal;
}
function initModalContent(modal, gameData) {
const header = modal.querySelector('.vgt_header');
header.innerHTML = `<h2><a href="${gameData.url}" target="_blank">${gameData.title}</a></h2>`;
}
function setupModalEvents(modal) {
modal.querySelector('.vgt_modal-overlay').addEventListener('click', () => modal.remove());
const closeBtn = modal.querySelector('.vgt_close');
closeBtn.addEventListener('mouseenter', () => closeBtn.style.color = '#67c1f5');
closeBtn.addEventListener('mouseleave', () => closeBtn.style.color = '#aaa');
closeBtn.onclick = () => modal.remove();
}
async function loadPrices(dataId, modal, skip) {
const content = modal.querySelector('.vgt_content');
content.innerHTML = '<div class="vgt_loading">Загрузка цен...</div>';
try {
const params = new URLSearchParams({
skin: 'vgtimes',
id: dataId,
skip: skip,
sort: 'rele',
shop: 'all',
payment_method: 'all',
platform: 'all',
custom_filter: ''
});
const response = await postRequest(VGT_API_URL, params.toString());
const data = JSON.parse(response.responseText);
if (data.offercount === 0 || data.result.includes('notf gp_lb')) {
content.innerHTML = '<div class="vgt_error">Информация о ценах отсутствует в базе VGTimes.</div>';
return;
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.result;
if (tempDiv.querySelector('.notf.gp_lb')) {
content.innerHTML = '<div class="vgt_error">Информация о ценах отсутствует в базе VGTimes.</div>';
return;
}
tempDiv.querySelectorAll('.int, .s_rating, .s_reviews, .boosted, .s_promocodes, .promocode').forEach(el => el.remove());
processElements(tempDiv);
const shops = groupByShops(tempDiv);
const sortedShops = sortShops(shops);
content.innerHTML = generateShopColumns(sortedShops);
addExpandHandlers(content);
if (data.offercount > skip + ITEMS_PER_PAGE) {
const loadMoreBtn = document.createElement('button');
loadMoreBtn.className = 'vgt_load_more';
loadMoreBtn.textContent = 'Загрузить ещё';
loadMoreBtn.onclick = () => loadPrices(dataId, modal, skip + ITEMS_PER_PAGE);
content.appendChild(loadMoreBtn);
}
} catch (error) {
content.innerHTML = '<div class="vgt_error">Ошибка загрузки цен</div>';
}
}
function processElements(container) {
container.querySelectorAll('img').forEach(img => img.remove());
container.querySelectorAll('a').forEach(link => {
let href = link.getAttribute('href');
try {
if (href.startsWith('/')) {
href = 'https://vgtimes.ru' + href;
}
if (href.includes('/shop_redirect')) {
const urlObj = new URL(href);
const realUrl = urlObj.searchParams.get('url');
if (realUrl) {
href = decodeURIComponent(realUrl)
.replace(/(https?:\/\/)?store\.steampowered\.com\/?/i, '')
.replace(/^\/+/g, '');
const cleanUrl = href.split('?')[0];
href = cleanUrl.startsWith('http') ? cleanUrl : `https://${cleanUrl}`;
}
}
if (link.classList.contains('shopm')) {
const shopPath = new URL(href).pathname;
href = `https://vgtimes.ru${shopPath}`;
}
link.href = href;
} catch (e) {
console.error('URL processing error:', e);
link.href = '#';
}
link.removeAttribute('style');
});
}
function groupByShops(container) {
const shopsMap = new Map();
container.querySelectorAll('.products_search_par').forEach(item => {
const shopElement = item.querySelector('.shopm');
if (!shopElement) return;
const shopName = shopElement.textContent.trim();
if (!shopsMap.has(shopName)) {
shopsMap.set(shopName, {
name: shopName,
items: [],
minPrice: Infinity,
url: shopElement.href
});
}
const priceElement = item.querySelector('.aprice');
let price = parsePrice(priceElement.textContent);
const shopData = shopsMap.get(shopName);
shopData.items.push(item);
if (price < shopData.minPrice) shopData.minPrice = price;
});
return Array.from(shopsMap.values());
}
function parsePrice(priceText) {
if (priceText.toLowerCase() === 'бесплатно') return 0;
const number = priceText.replace(/[^0-9,]/g, '').replace(',', '.');
return parseFloat(number) || Infinity;
}
function sortShops(shops) {
return shops.sort((a, b) => a.minPrice - b.minPrice);
}
function generateShopColumns(shops) {
return `
<div class="vgt_columns">
${shops.map(shop => `
<div class="vgt_shop_column">
<div class="vgt_shop_header">
<a href="${shop.url}" target="_blank">${shop.name}</a>
</div>
<div class="vgt_shop_items">
${shop.items.slice(0, 3).map(item => generateItemHtml(item)).join('')}
</div>
${shop.items.length > 3 ? `
<div class="vgt_shop_expand" data-shop="${shop.name}" data-expanded="false">
<div class="vgt_expand_content" style="display: none;">
${shop.items.slice(3).map(item => generateItemHtml(item)).join('')}
</div>
<div class="vgt_expand_toggle">...</div>
</div>
` : ''}
</div>
`).join('')}
</div>
`;
}
function generateItemHtml(item) {
const title = item.querySelector('.title').textContent.trim();
const priceElement = item.querySelector('.aprice');
const oldPriceElement = item.querySelector('.oldprice');
const discountElement = item.querySelector('.percent');
const link = item.querySelector('a.f_click').href;
return `
<div class="vgt_shop_item">
<a href="${link}" target="_blank" class="vgt_item_link">
<div class="vgt_item_title">${title}</div>
<div class="vgt_prices">
${oldPriceElement ? `<span class="vgt_oldprice">${oldPriceElement.textContent}</span>` : ''}
<span class="vgt_aprice">${priceElement.textContent}</span>
${discountElement ? `<span class="vgt_percent">${discountElement.textContent}</span>` : ''}
</div>
</a>
</div>
`;
}
function addExpandHandlers(content) {
content.querySelectorAll('.vgt_shop_expand').forEach(expand => {
expand.querySelector('.vgt_expand_toggle').addEventListener('click', () => {
const isExpanded = expand.dataset.expanded === 'true';
expand.dataset.expanded = !isExpanded;
expand.querySelector('.vgt_expand_content').style.display = isExpanded ? 'none' : 'block';
expand.querySelector('.vgt_expand_toggle').textContent = isExpanded ? '...' : '▲';
});
});
}
function getAppId() {
return window.location.pathname.split('/')[2];
}
function getGameName() {
return document.querySelector('.apphub_AppName')?.textContent || '';
}
function fetchJson(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: (r) => resolve(JSON.parse(r.responseText)),
onerror: reject
});
});
}
function postRequest(url, body) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
},
data: body,
onload: resolve,
onerror: reject
});
});
}
function showLoading(modal) {
modal.querySelector('.vgt_content').innerHTML = '<div class="vgt_loading">Загрузка...</div>';
}
function showError(modal, message) {
modal.querySelector('.vgt_content').innerHTML = `<div class="vgt_error">${message}</div>`;
}
GM_addStyle(`
.vgt_modal {
display: block;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.vgt_modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
}
.vgt_modal-content {
background: #1b2838;
margin: 5% auto;
padding: 20px;
border: 1px solid #67c1f5;
width: 90%;
max-width: 1200px;
color: #c6d4df;
position: relative;
max-height: 80vh;
overflow-y: auto;
z-index: 10001;
}
.vgt_columns {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.vgt_shop_column {
background: #16202d;
border-radius: 4px;
padding: 15px;
border: 1px solid #2a475e;
}
.vgt_shop_header {
font-weight: bold;
font-size: 16px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #67c1f5;
}
.vgt_shop_header a {
color: #67c1f5 !important;
text-decoration: none;
}
.vgt_shop_item {
margin: 10px 0;
padding: 10px;
background: rgba(27, 40, 56, 0.7);
border-radius: 3px;
}
.vgt_item_title {
font-size: 14px;
margin-bottom: 5px;
line-height: 1.3;
}
.vgt_prices {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.vgt_aprice {
color: #5ba32b;
font-weight: bold;
font-size: 15px;
}
.vgt_oldprice {
text-decoration: line-through;
opacity: 0.6;
font-size: 13px;
}
.vgt_percent {
color: #67c1f5;
font-size: 13px;
}
.vgt_shop_expand {
text-align: center;
margin-top: 10px;
cursor: pointer;
}
.vgt_expand_toggle {
color: #67c1f5;
font-weight: bold;
padding: 5px;
transition: opacity 0.2s;
}
.vgt_expand_toggle:hover {
opacity: 0.8;
}
.vgt_load_more {
background: #67c1f5;
color: #1b2838;
border: none;
padding: 10px 20px;
margin: 20px auto 0;
display: block;
cursor: pointer;
border-radius: 3px;
transition: background 0.2s;
}
.vgt_load_more:hover {
background: #4fa0d1;
}
.vgt_close {
color: #aaa;
position: absolute;
right: 20px;
top: 10px;
font-size: 28px;
cursor: pointer;
transition: color 0.2s;
}
.vgt_close:hover {
color: #67c1f5;
}
.vgt_loading, .vgt_error {
text-align: center;
padding: 20px;
font-size: 16px;
}
.vgt_price_button .btnv6_blue_hoverfade {
/* Убери 'height: 32px;' отсюда, если добавил его инлайн в HTML */
margin: 0;
padding: 0 15px;
font-size: 15px;
/* height: 32px; */ /* Можно оставить тут или задать инлайн */
display: flex; /* Для выравнивания текста внутри */
align-items: center; /* Для выравнивания текста внутри */
}
.vgt_selection {
padding: 20px;
text-align: center;
}
.vgt_games_list {
display: grid;
gap: 10px;
margin-top: 15px;
}
.vgt_game_item {
padding: 15px;
background: #16202d;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.vgt_game_item:hover {
background: #1b2838;
}
.vgt_game_title {
color: #67c1f5;
font-weight: bold;
margin-bottom: 5px;
}
.vgt_game_similar {
color: #8f98a0;
font-size: 12px;
}
.vgt_selection_buttons {
margin-top: 20px;
text-align: center;
}
.vgt_cancel_btn {
background: #a34d4d;
color: #fff;
border: none;
padding: 8px 20px;
cursor: pointer;
border-radius: 3px;
transition: background 0.2s;
}
.vgt_cancel_btn:hover {
background: #c25555;
}
`);
addVGTButton();
})();
}
})();