// ==UserScript==
// @name Ultimate Steam Enhancer
// @namespace https://store.steampowered.com/
// @version 2.1.0
// @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
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-datalabels.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
// @grant unsafeWindow
// @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
// @connect steambuy.com
// @connect steammachine.ru
// @connect playo.ru
// @connect steampay.com
// @connect gabestore.ru
// @connect static.gabestore.ru
// @connect gamersbase.store
// @connect coreplatform.blob.core.windows.net
// @connect cdn-contentprod.azureedge.net
// @connect cdn-resize.enaza.games
// @connect cdn-static.enaza.games
// @connect www.igromagaz.ru
// @connect gamesforfarm.com
// @connect shared.fastly.steamstatic.com
// @connect i.imgur.com
// @connect zaka-zaka.com
// @connect images.zaka-zaka.com
// @connect gamazavr.ru
// @connect gameray.ru
// @connect shop.buka.ru
// @connect upload.wikimedia.org
// @connect keysforgamers.com
// @connect api4.ggsel.com
// @connect ggsel.net
// @connect cdn.ggsel.com
// @connect explorer.kupikod.com
// @connect cdn.jsdelivr.net
// ==/UserScript==
(function() {
'use strict';
function makeGMRequest(options) {
return new Promise((resolve, reject) => {
options.onload = (response) => {
if (response.status >= 200 && response.status < 400) {
resolve(response);
} else {
reject(new Error(`Запрос не удался, статус ${response.status}: ${options.method} ${options.url}`));
}
};
options.onerror = (err) => reject(new Error(`Сетевая ошибка: ${options.method} ${options.url}. Details: ${JSON.stringify(err)}`));
options.ontimeout = () => reject(new Error(`Таймаут: ${options.method} ${options.url}`));
GM_xmlhttpRequest(options);
});
}
function injectPageAndRunModules(responseText) {
for (let i = 1; i < 99999; i++) {
window.clearInterval(i);
window.clearTimeout(i);
}
const injectionScript = `
<script type="text/javascript">
window.addEventListener('DOMContentLoaded', () => {
// Устанавливаем флаг в глобальном объекте window новой страницы
window.USE_unlocked_page_ready = true;
console.log('[U.S.E. Injected Script] DOMContentLoaded сработал, страница готова.');
});
</script>
`;
const modifiedResponseText = responseText.replace('</head>', `${injectionScript}</head>`);
document.open();
document.write(modifiedResponseText);
document.close();
const maxRetries = 40;
let retries = 0;
const readyCheckInterval = setInterval(() => {
if (unsafeWindow.USE_unlocked_page_ready) {
clearInterval(readyCheckInterval);
console.log('[U.S.E. Userscript] Обнаружен сигнал готовности от новой страницы. Запускаем модули USE.');
setTimeout(runUSEModules, 250);
} else {
retries++;
if (retries > maxRetries) {
clearInterval(readyCheckInterval);
console.error('[U.S.E.] Время ожидания готовности страницы истекло. Пытаюсь запустить модули вслепую...');
setTimeout(runUSEModules, 250);
}
}
}, 250);
}
async function handleComplexBypass() {
const appId = window.location.pathname.match(/\/app\/(\d+)/)?.[1];
if (!appId) {
console.error('[U.S.E.] Не удалось определить AppID на странице.');
return;
}
document.body.innerHTML = `
<div style="font-family: 'Motiva Sans', sans-serif; color: #c7d5e0; background-color: #1b2838; text-align: center; padding: 100px 20px; height: 100vh; font-size: 1.8em; line-height: 1.5;">
<p>Обнаружена региональная блокировка.</p>
<p>Запускаю многоступенчатый обход...</p>
<p id="use-unblock-status" style="font-size: 0.8em; color: #8f98a0; margin-top: 20px;"></p>
<p style="font-size: 0.7em; color: #8f98a0;">(Ultimate Steam Enhancer)</p>
</div>`;
const statusElement = document.getElementById('use-unblock-status');
try {
statusElement.textContent = 'Шаг 1/3: Получение анонимной сессии...';
const { sessionid, browserid } = await getAnonymousSession();
const baseCookies = `sessionid=${sessionid}; browserid=${browserid};`;
const countryCookie = `steamCountry=US;`;
statusElement.textContent = 'Шаг 2/3: Проверка страницы...';
const gamePageUrl = `https://store.steampowered.com/app/${appId}/?cc=us&l=russian`;
const initialPageResponse = await makeGMRequest({
method: "GET",
url: gamePageUrl,
headers: {
'Cookie': `${baseCookies} ${countryCookie}`,
'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7'
},
anonymous: true
});
const responseText = initialPageResponse.responseText;
if (responseText.includes('agegate_birthday_selector')) {
statusElement.textContent = 'Шаг 3/3: Обход возрастного ограничения и загрузка...';
const ageBypassCookies = `birthtime=631152001; wants_mature_content=1;`;
const finalPageResponse = await makeGMRequest({
method: "GET",
url: gamePageUrl,
headers: {
'Cookie': `${baseCookies} ${countryCookie} ${ageBypassCookies}`,
'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7'
},
anonymous: true
});
if (finalPageResponse.responseText.includes('agegate_birthday_selector')) {
throw new Error('Не удалось обойти возрастное ограничение с помощью cookie. Возможно, Steam изменил логику.');
}
injectPageAndRunModules(finalPageResponse.responseText);
} else {
statusElement.textContent = 'Возрастное ограничение не найдено. Загрузка страницы...';
injectPageAndRunModules(responseText);
}
} catch (error) {
console.error('[U.S.E.] Ошибка в процессе обхода:', error);
if (statusElement) {
statusElement.textContent = `Ошибка: ${error.message}`;
statusElement.style.color = '#ff6961';
}
}
}
async function getAnonymousSession(retryCount = 0) {
const MAX_RETRIES = 2;
console.log(`[U.S.E. Unblocker] Попытка #${retryCount + 1} получения анонимной сессии...`);
const sessionUrl = 'https://store.steampowered.com/join/?l=russian';
try {
const response = await makeGMRequest({
method: "GET",
url: sessionUrl,
headers: {
'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7'
},
anonymous: true
});
const headers = response.responseHeaders;
const cookies = headers.trim().split(/[\r\n]+/)
.filter(h => h.toLowerCase().startsWith('set-cookie:'))
.map(h => h.substring(h.indexOf(':') + 1).trim());
const sessionid = cookies.find(c => c.startsWith('sessionid='))?.split(';')[0].split('=')[1] || null;
const browserid = cookies.find(c => c.startsWith('browserid='))?.split(';')[0].split('=')[1] || null;
if (sessionid && browserid) {
console.log(`[U.S.E. Unblocker] Успешно получены sessionid=${sessionid}, browserid=${browserid}`);
return { sessionid, browserid };
}
console.warn('[U.S.E. Unblocker] Не удалось получить cookies с текущей попытки. Заголовки ответа:', { headers });
if (retryCount < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, 500));
return getAnonymousSession(retryCount + 1);
} else {
throw new Error('Не удалось получить анонимный sessionid/browserid после нескольких попыток.');
}
} catch (error) {
console.error(`[U.S.E. Unblocker] Ошибка при запросе сессии (попытка ${retryCount + 1}):`, error);
if (retryCount < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, 500));
return getAnonymousSession(retryCount + 1);
}
throw error instanceof Error ? error : new Error('Не удалось выполнить запрос для получения анонимной сессии.');
}
}
(function unblockerDispatcher() {
const storedSettings = GM_getValue('useSettings', {});
const isUnblockerEnabled = storedSettings.regionUnblocker !== false;
if (isUnblockerEnabled && window.location.pathname.includes('/app/')) {
const regionLockMessage = 'Данный товар недоступен в вашем регионе';
const errorBox = document.getElementById('error_box');
if (errorBox && errorBox.innerText.includes(regionLockMessage)) {
handleComplexBypass();
return;
}
}
runUSEModules();
})();
function runUSEModules() {
const scriptsConfig = {
// Основные скрипты
regionUnblocker: true,
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/*
platiSales: true, // Скрипт для страницы игры (Plati; отображение цен с Plati.Market) | https://store.steampowered.com/app/*
salesMaster: true, // Скрипт для страницы игры (%; агрегатор цен из разных магазинов) | https://store.steampowered.com/app/*
pageGiftHelper: true, // Скрипт для страницы игры, для проверки возможности отправки подарка друзьям в других странах | 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/
stelicasRoulette: true, //Скрипт для выбора случайной игры из ваших коллекций с помощью Stelicas и рулетки на странице вашей активности Steam | https://steamcommunity.com/my/
Sledilka: true, // Скрипт для получения уведомлений об изменении дат/статуса игр (вишлист/библиотека) и показа календаря релизов | Глобально
wishlistGiftHelper: true, // Скрипт для проверки возможности отправки подарка из списка желаемого друзьям в других странах | https://steamcommunity.com/my/wishlist/
RuRegionalPriceAnalyzer: true, // Скрипт для страницы игры (Анализатор цен; цена РФ vs рекомендованная; рейтинг цен) | https://store.steampowered.com/app/*
// Дополнительные настройки
autoExpandHltb: false, // Автоматически раскрывать спойлер HLTB
autoLoadReviews: false, // Автоматически загружать дополнительные обзоры
toggleEnglishLangInfo: false // Отображает данные об английском языке в дополнительной информации при поиске по каталогу и в активности (функция для переводчиков)
};
/* --- Код для настроек U.S.E. --- */
const useDefaultSettings = {
regionUnblocker: true, gamePage: true, hltbData: true, friendsPlaytime: true, earlyaccdata: true, zogInfo: true, pageGiftHelper: true,
platiSales: true, salesMaster: true, catalogInfo: true, catalogHider: false, newsFilter: true,
Kaznachei: true, homeInfo: true, Sledilka: true, wishlistGiftHelper: true, stelicasRoulette: true, RuRegionalPriceAnalyzer: true,
autoExpandHltb: false, autoLoadReviews: false, toggleEnglishLangInfo: false
};
const dependentModules = {
gamePage: ['hltbData', 'zogInfo', 'friendsPlaytime']
};
let useCurrentSettings = { ...useDefaultSettings, ...GM_getValue('useSettings', {}) };
Object.assign(scriptsConfig, useCurrentSettings);
// --- Данные для настроек: метки, заголовки, описания и категории ---
const settingInfo = {
// --- Страница игры ---
gamePage: {
category: 'gamePage',
label: "Индикаторы / Доп. обзоры / Монитор обзоров",
title: "Индикаторы перевода, доп. обзоры и глобальный монитор обзоров",
details: `
<p><strong>Что делает:</strong></p>
<ol style="margin-left: 20px; padding-left: 5px; list-style-type: decimal;">
<li style="margin-bottom: 0.7em;">Отображает значки-индикаторы наличия русского языка (интерфейс, озвучка, субтитры) прямо на странице игры.</li>
<li style="margin-bottom: 0.7em;">Добавляет под стандартными обзорами блок с расширенной статистикой (загружается по щелчку или автоматически, если включена опция):
<ul>
<li><strong>Тотальные:</strong> Все обзоры Steam (включая активации ключами).</li>
<li><strong>Безкитайские:</strong> Обзоры за исключением написанных на китайском языке.</li>
<li><strong>Русские:</strong> Только обзоры на русском языке.</li>
</ul>
</li>
<img src="https://i.imgur.com/fcdZh8b.png" alt="Пример индикаторов и обзоров 1" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
<hr style="border: none; border-top: 1px solid #444a52; margin: 1.5em 0;">
<li style="margin-bottom: 0.7em;">Модальные окна:
<ul>
<li>При щелчке по строке "Русские" открывается окно с актуальными <strong>русскоязычными обзорами</strong> для этой игры.</li>
<img src="https://i.imgur.com/MOEyAlM.png" alt="Пример индикаторов и обзоров 2" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
<hr style="border: none; border-top: 1px solid #444a52; margin: 1.5em 0;">
<li>При щелчке по строке "Тотальные" открывается окно <strong>"Глобальный монитор обзоров"</strong>. Этот инструмент позволяет собрать (по кнопке "Собрать") и визуализировать статистику обзоров по <strong>27 языкам</strong> Steam. Он показывает таблицу с рейтингом языков по количеству обзоров и доле в общей массе, а также интерактивную круговую диаграмму для наглядного представления.</li>
<img src="https://i.imgur.com/2azVyAW.png" alt="Пример индикаторов и обзоров 3" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
</ul>
</li>
</ol>
<div style="margin-top: 15px; margin-bottom: 10px; padding: 10px; background-color: rgba(255, 179, 0, 0.1); border: 1px solid rgba(255, 179, 0, 0.4); border-radius: 4px; font-size: 0.95em; line-height: 1.4;">
<p style="margin: 0 0 5px 0; font-weight: bold; color: #FFB300;">⚠️ Важное замечание о зависимостях:</p>
<p style="margin: 0; color: #c6d4df;">Отключение этого модуля приведет к автоматическому отключению или нарушению корректной работы модулей «Время прохождения (HLTB)», «Русификаторы (ZOG)» и «Время друзей / Глобальные достижения», так как они критически зависят от его функционала по отображению элементов на странице игры.</p>
</div>
`
},
RuRegionalPriceAnalyzer: {
category: 'gamePage',
label: "Анализатор цен",
title: "Анализатор цен",
details: `
<p><strong>Что делает:</strong> Добавляет кнопку "Анализатор цен" на страницу игры. Этот инструмент позволяет анализировать региональные цены двумя способами: в рублях (по умолчанию) и в долларах США.</p>
<p>После нажатия кнопки "Сбор данных" в специальном окне, модуль выполняет следующее в зависимости от выбранного режима:</p>
<div style="display: flex; gap: 20px; margin-top:10px; margin-bottom:10px;">
<div style="flex: 1; padding:10px; background-color: rgba(0,0,0,0.1); border-radius:4px;">
<h4 style="margin-top:0; color: #67c1f5;">Режим Рублей (по умолчанию):</h4>
<ul>
<li>Определяет AppID текущей игры и запрашивает цены через официальное API Steam (<code>IStoreBrowseService/GetItems</code>) для множества регионов.</li>
<li>В качестве базы для расчета <strong>рекомендованной рублевой цены</strong> используется цена в США (USD).</li>
<li>Цены из всех регионов, включая Россию, <strong>конвертируются в рубли</strong> по актуальным обменным курсам для прямого сопоставления.</li>
<li>Производится ключевое сравнение: фактическая цена в российском Steam сопоставляется с <strong>официально рекомендованной Valve ценой для России</strong>. Отклонения подсвечиваются.</li>
<li>Отображается <strong>рейтинг российской цены</strong> среди всех проанализированных стран, позволяя увидеть её место от самой дешёвой к самой дорогой в рублевом эквиваленте.</li>
</ul>
</div>
<div style="flex: 1; padding:10px; background-color: rgba(0,0,0,0.1); border-radius:4px;">
<h4 style="margin-top:0; color: #67c1f5;">Режим Долларов США (переключаемый):</h4>
<ul>
<li>Активируется кнопкой "USD" в окне анализатора. Интерфейс и названия валют <strong>переключаются на английский язык</strong>.</li>
<li>Цены всех регионов также запрашиваются через API Steam и <strong>конвертируются в доллары США</strong>.</li>
<li>Цена в США используется как <strong>базовый ориентир (100%)</strong> для сравнения.</li>
<li>Отображается <strong>процентное отклонение</strong> цен других регионов от цены в США.</li>
<li>Представляется общий рейтинг всех региональных цен в долларовом эквиваленте.</li>
<li>Этот режим полезен для оценки ценовой политики при общении с разработчиками/издателями.</li>
</ul>
</div>
</div>
<p>В обоих режимах, если игра в США бесплатна или цена не найдена, возможности анализа могут быть ограничены. Вся собранная информация представляется в модальном окне.</p>
<div style="margin-top: 15px; padding: 10px; background-color: rgba(103, 193, 245, 0.1); border: 1px solid rgba(103, 193, 245, 0.35); border-radius: 4px; font-size: 0.95em; line-height: 1.4;">
<p style="margin: 0 0 5px 0; font-weight: bold; color: #67c1f5;">⚠️ Важная информация:</p>
<p style="margin: 0; color: #c6d4df;">Каждый полный сбор данных подразумевает отправку <strong>~41 запроса</strong> к серверам Steam (количество зависит от числа доступных регионов). Пожалуйста, используйте эту функцию обдуманно. Частое нажатие кнопки на разных играх в течение короткого периода времени может привести к временному ограничению доступа к API Steam (обычно на 5-15 минут).</p>
</div>
<img src="https://i.imgur.com/OzebvaA.png" alt="Пример интерфейса анализатора цен" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
hltbData: {
category: 'gamePage',
label: "Время прохождения (HLTB)",
title: "Время прохождения HLTB",
details: `
<p><strong>Что делает:</strong> Добавляет компактный блок с информацией о времени прохождения игры, полученной с популярного сайта HowLongToBeat.com.</p>
<p>Показывает среднее время для разных стилей:</p>
<ul>
<li>Только основной сюжет.</li>
<li>Сюжет + дополнительные задания.</li>
<li>Полное прохождение (100%).</li>
<li>Усредненное время для всех стилей.</li>
</ul>
<p>Рядом со временем указывается количество игроков, на чьих данных основана статистика. Поиск игры в базе HLTB идет по названию, при неоднозначности предлагается выбор из похожих вариантов.</p>
<img src="https://i.imgur.com/6tgxA2s.png" alt="Пример HLTB 1" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
platiSales: {
category: 'gamePage',
label: "Поиск цен Plati.Market",
title: "Поиск цен на Plati.Market",
details: `
<p><strong>Что делает:</strong> Добавляет кнопку "Plati" рядом с кнопкой "В желаемое" на странице игры. Нажатие открывает полноэкранное окно для поиска предложений по этой игре на торговой площадке Plati.Market.</p>
<p><strong>Возможности окна поиска:</strong></p>
<ul>
<li>Автозаполнение поиска названием текущей игры.</li>
<li>Ручной ввод и поиск.</li>
<li>Подсказки при вводе (API Plati).</li>
<li>Сортировка по цене, продажам, релевантности, названию, дате, рейтингу продавца и др.</li>
<li>Фильтрация по цене (RUR, USD, EUR, UAH), продажам, рейтингу, наличию плохих отзывов/возвратов, участию в скидках, дате добавления.</li>
<li>Исключение товаров по ключевым словам (панель справа).</li>
<li>Сохранение фильтров, сортировки, валюты и исключений.</li>
</ul>
<p>Используются официальные API Plati.Market.</p>
<img src="https://i.imgur.com/lyL8i5g.png" alt="Кнопка Plati" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
<img src="https://i.imgur.com/j1TGmY8.png" alt="Окно Plati" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
zogInfo: {
category: 'gamePage',
label: "Русификаторы (ZOG)",
title: "Информация о наличии переводов с ZOG (ZoneOfGames)",
details: `
<p><strong>Что делает:</strong> Добавляет блок с информацией о наличии русификаторов для игры на сайте ZoneOfGames.ru.</p>
<p>В блоке отображается:</p>
<ul>
<li>Название игры (ведет на страницу игры в базе ZOG).</li>
<li>Список доступных русификаторов. Каждая запись является ссылкой на соответствующий файл/страницу на ZOG.</li>
<li>Если переводы не найдены, выводится соответствующее сообщение.</li>
</ul>
<p>Поиск происходит в <strong>реальном времени</strong>. Скрипт автоматически определяет название игры, выполняет поиск по алфавитному указателю на ZoneOfGames.ru и предлагает вам выбрать наиболее точное совпадение.</p>
<img src="https://i.imgur.com/XgAVWAp.png" alt="Пример ZOG 21" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
salesMaster: {
category: 'gamePage',
label: "Агрегатор цен (%)",
title: "Агрегатор цен (%)",
details: `
<p><strong>Что делает:</strong> Добавляет кнопку "%" рядом с кнопкой "В желаемое" на странице игры. Нажатие открывает модальное окно с ценами на эту игру из различных цифровых магазинов.</p>
<p><strong>Возможности окна агрегатора:</strong></p>
<ul>
<li>Отображение предложений из магазинов: SteamBuy, Playo, SteamPay, Gabestore, GamersBase, Igromagaz, GamesForFarm, Gamazavr, GameRay, KupiKod, KeysForGamers, Zaka-zaka, Buka, GGSEL, Plati.Market и текущей страницы Steam.</li>
<li>Переключение валют: Возможность просмотра всех цен в рублях (RUB, по умолчанию) или в долларах США (USD), с автоматической конвертацией по актуальному курсу. Выбор валюты сохраняется.</li>
<li>Сортировка по цене, проценту скидки, сумме скидки, названию.</li>
<li>Фильтрация по диапазону цен, проценту и сумме скидки, наличию скидки, названию (слова через ";"), магазинам.</li>
<li>Исключение товаров по ключевым словам.</li>
<li>Сохранение состояния фильтров, сортировки и исключений.</li>
</ul>
<p>Использует различные методы для получения цен (API, парсинг HTML).</p>
<img src="https://i.imgur.com/PsrocCt.png" alt="Пример Агрегатора 1" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
<img src="https://i.imgur.com/DcidcTe.png" alt="Пример Агрегатора 2" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
friendsPlaytime: {
category: 'gamePage',
label: "Время друзей / Глобальные достижения",
title: "Информация о времени друзей и статистике достижений",
details: `
<p><strong>Что делает:</strong> Добавляет на страницу игры два независимых блока, расширяющих информацию о действиях ваших друзей и глобальной статистике достижений.</p>
<h4 style="color: #67c1f5; border-bottom: 1px solid #444a52; padding-bottom: 5px; margin-top: 20px;">1. Плавающий блок статистики (у шапки игры)</h4>
<p>Справа от изображения игры появляется компактный значок, при нажатии на который загружается и отображается сводная статистика:</p>
<div style="display: flex; gap: 20px; margin-top:10px; margin-bottom:10px;">
<div style="flex: 1;">
<p><strong>Время друзей:</strong></p>
<ul>
<li>Максимальное время в игре (и ник друга со ссылкой).</li>
<li>Среднее время (с указанием кол-ва друзей).</li>
<li>Минимальное время в игре.</li>
</ul>
</div>
<div style="flex: 1;">
<p><strong>Глобальные достижения:</strong></p>
<ul>
<li>Процент "платины" (самое редкое достижение).</li>
<li>Средний прогресс выполнения всех достижений.</li>
</ul>
</div>
</div>
<img src="https://i.imgur.com/9TaMCbZ.png" alt="Пример Время друзей / Ачивки" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
<hr style="border: none; border-top: 1px solid #444a52; margin: 1.5em 0;">
<h4 style="color: #67c1f5; border-bottom: 1px solid #444a52; padding-bottom: 5px; margin-top: 20px;">2. Информационный блок друзей (в правой колонке)</h4>
<div style="margin-top: 10px; margin-bottom: 15px; padding: 10px; background-color: rgba(255, 179, 0, 0.1); border: 1px solid rgba(255, 179, 0, 0.4); border-radius: 4px; font-size: 0.95em; line-height: 1.4;">
<p style="margin: 0 0 5px 0; font-weight: bold; color: #FFB300;">⚠️ Важное примечание:</p>
<p style="margin: 0; color: #c6d4df;">Этот блок появляется только в том случае, если Steam не отображает на странице стандартный блок с рекомендациями друзей (например, на страницах, открытых с помощью виртуального режима инкогнито).</p>
</div>
<p>Скрипт добавляет в правую колонку подробный список друзей, взаимодействовавших с игрой, сгруппированный по категориям:</p>
<ul>
<li>Друзья, игравшие недавно (за последние 2 недели).</li>
<li>Все друзья, когда-либо игравшие в игру (объединяет недавних и игравших ранее).</li>
<li>Все друзья, имеющие игру в библиотеке.</li>
<li>Друзья, которые добавили игру в свой список желаемого.</li>
</ul>
<p>Каждая категория сопровождается сеткой из шести аватаров друзей со ссылками на их профили.</p>
<img src="https://i.imgur.com/6jbZ03L.png" alt="Пример" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
pageGiftHelper: {
category: 'gamePage',
label: "Доступность подарков (страница игры)",
title: "Доступность подарков (страница игры)",
details: `
<p><strong>Что делает:</strong> Добавляет кнопку "GIFT" в блок с кнопкой "В желаемое" на странице игры.</p>
<p>Нажатие открывает окно, где можно:</p>
<ul>
<li>Выбрать регион друга из списка.</li>
<li>Нажать "Узнать", чтобы запросить цену игры в этом регионе.</li>
<li>Увидеть цену друга (сконвертированную в вашу валюту), процент разницы и вердикт (<span style="color:#77dd77; font-weight:bold;">Можно подарить</span> / <span style="color:#ff6961; font-weight:bold;">Нельзя подарить</span>), основанный на правилах Steam о разнице цен (±10%).</li>
</ul>
<p>Использует те же механизмы получения цен и курсов валют, что и помощник для списка желаемого.</p>
<img src="https://i.imgur.com/jDdf4pR.png" alt="Пример PageGiftHelper 1" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
earlyaccdata: {
category: 'gamePage',
label: "Индикатор раннего доступа",
title: "Индикатор раннего доступа",
details: `
<p><strong>Что делает:</strong> Показывает небольшую плашку над изображением игры с информацией о статусе раннего доступа (Early Access).</p>
<ul>
<li><strong>Если игра еще в раннем доступе:</strong> Отображается, сколько времени игра уже находится в нем (например, "В раннем доступе уже 1 год и 3 месяца").</li>
<img src="https://i.imgur.com/6iGlcTf.png" alt="Пример Раннего Доступа 2" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
<hr style="border: none; border-top: 1px solid #444a52; margin: 1.5em 0;">
<li><strong>Если игра вышла из раннего доступа:</strong> Отображается, сколько времени игра провела в нем до релиза (например, "Вышла спустя 2 года раннего доступа").</li>
<img src="https://i.imgur.com/SPzJrpi.png" alt="Пример Раннего Доступа 1" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
</ul>
<p>Расчет времени динамический. Использует даты со страницы Steam, а также может подтягивать дату старта раннего доступа из собственной базы для вышедших игр, если Steam ее не показывает.</p>
`
},
// --- Каталог ---
catalogInfo: {
category: 'catalog',
label: "Доп. инфо / Фильтры",
title: "Дополнительная информация и фильтрация в каталоге поиска",
details: `
<p><strong>Что делает:</strong> Расширяет функционал страницы поиска по каталогу Steam (<a href="https://store.steampowered.com/search/" target="_blank" style="color:#67c1f5;">store.steampowered.com/search/</a>).</p>
<p><strong>При наведении:</strong></p>
<ul>
<li>Появляется всплывающая подсказка слева от строки игры с подробной информацией: издатели, разработчики, серия, отзывы (% и кол-во), статус раннего доступа, поддержка русского/английского языков, первые 5 меток, краткое описание.</li>
<img src="https://i.imgur.com/U7DYIvJ.png" alt="Пример Доп. Инфо" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
</ul>
<hr style="border: none; border-top: 1px solid #444a52; margin: 1.5em 0;">
<p><strong>Фильтры (панель справа):</strong></p>
<ul>
<li><strong>Русский перевод:</strong>
<ul>
<li><em>Только текст:</em> Игры с рус. интерфейсом/субтитрами (без озвучки).</li>
<li><em>Озвучка:</em> Игры с русской озвучкой.</li>
<li><em>Без перевода:</em> Игры без русского языка.</li>
</ul>
<img src="https://i.imgur.com/nLfsBzR.png" alt="Пример Фильтра Языка" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
</li>
<hr style="border: none; border-top: 1px solid #444a52; margin: 1.5em 0;">
<li><strong>DLC:</strong>
<ul>
<li><em>Только ваши DLC:</em> Показывает только DLC для игр, которые есть в вашей библиотеке (сами DLC подсвечиваются фиолетовым фоном).</li>
</ul>
</li>
<img src="https://i.imgur.com/MqjuXoD.png" alt="Пример Фильтра DLC" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
</ul>
<p>Фильтры применяются динамически по мере получения данных от API.</p>
`
},
catalogHider: {
category: 'catalog',
label: "Скрытие игр",
title: "Система скрытия игр в каталоге поиска",
details: `
<p><strong>Что делает:</strong> Добавляет инструменты для массового скрытия неинтересующих игр прямо со страницы поиска по каталогу.</p>
<p><strong>Элементы интерфейса:</strong></p>
<ul>
<li>Счетчик отображаемых игр (слева вверху).</li>
<li>Чекбокс слева от каждой игры (кроме уже купленных/скрытых/в желаемом) для отметки на скрытие.</li>
<li>Кнопка "Скрыть выбранное" (слева вверху).</li>
</ul>
<p><strong>Принцип работы:</strong></p>
<ol>
<li>Отмечаете чекбоксами игры, которые хотите скрыть.</li>
<li>Нажимаете "Скрыть выбранное".</li>
<li>Скрипт добавляет эти игры в ваш официальный список игнорируемых в Steam и удаляет их элементы со страницы.</li>
</ol>
<p>В отличие от стандартного механизма Steam, элементы полностью удаляются из DOM, что улучшает производительность при работе с большим количеством результатов.</p>
<p><strong>Внимание:</strong> Рекомендуется использовать только при необходимости массового скрытия. Для обычного просмотра каталога лучше отключать эту опцию.</p>
<img src="https://i.imgur.com/uCA8x2P.png" alt="Пример Скрытия Игр" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
// --- Сообщество / Активность ---
homeInfo: {
category: 'community',
label: "Доп. инфо в ленте активности",
title: "Дополнительная информация в ленте активности Steam",
details: `
<p><strong>Что делает:</strong> Добавляет всплывающую подсказку при наведении на название игры в вашей ленте активности Steam (<a href="https://steamcommunity.com/my/home" target="_blank" style="color:#67c1f5;">steamcommunity.com/my/home</a>).</p>
<p>Подсказка содержит подробную информацию об игре, аналогичную той, что показывается в каталоге поиска:</p>
<ul>
<li>Название и изображение-шапка.</li>
<li>Дата выхода.</li>
<li>Издатели, разработчики, серия игр.</li>
<li>Отзывы (% и кол-во).</li>
<li>Статус раннего доступа.</li>
<li>Поддержка русского и английского языков.</li>
<li>Первые 5 меток.</li>
<li>Краткое описание.</li>
</ul>
<p>Данные загружаются через API Steam.</p>
<img src="https://i.imgur.com/xE75iU8.png" alt="Пример Инфо в Ленте" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
stelicasRoulette: {
category: 'community',
label: "Рулетка Stelicas",
title: "Рулетка Stelicas - Случайный выбор игры из ваших коллекций",
details: `
<p><strong>Что делает:</strong> Добавляет блок "Рулетка Stelicas" на страницу вашей активности Steam (<a href="https://steamcommunity.com/my/home" target="_blank" style="color:#67c1f5;">steamcommunity.com/my/home</a>). Позволяет загрузить CSV-файл, сгенерированный приложением <a href="https://github.com/0wn3dg0d/Stelicas" target="_blank" style="color:#67c1f5;">Stelicas</a>, применить к нему разнообразные фильтры и случайным образом выбрать игру из вашей коллекции.</p>
<p><strong>Возможности:</strong></p>
<ul style="margin-left: 20px; padding-left: 5px; list-style-type: disc;">
<li style="margin-bottom: 0.5em;">Загрузка CSV-данных из Stelicas (содержащих информацию о ваших играх и коллекциях).</li>
<li style="margin-bottom: 0.5em;"><strong>Система фильтрации:</strong> по категориям коллекций Stelicas, дате выхода, пользовательским тегам, поддержке русского языка (интерфейс, субтитры, озвучка), а также по диапазонам количества отзывов и общего рейтинга игры.</li>
<li style="margin-bottom: 0.5em;">Анимированная рулетка для выбора случайной игры из отфильтрованного списка.</li>
<li style="margin-bottom: 0.5em;">Возможность включить приоритет по отзывам и рейтингу, чтобы игры с лучшими показателями имели больше шансов на выпадение.</li>
<li style="margin-bottom: 0.5em;">Переключение в режим <strong>просмотра всей отфильтрованной подборки</strong> игр в виде удобных карточек (изображение и название).</li>
<li style="margin-bottom: 0.5em;">Отображение подробной информации о выбранной (или просматриваемой в подборке) игре: постер, название, рейтинг и количество отзывов, краткое описание, основные теги, точная дата выхода, разработчики/издатели, информация о поддержке русского языка.</li>
<li style="margin-bottom: 0.5em;">Прямые ссылки на страницу игры в сообществе Steam и для её запуска через протокол <code>steam://run/<AppID></code>.</li>
</ul>
<p><strong>Как пользоваться:</strong></p>
<ul style="margin-left: 20px; padding-left: 5px; list-style-type: disc;">
<li style="margin-bottom: 0.5em;">Подробная инструкция по подготовке CSV-файла и использованию всех функций рулетки доступна по нажатию на значок вопроса <strong>?</strong> в правом верхнем углу окна самой рулетки.</li>
</ul>
<p><em>Примечание: Качество работы и полнота информации в рулетке напрямую зависят от корректности и актуальности данных в предоставленном CSV-файле из Stelicas.</em></p>
<img src="https://i.imgur.com/KDfW10m.png" alt="Пример модального окна Рулетки Stelicas с фильтрами" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333; border-radius: 4px;">
`
},
// --- Торговая площадка ---
Kaznachei: {
category: 'market',
label: "Продажи предмета",
title: "Информация об исторических продажах на торговой площадке Steam",
details: `
<p><strong>Что делает:</strong> Добавляет информационный блок на страницу предмета на торговой площадке Steam (<a href="https://steamcommunity.com/market/" target="_blank" style="color:#67c1f5;">steamcommunity.com/market/</a>).</p>
<p>Блок содержит:</p>
<ul>
<li>Таблицу с историей продаж по годам:
<ul>
<li>Общая сумма продаж за год (в рублях).</li>
<li>Примерная сумма, полученная разработчиком игры.</li>
<li>Примерная сумма, полученная Valve.</li>
</ul>
</li>
<li>Итоговую сумму продаж за все время.</li>
<li>Итоговые суммы, полученные разработчиком и Valve.</li>
</ul>
<p>Данные загружаются через API истории цен Steam.</p>
<img src="https://i.imgur.com/ZPnzyNH.png" alt="Пример Продаж 1" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
`
},
// --- Новости / Список желаемого ---
Sledilka: {
category: 'news_wishlist',
label: "Наблюдатель (Желаемое/Библиотека)",
title: "Наблюдатель: Отслеживание изменений в списке желаемого и библиотеке",
details: `
<p><strong>Что делает:</strong> Отслеживает изменения в вашем списке желаемого Steam и в вашей библиотеке игр, отображает календарь релизов.</p>
<p><strong>Основные функции:</strong></p>
<ol style="margin-left: 20px; padding-left: 5px; list-style-type: decimal;">
<li style="margin-bottom: 0.7em;">В правом верхнем углу страниц Steam появляется кнопка "Наблюдатель".</li>
<li style="margin-bottom: 0.7em;"><strong>Индикаторы статуса (Ж/Б):</strong> Показывают, как давно обновлялись данные для <strong>Ж</strong>елаемого и <strong>Б</strong>иблиотеки.</li>
<li style="margin-bottom: 0.7em;"><strong>Счетчик уведомлений:</strong> Показывает количество новых (непрочитанных) изменений.</li>
<li style="margin-bottom: 0.7em;"><strong>Панель уведомлений (по щелчку на кнопку):</strong>
<ul style="margin-top: 0.8em; margin-left: 15px; list-style-type: disc;">
<li style="margin-bottom: 0.5em;">Кнопка "Обновить" для ручного запуска проверки (использует Steam API).</li>
<li style="margin-bottom: 0.5em;"><strong>Настройки (значок ⚙️):</strong> Открывает выпадающее меню с опциями:
<ul style="margin-top: 0.5em; margin-left: 15px; list-style-type: square;">
<li style="margin-bottom: 0.3em;"><strong>Список желаемого / Библиотека:</strong> Позволяют включать/отключать проверку для каждой из секций.</li>
<li style="margin-bottom: 0.3em;"><strong>Перепроверять игры с русским:</strong> <em>(Опция для Библиотеки)</em>. По умолчанию включена. Если её <strong>отключить</strong>, скрипт пропустит проверку игр, для которых уже известно о наличии <strong>любой</strong> русской локализации. Это значительно ускоряет повторные обновления.</li>
<li style="margin-bottom: 0.3em;"><strong>...только без полной локализации:</strong> <em>(Доступна, если предыдущая опция отключена)</em>. Если включить, то из повторной проверки будут исключаться только игры с <strong>полной</strong> локализацией (интерфейс+озвучка+субтитры). Игры с частичным переводом продолжат проверяться.</li>
</ul>
</li>
<li style="margin-bottom: 0.5em;">Список изменений:
<ul style="margin-top: 0.5em; margin-left: 15px; list-style-type: square;">
<li style="margin-bottom: 0.3em;"><strong>Список желаемого:</strong> Изменение даты выхода, статуса раннего доступа или русского языка.</li>
<li style="margin-bottom: 0.3em;"><strong>Библиотека:</strong> Выход игры из раннего доступа, появление/изменение русского языка (и тип локализации).</li>
</ul>
</li>
<li style="margin-bottom: 0.5em;">Кнопки для отметки уведомления как прочитанного (конверт) или удаления (крестик).</li>
<li style="margin-bottom: 0.5em;">Кнопка "Очистить" для удаления всех уведомлений.</li>
<li style="margin-bottom: 0.5em;">Кнопка "Календарь".</li>
<li style="margin-bottom: 0.5em;">Кнопка "Хранилище" для очистки сохраненных данных.</li>
</ul>
</li>
<img src="https://i.imgur.com/BpuDq6U.png" alt="Пример Трекера 1" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
<hr style="border: none; border-top: 1px solid #444a52; margin: 1.5em 0;">
<li style="margin-bottom: 0.7em;"><strong>Календарь релизов (по щелчку на кнопку "Календарь"):</strong>
<ul style="margin-top: 0.8em; margin-left: 15px; list-style-type: disc;">
<li style="margin-bottom: 0.5em;">Отображает игры из вашего списка желаемого в виде календаря по месяцам.</li>
<li style="margin-bottom: 0.5em;">Показывает игры с точными датами выхода в будущем.</li>
<li style="margin-bottom: 0.5em;">Для игр с примерной датой (месяц, квартал, год) отображается подсказка при наведении.</li>
<li style="margin-bottom: 0.5em;">Позволяет подгружать следующие месяцы.</li>
</ul>
</li>
<img src="https://i.imgur.com/b5PDYG3.png" alt="Пример Календаря" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
<hr style="border: none; border-top: 1px solid #444a52; margin: 1.5em 0;">
<li style="margin-bottom: 0.7em;"><strong>Хранилище (по щелчку на кнопку "Хранилище"):</strong>
<ul style="margin-top: 0.8em; margin-left: 15px; list-style-type: disc;">
<li style="margin-bottom: 0.5em;">Позволяет очистить кэш дат/статусов для списка желаемого или для игр библиотеки.</li>
</ul>
</li>
<img src="https://i.imgur.com/nI6Uoo0.png" alt="Пример Хранилища" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
</ol>
<p>Требует авторизации. Обработка больших списков/библиотек может занять время. Используйте новые опции в настройках для ускорения сканирования библиотеки.</p>
`
},
newsFilter: {
category: 'news_wishlist',
label: "Фильтр новостей",
title: "Система скрытия новостей в новостном центре",
details: `
<p><strong>Что делает:</strong> Позволяет гибко управлять отображением новостей в новостном центре Steam (<a href="https://store.steampowered.com/news/" target="_blank" style="color:#67c1f5;">store.steampowered.com/news/</a>), скрывая неинтересные материалы.</p>
<p><strong>Основные возможности и использование:</strong></p>
<ol style="margin-left: 20px; padding-left: 5px; list-style-type: decimal;">
<li style="margin-bottom: 0.7em;">
<strong>Выбор новостей для скрытия:</strong>
<ul>
<li>На каждой новости в правой части изображения появляется крупный квадратный чекбокс.</li>
<li>При установке галочки новость становится полупрозрачной (<em>"мягкое" скрытие</em>) и отмечается для последующего подтверждения скрытия. Повторный щелчок снимает отметку.</li>
</ul>
</li>
<li style="margin-bottom: 0.7em;">
<strong>Панель управления (справа вверху):</strong>
<ul>
<li><strong>"Скрыть выбранные (X)":</strong> Нажатие этой кнопки перемещает все отмеченные (полупрозрачные) новости в постоянное хранилище. Новость исчезает с экрана (или становится затемненной, если включен режим "Показать скрытое"). Счетчик в скобках показывает, сколько новостей сейчас выбрано.</li>
<li><strong>"В хранилище: X":</strong> Эта надпись показывает общее количество новостей, находящихся в вашем постоянном хранилище скрытых новостей.</li>
<li><strong>"Отменить":</strong> Появляется после подтверждения скрытия и активна 6 секунд. Позволяет отменить последнее действие по добавлению новостей в хранилище.</li>
<li><strong>"Показать скрытое" / "Спрятать скрытое":</strong> Переключатель. Если выбрано "Показать скрытое", новости из вашего хранилища будут отображаться в ленте, но в затемненном виде. В режиме "Спрятать скрытое" они полностью исчезают.</li>
<li><strong>"Хранилище":</strong> Открывает модальное окно для управления списком постоянно скрытых новостей.</li>
</ul>
</li>
<img src="https://i.imgur.com/iYTtyWk.png" alt="Пример интерфейса фильтра новостей" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
<hr style="border: none; border-top: 1px solid #444a52; margin: 1.5em 0;">
<li style="margin-bottom: 0.7em;">
<strong>Панель "Хранилище скрытых новостей":</strong>
<ul>
<li>Отображает список всех новостей, добавленных в постоянное хранилище. Для каждой записи указывается название игры, заголовок новости и ее AppID.</li>
<li><strong>"Вернуть":</strong> Кнопка напротив каждой записи позволяет удалить новость из хранилища и немедленно отобразить ее в ленте (если она еще присутствует в DOM). Чекбокс на этой новости также снова станет активным.</li>
<li><strong>"Очистить хранилище":</strong> Удаляет все новости из вашего списка постоянно скрытых. Требует подтверждения.</li>
<li><strong>"Закрыть":</strong> Закрывает панель хранилища.</li>
<img src="https://i.imgur.com/T5pUb9a.png" alt="Пример интерфейса хранилища" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">
</ul>
</li>
</ol>
`
},
wishlistGiftHelper: {
category: 'news_wishlist',
label: "Доступность подарков (список желаемого)",
title: "Доступность подарков (список желаемого)",
details: `
<p><strong>Что делает:</strong> Добавляет кнопку со значком лупы <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="currentColor" style="vertical-align: middle;"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></svg> на страницу списка желаемого, позволяющую определить, какие игры можно подарить друзьям в других регионах.</p>
<p><strong>Основные функции:</strong></p>
<ul>
<li>Загружает игры из отображаемого списка желаемого и выводит их в виде информативных карточек с возможностью сортировки.</li>
<li>Активирует режим <strong>помощника подарков</strong>:
<ul>
<li>Вы выбираете регион вашего друга.</li>
<li>Скрипт запрашивает цены на игры из списка желаемого для выбранного региона.</li>
<li>Цены друга конвертируются в вашу валюту (используется API курсов валют).</li>
<li>Отображается <strong>разница в цене</strong> между вашим регионом и регионом друга (с цветовой индикацией: <span style="color:#77dd77; font-weight:bold;">зелёный</span> - можно дарить (разница до ±10%), <span style="color:#ff6961; font-weight:bold;">красный</span> - нельзя).</li>
<li>Доступен фильтр <strong>"Можно подарить"</strong>, который показывает только те игры, у которых разница в цене до ±10% и которые Steam разрешает покупать в подарок.</li>
</ul>
</li>
</ul>
<p>Это помогает легко найти подходящие и экономически целесообразные подарки для друзей за границей.</p>
<p><i><small>*Примечание: Скорость загрузки данных зависит от размера списка желаемого.</small></i></p>
<img src="https://i.imgur.com/WPbhyPI.png" alt="Пример WishlistGiftHelper 1" style="max-width: 90%; height: auto; margin-top: 10px; display: block; margin-left: auto; margin-right: auto; border: 1px solid #333;">`
},
// --- Дополнительные ---
autoExpandHltb: {
category: 'additional',
label: "Авто-раскрытие HLTB",
title: "Автоматически раскрывать спойлер HLTB",
details: "<p>Если включено, блок с информацией о времени прохождения (HLTB) на странице игры будет автоматически раскрываться при загрузке страницы (если основной модуль HLTB включен).</p><p>Удобно, если вы всегда хотите видеть эту информацию без лишнего щелчка.</p>"
},
autoLoadReviews: {
category: 'additional',
label: "Авто-загрузка доп. обзоров",
title: "Автоматически загружать дополнительные обзоры",
details: "<p>Если включено, блок с дополнительными обзорами (Тотальные, Безкитайские, Русские) на странице игры будет загружаться автоматически при загрузке страницы (если основной модуль 'Индикаторы/Обзоры' включен).</p><p>Экономит щелчок, если вам всегда нужна эта статистика.</p>"
},
toggleEnglishLangInfo: {
category: 'additional',
label: "Показ инфо об англ. языке",
title: "Отображать данные об английском языке",
details: "<p><strong>Функция для переводчиков и интересующихся.</strong> Если включено, в блоках дополнительной информации (в каталоге при наведении и в ленте активности при наведении) будет также отображаться информация о поддержке английского языка (интерфейс, озвучка, субтитры), аналогично русскому.</p><p>По умолчанию эта информация скрыта для экономии места.</p>"
}
};
function showInfoModal(settingKey) {
const existingInfoModal = document.getElementById('useSettingInfoModal');
if (existingInfoModal) existingInfoModal.remove();
const infoData = settingInfo[settingKey];
if (!infoData) return;
const infoModal = document.createElement('div');
infoModal.id = 'useSettingInfoModal';
infoModal.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #1f2c3a;
color: #c6d4df;
padding: 25px;
border-radius: 5px;
border: 1px solid #8f98a0;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.7);
z-index: 10002;
display: block;
width: 800px;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
font-family: "Motiva Sans",
Sans-serif,
Arial;
font-size: 14px;
scrollbar-color: #4b6f9c #1b2838;
scrollbar-width: thin;
`;
infoModal.style.setProperty('--scrollbar-track-color-info', '#1b2838');
infoModal.style.setProperty('--scrollbar-thumb-color-info', '#4b6f9c');
GM_addStyle(`
#useSettingInfoModal::-webkit-scrollbar {
width: 8px;
}
#useSettingInfoModal::-webkit-scrollbar-track {
background: var(--scrollbar-track-color-info);
border-radius: 4px;
}
#useSettingInfoModal::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-color-info);
border-radius: 4px;
border: 2px solid var(--scrollbar-track-color-info);
}
#useSettingInfoModal::-webkit-scrollbar-thumb:hover {
background-color: #67c1f5;
}
#useSettingInfoModal p {
margin-bottom: 1em;
line-height: 1.6;
}
#useSettingInfoModal ul {
margin-left: 20px;
margin-bottom: 5px;
list-style-position: outside;
}
#useSettingInfoModal li {
margin-bottom: 0.5em;
}
#useSettingInfoModal strong {
color: #67c1f5;
}
#useSettingInfoModal img {
border-radius: 3px;
}
#useSettingInfoModal a {
color: #67c1f5;
text-decoration: none;
}
#useSettingInfoModal a:hover {
text-decoration: underline;
}
`);
const title = document.createElement('h3');
title.textContent = infoData.title || "Информация";
title.style.cssText = 'margin-top: 0; margin-bottom: 20px; color: #67c1f5; text-align: center; font-weight: 500; font-size: 17px;';
infoModal.appendChild(title);
const detailsDiv = document.createElement('div');
detailsDiv.innerHTML = infoData.details || "Описание отсутствует.";
infoModal.appendChild(detailsDiv);
const closeButton = document.createElement('button');
closeButton.textContent = 'Закрыть';
closeButton.style.cssText = `
display: block; margin: 25px auto 0; padding: 10px 25px;
background-color: #8f98a0; color: #1b2838; border: none;
border-radius: 3px; cursor: pointer; font-size: 14px; font-weight: bold;
transition: background-color 0.2s;
`;
closeButton.onmouseover = () => closeButton.style.backgroundColor = '#aab5c1';
closeButton.onmouseout = () => closeButton.style.backgroundColor = '#8f98a0';
closeButton.addEventListener('click', function() {
infoModal.remove();
});
infoModal.appendChild(closeButton);
document.body.appendChild(infoModal);
}
function createSettingRow(key) {
const settingData = settingInfo[key];
if (!settingData) return null;
const row = document.createElement('div');
row.style.cssText = 'display: flex; align-items: center; justify-content: space-between; min-height: 24px;';
const labelContainer = document.createElement('label');
labelContainer.style.cssText = 'display: flex; align-items: center; cursor: pointer; flex-grow: 1; margin-right: 10px;';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = useCurrentSettings[key];
checkbox.dataset.settingKey = key;
checkbox.style.cssText = 'margin-right: 10px; accent-color: #67c1f5; cursor: pointer; width: 16px; height: 16px; flex-shrink: 0;';
const labelText = document.createElement('span');
labelText.textContent = settingData.label || key;
labelText.style.lineHeight = '1.3';
if (key === 'gamePage') {
labelText.style.color = '#9E9E9E';
checkbox.style.accentColor = '#FFB300';
const dependentLabelsTooltip = dependentModules.gamePage
.map(depKey => `'${settingInfo[depKey]?.label || depKey}'`)
.join(', ');
labelContainer.title = `Отключение этого модуля приведет к нарушению работы или полному отключению модулей: ${dependentLabelsTooltip}. Эти модули критически зависят от данного модуля.`;
}
checkbox.addEventListener('change', function() {
const currentSettingKey = this.dataset.settingKey;
const isChecked = this.checked;
if (currentSettingKey === 'gamePage' && !isChecked) {
const dependentFullNames = dependentModules.gamePage
.map(depKey => `'${settingInfo[depKey]?.label || depKey}'`)
.join(', ');
showConfirmationModal(
'Подтверждение отключения',
`Отключение этого модуля приведёт к отключению модулей: ${dependentFullNames}. Вы уверены?`,
() => {
useCurrentSettings[currentSettingKey] = false;
scriptsConfig[currentSettingKey] = false;
dependentModules.gamePage.forEach(depKey => {
useCurrentSettings[depKey] = false;
scriptsConfig[depKey] = false;
const depCheckbox = document.querySelector(`input[data-setting-key="${depKey}"]`);
if (depCheckbox) {
depCheckbox.checked = false;
}
});
GM_setValue('useSettings', useCurrentSettings);
},
() => {
this.checked = true;
}
);
} else {
useCurrentSettings[currentSettingKey] = isChecked;
scriptsConfig[currentSettingKey] = isChecked;
GM_setValue('useSettings', useCurrentSettings);
}
});
labelContainer.appendChild(checkbox);
labelContainer.appendChild(labelText);
row.appendChild(labelContainer);
const infoButton = document.createElement('span');
infoButton.textContent = 'ⓘ';
infoButton.style.cssText = `
cursor: pointer; color: #67c1f5; font-size: 18px;
line-height: 1; font-weight: bold;
margin-left: 5px; padding: 0 4px; border-radius: 3px; user-select: none;
transition: color 0.2s, background-color 0.2s;
flex-shrink: 0; vertical-align: middle;
`;
infoButton.title = 'Подробнее...';
infoButton.onmouseover = () => { infoButton.style.backgroundColor = 'rgba(103, 193, 245, 0.2)'; };
infoButton.onmouseout = () => { infoButton.style.backgroundColor = 'transparent'; };
infoButton.addEventListener('click', (e) => {
e.stopPropagation();
showInfoModal(key);
});
row.appendChild(infoButton);
return row;
}
function createSettingsModal() {
const existingModal = document.getElementById('useSettingsModal');
if (existingModal) existingModal.remove();
const modal = document.createElement('div');
modal.id = 'useSettingsModal';
modal.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #171a21;
color: #c6d4df;
padding: 30px;
border-radius: 5px;
border: 1px solid #67c1f5;
box-shadow: 0 5px 30px rgba(0, 0, 0, 0.8);
z-index: 10001;
display: block;
width: 800px;
max-height: 90vh;
overflow-y: auto;
font-family: "Motiva Sans",
Sans-serif,
Arial;
font-size: 14px;
scrollbar-color: #4b6f9c #1b2838;
scrollbar-width: thin;
`;
modal.style.setProperty('--scrollbar-track-color', '#1b2838');
modal.style.setProperty('--scrollbar-thumb-color', '#4b6f9c');
GM_addStyle(`
#useSettingsModal::-webkit-scrollbar {
width: 8px;
}
#useSettingsModal::-webkit-scrollbar-track {
background: var(--scrollbar-track-color);
border-radius: 4px;
}
#useSettingsModal::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-color);
border-radius: 4px;
border: 2px solid var(--scrollbar-track-color);
}
#useSettingsModal::-webkit-scrollbar-thumb:hover {
background-color: #67c1f5;
}
#useCreditsFooter {
position: absolute;
bottom: 20px;
right: 25px;
font-size: 12px;
color: #8091a2;
text-align: right;
line-height: 1.4;
z-index: 1;
}
#useCreditsFooter a {
color: #8f98a0;
text-decoration: none;
}
#useCreditsFooter a:hover {
color: #67c1f5;
text-decoration: underline;
}
#useCreditsFooter .author-line {
margin-bottom: 3px;
}
`);
const mainTitleHeader = document.createElement('h2');
mainTitleHeader.textContent = 'Настройки Ultimate Steam Enhancer';
mainTitleHeader.style.cssText = 'margin-top: 0; margin-bottom: 25px; color: #67c1f5; text-align: center; font-weight: 500; font-size: 18px;';
modal.appendChild(mainTitleHeader);
const categories = {
gamePage: { title: 'Для страницы игры', container: document.createElement('div') },
catalog: { title: 'Для каталога', container: document.createElement('div') },
community: { title: 'Для ленты активности', container: document.createElement('div') },
market: { title: 'Для торговой площадки', container: document.createElement('div') },
news_wishlist: { title: 'Для списка желаемого / Новостей', container: document.createElement('div') },
additional: { title: 'Дополнительные настройки', container: document.createElement('div') }
};
for (const catKey in categories) {
const category = categories[catKey];
category.container.style.marginBottom = '25px';
const categoryTitle = document.createElement('h4');
categoryTitle.textContent = category.title;
categoryTitle.style.cssText = 'color: #c6d4df; border-bottom: 1px solid #4b6f9c; padding-bottom: 6px; margin-bottom: 12px; font-size: 15px; font-weight: normal;';
category.container.appendChild(categoryTitle);
const checkboxesGrid = document.createElement('div');
checkboxesGrid.style.cssText = 'display: grid; grid-template-columns: 1fr 1fr; gap: 10px 25px;';
category.container.appendChild(checkboxesGrid);
modal.appendChild(category.container);
}
for (const key of Object.keys(settingInfo)) {
const settingData = settingInfo[key];
if (settingData && settingData.category && categories[settingData.category]) {
if (useCurrentSettings.hasOwnProperty(key)) {
const settingRow = createSettingRow(key);
if (settingRow) {
const gridContainer = categories[settingData.category].container.querySelector('div[style*="grid-template-columns"]');
if (gridContainer) {
gridContainer.appendChild(settingRow);
}
}
}
}
}
const creditsFooter = document.createElement('div');
creditsFooter.id = 'useCreditsFooter';
const authorLine = document.createElement('div');
authorLine.className = 'author-line';
authorLine.textContent = 'by 0wn3df1x';
creditsFooter.appendChild(authorLine);
const zogLine = document.createElement('div');
zogLine.appendChild(document.createTextNode('и '));
const zogLink = document.createElement('a');
zogLink.href = 'https://www.zoneofgames.ru';
zogLink.target = '_blank';
zogLink.title = 'Перейти на ZoneOfGames.ru';
zogLink.textContent = 'команда ZoneOfGames.ru';
zogLine.appendChild(zogLink);
creditsFooter.appendChild(zogLine);
modal.appendChild(creditsFooter);
const closeButton = document.createElement('button');
closeButton.textContent = 'Закрыть';
closeButton.style.cssText = `
display: block; margin: 30px auto 0; padding: 10px 30px;
background-color: #67c1f5; color: #1b2838; border: none;
border-radius: 3px; cursor: pointer; font-size: 15px; font-weight: bold;
transition: background-color 0.2s;
`;
closeButton.onmouseover = () => closeButton.style.backgroundColor = '#8ad3f7';
closeButton.onmouseout = () => closeButton.style.backgroundColor = '#67c1f5';
closeButton.addEventListener('click', function() {
modal.remove();
});
modal.appendChild(closeButton);
document.body.appendChild(modal);
}
function addLoggedInSettingsMenuItem(accountDropdown) {
const logoutLink = accountDropdown.querySelector('a[href="javascript:Logout();"]');
if (!logoutLink || document.getElementById('use_settings_menu_item')) {
return;
}
const settingsMenuItem = document.createElement('a');
settingsMenuItem.className = 'popup_menu_item';
settingsMenuItem.id = 'use_settings_menu_item';
settingsMenuItem.href = '#';
settingsMenuItem.textContent = 'Настройки U.S.E.';
settingsMenuItem.style.color = '#67c1f5';
settingsMenuItem.style.fontWeight = 'bold';
settingsMenuItem.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
createSettingsModal();
});
const popupBody = accountDropdown.querySelector('.popup_body.popup_menu');
if (popupBody) {
popupBody.insertBefore(settingsMenuItem, logoutLink);
}
}
function addLoggedOutSettingsMenuItem(globalActionMenu) {
const languagePulldown = document.getElementById('language_pulldown');
const loginLink = globalActionMenu.querySelector('a[href*="/login"]');
if (!languagePulldown || !loginLink || document.getElementById('use_settings_logged_out_link')) {
return;
}
const separator = document.createTextNode('\u00A0|\u00A0');
const settingsLinkElement = document.createElement('a');
settingsLinkElement.href = '#';
settingsLinkElement.id = 'use_settings_logged_out_link';
settingsLinkElement.className = 'global_action_link';
settingsLinkElement.textContent = 'Настройки U.S.E.';
settingsLinkElement.style.color = '#67c1f5';
settingsLinkElement.style.fontWeight = 'bold';
settingsLinkElement.addEventListener('click', (e) => {
e.preventDefault();
createSettingsModal();
});
globalActionMenu.appendChild(separator);
globalActionMenu.appendChild(settingsLinkElement);
}
function addSettingsButtonGlobal() {
const globalActionMenu = document.getElementById('global_action_menu');
if (!globalActionMenu) {
return;
}
const existingLoggedInButton = document.getElementById('use_settings_menu_item');
if (existingLoggedInButton) {
existingLoggedInButton.remove();
}
const existingLoggedOutLink = document.getElementById('use_settings_logged_out_link');
if (existingLoggedOutLink) {
const prevNode = existingLoggedOutLink.previousSibling;
if (prevNode && prevNode.nodeType === Node.TEXT_NODE && prevNode.textContent === '\u00A0|\u00A0') {
prevNode.remove();
}
existingLoggedOutLink.remove();
}
const accountDropdown = document.getElementById('account_dropdown');
if (accountDropdown) {
addLoggedInSettingsMenuItem(accountDropdown);
} else {
addLoggedOutSettingsMenuItem(globalActionMenu);
}
}
let globalUiAttempts = 0;
const globalUiMaxAttempts = 20;
const globalUiInterval = setInterval(() => {
const globalHeader = document.getElementById('global_header');
if (globalHeader || globalUiAttempts >= globalUiMaxAttempts) {
clearInterval(globalUiInterval);
if (globalHeader) {
addSettingsButtonGlobal();
const observer = new MutationObserver((mutationsList, obs) => {
const isNowLoggedIn = !!document.getElementById('account_dropdown');
const loggedInButtonPresent = !!document.getElementById('use_settings_menu_item');
const loggedOutLinkPresent = !!document.getElementById('use_settings_logged_out_link');
let needsRebuild = false;
if (isNowLoggedIn) {
if (!loggedInButtonPresent || loggedOutLinkPresent) {
needsRebuild = true;
}
} else {
const loginPageMarker = !!document.querySelector('#global_action_menu a[href*="/login"]');
if (loginPageMarker) {
if (!loggedOutLinkPresent || loggedInButtonPresent) {
needsRebuild = true;
}
} else if (loggedInButtonPresent || loggedOutLinkPresent) {
needsRebuild = true;
}
}
if (needsRebuild) {
addSettingsButtonGlobal();
}
});
observer.observe(globalHeader, {
childList: true,
subtree: true
});
}
}
globalUiAttempts++;
}, 500);
function showConfirmationModal(titleText, messageText, onConfirm, onCancel) {
const existingConfirmModal = document.getElementById('useConfirmationModal');
if (existingConfirmModal) existingConfirmModal.remove();
const confirmModal = document.createElement('div');
confirmModal.id = 'useConfirmationModal';
confirmModal.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #1f2c3a;
color: #c6d4df;
padding: 25px;
border-radius: 5px;
border: 1px solid #FFB300;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.7);
z-index: 10003;
display: block;
width: 500px;
max-width: 90vw;
font-family: "Motiva Sans", Sans-serif, Arial;
font-size: 14px;
`;
const title = document.createElement('h3');
title.textContent = titleText;
title.style.cssText = 'margin-top: 0; margin-bottom: 15px; color: #FFB300; text-align: center; font-weight: 500; font-size: 17px;';
confirmModal.appendChild(title);
const message = document.createElement('p');
message.textContent = messageText;
message.style.cssText = 'margin-bottom: 25px; line-height: 1.6; text-align: center;';
confirmModal.appendChild(message);
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = 'display: flex; justify-content: space-around;';
const confirmButton = document.createElement('button');
confirmButton.textContent = 'Да';
confirmButton.style.cssText = `
padding: 10px 25px; background-color: #FFB300; color: #1b2838; border: none;
border-radius: 3px; cursor: pointer; font-size: 14px; font-weight: bold;
transition: background-color 0.2s;
`;
confirmButton.onmouseover = () => confirmButton.style.backgroundColor = '#FFC107';
confirmButton.onmouseout = () => confirmButton.style.backgroundColor = '#FFB300';
confirmButton.addEventListener('click', function() {
onConfirm();
confirmModal.remove();
});
const cancelButton = document.createElement('button');
cancelButton.textContent = 'Нет';
cancelButton.style.cssText = `
padding: 10px 25px; background-color: #8f98a0; color: #1b2838; border: none;
border-radius: 3px; cursor: pointer; font-size: 14px; font-weight: bold;
transition: background-color 0.2s;
`;
cancelButton.onmouseover = () => cancelButton.style.backgroundColor = '#aab5c1';
cancelButton.onmouseout = () => cancelButton.style.backgroundColor = '#8f98a0';
cancelButton.addEventListener('click', function() {
onCancel();
confirmModal.remove();
});
buttonContainer.appendChild(confirmButton);
buttonContainer.appendChild(cancelButton);
confirmModal.appendChild(buttonContainer);
document.body.appendChild(confirmModal);
}
// Скрипт для страницы игры (индикаторы о наличии русского перевода; получение дополнительных обзоров) | https://store.steampowered.com/app/*
if (scriptsConfig.gamePage && unsafeWindow.location.pathname.includes('/app/')) {
(function() {
'use strict';
if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels);
} else {
console.error("ChartDataLabels plugin is not loaded. Make sure the @require line is correct.");
}
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}`;
if (svgElement) {
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 existingIndicatorContainer = lemon.querySelector('.use-rus-indicator-container');
if (existingIndicatorContainer) {
existingIndicatorContainer.remove();
}
const lime = document.createElement('div');
lime.className = 'use-rus-indicator-container';
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 additionalReviewsSettings = {
showTotalReviews: true,
showNonChineseReviews: true,
showRussianReviews: true
};
let allReviewsDataGlobal = null;
let schineseDataGlobal = null;
let russianDataGlobal = null;
let appidGlobal = null;
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,
timeout: 15000,
onload: function(response) {
if (response.status >= 200 && response.status < 400) {
try {
let data = JSON.parse(response.responseText);
if (data.success === 1) {
callback(data.query_summary);
} else {
console.error(`Steam API error for ${language}:`, data);
callback(null);
}
} catch (e) {
console.error(`Error parsing Steam API response for ${language}:`, e);
callback(null);
}
} else {
console.error(`Steam API request failed for ${language}. Status: ${response.status}`);
callback(null);
}
},
onerror: function(error) {
console.error(`Network error fetching reviews for ${language}:`, error);
callback(null);
},
ontimeout: function() {
console.error(`Timeout fetching reviews for ${language}`);
callback(null);
}
});
}
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,
timeout: 15000,
onload: function(response) {
if (response.status >= 200 && response.status < 400) {
try {
let data = JSON.parse(response.responseText);
if (data.success === 1) {
callback(data.html);
} else {
console.error('Error fetching Russian reviews HTML (success != 1):', data);
callback('<p style="color: #ff6961;">Ошибка загрузки обзоров.</p>');
}
} catch (e) {
console.error('Error parsing Russian reviews HTML:', e);
callback('<p style="color: #ff6961;">Ошибка обработки ответа.</p>');
}
} else {
console.error(`Failed to fetch Russian reviews HTML. Status: ${response.status}`);
callback('<p style="color: #ff6961;">Ошибка сети при загрузке обзоров.</p>');
}
},
onerror: function(error) {
console.error('Network error fetching Russian reviews HTML:', error);
callback('<p style="color: #ff6961;">Ошибка сети.</p>');
},
ontimeout: function() {
console.error('Timeout fetching Russian reviews HTML');
callback('<p style="color: #ff6961;">Таймаут загрузки обзоров.</p>');
}
});
}
function addStyles() {
GM_addStyle(`
.additional-reviews {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #3e4c583d;
}
.additional-reviews .user_reviews_summary_row {
display: flex;
line-height: 16px;
margin-bottom: 5px;
}
.additional-reviews .user_reviews_summary_row.clickable {
cursor: pointer;
transition: background-color 0.2s;
border-radius: 2px;
padding: 2px 4px;
margin: 0 5px 5px -4px;
}
.additional-reviews .user_reviews_summary_row.clickable:hover {
background-color: rgba(103, 193, 245, 0.1);
}
.additional-reviews .subtitle {
flex: 1;
color: #556772;
font-size: 12px;
user-select: none;
}
.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;
}
.additional-reviews .summary-error {
color: #ff6961;
font-style: italic;
}
.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.85);
backdrop-filter: blur(3px);
}
.ofxmodal-content {
background-color: #1b2838;
margin: 8% auto;
padding: 25px;
border: 1px solid #567d9c;
width: 80%;
max-width: 900px;
color: #c6d4df;
position: relative;
max-height: 80vh;
overflow-y: auto;
border-radius: 4px;
font-family: "Motiva Sans", Arial, sans-serif;
scrollbar-color: #4b6f9c #1b2838;
scrollbar-width: thin;
}
.ofxmodal-content::-webkit-scrollbar {
width: 8px;
}
.ofxmodal-content::-webkit-scrollbar-track {
background: #1b2838;
border-radius: 4px;
}
.ofxmodal-content::-webkit-scrollbar-thumb {
background-color: #4b6f9c;
border-radius: 4px;
border: 2px solid #1b2838;
}
.ofxclose {
background: none;
border: none;
color: #aaa;
font-size: 30px;
font-weight: normal;
padding: 0 5px;
line-height: 1;
cursor: pointer;
transition: color 0.2s ease;
position: absolute;
top: 15px;
right: 15px;
z-index: 1001;
}
.ofxclose:hover {
color: #fff;
background: none;
transform: none;
}
.ofxclose:active {
color: #fff;
background: none;
transform: none;
}
.refresh-button {
position: absolute;
top: 20px;
left: 25px;
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;
font-weight: 500;
}
.refresh-button:hover {
background: #45b0e6;
color: #fff;
}
.refresh-button:active {
background: #329cd4;
transform: translateY(1px);
}
#reviews-container .review_box {
background-color: #16202d;
border: 1px solid #2a3f5a;
margin-bottom: 15px;
border-radius: 3px;
}
#reviews-container .title {
color: #67c1f5;
}
#reviews-container .hours {
color: #8f98a0;
}
#reviews-container .content {
color: #acb2b8;
}
#reviews-container .posted,
#reviews-container .found_helpful {
color: #556772;
font-size: 11px;
}
#globalReviewsModal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(5px);
color: #c6d4df;
font-family: "Motiva Sans", Arial, sans-serif;
}
#globalReviewsModal .modal-content-inner {
background-color: #101822;
margin: 3vh auto;
padding: 0;
border: 1px solid #67c1f5;
width: 94%;
max-width: 1400px;
height: 94vh;
display: flex;
flex-direction: column;
border-radius: 5px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
}
#globalReviewsModal .modal-header {
padding: 15px 25px;
background-color: #16202d;
border-bottom: 1px solid #2a3f5a;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
#globalReviewsModal .modal-title {
font-size: 20px;
color: #67c1f5;
font-weight: 500;
margin: 0;
}
#globalReviewsModal .modal-close-btn {
font-size: 30px;
color: #aaa;
background: none;
border: none;
cursor: pointer;
line-height: 1;
padding: 0 5px;
transition: color 0.2s;
}
#globalReviewsModal .modal-close-btn:hover {
color: #fff;
}
#globalReviewsModal .modal-body {
display: flex;
flex-grow: 1;
overflow: hidden;
padding: 20px 25px;
gap: 25px;
}
#globalReviewsModal .modal-left-panel {
width: 55%;
display: flex;
flex-direction: column;
overflow: hidden;
}
#globalReviewsModal .modal-right-panel {
width: 45%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
background-color: #16202d;
border-radius: 4px;
border: 1px solid #2a3f5a;
padding: 15px;
}
#globalReviewsModal .controls-area {
margin-bottom: 15px;
display: flex;
gap: 15px;
align-items: center;
flex-shrink: 0;
}
#globalReviewsModal .collect-btn {
background: #66c0f4;
color: #1b2838;
padding: 10px 22px;
border: none;
cursor: pointer;
border-radius: 3px;
font-size: 14px;
font-weight: bold;
transition: background 0.2s, transform 0.1s;
}
#globalReviewsModal .collect-btn:hover:not(:disabled) {
background: #8ad3f7;
}
#globalReviewsModal .collect-btn:active:not(:disabled) {
transform: scale(0.98);
}
#globalReviewsModal .collect-btn:disabled {
background: #556772;
color: #8f98a0;
cursor: not-allowed;
opacity: 0.7;
}
#globalReviewsModal .progress-area {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
#globalReviewsModal .progress-bar-container {
width: 100%;
background-color: #0a1016;
border-radius: 5px;
height: 20px;
overflow: hidden;
border: 1px solid #2a3f5a;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4);
}
#globalReviewsModal .progress-bar-inner {
width: 0%;
height: 100%;
background-color: #4b6f9c;
background-image: linear-gradient(-45deg, rgba(255, 255, 255, .1) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .1) 50%, rgba(255, 255, 255, .1) 75%, transparent 75%, transparent);
background-size: 30px 30px;
transition: width 0.4s ease;
text-align: right;
color: #fff;
font-weight: bold;
line-height: 20px;
font-size: 11px;
padding-right: 8px;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}
#globalReviewsModal .progress-text {
font-size: 11px;
color: #8f98a0;
margin-top: 3px;
text-align: left;
height: 1.2em;
}
#globalReviewsModal .table-container {
flex-grow: 1;
overflow: auto;
border: 1px solid #2a3f5a;
border-radius: 3px;
background: #16202d;
scrollbar-color: #4b6f9c #16202d;
scrollbar-width: thin;
}
#globalReviewsModal .table-container::-webkit-scrollbar {
width: 8px;
}
#globalReviewsModal .table-container::-webkit-scrollbar-track {
background: #16202d;
border-radius: 0 3px 3px 0;
}
#globalReviewsModal .table-container::-webkit-scrollbar-thumb {
background-color: #4b6f9c;
border-radius: 4px;
border: 2px solid #16202d;
}
#globalReviewsModal .reviews-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
#globalReviewsModal .reviews-table th,
#globalReviewsModal .reviews-table td {
padding: 9px 12px;
text-align: left;
border-bottom: 1px solid #2a3f5a;
white-space: nowrap;
}
#globalReviewsModal .reviews-table th {
background-color: #1f2c3a;
color: #8f98a0;
font-weight: normal;
position: sticky;
top: 0;
z-index: 1;
}
#globalReviewsModal .reviews-table td {
color: #acb2b8;
vertical-align: middle;
}
#globalReviewsModal .reviews-table tr:last-child td {
border-bottom: none;
}
#globalReviewsModal .reviews-table tr:hover td {
background-color: rgba(103, 193, 245, 0.05);
}
#globalReviewsModal .reviews-table .col-rank {
width: 40px;
text-align: center;
color: #8f98a0;
}
#globalReviewsModal .reviews-table .col-check {
width: 40px;
text-align: center;
}
#globalReviewsModal .reviews-table .col-lang {
width: auto;
}
#globalReviewsModal .reviews-table .col-count {
width: 90px;
text-align: right;
}
#globalReviewsModal .reviews-table .col-percent-total {
width: 80px;
text-align: right;
color: #8f98a0;
}
#globalReviewsModal .reviews-table .col-percent-pos {
width: 70px;
text-align: right;
}
#globalReviewsModal .reviews-table input[type=checkbox] {
accent-color: #67c1f5;
width: 16px;
height: 16px;
cursor: pointer;
vertical-align: middle;
}
#globalReviewsModal .no-reviews-info {
margin-top: 15px;
font-size: 13px;
color: #8f98a0;
padding: 0 5px;
}
#globalReviewsModal .chart-container {
width: 100%;
height: 100%;
position: relative;
}
#globalReviewsModal #reviewsPieChart {
max-width: 100%;
max-height: 100%;
}
#globalReviewsModal .modal-footer {
padding: 15px 25px;
border-top: 1px solid #2a3f5a;
background-color: #16202d;
text-align: center;
font-size: 12px;
color: #8f98a0;
line-height: 1.4;
flex-shrink: 0;
}
`);
}
function formatNumber(number) {
return number.toLocaleString('ru-RU');
}
function getReviewClass(percent, totalReviews) {
if (totalReviews === 0) return 'no_reviews';
if (percent === null || typeof percent === 'undefined') return 'no_reviews';
if (percent >= 70) return 'positive';
if (percent >= 40) return 'mixed';
return 'negative';
}
function addLoadButton() {
let reviewsContainer = document.querySelector('.user_reviews');
if (reviewsContainer) {
const existingButton = document.getElementById('load-reviews-button');
if (existingButton) existingButton.remove();
const existingReviews = document.querySelector('.additional-reviews');
if (existingReviews) existingReviews.remove();
let additionalReviewsContainer = document.createElement('div');
additionalReviewsContainer.className = 'additional-reviews';
let loadButton = document.createElement('div');
loadButton.className = 'user_reviews_summary_row clickable';
loadButton.id = 'load-reviews-button';
loadButton.innerHTML = `
<div class="subtitle column all">Доп. обзоры:</div>
<div class="summary column">
<span class="game_review_summary no_reviews">Загрузить статистику</span>
</div>
`;
additionalReviewsContainer.appendChild(loadButton);
reviewsContainer.appendChild(additionalReviewsContainer);
loadButton.addEventListener('click', loadAdditionalReviews);
if (scriptsConfig.autoLoadReviews) {
loadAdditionalReviews();
}
}
}
function loadAdditionalReviews() {
appidGlobal = unsafeWindow.location.pathname.match(/\/app\/(\d+)/)[1];
if (!appidGlobal) return;
let loadButton = document.getElementById('load-reviews-button');
if (loadButton) {
loadButton.querySelector('.game_review_summary').textContent = 'Загрузка...';
loadButton.style.pointerEvents = 'none';
loadButton.style.opacity = '0.6';
}
const languagesToFetch = ['all', 'schinese', 'russian'];
let fetchedData = {};
let completedRequests = 0;
languagesToFetch.forEach(language => {
fetchReviews(appidGlobal, language, (summaryData) => {
fetchedData[language] = summaryData;
completedRequests++;
if (completedRequests === languagesToFetch.length) {
allReviewsDataGlobal = fetchedData['all'];
schineseDataGlobal = fetchedData['schinese'];
russianDataGlobal = fetchedData['russian'];
displayAdditionalReviews(allReviewsDataGlobal, schineseDataGlobal, russianDataGlobal);
if (loadButton) {
loadButton.remove();
}
}
});
});
}
function displayAdditionalReviews(allReviews, schineseReviews, russianReviews) {
let additionalReviewsContainer = document.querySelector('.additional-reviews');
if (!additionalReviewsContainer) return;
additionalReviewsContainer.innerHTML = '';
const addReviewRow = (id, title, reviewsData, isClickable = false, onClick = null) => {
const row = document.createElement('div');
row.className = 'user_reviews_summary_row';
if (isClickable) {
row.classList.add('clickable');
row.addEventListener('click', onClick);
}
row.id = id;
let percent = 0;
let total = 0;
let statusClass = 'no_reviews';
let summaryText = 'Нет данных';
let errorText = null;
if (reviewsData === null) {
errorText = 'Ошибка загрузки';
statusClass = 'summary-error';
} else if (reviewsData) {
total = reviewsData.total_reviews;
if (total > 0) {
percent = total > 0 ? Math.round((reviewsData.total_positive / total) * 100) : 0;
statusClass = getReviewClass(percent, total);
summaryText = `${percent}% из ${formatNumber(total)} положительные`;
} else {
summaryText = 'Нет обзоров';
statusClass = 'no_reviews';
}
} else {
summaryText = 'Нет данных';
statusClass = 'no_reviews';
}
row.innerHTML = `
<div class="subtitle column all">${title}:</div>
<div class="summary column">
<span class="game_review_summary ${statusClass}">${errorText || summaryText}</span>
</div>
`;
additionalReviewsContainer.appendChild(row);
};
if (additionalReviewsSettings.showTotalReviews) {
addReviewRow('total-reviews-row', 'Тотальные', allReviews, true, openGlobalReviewsModal);
}
if (additionalReviewsSettings.showNonChineseReviews) {
let nonChineseSummary = null;
let nonChineseError = null;
if (allReviews === null || schineseReviews === null) {
nonChineseError = 'Ошибка загрузки';
} else if (allReviews && schineseReviews) {
nonChineseSummary = { total_reviews: 0, total_positive: 0, review_score: 0 };
nonChineseSummary.total_reviews = Math.max(0, allReviews.total_reviews - schineseReviews.total_reviews);
nonChineseSummary.total_positive = Math.max(0, allReviews.total_positive - schineseReviews.total_positive);
if (nonChineseSummary.total_reviews > 0) {
const percent = Math.round((nonChineseSummary.total_positive / nonChineseSummary.total_reviews) * 100);
if (percent >= 70) nonChineseSummary.review_score = 8;
else if (percent >= 40) nonChineseSummary.review_score = 5;
else nonChineseSummary.review_score = 2;
} else {
nonChineseSummary.review_score = 0;
}
}
const row = document.createElement('div');
row.className = 'user_reviews_summary_row';
row.id = 'non-chinese-reviews-row';
let percent = 0;
let total = 0;
let statusClass = 'no_reviews';
let summaryText = 'Нет данных';
if (nonChineseError) {
summaryText = nonChineseError;
statusClass = 'summary-error';
} else if (nonChineseSummary) {
total = nonChineseSummary.total_reviews;
if (total > 0) {
percent = Math.round((nonChineseSummary.total_positive / total) * 100);
statusClass = getReviewClass(percent, total);
summaryText = `${percent}% из ${formatNumber(total)} положительные`;
} else {
summaryText = 'Нет обзоров';
statusClass = 'no_reviews';
}
}
row.innerHTML = `
<div class="subtitle column all">Безкитайские:</div>
<div class="summary column">
<span class="game_review_summary ${statusClass}">${summaryText}</span>
</div>
`;
additionalReviewsContainer.appendChild(row);
}
if (additionalReviewsSettings.showRussianReviews) {
addReviewRow('russian-reviews-row', 'Русские', russianReviews, true, openRussianReviewsModal);
}
}
function openRussianReviewsModal() {
let modal = document.getElementById('russianReviewsModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'russianReviewsModal';
modal.className = 'ofxmodal';
modal.innerHTML = `
<div class="ofxmodal-content">
<span class="ofxclose" title="Закрыть">×</span>
<button class="refresh-button" id="refresh-reviews">Загрузить актуальные</button>
<h3 style="text-align: center; color: #67c1f5; margin-top: 5px; margin-bottom: 25px;">Русскоязычные обзоры</h3>
<div id="reviews-container" style="padding-top: 10px;">Загрузка...</div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('.ofxclose').addEventListener('click', () => modal.style.display = 'none');
modal.querySelector('#refresh-reviews').addEventListener('click', () => refreshRussianReviews(modal));
modal.addEventListener('click', (event) => {
if (event.target === modal) {
modal.style.display = 'none';
}
});
}
modal.style.display = 'block';
loadRussianReviews(modal, 'all');
}
function refreshRussianReviews(modal) {
modal.querySelector('#reviews-container').innerHTML = 'Загрузка актуальных...';
loadRussianReviews(modal, 'recent');
}
function loadRussianReviews(modal, filter) {
if (!appidGlobal) return;
fetchRussianReviewsHTML(appidGlobal, filter, function(html) {
const container = modal.querySelector('#reviews-container');
container.innerHTML = html;
container.querySelector('#LoadMoreReviewsall')?.remove();
container.querySelector('#LoadMoreReviewsrecent')?.remove();
container.querySelectorAll('a').forEach(a => a.target = '_blank');
});
}
let globalReviewsChart = null;
let allLanguageData = [];
const steamLanguages = {
'english': 'Английский', 'german': 'Немецкий', 'french': 'Французский', 'italian': 'Итальянский',
'koreana': 'Корейский', 'spanish': 'Испанский', 'schinese': 'Упр. китайский',
'tchinese': 'Трад. китайский',
'russian': 'Русский', 'thai': 'Тайский', 'japanese': 'Японский',
'portuguese': 'Португальский', 'polish': 'Польский', 'danish': 'Датский', 'dutch': 'Нидерландский',
'finnish': 'Финский', 'norwegian': 'Норвежский', 'swedish': 'Шведский', 'hungarian': 'Венгерский',
'czech': 'Чешский', 'romanian': 'Румынский', 'turkish': 'Турецкий', 'bulgarian': 'Болгарский',
'greek': 'Греческий', 'ukrainian': 'Украинский', 'latam': 'Испанский Лат. Ам.',
'vietnamese': 'Вьетнамский', 'indonesian': 'Индонезийский', 'brazilian': 'Португ. (Браз.)'
};
function openGlobalReviewsModal() {
let modal = document.getElementById('globalReviewsModal');
if (!modal) {
modal = createGlobalReviewsModalStructure();
document.body.appendChild(modal);
}
const tableBody = document.getElementById('global-reviews-table-body');
const noReviewsInfo = document.getElementById('global-reviews-no-reviews-info');
const collectBtn = document.getElementById('global-reviews-collect-btn');
const progressBar = document.getElementById('global-reviews-progress-bar');
const progressText = document.getElementById('global-reviews-progress-text');
if (tableBody) {
tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding: 20px; color: #8f98a0;">Нажмите "Собрать", чтобы загрузить данные.</td></tr>';
}
if (noReviewsInfo) {
noReviewsInfo.textContent = '';
} else {
console.error("Element with ID 'global-reviews-no-reviews-info' not found during reset!");
}
if (collectBtn) {
collectBtn.disabled = false;
}
if (progressBar) {
progressBar.style.width = '0%';
progressBar.textContent = '';
}
if (progressText) {
progressText.textContent = '';
}
if (globalReviewsChart) {
globalReviewsChart.destroy();
globalReviewsChart = null;
}
const modalElement = document.getElementById('globalReviewsModal');
if (modalElement) {
modalElement.style.display = 'block';
} else {
console.error("Modal element #globalReviewsModal not found when trying to display!");
}
document.body.style.overflow = 'hidden';
}
function closeGlobalReviewsModal() {
const modal = document.getElementById('globalReviewsModal');
if (modal) {
modal.style.display = 'none';
if (modal._escHandler) {
document.removeEventListener('keydown', modal._escHandler);
delete modal._escHandler;
}
}
document.body.style.overflow = '';
}
function createGlobalReviewsModalStructure() {
const modal = document.createElement('div');
modal.id = 'globalReviewsModal';
modal.innerHTML = `
<div class="modal-content-inner">
<div class="modal-header">
<h3 class="modal-title">Глобальный монитор обзоров</h3>
<button class="modal-close-btn" title="Закрыть">×</button>
</div>
<div class="modal-body">
<div class="modal-left-panel">
<div class="controls-area">
<button id="global-reviews-collect-btn" class="collect-btn">Собрать</button>
<div class="progress-area">
<div class="progress-bar-container">
<div id="global-reviews-progress-bar" class="progress-bar-inner"></div>
</div>
<div id="global-reviews-progress-text" class="progress-text"></div>
</div>
</div>
<div class="table-container">
<table class="reviews-table">
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-check" title="Показать на диаграмме">📊</th>
<th class="col-lang">Язык</th>
<th class="col-count">Обзоры</th>
<th class="col-percent-total">% Общ</th>
<th class="col-percent-pos">% 👍</th>
</tr>
</thead>
<tbody id="global-reviews-table-body">
<tr><td colspan="6" style="text-align:center; padding: 20px; color: #8f98a0;">Нажмите "Собрать", чтобы загрузить данные.</td></tr>
</tbody>
</table>
</div>
<div id="global-reviews-no-reviews-info" class="no-reviews-info"></div>
</div>
<div class="modal-right-panel">
<div class="chart-container">
<canvas id="reviewsPieChart"></canvas>
</div>
</div>
</div>
<div class="modal-footer">
<strong>Внимание:</strong> Загрузка данных для каждого языка отправляет отдельный запрос к Steam.<br>Частое использование на множестве игр может привести к временным ограничениям со стороны Steam.
</div>
</div>
`;
modal.querySelector('.modal-close-btn').addEventListener('click', closeGlobalReviewsModal);
modal.querySelector('#global-reviews-collect-btn').addEventListener('click', () => {
startGlobalReviewCollection(appidGlobal, russianDataGlobal, schineseDataGlobal);
});
modal.addEventListener('click', (event) => {
if (event.target === modal) {
closeGlobalReviewsModal();
}
});
modal._escHandler = (event) => {
if (event.key === "Escape") {
closeGlobalReviewsModal();
}
};
document.addEventListener('keydown', modal._escHandler);
return modal;
}
async function startGlobalReviewCollection(appid, existingRussianData, existingSchineseData) {
const collectBtn = document.getElementById('global-reviews-collect-btn');
const progressBar = document.getElementById('global-reviews-progress-bar');
const progressText = document.getElementById('global-reviews-progress-text');
const tableBody = document.getElementById('global-reviews-table-body');
const noReviewsInfo = document.getElementById('global-reviews-no-reviews-info');
if (!collectBtn || !progressBar || !progressText || !tableBody || !noReviewsInfo) {
console.error("[Global Reviews Monitor] Could not find all necessary modal elements. Aborting collection.");
if (tableBody) tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding: 20px; color: #ff6961;">Ошибка: Не найдены элементы интерфейса. Попробуйте закрыть и снова открыть окно.</td></tr>';
return;
}
collectBtn.disabled = true;
tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding: 20px; color: #8f98a0;">Загрузка данных... <span class="spinner"></span></td></tr>';
noReviewsInfo.textContent = '';
if (globalReviewsChart) {
globalReviewsChart.destroy();
globalReviewsChart = null;
}
const canvas = document.getElementById('reviewsPieChart');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
allLanguageData = [];
if (existingRussianData && typeof existingRussianData === 'object') {
allLanguageData.push({ langCode: 'russian', langName: steamLanguages['russian'], summary: existingRussianData, visible: true, fetched: true });
} else {
allLanguageData.push({ langCode: 'russian', langName: steamLanguages['russian'], summary: null, visible: true, fetched: false });
}
if (existingSchineseData && typeof existingSchineseData === 'object') {
allLanguageData.push({ langCode: 'schinese', langName: steamLanguages['schinese'], summary: existingSchineseData, visible: true, fetched: true });
} else {
allLanguageData.push({ langCode: 'schinese', langName: steamLanguages['schinese'], summary: null, visible: true, fetched: false });
}
const languagesToFetch = Object.keys(steamLanguages).filter(lang => lang !== 'russian' && lang !== 'schinese');
const totalLanguagesToFetch = languagesToFetch.length;
let completedFetches = 0;
let errors = 0;
const updateProgress = () => {
const totalCompleted = completedFetches + errors;
const percentage = totalLanguagesToFetch > 0 ? Math.round((totalCompleted / totalLanguagesToFetch) * 100) : 100;
if (progressBar) {
progressBar.style.width = `${percentage}%`;
progressBar.textContent = `${percentage}%`;
}
if (progressText) {
progressText.textContent = `Обработано: ${totalCompleted} из ${totalLanguagesToFetch} языков (Успешно: ${completedFetches}, Ошибок: ${errors})`;
}
};
progressBar.textContent = '';
updateProgress();
for (const langCode of languagesToFetch) {
if (!appid) {
console.error("[Global Reviews Monitor] AppID is missing, stopping fetch loop.");
if (progressText) progressText.textContent = 'Ошибка: AppID не найден.';
if (collectBtn) collectBtn.disabled = false;
return;
}
const langName = steamLanguages[langCode];
try {
const summaryData = await new Promise((resolve) => {
fetchReviews(appid, langCode, resolve);
});
const existingIndex = allLanguageData.findIndex(d => d.langCode === langCode);
if (existingIndex > -1) {
allLanguageData[existingIndex].summary = summaryData;
allLanguageData[existingIndex].fetched = (summaryData !== null);
} else {
allLanguageData.push({ langCode, langName, summary: summaryData, visible: true, fetched: (summaryData !== null) });
}
if (summaryData !== null) {
completedFetches++;
} else {
errors++;
}
} catch (error) {
console.error(`Error processing fetch for ${langName}:`, error);
const existingIndex = allLanguageData.findIndex(d => d.langCode === langCode);
if (existingIndex === -1) {
allLanguageData.push({ langCode, langName, summary: null, visible: true, fetched: false });
}
errors++;
}
updateProgress();
await new Promise(resolve => setTimeout(resolve, 200));
}
renderGlobalReviewResults();
if (collectBtn) collectBtn.disabled = false;
if (progressText) progressText.textContent += ' - Готово!';
}
function renderGlobalReviewResults() {
const tableBody = document.getElementById('global-reviews-table-body');
const noReviewsInfo = document.getElementById('global-reviews-no-reviews-info');
if (!tableBody || !noReviewsInfo) return;
tableBody.innerHTML = '';
noReviewsInfo.textContent = '';
const languagesWithErrors = allLanguageData.filter(data => !data.fetched && data.summary === null);
const validLanguageData = allLanguageData.filter(data => data.fetched && data.summary !== null);
const languagesWithZeroReviews = [];
const totalReviews = validLanguageData.reduce((sum, data) => sum + (data.summary?.total_reviews || 0), 0);
validLanguageData.sort((a, b) => (b.summary?.total_reviews || 0) - (a.summary?.total_reviews || 0));
if (validLanguageData.length === 0 && languagesWithErrors.length === 0) {
tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding: 20px; color: #8f98a0;">Нет обзоров ни на одном языке или не удалось загрузить данные.</td></tr>';
return;
} else if (validLanguageData.length === 0 && languagesWithErrors.length > 0) {
tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding: 20px; color: #ff6961;">Не удалось загрузить данные об обзорах.</td></tr>';
}
validLanguageData.forEach((data, index) => {
const summary = data.summary;
const reviewCount = summary?.total_reviews || 0;
const positiveCount = summary?.total_positive || 0;
const percentTotal = totalReviews > 0 ? ((reviewCount / totalReviews) * 100).toFixed(1) : '0.0';
const percentPositive = reviewCount > 0 ? Math.round((positiveCount / reviewCount) * 100) : 0;
const reviewClass = getReviewClass(percentPositive, reviewCount);
if (reviewCount === 0) {
languagesWithZeroReviews.push(data.langName);
return;
}
const row = document.createElement('tr');
row.dataset.langCode = data.langCode;
row.innerHTML = `
<td class="col-rank">${index + 1}</td>
<td class="col-check"><input type="checkbox" class="chart-toggle-checkbox" data-lang-code="${data.langCode}" ${data.visible ? 'checked' : ''}></td>
<td class="col-lang">${data.langName}</td>
<td class="col-count">${formatNumber(reviewCount)}</td>
<td class="col-percent-total">${percentTotal}%</td>
<td class="col-percent-pos"><span class="game_review_summary ${reviewClass}">${reviewCount > 0 ? percentPositive + '%' : '-'}</span></td>
`;
tableBody.appendChild(row);
});
tableBody.querySelectorAll('.chart-toggle-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', handleChartToggle);
});
let infoTextParts = [];
if (languagesWithZeroReviews.length > 0) {
infoTextParts.push(`На этих языках нет обзоров: ${languagesWithZeroReviews.join(', ')}`);
}
if (languagesWithErrors.length > 0) {
infoTextParts.push(`Ошибки загрузки для: ${languagesWithErrors.map(l => l.langName).join(', ')}`);
}
noReviewsInfo.textContent = infoTextParts.join('. ');
updateGlobalReviewsChart();
}
function handleChartToggle(event) {
const langCode = event.target.dataset.langCode;
const isVisible = event.target.checked;
const langData = allLanguageData.find(d => d.langCode === langCode);
if (langData) {
langData.visible = isVisible;
}
updateGlobalReviewsChart();
}
function updateGlobalReviewsChart() {
const ctx = document.getElementById('reviewsPieChart')?.getContext('2d');
if (!ctx) return;
const visibleData = allLanguageData
.filter(data => data.visible && data.summary && data.summary.total_reviews > 0)
.sort((a, b) => (b.summary?.total_reviews || 0) - (a.summary?.total_reviews || 0));
const chartLabels = [];
const chartDataPoints = [];
const chartColors = [];
let otherCount = 0;
const otherLanguagesTooltipDetails = [];
const topN = 6;
const totalVisibleReviews = visibleData.reduce((sum, data) => sum + data.summary.total_reviews, 0);
const colorPalette = [
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b',
'#e377c2', '#7f7f7f', '#bcbd22', '#17becf', '#aec7e8', '#ffbb78',
'#98df8a', '#ff9896', '#c5b0d5', '#c49c94', '#f7b6d2', '#c7c7c7',
'#dbdb8d', '#9edae5'
];
visibleData.forEach((data, index) => {
const count = data.summary.total_reviews;
if (index < topN) {
chartLabels.push(data.langName);
chartDataPoints.push(count);
chartColors.push(colorPalette[index % colorPalette.length]);
} else {
otherCount += count;
otherLanguagesTooltipDetails.push(`${data.langName}: ${formatNumber(count)}`);
}
});
if (otherCount > 0) {
chartLabels.push('Другие');
chartDataPoints.push(otherCount);
chartColors.push('#808080');
}
if (globalReviewsChart) {
globalReviewsChart.destroy();
globalReviewsChart = null;
}
if (chartDataPoints.length > 0) {
globalReviewsChart = new Chart(ctx, {
type: 'pie',
data: {
labels: chartLabels,
datasets: [{
data: chartDataPoints,
backgroundColor: chartColors,
borderColor: '#101822',
borderWidth: 2,
hoverOffset: 8,
hoverBorderColor: '#FFF'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
animateScale: true,
animateRotate: true
},
plugins: {
legend: {
position: 'right',
align: 'center',
labels: {
color: '#c6d4df',
padding: 12,
font: { size: 12 },
boxWidth: 12,
usePointStyle: true,
}
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.label || '';
const value = context.parsed || 0;
const percentage = totalVisibleReviews > 0 ? ((value / totalVisibleReviews) * 100).toFixed(1) : 0;
let tooltipText = [];
if (label) {
tooltipText.push(`${label}: ${formatNumber(value)} (${percentage}%)`);
}
if (context.label === 'Другие' && otherLanguagesTooltipDetails.length > 0) {
tooltipText.push('');
tooltipText.push('Включает:');
const maxTooltipItems = 10;
const displayedItems = otherLanguagesTooltipDetails.slice(0, maxTooltipItems);
const remainingItems = otherLanguagesTooltipDetails.length - maxTooltipItems;
tooltipText.push(...displayedItems);
if (remainingItems > 0) {
tooltipText.push(`...и еще ${remainingItems}`);
}
}
return tooltipText;
}
},
backgroundColor: 'rgba(16, 24, 34, 0.9)',
titleFont: { size: 13, weight: 'bold' },
bodyFont: { size: 12 },
padding: 10,
cornerRadius: 4,
bodySpacing: 4,
multiKeyBackground: 'transparent'
},
datalabels: {
formatter: (value, ctx) => {
const percentage = totalVisibleReviews > 0 ? ((value / totalVisibleReviews) * 100) : 0;
if (percentage < 3) {
return null;
}
let label = ctx.chart.data.labels[ctx.dataIndex] || '';
if (label.length > 15 && label !== 'Другие') {
const parts = label.split(' ');
label = parts.length > 1 ? parts[0] : label.substring(0, 12) + '...';
}
return `${label}\n${percentage.toFixed(0)}%`;
},
color: '#FFFFFF',
font: {
weight: 'bold',
size: 12,
family: '"Motiva Sans", Arial, sans-serif'
},
backgroundColor: 'rgba(16, 24, 34, 0.75)',
borderRadius: 3,
padding: {
top: 3,
bottom: 3,
left: 6,
right: 6
},
anchor: 'end',
align: 'start',
offset: 15,
display: function(context) {
const percentage = totalVisibleReviews > 0 ? ((context.dataset.data[context.dataIndex] / totalVisibleReviews) * 100) : 0;
return percentage >= 3;
},
}
},
layout: {
padding: {
top: 25,
bottom: 25,
left: 25,
right: 25
}
}
}
});
} else {
const canvas = document.getElementById('reviewsPieChart');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8f98a0';
ctx.textAlign = 'center';
ctx.font = '14px "Motiva Sans", Arial, sans-serif';
ctx.fillText('Нет данных для отображения диаграммы', canvas.width / 2, canvas.height / 2);
}
}
}
function getLanguageColor(index) {
const colors = [
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b',
'#e377c2', '#7f7f7f', '#bcbd22', '#17becf', '#aec7e8', '#ffbb78',
'#98df8a', '#ff9896', '#c5b0d5', '#c49c94', '#f7b6d2', '#c7c7c7',
'#dbdb8d', '#9edae5'
];
return colors[index % colors.length];
}
function main() {
addStyles();
addRussianIndicators();
addLoadButton();
}
const observer = new MutationObserver((mutations, obs) => {
const reviewsContainer = document.querySelector('.user_reviews');
const headerImage = document.querySelector('#gameHeaderImageCtn');
const langTable = document.querySelector('#languageTable');
if (headerImage) {
main();
obs.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();
}
// Скрипт для страницы игры (HLTB; получение сведений о времени прохождения) | https://store.steampowered.com/app/*
if (window.location.pathname.includes('/app/') && scriptsConfig.hltbData) {
(async function() {
let hltbBlock = document.createElement('div');
Object.assign(hltbBlock.style, {
position: 'absolute',
top: '0',
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',
opacity: '0',
transition: 'opacity 0.3s ease, width 0.3s ease, height 0.3s ease'
});
let triangle = document.createElement('div');
triangle.className = 'triangle-down';
Object.assign(triangle.style, {
position: 'absolute',
bottom: '5px',
left: '50%',
transform: 'translateX(-50%)',
width: '0',
height: '0',
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: '5px solid #67c1f5',
cursor: 'pointer'
});
let title = document.createElement('div');
Object.assign(title.style, {
fontSize: '12px',
fontWeight: 'bold',
color: '#67c1f5',
marginBottom: '10px',
cursor: 'pointer'
});
title.textContent = 'HLTB';
let content = document.createElement('div');
Object.assign(content.style, {
fontSize: '14px',
color: '#c6d4df',
display: 'none',
whiteSpace: 'auto',
padding: '0 0'
});
hltbBlock.append(triangle, title, content);
document.querySelector('#gameHeaderImageCtn').appendChild(hltbBlock);
let hltb_gameHeaderImageCtnNode = null;
let hltb_gameHeaderImageCtnObserverInstance = null;
let hltb_russianIndicatorsNode = null;
let hltb_russianIndicatorsObserverInstance = null;
const fadeInElement = (element) => {
element.style.opacity = '0';
requestAnimationFrame(() => {
element.style.opacity = '1';
});
};
const updateHltbPosition = () => {
let topPosition = '0px';
if (scriptsConfig.gamePage) {
const russianIndicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
if (russianIndicators) {
topPosition = `${russianIndicators.offsetTop + russianIndicators.offsetHeight + 16}px`;
}
}
hltbBlock.style.top = topPosition;
hltbBlock.style.left = '334px';
if (hltbBlock.style.opacity === '0') {
fadeInElement(hltbBlock);
}
};
const hltb_manageRussianIndicatorsObserver = () => {
const currentRussianIndicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
if (currentRussianIndicators) {
if (currentRussianIndicators !== hltb_russianIndicatorsNode || !hltb_russianIndicatorsObserverInstance) {
if (hltb_russianIndicatorsObserverInstance) {
hltb_russianIndicatorsObserverInstance.disconnect();
}
hltb_russianIndicatorsNode = currentRussianIndicators;
if (scriptsConfig.gamePage) {
hltb_russianIndicatorsObserverInstance = new MutationObserver(() => {
updateHltbPosition();
});
hltb_russianIndicatorsObserverInstance.observe(hltb_russianIndicatorsNode, {
attributes: true,
childList: true,
subtree: true
});
}
}
} else {
if (hltb_russianIndicatorsObserverInstance) {
hltb_russianIndicatorsObserverInstance.disconnect();
hltb_russianIndicatorsObserverInstance = null;
}
hltb_russianIndicatorsNode = null;
}
};
const hltb_setupPageChangeObservers = () => {
hltb_gameHeaderImageCtnNode = document.querySelector('#gameHeaderImageCtn');
if (!hltb_gameHeaderImageCtnNode) {
console.warn("HLTB: #gameHeaderImageCtn not found for main observer.");
return;
}
if (hltb_gameHeaderImageCtnObserverInstance) {
hltb_gameHeaderImageCtnObserverInstance.disconnect();
}
hltb_gameHeaderImageCtnObserverInstance = new MutationObserver((mutations) => {
let needsPosUpdateDueToContainerChange = false;
let russianIndicatorsPotentiallyChanged = false;
for (const mutation of mutations) {
if (mutation.type === 'childList') {
russianIndicatorsPotentiallyChanged = true;
needsPosUpdateDueToContainerChange = true;
break;
}
}
if (russianIndicatorsPotentiallyChanged) {
hltb_manageRussianIndicatorsObserver();
}
if (needsPosUpdateDueToContainerChange) {
updateHltbPosition();
}
});
hltb_gameHeaderImageCtnObserverInstance.observe(hltb_gameHeaderImageCtnNode, {
childList: true,
subtree: true
});
hltb_manageRussianIndicatorsObserver();
};
updateHltbPosition();
hltb_setupPageChangeObservers();
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,
ontimeout: reject,
timeout: 7000
});
});
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 = unsafeWindow.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();
}
})();
}
// Скрипт для страницы игры (ZOG; получение сведений о наличии русификаторов) | https://store.steampowered.com/app/*
if (window.location.pathname.includes('/app/') && scriptsConfig.zogInfo) {
(async function() {
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', visibility: 'hidden', opacity: '0'
});
let hltbBlockElement, hltbObserverInstance, hltbTransitionHandlerFunc,
russianIndicatorsNode, russianIndicatorsObserverInstance,
gameHeaderImageCtnNode, gameHeaderImageCtnObserverInstance;
let isFirstUpdate = true;
const alphabetMap = {
'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6, 'g': 7, 'h': 8, 'i': 9, 'j': 10,
'k': 11, 'l': 12, 'm': 13, 'n': 14, 'o': 15, 'p': 16, 'q': 17, 'r': 18, 's': 19,
't': 20, 'u': 21, 'v': 22, 'w': 23, 'x': 24, 'y': 25, 'z': 26, '#': 0
};
const russianAlphabetMap = {
'а': 1, 'б': 2, 'в': 3, 'г': 4, 'д': 5, 'е': 6, 'ё': 6, 'ж': 7, 'з': 8, 'и': 9,
'й': 9, 'к': 10, 'л': 11, 'м': 12, 'н': 13, 'о': 14, 'п': 15, 'р': 16, 'с': 17,
'т': 18, 'у': 19, 'ф': 20, 'х': 21, 'ц': 22, 'ч': 23, 'ш': 24, 'щ': 25, 'э': 26,
'ю': 27, 'я': 28
};
const updatePosition = () => {
const localHltbBlock = document.querySelector('#gameHeaderImageCtn > div[style*="background: rgba(27, 40, 56, 0.95)"]');
const localRussianIndicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
if (localHltbBlock && scriptsConfig.hltbData) {
zogBlock.style.top = `${localHltbBlock.offsetTop + localHltbBlock.offsetHeight + 16}px`;
} else if (localRussianIndicators && scriptsConfig.gamePage) {
zogBlock.style.top = `${localRussianIndicators.offsetTop + localRussianIndicators.offsetHeight + 16}px`;
} else {
if (document.querySelector('#gameHeaderImageCtn')) zogBlock.style.top = '0px';
}
zogBlock.style.left = '334px';
zogBlock.style.zIndex = '2';
if (isFirstUpdate) {
requestAnimationFrame(() => {
zogBlock.style.visibility = 'visible';
zogBlock.style.opacity = '1';
});
isFirstUpdate = false;
}
};
const manageHltbObserver = () => {
const currentHltbBlock = document.querySelector('#gameHeaderImageCtn > div[style*="background: rgba(27, 40, 56, 0.95)"]');
if (currentHltbBlock) {
if (currentHltbBlock !== hltbBlockElement) {
if (hltbObserverInstance) hltbObserverInstance.disconnect();
if (hltbBlockElement && hltbTransitionHandlerFunc) hltbBlockElement.removeEventListener('transitionend', hltbTransitionHandlerFunc);
hltbBlockElement = currentHltbBlock;
if (scriptsConfig.hltbData) {
hltbObserverInstance = new ResizeObserver(updatePosition);
hltbObserverInstance.observe(hltbBlockElement);
hltbTransitionHandlerFunc = updatePosition;
hltbBlockElement.addEventListener('transitionend', hltbTransitionHandlerFunc);
}
}
} else {
if (hltbObserverInstance) hltbObserverInstance.disconnect();
if (hltbBlockElement && hltbTransitionHandlerFunc) hltbBlockElement.removeEventListener('transitionend', hltbTransitionHandlerFunc);
hltbBlockElement = hltbObserverInstance = hltbTransitionHandlerFunc = null;
}
};
const manageRussianIndicatorsObserver = () => {
const currentIndicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
if (currentIndicators) {
if (currentIndicators !== russianIndicatorsNode) {
if (russianIndicatorsObserverInstance) russianIndicatorsObserverInstance.disconnect();
russianIndicatorsNode = currentIndicators;
if (scriptsConfig.gamePage) {
russianIndicatorsObserverInstance = new MutationObserver(mutations => mutations.forEach(m => { if (m.type === 'attributes' && m.attributeName === 'style') updatePosition(); }));
russianIndicatorsObserverInstance.observe(russianIndicatorsNode, { attributes: true, attributeFilter: ['style'] });
}
}
} else {
if (russianIndicatorsObserverInstance) russianIndicatorsObserverInstance.disconnect();
russianIndicatorsNode = russianIndicatorsObserverInstance = null;
}
};
const initDynamicElementObservers = () => {
manageHltbObserver();
manageRussianIndicatorsObserver();
};
const setupPageChangeObservers = () => {
gameHeaderImageCtnNode = document.querySelector('#gameHeaderImageCtn');
if (!gameHeaderImageCtnNode) return;
if (gameHeaderImageCtnObserverInstance) gameHeaderImageCtnObserverInstance.disconnect();
gameHeaderImageCtnObserverInstance = new MutationObserver(mutations => {
if (mutations.some(m => m.type === 'childList')) {
initDynamicElementObservers();
updatePosition();
}
});
gameHeaderImageCtnObserverInstance.observe(gameHeaderImageCtnNode, { childList: true, subtree: true });
initDynamicElementObservers();
};
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 = 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%)' });
zogBlock.append(arrow, title, content);
document.querySelector('#gameHeaderImageCtn').appendChild(zogBlock);
await new Promise(resolve => setTimeout(resolve, 10));
setupPageChangeObservers();
updatePosition();
const toggleBlock = async (el) => content.style.display === 'none' ? await expandBlock(el) : collapseBlock(el);
title.onclick = () => toggleBlock(arrow);
arrow.onclick = () => toggleBlock(arrow);
async function expandBlock(arrowElement) {
Object.assign(zogBlock.style, { transition: 'width 0.3s ease, height 0.3s ease', width: '300px', 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(r => requestAnimationFrame(r));
const englishName = await getEnglishGameName(getAppId()) || getGameName();
content.textContent = 'Поиск на Zone of Games...';
await new Promise(r => requestAnimationFrame(r));
try {
const possibleMatches = await findGamesOnZog(englishName);
renderPossibleMatches(possibleMatches, englishName);
zogBlock.style.height = `${content.scrollHeight + 40}px`;
updatePosition();
} catch (error) {
console.error("ZOG Search Error:", error);
content.textContent = 'Ошибка поиска на ZOG.';
}
}
function collapseBlock(arrowElement) {
Object.assign(zogBlock.style, { transition: 'width 0.3s ease, height 0.3s ease', width: '30px', height: '30px' });
arrowElement.style.transform = 'translateX(-50%) rotate(0deg)';
content.style.display = 'none';
content.innerHTML = '';
updatePosition();
}
async function getEnglishGameName(appId) {
const url = `https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=${JSON.stringify({ ids: [{ appid: parseInt(appId) }], context: { language: "english", country_code: "US" }, data_request: { include_basic_info: true } })}`;
try {
const response = await new Promise((resolve, reject) => GM_xmlhttpRequest({ method: "GET", url, onload: resolve, onerror: reject, ontimeout: reject }));
if (response.status === 200) return JSON.parse(response.responseText).response?.store_items?.[0]?.name;
} catch (e) { console.error('Ошибка при запросе к Steam API:', e); }
return null;
}
async function findGamesOnZog(gameName) {
const isRussian = /[а-яё]/i.test(gameName);
const activeMap = isRussian ? russianAlphabetMap : alphabetMap;
const articles = ['the', 'a', 'an'];
const words = gameName.toLowerCase().split(' ');
const searchLetters = new Set();
if (!isRussian && articles.includes(words[0]) && words.length > 1) {
searchLetters.add(words[0][0]);
if (activeMap[words[1][0]]) searchLetters.add(words[1][0]);
} else {
let firstChar = gameName.toLowerCase().charAt(0);
searchLetters.add(activeMap.hasOwnProperty(firstChar) ? firstChar : '#');
}
const allGamesFound = [];
const uniquePaths = new Set();
for (const letter of searchLetters) {
const isNonAlpha = letter === '#';
const pageNum = activeMap[letter];
if (pageNum === undefined) continue;
const baseUrl = isNonAlpha ? 'https://www.zoneofgames.ru/games/eng/' : (isRussian ? 'https://www.zoneofgames.ru/games/rus/' : 'https://www.zoneofgames.ru/games/eng/');
const url = `${baseUrl}${pageNum}/`;
try {
const response = await new Promise((resolve, reject) => GM_xmlhttpRequest({ method: 'GET', url, onload: resolve, onerror: reject }));
const doc = new DOMParser().parseFromString(response.responseText, 'text/html');
doc.querySelectorAll('td.gameinfoblock a').forEach(link => {
const path = link.getAttribute('href');
if (path && !uniquePaths.has(path)) {
const rawTitle = link.textContent.trim();
const articleMatch = rawTitle.match(/,\s+(The|An|A)$/i);
let title = articleMatch ? `${articleMatch[1]} ${rawTitle.replace(articleMatch[0], '').trim()}` : rawTitle;
allGamesFound.push({ title, path });
uniquePaths.add(path);
}
});
} catch (e) { console.error(`Ошибка при загрузке страницы '${url}':`, e); }
}
return allGamesFound;
}
async function fetchAndRenderLocalizations(gamePath) {
const fullUrl = `https://www.zoneofgames.ru${gamePath}`;
content.innerHTML = 'Загрузка русификаторов...';
try {
const response = await new Promise((resolve, reject) => GM_xmlhttpRequest({ method: 'GET', url: fullUrl, onload: resolve, onerror: reject }));
const doc = new DOMParser().parseFromString(response.responseText, 'text/html');
const localizations = [];
const translationLabel = Array.from(doc.querySelectorAll('b')).find(b => b.textContent.trim() === 'Переводы:');
if (translationLabel) {
const table = translationLabel.closest('table');
if (table) {
table.querySelectorAll('tr').forEach(row => {
const linkEl = row.querySelector('a');
if (linkEl?.getAttribute('href')) {
localizations.push({
name: linkEl.textContent.trim(),
size: row.querySelector('td:last-child')?.textContent.trim() || '',
link: `https://www.zoneofgames.ru${linkEl.getAttribute('href')}`
});
}
});
}
}
const gameTitle = doc.querySelector('td.blockstyle > b > font')?.textContent.trim() || 'Выбранная игра';
renderContent({ title: gameTitle, url: fullUrl, localizations });
zogBlock.style.height = `${content.scrollHeight + 40}px`;
updatePosition();
} catch (e) {
content.textContent = 'Ошибка загрузки локализаций.';
console.error("Ошибка получения страницы игры:", e);
}
}
function renderContent(entry) {
content.innerHTML = '';
if (!entry) {
content.textContent = 'Игра не найдена в базе ZOG';
return;
}
const titleLink = Object.assign(document.createElement('a'), { href: entry.url, target: '_blank', textContent: entry.title || 'Без названия' });
Object.assign(titleLink.style, { color: '#67c1f5', wordBreak: 'break-word', textDecoration: 'none' });
content.appendChild(titleLink);
const list = document.createElement('ul');
Object.assign(list.style, { paddingLeft: '15px', marginTop: '5px', marginBottom: '0' });
if (entry.localizations?.length > 0) {
entry.localizations.forEach(loc => {
const li = document.createElement('li');
li.style.marginBottom = '8px';
const link = Object.assign(document.createElement('a'), { href: loc.link, target: '_blank', textContent: `${loc.name} ${loc.size}` });
Object.assign(link.style, { color: '#c6d4df', wordBreak: 'break-word', textDecoration: 'none' });
li.appendChild(link);
list.appendChild(li);
});
} else {
const li = Object.assign(document.createElement('li'), { textContent: 'Русификаторы отсутствуют' });
li.style.color = '#999';
list.appendChild(li);
}
content.appendChild(list);
}
function renderPossibleMatches(matches, originalGameName) {
content.innerHTML = '';
const title = Object.assign(document.createElement('div'), { textContent: 'Возможные совпадения:' });
Object.assign(title.style, { color: '#67c1f5', marginBottom: '10px' });
content.appendChild(title);
const list = document.createElement('ul');
Object.assign(list.style, { paddingLeft: '15px', marginTop: '5px', marginBottom: '0' });
const processedMatches = findPossibleMatches(originalGameName, matches);
if (processedMatches.length === 0) {
renderContent(null);
return;
}
processedMatches.forEach(match => {
const li = document.createElement('li');
li.style.marginBottom = '8px';
const link = Object.assign(document.createElement('a'), { href: `https://www.zoneofgames.ru${match.item.path}`, target: '_blank', textContent: `${match.item.title} (${match.percentage}%)` });
Object.assign(link.style, { color: '#c6d4df', wordBreak: 'break-word', textDecoration: 'none', cursor: 'pointer' });
link.onclick = (e) => {
e.preventDefault();
fetchAndRenderLocalizations(match.item.path);
};
li.appendChild(link);
list.appendChild(li);
});
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();
return { item, percentage: calculateSimilarity(cleanGameName, cleanItemName), startsWith: cleanItemName.startsWith(cleanGameName) };
})
.filter(item => item.percentage > 45 || 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) {
let longer = str1, shorter = str2;
if (str1.length < str2.length) { longer = str2; shorter = str1; }
if (longer.length === 0) return 100.0;
return Math.round(((longer.length - levenshteinDistance(longer, shorter)) / longer.length) * 100);
}
function levenshteinDistance(s1, s2) {
const costs = [];
for (let i = 0; i <= s1.length; i++) {
let lastValue = i;
for (let j = 0; j <= s2.length; j++) {
if (i === 0) costs[j] = j;
else if (j > 0) {
let newValue = costs[j - 1];
if (s1.charAt(i - 1) !== s2.charAt(j - 1)) newValue = Math.min(newValue, lastValue, costs[j]) + 1;
costs[j - 1] = lastValue;
lastValue = newValue;
}
}
if (i > 0) costs[s2.length] = lastValue;
}
return costs[s2.length];
}
function getAppId() { return unsafeWindow.location.pathname.split('/')[2]; }
function getGameName() { return document.querySelector('.apphub_AppName')?.textContent.trim() || ''; }
})();
}
// Скрипт для страницы игры (Время друзей & Достижения) | https://store.steampowered.com/app/*
if (window.location.pathname.includes('/app/') && scriptsConfig.friendsPlaytime) {
(async function() {
const appIdMatch = unsafeWindow.location.pathname.match(/\/app\/(\d+)/);
if (!appIdMatch) return;
const appId = appIdMatch[1];
const gameName = document.querySelector('#appHubAppName')?.textContent.trim() || 'этой игры';
async function fetchAndParseAllFriendsData(appId) {
const url = `https://steamcommunity.com/my/friendsthatplay/${appId}`;
const results = {
recent: {
count: 0,
blocks: []
},
ever: {
count: 0,
blocks: []
},
own: {
count: 0,
blocks: []
},
wishlist: {
count: 0,
blocks: []
}
};
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
timeout: 15000,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error('Timeout'))
});
});
if (response.status !== 200) throw new Error(`HTTP Status ${response.status}`);
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, 'text/html');
const allHeaders = doc.querySelectorAll('.mainSectionHeader.friendListSectionHeader');
const keywords = {
recent: ['последние 2 недели', 'последних 2 недель', 'in the last 2 weeks'],
ever: ['когда-либо игравшие', 'когда-либо игравшие в', 'previously'],
own: ['имеющие', 'имеющие в', 'in their library'],
wishlist: ['которые хотят', 'who want']
};
allHeaders.forEach(header => {
const text = header.textContent.trim().toLowerCase();
const container = header.nextElementSibling;
if (!container || !container.matches('.profile_friends')) return;
const blocks = Array.from(container.querySelectorAll('.friendBlock'));
const count = blocks.length;
for (const key in keywords) {
if (keywords[key].some(k => text.includes(k.toLowerCase()))) {
results[key] = { count, blocks };
break;
}
}
});
return results;
} catch (error) {
return results;
}
}
function buildAvatarGrid(blocks) {
const grid = document.createElement('div');
grid.className = 'friend_blocks_grid';
blocks.slice(0, 6).forEach(block => {
const profileLink = block.querySelector('a.friendBlockLinkOverlay')?.href;
const miniprofile = block.dataset.miniprofile;
const avatarImgSrc = block.querySelector('.playerAvatar img')?.src;
const statusClass = block.classList.contains('in-game') ? 'friend_status_in-game' : (block.classList.contains('online') ? 'friend_status_online' : 'friend_status_offline');
if (!profileLink || !miniprofile || !avatarImgSrc) return;
const link = document.createElement('a');
link.style.display = 'inline-block';
link.href = profileLink;
link.className = `friend_block_holder ${statusClass}`;
link.dataset.miniprofile = miniprofile;
const avatarDiv = document.createElement('div');
avatarDiv.className = 'friend_block_avatar';
avatarDiv.style.display = 'inline-block';
const img = document.createElement('img');
img.src = avatarImgSrc;
avatarDiv.appendChild(img);
link.appendChild(avatarDiv);
grid.appendChild(link);
});
return grid;
}
async function createFriendsInfoBlock() {
const targetContainer = document.querySelector('.rightcol.game_meta_data');
if (!targetContainer) return;
const friendsBlock = document.createElement('div');
friendsBlock.className = 'block responsive_apppage_details_right recommendation_reasons';
friendsBlock.innerHTML = `<p class="reason info" style="text-align:center;">Загрузка данных о друзьях (U.S.E.)...</p>`;
targetContainer.prepend(friendsBlock);
const data = await fetchAndParseAllFriendsData(appId);
const getUniqueBlocks = (blocksArrays) => {
const unique = new Map();
blocksArrays.flat().forEach(block => {
const id = block.dataset.miniprofile;
if (id && !unique.has(id)) {
unique.set(id, block);
}
});
return Array.from(unique.values());
};
const allEverPlayedBlocks = getUniqueBlocks([data.recent.blocks, data.ever.blocks]);
const allOwnedBlocks = getUniqueBlocks([data.recent.blocks, data.ever.blocks, data.own.blocks]);
let finalHTML = '';
if (data.recent.count > 0) {
finalHTML += `<p class="reason for">Друзей, игравших недавно: ${data.recent.count}</p>`;
finalHTML += buildAvatarGrid(data.recent.blocks).outerHTML + '<hr>';
}
if (allEverPlayedBlocks.length > 0) {
finalHTML += `<p class="reason for"><a href="https://steamcommunity.com/my/friendsthatplay/${appId}" target="_blank">Друзей, когда-либо игравших в игру: ${allEverPlayedBlocks.length}</a></p>`;
finalHTML += buildAvatarGrid(allEverPlayedBlocks).outerHTML + '<hr>';
}
if (allOwnedBlocks.length > 0) {
finalHTML += `<p class="reason for"><a href="https://steamcommunity.com/my/friendsthatplay/${appId}" target="_blank">Друзей, имеющих игру в библиотеке: ${allOwnedBlocks.length}</a></p>`;
finalHTML += buildAvatarGrid(allOwnedBlocks).outerHTML + '<hr>';
}
if (data.wishlist.count > 0) {
finalHTML += `<p class="reason for"><a href="https://steamcommunity.com/my/friendsthatplay/${appId}" target="_blank">Друзей, которые хотят эту игру: ${data.wishlist.count}</a></p>`;
finalHTML += buildAvatarGrid(data.wishlist.blocks).outerHTML + '<hr>';
}
friendsBlock.innerHTML = finalHTML.length > 0 ? finalHTML.replace(/<hr>$/, '') : `<p class="reason info">Нет данных о действиях друзей с этой игрой.</p>`;
}
const recommendationBlock = document.querySelector('.recommendation_reasons');
if (!recommendationBlock) {
await createFriendsInfoBlock();
}
const headerCtn = document.querySelector('#gameHeaderImageCtn');
if (headerCtn) {
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 loadFriendsDataForStats();
const achievementsData = await loadAchievementsDataForStats();
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 loadFriendsDataForStats() {
const friendsUrl = `https://steamcommunity.com/my/friendsthatplay/${appId}`;
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: friendsUrl,
onload: resolve,
onerror: reject,
timeout: 5000
});
});
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, 'text/html');
const playedBlocks = [];
const headers = doc.querySelectorAll('.mainSectionHeader.friendListSectionHeader');
const playedKeywords = ['последние 2 недели', 'in the last 2 weeks', 'когда-либо игравшие', 'previously'];
headers.forEach(header => {
const headerText = header.textContent;
if (playedKeywords.some(keyword => headerText.includes(keyword))) {
const container = header.nextElementSibling;
if (container && container.matches('.profile_friends')) {
const blocks = container.querySelectorAll('.friendBlock');
playedBlocks.push(...blocks);
}
}
});
return playedBlocks.map(block => {
const contentBlock = block.querySelector('.friendBlockContent');
if (!contentBlock) return null;
const timeText = contentBlock.querySelector('.friendSmallText')?.textContent.trim();
let hours = 0;
if (timeText) {
const timeParts = timeText.split('/');
const relevantTimePart = timeParts.length > 1 ? timeParts[1] : timeParts[0];
const hoursMatch = relevantTimePart.match(/(\d+[,.]?\d*)\s*(?:ч|hrs)\.?/);
if (hoursMatch && hoursMatch[1]) {
hours = parseFloat(hoursMatch[1].replace(',', '.')) || 0;
}
}
return {
name: contentBlock.firstChild.textContent.trim(),
hours: hours,
profile: block.querySelector('a.friendBlockLinkOverlay')?.href
};
}).filter(f => f && f.hours > 0);
} catch (error) {
return [];
}
}
async function loadAchievementsDataForStats() {
const achievementsUrl = `https://steamcommunity.com/stats/${appId}/achievements/`;
try {
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 => parseFloat(el.textContent.trim().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);
headerCtn.appendChild(statsBlock);
}
})();
}
// Скрипт для страницы игры (Ранний доступ) | 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 = unsafeWindow.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();
})();
}
// Скрипт для страницы игры (Анализатор цен; цена РФ vs рекомендованная; рейтинг цен) | https://store.steampowered.com/app/*
if (scriptsConfig.RuRegionalPriceAnalyzer && unsafeWindow.location.pathname.includes('/app/')) {
(function regionalPriceAnalyzer() {
'use strict';
let rpa_currentDisplayMode = 'RUB';
let rpa_hideUsdSwitchWarning = localStorage.getItem('rpa_hide_usd_switch_warning') === 'true';
const userProvidedRegionsRU = {
'US': {
name: 'Доллар США',
code: 1,
iso: 'usd'
},
'EU': {
name: 'Евро',
code: 3,
iso: 'eur'
},
'AR': {
name: 'Лат. Ам. - Доллар США',
code: 1,
iso: 'usd'
},
'AU': {
name: 'Австралийский доллар',
code: 21,
iso: 'aud'
},
'BR': {
name: 'Бразильский реал',
code: 7,
iso: 'brl'
},
'GB': {
name: 'Британский фунт',
code: 2,
iso: 'gbp'
},
'CA': {
name: 'Канадский доллар',
code: 20,
iso: 'cad'
},
'CL': {
name: 'Чилийское песо',
code: 25,
iso: 'clp'
},
'CN': {
name: 'Китайский юань',
code: 23,
iso: 'cny'
},
'AZ': {
name: 'СНГ - Доллар США',
code: 1,
iso: 'usd'
},
'CO': {
name: 'Колумбийское песо',
code: 27,
iso: 'cop'
},
'CR': {
name: 'Коста-риканский колон',
code: 40,
iso: 'crc'
},
'HK': {
name: 'Гонконгский доллар',
code: 29,
iso: 'hkd'
},
'IN': {
name: 'Индийская рупия',
code: 24,
iso: 'inr'
},
'ID': {
name: 'Индонезийская рупия',
code: 10,
iso: 'idr'
},
'IL': {
name: 'Израильский новый шекель',
code: 35,
iso: 'ils'
},
'JP': {
name: 'Японская иена',
code: 8,
iso: 'jpy'
},
'KZ': {
name: 'Казахстанский тенге',
code: 37,
iso: 'kzt'
},
'KW': {
name: 'Кувейтский динар',
code: 38,
iso: 'kwd'
},
'MY': {
name: 'Малазийский ринггит',
code: 11,
iso: 'myr'
},
'MX': {
name: 'Мексиканское песо',
code: 19,
iso: 'mxn'
},
'NZ': {
name: 'Новозеландский доллар',
code: 22,
iso: 'nzd'
},
'NO': {
name: 'Норвежская крона',
code: 9,
iso: 'nok'
},
'PE': {
name: 'Перуанский соль',
code: 26,
iso: 'pen'
},
'PH': {
name: 'Филиппинское песо',
code: 12,
iso: 'php'
},
'PL': {
name: 'Польский злотый',
code: 6,
iso: 'pln'
},
'QA': {
name: 'Катарский риал',
code: 39,
iso: 'qar'
},
'RU': {
name: 'Российский рубль',
code: 5,
iso: 'rub'
},
'SA': {
name: 'Саудовский риал',
code: 31,
iso: 'sar'
},
'SG': {
name: 'Сингапурский доллар',
code: 13,
iso: 'sgd'
},
'ZA': {
name: 'Южноафриканский рэнд',
code: 28,
iso: 'zar'
},
'PK': {
name: 'Юж. Азия - Доллар США',
code: 1,
iso: 'usd'
},
'KR': {
name: 'Южнокорейская вона',
code: 16,
iso: 'krw'
},
'CH': {
name: 'Швейцарский франк',
code: 4,
iso: 'chf'
},
'TW': {
name: 'Тайваньский доллар',
code: 30,
iso: 'twd'
},
'TH': {
name: 'Тайский бат',
code: 14,
iso: 'thb'
},
'TR': {
name: 'MENA - Доллар США',
code: 1,
iso: 'usd'
},
'AE': {
name: 'Дирхам ОАЭ',
code: 32,
iso: 'aed'
},
'UA': {
name: 'Украинская гривна',
code: 18,
iso: 'uah'
},
'UY': {
name: 'Уругвайское песо',
code: 41,
iso: 'uyu'
},
'VN': {
name: 'Вьетнамский донг',
code: 15,
iso: 'vnd'
}
};
const userProvidedRegionsEN = {
'US': {
name: 'United States Dollar',
code: 1,
iso: 'usd'
},
'EU': {
name: 'Euro',
code: 3,
iso: 'eur'
},
'AR': {
name: 'Latin America - USD',
code: 1,
iso: 'usd'
},
'AU': {
name: 'Australian Dollar',
code: 21,
iso: 'aud'
},
'BR': {
name: 'Brazilian Real',
code: 7,
iso: 'brl'
},
'GB': {
name: 'British Pound',
code: 2,
iso: 'gbp'
},
'CA': {
name: 'Canadian Dollar',
code: 20,
iso: 'cad'
},
'CL': {
name: 'Chilean Peso',
code: 25,
iso: 'clp'
},
'CN': {
name: 'Chinese Yuan Renminbi',
code: 23,
iso: 'cny'
},
'AZ': {
name: 'CIS - USD',
code: 1,
iso: 'usd'
},
'CO': {
name: 'Colombian Peso',
code: 27,
iso: 'cop'
},
'CR': {
name: 'Costa Rican Colon',
code: 40,
iso: 'crc'
},
'HK': {
name: 'Hong Kong Dollar',
code: 29,
iso: 'hkd'
},
'IN': {
name: 'Indian Rupee',
code: 24,
iso: 'inr'
},
'ID': {
name: 'Indonesian Rupiah',
code: 10,
iso: 'idr'
},
'IL': {
name: 'Israeli New Shekel',
code: 35,
iso: 'ils'
},
'JP': {
name: 'Japanese Yen',
code: 8,
iso: 'jpy'
},
'KZ': {
name: 'Kazakhstani Tenge',
code: 37,
iso: 'kzt'
},
'KW': {
name: 'Kuwaiti Dinar',
code: 38,
iso: 'kwd'
},
'MY': {
name: 'Malaysian Ringgit',
code: 11,
iso: 'myr'
},
'MX': {
name: 'Mexican Peso',
code: 19,
iso: 'mxn'
},
'NZ': {
name: 'New Zealand Dollar',
code: 22,
iso: 'nzd'
},
'NO': {
name: 'Norwegian Krone',
code: 9,
iso: 'nok'
},
'PE': {
name: 'Peruvian Sol',
code: 26,
iso: 'pen'
},
'PH': {
name: 'Philippine Peso',
code: 12,
iso: 'php'
},
'PL': {
name: 'Polish Zloty',
code: 6,
iso: 'pln'
},
'QA': {
name: 'Qatari Riyal',
code: 39,
iso: 'qar'
},
'RU': {
name: 'Russian Ruble',
code: 5,
iso: 'rub'
},
'SA': {
name: 'Saudi Riyal',
code: 31,
iso: 'sar'
},
'SG': {
name: 'Singapore Dollar',
code: 13,
iso: 'sgd'
},
'ZA': {
name: 'South African Rand',
code: 28,
iso: 'zar'
},
'PK': {
name: 'South Asia - USD',
code: 1,
iso: 'usd'
},
'KR': {
name: 'South Korean Won',
code: 16,
iso: 'krw'
},
'CH': {
name: 'Swiss Franc',
code: 4,
iso: 'chf'
},
'TW': {
name: 'New Taiwan Dollar',
code: 30,
iso: 'twd'
},
'TH': {
name: 'Thai Baht',
code: 14,
iso: 'thb'
},
'TR': {
name: 'MENA - USD',
code: 1,
iso: 'usd'
},
'AE': {
name: 'UAE Dirham',
code: 32,
iso: 'aed'
},
'UA': {
name: 'Ukrainian Hryvnia',
code: 18,
iso: 'uah'
},
'UY': {
name: 'Uruguayan Peso',
code: 41,
iso: 'uyu'
},
'VN': {
name: 'Vietnamese Dong',
code: 15,
iso: 'vnd'
}
};
const RPA_CONFIG = {
regions: [],
currencyApiUrl: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/',
steamApiUrl: 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1/',
priorButtonText: '',
modalTitle: '',
fetchButtonText: '',
delayBetweenBatches: 300,
batchSize: 10,
};
function rpa_updateTextsAndRegionNames() {
const isUSDMode = rpa_currentDisplayMode === 'USD';
RPA_CONFIG.priorButtonText = isUSDMode ? 'Price Analyzer' : 'Анализатор цен';
RPA_CONFIG.modalTitle = isUSDMode ? 'Regional Price Analyzer' : 'Анализатор цен';
RPA_CONFIG.fetchButtonText = isUSDMode ? 'Fetch Data' : 'Сбор данных';
const sourceRegions = isUSDMode ? userProvidedRegionsEN : userProvidedRegionsRU;
RPA_CONFIG.regions = Object.entries(sourceRegions).map(([key, value]) => ({
cc: key === 'EU' ? 'DE' : key,
name: value.name,
currencyApiCode: value.iso.toLowerCase(),
steamCurrencyId: value.code
}));
if (rpa_priorButton) rpa_priorButton.textContent = RPA_CONFIG.priorButtonText;
const modalTitleEl = rpa_modal ? rpa_modal.querySelector('#rpaHeaderBar h3') : null;
if (modalTitleEl) modalTitleEl.textContent = RPA_CONFIG.modalTitle;
const fetchBtnEl = rpa_modal ? rpa_modal.querySelector('#rpaFetchDataButton') : null;
if (fetchBtnEl) fetchBtnEl.textContent = RPA_CONFIG.fetchButtonText;
rpa_updateModeToggleButtonText();
}
let rpa_exchangeRates = {};
let rpa_modal = null;
let rpa_priorButton = null;
let rpa_currentAppId = null;
let rpa_currentGameName = '';
let rpa_progressBarFill = null;
let rpa_fetchController = null;
let rpa_modeToggleButton = null;
function calculateRecommendedRubPrice(pUSD) {
if (typeof pUSD !== 'number' || isNaN(pUSD)) return null;
if (pUSD < 0.99) return 42;
if (pUSD >= 0.99 && pUSD < 1.99) return 42;
if (pUSD >= 1.99 && pUSD < 2.99) return 82;
if (pUSD >= 2.99 && pUSD < 3.99) return 125;
if (pUSD >= 3.99 && pUSD < 4.99) return 165;
if (pUSD >= 4.99 && pUSD < 5.99) return 200;
if (pUSD >= 5.99 && pUSD < 6.99) return 240;
if (pUSD >= 6.99 && pUSD < 7.99) return 280;
if (pUSD >= 7.99 && pUSD < 8.99) return 320;
if (pUSD >= 8.99 && pUSD < 9.99) return 350;
if (pUSD >= 9.99 && pUSD < 10.99) return 385;
if (pUSD >= 10.99 && pUSD < 11.99) return 420;
if (pUSD >= 11.99 && pUSD < 12.99) return 460;
if (pUSD >= 12.99 && pUSD < 13.99) return 490;
if (pUSD >= 13.99 && pUSD < 14.99) return 520;
if (pUSD >= 14.99 && pUSD < 15.99) return 550;
if (pUSD >= 15.99 && pUSD < 16.99) return 590;
if (pUSD >= 16.99 && pUSD < 17.99) return 620;
if (pUSD >= 17.99 && pUSD < 18.99) return 650;
if (pUSD >= 18.99 && pUSD < 19.99) return 680;
if (pUSD >= 19.99 && pUSD < 22.99) return 710;
if (pUSD >= 22.99 && pUSD < 27.99) return 880;
if (pUSD >= 27.99 && pUSD < 32.99) return 1100;
if (pUSD >= 32.99 && pUSD < 37.99) return 1200;
if (pUSD >= 37.99 && pUSD < 43.99) return 1300;
if (pUSD >= 43.99 && pUSD < 47.99) return 1500;
if (pUSD >= 47.99 && pUSD < 52.99) return 1600;
if (pUSD >= 52.99 && pUSD < 57.99) return 1750;
if (pUSD >= 57.99 && pUSD < 63.99) return 1900;
if (pUSD >= 63.99 && pUSD < 67.99) return 2100;
if (pUSD >= 67.99 && pUSD < 74.99) return 2250;
if (pUSD >= 74.99 && pUSD < 79.99) return 2400;
if (pUSD >= 79.99 && pUSD < 84.99) return 2600;
if (pUSD >= 84.99 && pUSD < 89.99) return 2700;
if (pUSD >= 89.99 && pUSD < 99.99) return 2900;
if (pUSD >= 99.99 && pUSD < 109.99) return 3200;
if (pUSD >= 109.99 && pUSD < 119.99) return 3550;
if (pUSD >= 119.99 && pUSD < 129.99) return 3900;
if (pUSD >= 129.99 && pUSD < 139.99) return 4200;
if (pUSD >= 139.99 && pUSD < 149.99) return 4500;
if (pUSD >= 149.99 && pUSD < 199.99) return 4800;
if (pUSD >= 199.99) return 6500;
return null;
}
function rpa_getAppIdFromUrl() {
const match = unsafeWindow.location.pathname.match(/\/app\/(\d+)/);
return match ? match[1] : null;
}
async function rpa_fetchExchangeRates(baseCurrency = 'usd', signal) {
baseCurrency = baseCurrency.toLowerCase();
if (rpa_exchangeRates[baseCurrency] && Object.keys(rpa_exchangeRates[baseCurrency]).length > 0) {
return rpa_exchangeRates[baseCurrency];
}
if (baseCurrency === 'rub' && !rpa_exchangeRates['rub']) rpa_exchangeRates['rub'] = {};
if (baseCurrency === 'rub' && rpa_exchangeRates['rub']['rub'] === 1) return rpa_exchangeRates['rub'];
if (baseCurrency === 'usd' && !rpa_exchangeRates['usd']) rpa_exchangeRates['usd'] = {};
if (baseCurrency === 'usd' && rpa_exchangeRates['usd']['usd'] === 1 && Object.keys(rpa_exchangeRates['usd']).length > 1) return rpa_exchangeRates['usd'];
try {
const response = await new Promise((resolve, reject) => {
const xhr = GM_xmlhttpRequest({
method: "GET",
url: `${RPA_CONFIG.currencyApiUrl}${baseCurrency}.json`,
timeout: 8000,
onload: function(resp) {
if (resp.status >= 200 && resp.status < 400) {
try {
resolve(JSON.parse(resp.responseText));
} catch (e) {
reject(new Error(`JSON Parse error for ${baseCurrency}: ${e.message}`));
}
} else {
reject(new Error(`Currency API error: ${resp.status} for ${baseCurrency}`));
}
},
onerror: (err) => reject(new Error(`Network error for ${baseCurrency}: ${err}`)),
ontimeout: () => reject(new Error(`Currency API timeout for ${baseCurrency}`))
});
if (signal) {
signal.addEventListener('abort', () => {
xhr.abort();
reject(new DOMException('Request aborted', 'AbortError'));
});
}
});
rpa_exchangeRates[baseCurrency] = response[baseCurrency] || {};
rpa_exchangeRates[baseCurrency][baseCurrency] = 1;
return rpa_exchangeRates[baseCurrency];
} catch (error) {
if (error.name === 'AbortError') {
console.log(`[RPA] Exchange rate request for ${baseCurrency} aborted.`);
} else {
console.error(`[RPA] Error fetching exchange rates for ${baseCurrency}:`, error);
}
throw error;
}
}
function rpa_getPriceInCents(purchaseOption) {
if (purchaseOption && purchaseOption.discount_pct > 0 && purchaseOption.original_price_in_cents) {
return parseInt(purchaseOption.original_price_in_cents, 10);
}
if (purchaseOption && purchaseOption.final_price_in_cents) {
return parseInt(purchaseOption.final_price_in_cents, 10);
}
return null;
}
function rpa_getDisplayFormattedPrice(purchaseOption) {
if (purchaseOption) {
if (purchaseOption.discount_pct > 0 && purchaseOption.formatted_original_price) {
return purchaseOption.formatted_original_price;
}
if (purchaseOption.formatted_final_price) {
return purchaseOption.formatted_final_price;
}
}
return 'N/A';
}
async function rpa_fetchItemData(appid, countryCode, signal) {
const lang = rpa_currentDisplayMode === 'USD' ? 'english' : 'russian';
const url = `${RPA_CONFIG.steamApiUrl}?input_json=${encodeURIComponent(JSON.stringify({
"ids": [{ "appid": parseInt(appid) }],
"context": { "country_code": countryCode, "steam_realm": 1, "language": lang },
"data_request": { "include_all_purchase_options": true, "include_basic_info": true }
}))}`;
return new Promise((resolve, reject) => {
const xhr = GM_xmlhttpRequest({
method: "GET",
url: url,
timeout: 15000,
onload: function(response) {
try {
if (response.status >= 200 && response.status < 400) {
const data = JSON.parse(response.responseText);
if (data.response && data.response.store_items && data.response.store_items.length > 0) {
resolve(data.response.store_items[0]);
} else {
resolve(null);
}
} else {
reject(new Error(`Steam API error: ${response.status} for ${countryCode}`));
}
} catch (e) {
reject(new Error(`Error parsing Steam API response for ${countryCode}: ${e.message}`));
}
},
onerror: (err) => reject(new Error(`Network error for ${countryCode}: ${err.toString()}`)),
ontimeout: () => reject(new Error(`Timeout for ${countryCode}`))
});
if (signal) {
signal.addEventListener('abort', () => {
xhr.abort();
reject(new DOMException('Request aborted', 'AbortError'));
});
}
});
}
function rpa_addPriorButton() {
if (document.getElementById('rpaPriorButton')) return;
let targetWrapper = document.querySelector('.game_area_purchase_game_wrapper');
if (!targetWrapper) {
return;
}
const allPurchaseWrappers = document.querySelectorAll('.game_area_purchase_game_wrapper');
for (let wrapper of allPurchaseWrappers) {
if (wrapper.querySelector('.game_purchase_action .price, .game_purchase_action .discount_block')) {
targetWrapper = wrapper;
break;
}
}
rpa_priorButton = document.createElement('button');
rpa_priorButton.id = 'rpaPriorButton';
rpa_priorButton.textContent = RPA_CONFIG.priorButtonText;
rpa_priorButton.className = 'btnv6_blue_hoverfade btn_medium';
Object.assign(rpa_priorButton.style, {
marginRight: '10px',
marginBottom: '10px',
height: '32px',
padding: '0 15px',
lineHeight: '32px'
});
rpa_priorButton.addEventListener('click', rpa_openModal);
const buttonContainer = document.createElement('div');
buttonContainer.appendChild(rpa_priorButton);
if (targetWrapper.parentNode) {
targetWrapper.parentNode.insertBefore(buttonContainer, targetWrapper);
}
}
function rpa_updateModeToggleButtonText() {
if (!rpa_modeToggleButton) return;
if (rpa_currentDisplayMode === 'USD') {
rpa_modeToggleButton.textContent = 'RUB';
rpa_modeToggleButton.title = 'Вернуться в режим рублей?';
} else {
rpa_modeToggleButton.textContent = 'USD';
rpa_modeToggleButton.title = 'Переключиться на режим долларов?';
}
}
function rpa_handleModeToggle() {
if (rpa_currentDisplayMode === 'RUB') {
if (rpa_hideUsdSwitchWarning) {
rpa_switchToUsdMode();
} else {
rpa_showUsdSwitchConfirmation();
}
} else {
rpa_switchToRubMode();
}
}
function rpa_switchToUsdMode() {
rpa_currentDisplayMode = 'USD';
rpa_updateTextsAndRegionNames();
if (rpa_modal && rpa_modal.style.display === 'flex') {
document.getElementById('rpaSummaryDiv').innerHTML = `<p>Click "Fetch Data" to begin.</p>`;
document.getElementById('rpaResultsDiv').innerHTML = '';
rpa_updateModalStatus('Click "Fetch Data" to start analysis.');
}
rpa_updateModeToggleButtonText();
}
function rpa_switchToRubMode() {
rpa_currentDisplayMode = 'RUB';
rpa_updateTextsAndRegionNames();
if (rpa_modal && rpa_modal.style.display === 'flex') {
document.getElementById('rpaSummaryDiv').innerHTML = '<p>Нажмите "Сбор данных" для начала.</p>';
document.getElementById('rpaResultsDiv').innerHTML = '';
rpa_updateModalStatus('Нажмите "Сбор данных" для начала анализа.');
}
rpa_updateModeToggleButtonText();
}
function rpa_showUsdSwitchConfirmation() {
let existingDialog = document.getElementById('rpaUsdConfirmDialog');
if (existingDialog) existingDialog.remove();
const dialog = document.createElement('div');
dialog.id = 'rpaUsdConfirmDialog';
Object.assign(dialog.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: '#1b2838',
color: '#c6d4df',
padding: '25px',
borderRadius: '5px',
boxShadow: '0 0 20px rgba(0,0,0,0.7)',
zIndex: '100005',
textAlign: 'left',
border: '1px solid #000',
maxWidth: '450px'
});
const message = document.createElement('p');
message.innerHTML = 'Вы переходите в режим долларов. Данный режим предназначен для получения цен в долларах США и может быть полезен для оценки ценовой политики при общении с разработчиками/издателями.<br><br>Продолжить?';
message.style.marginBottom = '20px';
message.style.lineHeight = '1.6';
const checkboxContainer = document.createElement('div');
checkboxContainer.style.marginBottom = '20px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'rpaDontShowAgainUsd';
checkbox.style.marginRight = '8px';
const label = document.createElement('label');
label.htmlFor = 'rpaDontShowAgainUsd';
label.textContent = 'Больше не показывать это сообщение';
checkboxContainer.appendChild(checkbox);
checkboxContainer.appendChild(label);
const buttonsContainer = document.createElement('div');
buttonsContainer.style.textAlign = 'right';
const yesButton = document.createElement('button');
yesButton.textContent = 'Да';
Object.assign(yesButton.style, {
padding: '8px 15px',
marginRight: '10px',
backgroundColor: '#76b72a',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer'
});
yesButton.onclick = () => {
if (checkbox.checked) {
rpa_hideUsdSwitchWarning = true;
localStorage.setItem('rpa_hide_usd_switch_warning', 'true');
}
rpa_switchToUsdMode();
dialog.remove();
};
const cancelButton = document.createElement('button');
cancelButton.textContent = 'Отмена';
Object.assign(cancelButton.style, {
padding: '8px 15px',
backgroundColor: '#55606e',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer'
});
cancelButton.onclick = () => {
dialog.remove();
};
buttonsContainer.appendChild(yesButton);
buttonsContainer.appendChild(cancelButton);
dialog.appendChild(message);
dialog.appendChild(checkboxContainer);
dialog.appendChild(buttonsContainer);
document.body.appendChild(dialog);
}
function rpa_createModal() {
if (document.getElementById('rpaRegionalPriceModal')) return;
rpa_modal = document.createElement('div');
rpa_modal.id = 'rpaRegionalPriceModal';
Object.assign(rpa_modal.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(21, 33, 45, 0.985)',
color: '#c6d4df',
zIndex: '100001',
display: 'flex',
flexDirection: 'column',
fontFamily: '"Motiva Sans", Sans-serif, Arial',
padding: '0'
});
const headerBar = document.createElement('div');
headerBar.id = 'rpaHeaderBar';
Object.assign(headerBar.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 20px',
backgroundColor: '#17202d',
borderBottom: '1px solid #2a3f5a',
flexShrink: '0'
});
const title = document.createElement('h3');
title.textContent = RPA_CONFIG.modalTitle;
Object.assign(title.style, {
margin: '0',
color: '#67c1f5',
fontSize: '18px',
fontWeight: '500'
});
const gameNameDisplay = document.createElement('p');
gameNameDisplay.id = 'rpaGameNameDisplay';
Object.assign(gameNameDisplay.style, {
margin: '0 0 0 20px',
fontSize: '16px',
color: '#e5e5e5',
fontWeight: 'bold',
flexGrow: '1',
textAlign: 'center'
});
rpa_modeToggleButton = document.createElement('button');
Object.assign(rpa_modeToggleButton.style, {
background: '#4b6f9c',
color: 'white',
border: '1px solid #2a3f5a',
borderRadius: '3px',
padding: '5px 10px',
cursor: 'pointer',
fontSize: '13px',
marginRight: '15px'
});
rpa_modeToggleButton.onmouseover = () => rpa_modeToggleButton.style.backgroundColor = '#67c1f5';
rpa_modeToggleButton.onmouseout = () => rpa_modeToggleButton.style.backgroundColor = '#4b6f9c';
rpa_modeToggleButton.addEventListener('click', rpa_handleModeToggle);
rpa_updateModeToggleButtonText();
const closeButton = document.createElement('button');
closeButton.innerHTML = '×';
Object.assign(closeButton.style, {
background: 'none',
border: 'none',
color: '#8f98a0',
fontSize: '30px',
cursor: 'pointer',
lineHeight: '1',
padding: '0 8px'
});
closeButton.onmouseover = () => closeButton.style.color = '#fff';
closeButton.onmouseout = () => closeButton.style.color = '#8f98a0';
closeButton.addEventListener('click', rpa_closeModal);
const rightControls = document.createElement('div');
rightControls.style.display = 'flex';
rightControls.style.alignItems = 'center';
rightControls.appendChild(rpa_modeToggleButton);
rightControls.appendChild(closeButton);
headerBar.appendChild(title);
headerBar.appendChild(gameNameDisplay);
headerBar.appendChild(rightControls);
rpa_modal.appendChild(headerBar);
const controlsBar = document.createElement('div');
controlsBar.id = 'rpaControlsBar';
Object.assign(controlsBar.style, {
display: 'flex',
alignItems: 'center',
gap: '15px',
padding: '10px 20px',
backgroundColor: '#1a2430',
borderBottom: '1px solid #23313f',
flexShrink: '0'
});
const fetchButton = document.createElement('button');
fetchButton.textContent = RPA_CONFIG.fetchButtonText;
fetchButton.id = 'rpaFetchDataButton';
Object.assign(fetchButton.style, {
padding: '8px 20px',
backgroundColor: '#76b72a',
color: 'white',
border: '1px solid #5c9e1f',
borderRadius: '3px',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
textShadow: '1px 1px 0px rgba(0,0,0,0.3)',
fontWeight: '500',
cursor: 'pointer',
fontSize: '14px',
lineHeight: '1.5'
});
fetchButton.onmouseover = () => {
fetchButton.style.backgroundColor = '#85c83a';
};
fetchButton.onmouseout = () => {
fetchButton.style.backgroundColor = '#76b72a';
};
fetchButton.onmousedown = () => {
fetchButton.style.backgroundColor = '#6aa424';
};
fetchButton.onmouseup = () => {
fetchButton.style.backgroundColor = '#85c83a';
};
fetchButton.addEventListener('click', rpa_handleFetchData);
controlsBar.appendChild(fetchButton);
const progressBarContainer = document.createElement('div');
progressBarContainer.id = 'rpaProgressBarContainer';
Object.assign(progressBarContainer.style, {
flexGrow: '1',
height: '10px',
backgroundColor: '#2a3f5a',
borderRadius: '5px',
overflow: 'hidden',
display: 'none'
});
rpa_progressBarFill = document.createElement('div');
rpa_progressBarFill.id = 'rpaProgressBarFill';
Object.assign(rpa_progressBarFill.style, {
width: '0%',
height: '100%',
backgroundColor: '#67c1f5',
borderRadius: '5px',
transition: 'width 0.2s ease-out'
});
progressBarContainer.appendChild(rpa_progressBarFill);
controlsBar.appendChild(progressBarContainer);
const statusDiv = document.createElement('div');
statusDiv.id = 'rpaStatusDiv';
Object.assign(statusDiv.style, {
minWidth: '250px',
textAlign: 'right',
fontSize: '13px',
color: '#8f98a0'
});
controlsBar.appendChild(statusDiv);
rpa_modal.appendChild(controlsBar);
const mainContentWrapper = document.createElement('div');
mainContentWrapper.id = 'rpaMainContentWrapper';
Object.assign(mainContentWrapper.style, {
display: 'flex',
flexGrow: '1',
overflow: 'hidden',
padding: '15px 20px'
});
const leftSidebar = document.createElement('div');
leftSidebar.id = 'rpaLeftSidebar';
Object.assign(leftSidebar.style, {
width: '280px',
flexShrink: '0',
paddingRight: '15px',
borderRight: '1px solid #23313f',
overflowY: 'auto',
scrollbarWidth: 'thin',
scrollbarColor: '#4b6f9c #1e2c3a'
});
leftSidebar.innerHTML = `<style>
#rpaLeftSidebar::-webkit-scrollbar { width: 6px; }
#rpaLeftSidebar::-webkit-scrollbar-track { background: #1e2c3a; border-radius: 3px; }
#rpaLeftSidebar::-webkit-scrollbar-thumb { background-color: #4b6f9c; border-radius: 3px; }
#rpaLeftSidebar h4 { color: #67c1f5; font-size: 15px; margin-top: 0; margin-bottom: 8px; border-bottom: 1px solid #2a3f5a; padding-bottom: 5px; }
#rpaLeftSidebar .summary-item { margin-bottom: 12px; }
#rpaLeftSidebar .summary-label { display: block; font-weight: bold; color: #67c1f5; margin-bottom: 3px; font-size: 13px; }
#rpaLeftSidebar .summary-value { display: block; color: #e5e5e5; font-size: 14px; }
#rpaLeftSidebar .summary-sub-value { display: block; font-size: 11px; margin-top: 2px; opacity: 0.85; }
</style>`;
const summaryDiv = document.createElement('div');
summaryDiv.id = 'rpaSummaryDiv';
leftSidebar.appendChild(summaryDiv);
mainContentWrapper.appendChild(leftSidebar);
const resultsArea = document.createElement('div');
resultsArea.id = 'rpaResultsArea';
Object.assign(resultsArea.style, {
flexGrow: '1',
overflow: 'auto',
paddingLeft: '15px',
scrollbarWidth: 'thin',
scrollbarColor: '#4b6f9c #1e2c3a'
});
resultsArea.innerHTML = `<style>
#rpaResultsArea::-webkit-scrollbar { width: 8px; height: 8px; }
#rpaResultsArea::-webkit-scrollbar-track { background: #1e2c3a; border-radius: 4px; }
#rpaResultsArea::-webkit-scrollbar-thumb { background-color: #4b6f9c; border-radius: 4px; border: 2px solid #1e2c3a; }
#rpaResultsArea::-webkit-scrollbar-thumb:hover { background-color: #67c1f5; }
</style>`;
const resultsDiv = document.createElement('div');
resultsDiv.id = 'rpaResultsDiv';
resultsArea.appendChild(resultsDiv);
mainContentWrapper.appendChild(resultsArea);
rpa_modal.appendChild(mainContentWrapper);
document.body.appendChild(rpa_modal);
rpa_modal.style.display = 'none';
const style = document.createElement('style');
style.textContent = `
@keyframes rpa_spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.rpa_spinner { border: 2px solid rgba(255, 255, 255, 0.3); border-radius: 50%; border-top-color: #fff; width: 0.9em; height: 0.9em; animation: rpa_spin 1s linear infinite; display: inline-block; vertical-align: middle; margin-left: 8px; }
.rpaSinglePriceTable { width: auto; min-width: 680px; border-collapse: collapse; font-size: 12px; table-layout: fixed; background-color: #1e2c3a; border-radius: 3px; margin-left: 0; }
.rpaSinglePriceTable th, .rpaSinglePriceTable td { border: 1px solid #2a3f5a; padding: 6px 8px; text-align: left; }
.rpaSinglePriceTable th { background-color: #23313f; color: #a0b1c3; font-weight: 500; position: sticky; top: 0; z-index: 1; }
.rpaSinglePriceTable th.col-rank-header { width: 45px; }
.rpaSinglePriceTable th.col-region-header { width: 230px; }
.rpaSinglePriceTable th.col-local-price-header { width: 110px; }
.rpaSinglePriceTable th.col-rub-price-header, .rpaSinglePriceTable th.col-usd-price-header { width: 110px; }
.rpaSinglePriceTable th.col-diff-ru-header, .rpaSinglePriceTable th.col-diff-us-header { width: 120px; }
.rpaSinglePriceTable td.col-rank { text-align: center; }
.rpaSinglePriceTable td.col-region { word-break: break-word; white-space: normal; }
.rpaSinglePriceTable td.col-local-price, .rpaSinglePriceTable td.col-rub-price, .rpaSinglePriceTable td.col-usd-price, .rpaSinglePriceTable td.col-diff-ru, .rpaSinglePriceTable td.col-diff-us { text-align: right; white-space: nowrap; }
.rpaSinglePriceTable td.col-diff-ru.positive, .rpaSinglePriceTable td.col-diff-us.positive { color: lightgreen; }
.rpaSinglePriceTable td.col-diff-ru.negative, .rpaSinglePriceTable td.col-diff-us.negative { color: salmon; }
.rpaSinglePriceTable td.col-diff-ru.neutral, .rpaSinglePriceTable td.col-diff-us.neutral { color: #c6d4df; }
.rpaSinglePriceTable tr.highlight-ru td, .rpaSinglePriceTable tr.highlight-us td { background-color: rgba(103, 193, 245, 0.15) !important; font-weight: bold; }
`;
document.head.appendChild(style);
}
function rpa_updateProgressBar(percentage) {
if (rpa_progressBarFill) {
rpa_progressBarFill.style.width = `${Math.min(100, Math.max(0, percentage))}%`;
}
const progressBarContainer = document.getElementById('rpaProgressBarContainer');
if (progressBarContainer) {
progressBarContainer.style.display = (percentage > 0 && percentage < 100) ? 'flex' : 'none';
}
}
function rpa_openModal() {
if (!rpa_modal) rpa_createModal();
rpa_updateTextsAndRegionNames();
rpa_currentAppId = rpa_getAppIdFromUrl();
const initialMsg = rpa_currentDisplayMode === 'USD' ? 'Click "Fetch Data" to begin.' : 'Нажмите "Сбор данных" для начала.';
const statusMsg = rpa_currentDisplayMode === 'USD' ? 'Click "Fetch Data" to start analysis.' : 'Нажмите "Сбор данных" для начала анализа.';
if (!rpa_currentAppId) {
alert(rpa_currentDisplayMode === 'USD' ? '[RPA] Could not determine AppID of the game.' : '[RPA] Не удалось определить AppID игры.');
return;
}
const gameTitleElement = document.querySelector('#appHubAppName');
rpa_currentGameName = gameTitleElement ? gameTitleElement.textContent.trim() : (rpa_currentDisplayMode === 'USD' ? `Game #${rpa_currentAppId}` : `Игра #${rpa_currentAppId}`);
const gameNameDisplay = document.getElementById('rpaGameNameDisplay');
if (gameNameDisplay) gameNameDisplay.textContent = rpa_currentGameName;
rpa_modal.style.display = 'flex';
document.getElementById('rpaSummaryDiv').innerHTML = `<p>${initialMsg}</p>`;
document.getElementById('rpaResultsDiv').innerHTML = '';
rpa_updateModalStatus(statusMsg);
document.getElementById('rpaFetchDataButton').disabled = false;
rpa_updateProgressBar(0);
}
function rpa_closeModal() {
if (rpa_fetchController) {
rpa_fetchController.abort();
rpa_fetchController = null;
}
if (rpa_modal) rpa_modal.style.display = 'none';
const statusMsg = rpa_currentDisplayMode === 'USD' ? 'Ready for new analysis.' : 'Готово к новому анализу.';
rpa_updateModalStatus(statusMsg);
rpa_updateProgressBar(0);
}
function rpa_updateModalStatus(message, isLoading = false) {
const statusDiv = document.getElementById('rpaStatusDiv');
const fetchBtn = document.getElementById('rpaFetchDataButton');
if (statusDiv) {
statusDiv.innerHTML = isLoading ? `${message} <span class="rpa_spinner"></span>` : message;
}
if (fetchBtn) {
fetchBtn.disabled = isLoading;
}
}
async function rpa_handleFetchData() {
if (rpa_fetchController) {
rpa_fetchController.abort();
console.log("[RPA] Previous data fetch aborted.");
}
rpa_fetchController = new AbortController();
const signal = rpa_fetchController.signal;
rpa_updateProgressBar(0);
rpa_updateModalStatus(rpa_currentDisplayMode === 'USD' ? 'Initializing...' : 'Инициализация...', true);
const summaryDiv = document.getElementById('rpaSummaryDiv');
const resultsDiv = document.getElementById('rpaResultsDiv');
summaryDiv.innerHTML = `<h4>${rpa_currentDisplayMode === 'USD' ? 'Summary:' : 'Сводная информация:'}</h4>`;
resultsDiv.innerHTML = '';
if (!rpa_currentAppId) {
rpa_updateModalStatus(rpa_currentDisplayMode === 'USD' ? 'Error: AppID not defined.' : 'Ошибка: AppID не определен.', false);
rpa_updateProgressBar(0);
return;
}
const gameTitleElement = document.querySelector('#appHubAppName');
rpa_currentGameName = gameTitleElement ? gameTitleElement.textContent.trim() : (rpa_currentDisplayMode === 'USD' ? `Game #${rpa_currentAppId}` : `Игра #${rpa_currentAppId}`);
const gameNameDisplay = document.getElementById('rpaGameNameDisplay');
if (gameNameDisplay) gameNameDisplay.textContent = rpa_currentGameName;
rpa_updateProgressBar(2);
let usData;
const usRegionConfig = RPA_CONFIG.regions.find(r => r.cc === 'US');
if (!usRegionConfig) {
rpa_updateModalStatus(rpa_currentDisplayMode === 'USD' ? 'Error: Configuration for US region not found.' : 'Ошибка: Конфигурация для региона США не найдена.', false);
rpa_updateProgressBar(0);
return;
}
try {
rpa_updateModalStatus(rpa_currentDisplayMode === 'USD' ? 'Fetching US price...' : 'Запрос цены для США...', true);
usData = await rpa_fetchItemData(rpa_currentAppId, usRegionConfig.cc, signal);
if (signal.aborted) throw new DOMException('Request aborted', 'AbortError');
if (usData && usData.basic_info && usData.basic_info.name) {
rpa_currentGameName = usData.basic_info.name;
if (gameNameDisplay) gameNameDisplay.textContent = rpa_currentGameName;
}
} catch (error) {
if (error.name === 'AbortError') {
rpa_updateModalStatus(rpa_currentDisplayMode === 'USD' ? 'Data fetch aborted.' : 'Сбор данных отменен.', false);
} else {
rpa_updateModalStatus((rpa_currentDisplayMode === 'USD' ? `Error fetching US price: ` : `Ошибка при запросе цены для США: `) + error.message, false);
}
rpa_updateProgressBar(0);
return;
}
if (!usData || !usData.best_purchase_option || !usData.best_purchase_option.final_price_in_cents) {
let msg = (rpa_currentDisplayMode === 'USD' ? `Game "${rpa_currentGameName}" ` : `Игра "${rpa_currentGameName}" `);
const isFreeOrUnavailableUS = (usData && usData.success === 1 && (!usData.best_purchase_option || usData.best_purchase_option.final_price_in_cents === "0" || !usData.best_purchase_option.final_price_in_cents));
msg += isFreeOrUnavailableUS ? (rpa_currentDisplayMode === 'USD' ? "is free or not available in the US." : "бесплатна или недоступна в США.") :
(rpa_currentDisplayMode === 'USD' ? "is unavailable in the US or has no price." : "недоступна в США или не имеет цены.");
msg += (rpa_currentDisplayMode === 'USD' ? " Analysis cannot proceed." : " Анализ невозможен.");
rpa_updateModalStatus(msg, false);
rpa_updateProgressBar(0);
return;
}
const basePriceCents = rpa_getPriceInCents(usData.best_purchase_option);
if (!basePriceCents) {
rpa_updateModalStatus(rpa_currentDisplayMode === 'USD' ? 'Could not determine base US price for calculation.' : 'Не удалось определить базовую цену в США для расчета.', false);
rpa_updateProgressBar(0);
return;
}
const baseUsdPrice = parseFloat(basePriceCents) / 100;
const baseUsdFormattedPrice = rpa_getDisplayFormattedPrice(usData.best_purchase_option);
rpa_updateProgressBar(5);
if (rpa_currentDisplayMode === 'USD') {
summaryDiv.innerHTML += `<div class="summary-item"><span class="summary-label">Base US Price:</span><span class="summary-value">${baseUsdPrice.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</span></div>`;
try {
await rpa_fetchExchangeRates('usd', signal);
if (signal.aborted) throw new DOMException('Request aborted', 'AbortError');
} catch (e) {
if (e.name === 'AbortError') {
rpa_updateModalStatus('Data fetch aborted.', false);
} else {
rpa_updateModalStatus('Error fetching base USD exchange rates.', false);
}
rpa_updateProgressBar(0);
return;
}
} else {
summaryDiv.innerHTML += `<div class="summary-item"><span class="summary-label">Базовая цена в США (для расчета):</span><span class="summary-value">$${baseUsdPrice.toFixed(2)}</span></div>`;
const recommendedRubPriceVal = calculateRecommendedRubPrice(baseUsdPrice);
if (recommendedRubPriceVal === null || typeof recommendedRubPriceVal === 'string') {
summaryDiv.innerHTML += `<div class="summary-item"><span class="summary-label">Рекомендуемая цена Steam в РФ:</span><span class="summary-value">${recommendedRubPriceVal || 'Не удалось рассчитать'}</span></div>`;
} else {
summaryDiv.innerHTML += `<div class="summary-item"><span class="summary-label">Рекомендуемая цена Steam в РФ:</span><span class="summary-value">${recommendedRubPriceVal.toLocaleString('ru-RU')} руб.</span></div>`;
}
try {
await rpa_fetchExchangeRates('rub', signal);
if (signal.aborted) throw new DOMException('Request aborted', 'AbortError');
} catch (e) {
if (e.name === 'AbortError') {
rpa_updateModalStatus('Сбор данных отменен.', false);
} else {
rpa_updateModalStatus('Ошибка загрузки базовых курсов валют.', false);
}
rpa_updateProgressBar(0);
return;
}
}
const regionalPrices = [];
let ruActualPriceData = null;
let ruActualPriceInRub = null;
if (rpa_currentDisplayMode === 'USD') {
regionalPrices.push({
regionName: usRegionConfig.name,
localPriceFormatted: baseUsdFormattedPrice,
priceInUsd: baseUsdPrice,
cc: usRegionConfig.cc
});
}
const otherRegions = RPA_CONFIG.regions.filter(r => rpa_currentDisplayMode === 'USD' ? r.cc !== usRegionConfig.cc : true);
const totalRegionsToProcess = otherRegions.length;
let regionsProcessedCount = 0;
for (let i = 0; i < totalRegionsToProcess; i += RPA_CONFIG.batchSize) {
if (signal.aborted) break;
const batch = otherRegions.slice(i, i + RPA_CONFIG.batchSize);
const batchProgressText = rpa_currentDisplayMode === 'USD' ?
`Workspaceing prices: batch ${Math.floor(i/RPA_CONFIG.batchSize)+1}/${Math.ceil(totalRegionsToProcess/RPA_CONFIG.batchSize)}` :
`Сбор цен: группа ${Math.floor(i/RPA_CONFIG.batchSize)+1}/${Math.ceil(totalRegionsToProcess/RPA_CONFIG.batchSize)}`;
rpa_updateModalStatus(`${batchProgressText} (${batch.map(r=>r.cc).join(', ')})...`, true);
const batchPromises = batch.map(region =>
rpa_fetchItemData(rpa_currentAppId, region.cc, signal)
.then(data => ({
region,
data
}))
.catch(error => {
if (error.name === 'AbortError') throw error;
console.warn(`[RPA] Error for region ${region.name} (${region.cc}): ${error.message}`);
return {
region,
error,
data: null
};
})
);
try {
const batchResults = await Promise.allSettled(batchPromises);
if (signal.aborted) throw new DOMException('Request aborted', 'AbortError');
for (const result of batchResults) {
regionsProcessedCount++;
let currentProgress = 5 + Math.round(((regionsProcessedCount + 1) / (totalRegionsToProcess + 1)) * 85);
rpa_updateProgressBar(currentProgress);
if (result.status === 'fulfilled' && result.value.data) {
const {
region,
data: regionData
} = result.value;
if (regionData && regionData.basic_info && regionData.best_purchase_option) {
const purchaseOption = regionData.best_purchase_option;
let priceInCents = rpa_getPriceInCents(purchaseOption);
let displayFormattedPrice = rpa_getDisplayFormattedPrice(purchaseOption);
if (priceInCents !== null && priceInCents >= 0) {
const localPrice = parseFloat(priceInCents) / 100;
if (rpa_currentDisplayMode === 'USD') {
let priceInUsd = null;
if (region.currencyApiCode.toLowerCase() === 'usd') {
priceInUsd = localPrice;
} else {
try {
const rates = await rpa_fetchExchangeRates(region.currencyApiCode.toLowerCase(), signal);
if (signal.aborted) throw new DOMException('Request aborted', 'AbortError');
if (rates && typeof rates.usd === 'number') {
priceInUsd = localPrice * rates.usd;
} else {
const usdBasedRates = await rpa_fetchExchangeRates('usd', signal);
if (usdBasedRates && typeof usdBasedRates[region.currencyApiCode.toLowerCase()] === 'number' && usdBasedRates[region.currencyApiCode.toLowerCase()] !== 0) {
priceInUsd = localPrice / usdBasedRates[region.currencyApiCode.toLowerCase()];
} else {
console.warn(`[RPA] USD rate not found for ${region.currencyApiCode}`);
}
}
} catch (rateError) {
if (rateError.name === 'AbortError') throw rateError;
console.warn(`[RPA] Rate error (USD) for ${region.currencyApiCode}: ${rateError.message}`);
}
}
if (priceInUsd !== null && region.cc !== 'US') {
regionalPrices.push({
regionName: region.name,
localPriceFormatted: displayFormattedPrice,
priceInUsd: priceInUsd,
cc: region.cc
});
}
} else {
let priceInRub = null;
if (region.currencyApiCode.toLowerCase() === 'rub') {
priceInRub = localPrice;
} else {
try {
const rates = await rpa_fetchExchangeRates(region.currencyApiCode.toLowerCase(), signal);
if (signal.aborted) throw new DOMException('Request aborted', 'AbortError');
if (rates && typeof rates.rub === 'number') {
priceInRub = localPrice * rates.rub;
} else {
console.warn(`[RPA] RUB rate not found for ${region.currencyApiCode}`);
}
} catch (rateError) {
if (rateError.name === 'AbortError') throw rateError;
console.warn(`[RPA] Rate error (RUB) for ${region.currencyApiCode}: ${rateError.message}`);
}
}
if (priceInRub !== null) {
regionalPrices.push({
regionName: region.name,
localPriceFormatted: displayFormattedPrice,
priceInRub: priceInRub,
cc: region.cc
});
}
if (region.cc === 'RU') {
ruActualPriceData = {
formatted: displayFormattedPrice,
rub: priceInRub
};
ruActualPriceInRub = priceInRub;
}
if (region.cc === 'US' && rpa_currentDisplayMode === 'RUB' && !regionalPrices.find(p => p.cc === 'US')) {
regionalPrices.push({
regionName: region.name,
localPriceFormatted: displayFormattedPrice,
priceInRub: priceInRub,
cc: region.cc
});
}
}
}
}
}
}
} catch (batchError) {
if (batchError.name === 'AbortError') {
rpa_updateModalStatus(rpa_currentDisplayMode === 'USD' ? 'Data fetch aborted.' : 'Сбор данных отменен.', false);
rpa_updateProgressBar(0);
rpa_fetchController = null;
return;
}
console.error("[RPA] Error processing batch:", batchError);
}
if (i + RPA_CONFIG.batchSize < totalRegionsToProcess && !signal.aborted) {
await new Promise(resolve => setTimeout(resolve, RPA_CONFIG.delayBetweenBatches));
}
if (signal.aborted) break;
}
if (signal.aborted) {
rpa_updateModalStatus(rpa_currentDisplayMode === 'USD' ? 'Data fetch aborted.' : 'Сбор данных отменен.', false);
rpa_updateProgressBar(0);
rpa_fetchController = null;
return;
}
rpa_updateProgressBar(98);
if (rpa_currentDisplayMode === 'USD') {
if (regionalPrices.length > 0) {
regionalPrices.sort((a, b) => a.priceInUsd - b.priceInUsd);
const cheapestRegion = regionalPrices[0];
const mostExpensiveRegion = regionalPrices[regionalPrices.length - 1];
summaryDiv.innerHTML += `<div class="summary-item"><span class="summary-label">Cheapest (USD):</span><span class="summary-value">${cheapestRegion.priceInUsd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} (${cheapestRegion.regionName})</span></div>`;
summaryDiv.innerHTML += `<div class="summary-item"><span class="summary-label">Most Expensive (USD):</span><span class="summary-value">${mostExpensiveRegion.priceInUsd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} (${mostExpensiveRegion.regionName})</span></div>`;
const usEntryIndex = regionalPrices.findIndex(r => r.cc === 'US');
if (usEntryIndex !== -1) {
summaryDiv.innerHTML += `<div class="summary-item"><span class="summary-label">US Price Rank:</span><span class="summary-value">${usEntryIndex + 1} of ${regionalPrices.length}</span></div>`;
}
generatePriceTable(regionalPrices, baseUsdPrice, resultsDiv);
} else {
resultsDiv.innerHTML = `<p style="text-align:center; margin-top:20px; color: #8f98a0;">Could not retrieve regional prices for comparison.</p>`;
}
} else {
const ruComparisonDiv = document.createElement('div');
ruComparisonDiv.id = 'rpaRuComparison';
ruComparisonDiv.style.cssText = "margin-top:10px; padding-top:10px; border-top: 1px solid #2a3f5a;";
summaryDiv.appendChild(ruComparisonDiv);
const recommendedRubPriceVal = calculateRecommendedRubPrice(baseUsdPrice);
if (ruActualPriceData && typeof recommendedRubPriceVal === 'number') {
const actualRu = ruActualPriceData.rub;
let factPriceValue = "N/A",
factPriceSubValue = "",
subValueColor = "#c6d4df";
if (actualRu !== null) {
factPriceValue = `${actualRu.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2})} руб.`;
const diff = actualRu - recommendedRubPriceVal;
const diffPercent = recommendedRubPriceVal !== 0 ? (diff / recommendedRubPriceVal) * 100 : (diff > 0 ? Infinity : (actualRu === 0 ? 0 : -Infinity));
if (diff < -0.01) {
subValueColor = 'lightgreen';
factPriceSubValue = `(на ${Math.abs(diff).toLocaleString('ru-RU', {maximumFractionDigits: 2})} руб. / ${Math.abs(diffPercent).toFixed(1)}% ДЕШЕВЛЕ рекомендуемой)`;
} else if (diff > 0.01) {
subValueColor = 'salmon';
factPriceSubValue = `(на ${diff.toLocaleString('ru-RU', {maximumFractionDigits: 2})} руб. / ${diffPercent.toFixed(1)}% ДОРОЖЕ рекомендуемой)`;
} else {
factPriceSubValue = `(соответствует рекомендуемой)`;
}
} else {
factPriceValue = `<span style="color: orange;">Цена в РФ не найдена/неконвертируема.</span>`;
}
ruComparisonDiv.innerHTML += `<div class="summary-item"><span class="summary-label">Фактическая цена в РФ:</span><span class="summary-value">${factPriceValue}${factPriceSubValue ? `<span class="summary-sub-value" style="color:${subValueColor};">${factPriceSubValue}</span>` : ''}</span></div>`;
} else if (typeof recommendedRubPriceVal === 'number') {
ruComparisonDiv.innerHTML += `<div class="summary-item"><span class="summary-label">Фактическая цена в РФ:</span><span class="summary-value" style="color:orange;">Не найдена.</span></div>`;
}
if (regionalPrices.length > 0) {
regionalPrices.sort((a, b) => a.priceInRub - b.priceInRub);
let ruRank = -1;
if (ruActualPriceInRub !== null) {
ruRank = regionalPrices.findIndex(rp => rp.cc === 'RU' && Math.abs(rp.priceInRub - ruActualPriceInRub) < 0.01) + 1;
if (ruRank === 0) {
const ruEntry = regionalPrices.find(rp => rp.cc === 'RU');
if (ruEntry) ruRank = regionalPrices.findIndex(rp => rp.priceInRub >= ruEntry.priceInRub) + 1;
if (ruRank === 0 && regionalPrices.length > 0) ruRank = 1;
}
}
let rankText = "Цена РФ отсутствует, мировой ранг не определен.";
if (ruRank > 0) {
rankText = `${ruRank} из ${regionalPrices.length}`;
} else if (ruActualPriceInRub !== null) {
rankText = `Не удалось точно определить ранг РФ.`;
}
ruComparisonDiv.innerHTML += `<div class="summary-item"><span class="summary-label">Место РФ в мировом рейтинге цен:</span><span class="summary-value">${rankText}</span></div>`;
generatePriceTable(regionalPrices, ruActualPriceInRub, resultsDiv);
} else {
resultsDiv.innerHTML = `<p style="text-align:center; margin-top:20px; color: #8f98a0;">Не удалось получить региональные цены для сравнения.</p>`;
}
}
rpa_updateModalStatus(rpa_currentDisplayMode === 'USD' ? 'Analysis complete.' : 'Анализ завершен.', false);
rpa_updateProgressBar(100);
setTimeout(() => rpa_updateProgressBar(0), 2000);
rpa_fetchController = null;
}
function generatePriceTable(prices, comparisonPriceBase, container) {
container.innerHTML = '';
const table = document.createElement('table');
table.className = 'rpaSinglePriceTable';
const thead = table.createTHead();
const headerRow = thead.insertRow();
const isUSDMode = rpa_currentDisplayMode === 'USD';
const headers = isUSDMode ? [{
text: '#',
class: 'col-rank-header'
}, {
text: 'Region (CC)',
class: 'col-region-header'
},
{
text: 'Local Price',
class: 'col-local-price-header'
}, {
text: 'Price (USD)',
class: 'col-usd-price-header'
},
{
text: 'Diff. vs US (%)',
class: 'col-diff-us-header'
}
] : [{
text: '№',
class: 'col-rank-header'
}, {
text: 'Регион (Код страны)',
class: 'col-region-header'
},
{
text: 'Цена (лок. вал.)',
class: 'col-local-price-header'
}, {
text: 'Цена (RUB)',
class: 'col-rub-price-header'
},
{
text: 'Разница с РФ (%)',
class: 'col-diff-ru-header'
}
];
headers.forEach(headerInfo => {
const th = document.createElement('th');
th.textContent = headerInfo.text;
th.className = headerInfo.class;
headerRow.appendChild(th);
});
const tbody = table.createTBody();
prices.forEach((rp, index) => {
const row = tbody.insertRow();
const highlightCC = isUSDMode ? 'US' : 'RU';
const highlightClass = isUSDMode ? 'highlight-us' : 'highlight-ru';
if (rp.cc === highlightCC) {
row.classList.add(highlightClass);
}
row.insertCell().textContent = index + 1;
row.cells[0].className = 'col-rank';
row.insertCell().textContent = `${rp.regionName} (${rp.cc})`;
row.cells[1].className = 'col-region';
const priceToDisplay = isUSDMode ? rp.priceInUsd : rp.priceInRub;
row.insertCell().textContent = rp.localPriceFormatted || (priceToDisplay === 0 ? (isUSDMode ? 'Free' : 'Бесплатно') : 'N/A');
row.cells[2].className = 'col-local-price';
const convertedPriceCell = row.insertCell();
convertedPriceCell.className = isUSDMode ? 'col-usd-price' : 'col-rub-price';
if (priceToDisplay !== null) {
convertedPriceCell.textContent = isUSDMode ?
priceToDisplay.toLocaleString('en-US', {
style: 'currency',
currency: 'USD'
}) :
priceToDisplay.toLocaleString('ru-RU', {
maximumFractionDigits: 0
}) + ' ₽';
} else {
convertedPriceCell.textContent = 'N/A';
}
const diffCell = row.insertCell();
diffCell.className = isUSDMode ? 'col-diff-us' : 'col-diff-ru';
if (rp.cc === highlightCC) {
diffCell.textContent = isUSDMode ? 'Base' : '-';
diffCell.classList.add('neutral');
} else if (comparisonPriceBase !== null && comparisonPriceBase > 0 && priceToDisplay !== null) {
const diffPercent = ((priceToDisplay - comparisonPriceBase) / comparisonPriceBase) * 100;
diffCell.textContent = (diffPercent >= 0 ? '+' : '') + diffPercent.toFixed(1) + '%';
if (diffPercent < -0.1) diffCell.classList.add('positive');
else if (diffPercent > 0.1) diffCell.classList.add('negative');
else diffCell.classList.add('neutral');
} else if (priceToDisplay === 0 && comparisonPriceBase === 0) {
diffCell.textContent = '0%';
diffCell.classList.add('neutral');
} else {
diffCell.textContent = 'N/A';
diffCell.classList.add('neutral');
}
});
container.appendChild(table);
}
function rpa_init() {
rpa_updateTextsAndRegionNames();
const initLoad = () => {
if (document.querySelector('.game_area_purchase_game_wrapper')) {
rpa_addPriorButton();
rpa_createModal();
} else {
setTimeout(initLoad, 500);
}
};
if (document.readyState === 'complete' || document.readyState === 'interactive') {
initLoad();
} else {
window.addEventListener('DOMContentLoaded', initLoad);
}
}
rpa_init();
})();
}
// Скрипт для получения дополнительной информации об игре при наведении на неё на странице поиска по каталогу | https://store.steampowered.com/search/
if (scriptsConfig.catalogInfo && unsafeWindow.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;
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 && unsafeWindow.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");
const sessionID = typeof unsafeWindow !== 'undefined' ? unsafeWindow.g_sessionID : window.g_sessionID;
if (!sessionID) {
console.error('[CatalogHider] Не удалось получить g_sessionID!');
alert('Не удалось получить ID сессии для скрытия игр. Пожалуйста, убедитесь, что вы авторизованы.');
return;
}
checkedLadybugs.forEach(ladybug => {
const aphid = ladybug.dataset.aphid;
const link = document.querySelector(`a[data-ds-appid="${aphid}"]`);
if (link) {
link.classList.add("ds_ignored", "ds_flagged");
ladybug.remove();
GM_xmlhttpRequest({
method: "POST",
url: "https://store.steampowered.com/recommended/ignorerecommendation/",
data: `sessionid=${sessionID}&appid=${aphid}&remove=0&snr=1_account_notinterested_`,
headers: { "Content-Type": "application/x-www-form-urlencoded" },
onload: function(response) {
console.log(`[CatalogHider] Игра с appid ${aphid} добавлена в список игнорируемого.`);
if (typeof unsafeWindow !== 'undefined' && unsafeWindow.GDynamicStore) {
unsafeWindow.GDynamicStore.InvalidateCache();
}
},
onerror: function(error) {
console.error(`[CatalogHider] Ошибка при скрытии игры ${aphid}:`, error);
}
});
}
});
setTimeout(updateAntCounter, 500);
}
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].my-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].my-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].my-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].my-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 && unsafeWindow.location.pathname.includes('/news')) {
(function() {
'use strict';
function runNewsMigration() {
const OLD_STORAGE_KEY = 'hiddenNews';
const NEW_STORAGE_KEY = 'use_hiddenNewsData_v4';
const oldDataRaw = localStorage.getItem(OLD_STORAGE_KEY);
if (!oldDataRaw) {
return;
}
try {
const oldData = JSON.parse(oldDataRaw);
if (!Array.isArray(oldData) || oldData.length === 0) {
localStorage.removeItem(OLD_STORAGE_KEY);
return;
}
const newData = GM_getValue(NEW_STORAGE_KEY, []);
const existingNewIds = new Set(newData.map(item => item.id));
const itemsToMigrate = [];
for (const oldItem of oldData) {
if (!oldItem.link || !oldItem.title) continue;
const match = oldItem.link.match(/\/app\/(\d+)\/view\/(\d+)/);
if (match && match[1] && match[2]) {
const appID = match[1];
const newsID = match[2];
if (!existingNewIds.has(newsID)) {
const newItem = {
id: newsID,
appID: appID,
gameName: "[N/A; 1.9.5]",
newsTitle: oldItem.title,
dateHidden: new Date(oldItem.date).getTime() || Date.now()
};
itemsToMigrate.push(newItem);
existingNewIds.add(newsID);
}
}
}
if (itemsToMigrate.length > 0) {
const finalData = [...newData, ...itemsToMigrate];
GM_setValue(NEW_STORAGE_KEY, finalData);
}
localStorage.removeItem(OLD_STORAGE_KEY);
} catch (e) {
localStorage.removeItem(OLD_STORAGE_KEY);
}
}
runNewsMigration();
const HIDDEN_NEWS_GM_KEY = 'use_hiddenNewsData_v4';
const NEWS_ITEM_SELECTOR = '._398u23KF15gxmeH741ZSyL';
const NEWS_APP_AREA_SELECTOR = '._3-0KOhYVQX2zIP3z-jCAdu';
const NEWS_APP_NAME_SELECTOR = '._71phFKOzg8aQlBU1rCA2T';
const NEWS_LINK_SELECTOR = 'a.Focusable[href^="/news/app/"]';
const NEWS_TITLE_SELECTOR = '._1M8-Pa3b3WboayCgd5VBJT';
const NEWS_IMAGE_CONTAINER_SELECTOR = '._3HF9tOy_soo1B_odf1XArk';
const NEWS_IMAGE_CONTAINER_FALLBACK_SELECTOR = '._2A8sQ35o5MKE0P2B9C0bAn';
let lastHiddenItems = [];
GM_addStyle(`
.use-newsfilter-checkbox-area {
position: absolute;
top: 0;
right: 0;
width: 90px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
-webkit-tap-highlight-color: transparent;
}
.use-newsfilter-checkbox {
appearance: none;
-webkit-appearance: none;
width: 46px;
height: 46px;
border: 3px solid rgba(102, 192, 244, 0.6);
border-radius: 5px;
background-color: rgba(27, 40, 56, 0.5);
cursor: pointer;
transition: all 0.2s ease-in-out;
opacity: 0.5;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.use-newsfilter-checkbox-area:hover .use-newsfilter-checkbox {
opacity: 1;
border-color: #ade0ff;
background-color: rgba(27, 40, 56, 0.75);
}
.use-newsfilter-checkbox:checked {
background-color: rgba(102, 192, 244, 1);
border-color: #e1e8ed;
opacity: 1;
}
.use-newsfilter-checkbox:checked::before {
content: '✔';
color: #0a121c;
font-size: 30px;
font-weight: bold;
}
.use-newsfilter-newsitem-selected {
opacity: 0.45;
transition: opacity 0.25s ease-in-out;
}
.use-newsfilter-newsitem-selected:hover {
opacity: 0.75;
}
.use-newsfilter-newsitem-persistently-hidden {
opacity: 0.25 !important;
border: 1px dashed #4a5562;
transition: opacity 0.3s, border 0.3s;
}
.use-newsfilter-controls-container {
position: fixed;
top: 20px;
right: 15px;
background: rgba(20, 23, 28, 0.92);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
padding: 8px;
border-radius: 4px;
z-index: 10001;
display: flex;
flex-direction: column;
gap: 6px;
width: 200px;
}
.use-newsfilter-button {
padding: 7px 12px;
background-color: #58a6ff;
color: #0d1117;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s, transform 0.1s;
width: 100%;
text-align: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.use-newsfilter-button:hover:not(:disabled) {
background-color: #79bbff;
transform: translateY(-1px);
}
.use-newsfilter-button:active:not(:disabled) {
transform: translateY(0px);
background-color: #4a90e2;
}
.use-newsfilter-button:disabled {
background-color: #30363d;
color: #6a737d;
cursor: not-allowed;
box-shadow: none;
}
.use-newsfilter-storage-count {
color: #99a1a8;
font-size: 11px;
margin-top: 2px;
text-align: center;
}
#use-newsfilter-confirm-hide-button:not(:disabled) {
background-color: #d9534f;
color: white;
}
#use-newsfilter-confirm-hide-button:not(:disabled):hover {
background-color: #c9302c;
}
#use-newsfilter-confirm-hide-button:not(:disabled):active {
background-color: #ac2925;
}
#use-newsfilter-undo-button {
background-color: #f0ad4e;
color: #0d1117;
}
#use-newsfilter-undo-button:hover:not(:disabled) {
background-color: #ec971f;
}
#use-newsfilter-manage-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 650px;
max-height: 75vh;
background-color: #171a21;
border: 1px solid #4a5562;
border-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
z-index: 10002;
padding: 15px;
display: none;
flex-direction: column;
color: #c6d4df;
}
#use-newsfilter-manage-panel h3 {
margin-top: 0;
margin-bottom: 10px;
color: #67c1f5;
text-align: center;
font-size: 16px;
}
#use-newsfilter-hidden-list {
list-style: none;
padding: 0;
margin: 10px 0;
overflow-y: auto;
flex-grow: 1;
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
#use-newsfilter-hidden-list li {
padding: 7px 10px;
border-bottom: 1px solid #232830;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
#use-newsfilter-hidden-list li:last-child {
border-bottom: none;
}
#use-newsfilter-hidden-list li .hidden-item-text {
flex-grow: 1;
margin-right: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#use-newsfilter-hidden-list li .hidden-item-text .game-name {
color: #67c1f5;
font-weight: bold;
}
#use-newsfilter-hidden-list li .hidden-item-text .news-title {
color: #b0b8c0;
display: block;
font-size: 0.9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#use-newsfilter-hidden-list li .hidden-item-text .app-id {
color: #76808c;
font-size: 0.85em;
display: block;
}
.use-newsfilter-restore-btn {
background-color: #55c655;
color: #0d1117;
border: none;
padding: 3px 7px;
font-size: 10px;
border-radius: 3px;
cursor: pointer;
font-weight: 500;
flex-shrink: 0;
}
.use-newsfilter-restore-btn:hover {
background-color: #4CAF50;
}
.use-newsfilter-manage-buttons {
display: flex;
justify-content: space-between;
margin-top: 10px;
gap: 10px;
}
.use-newsfilter-manage-buttons .use-newsfilter-button {
font-size: 13px;
padding: 7px 12px;
}
@keyframes use-newsfilter-fadeout {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-20px);
}
}
.use-newsfilter-fading-out {
animation: use-newsfilter-fadeout 0.35s forwards ease-out;
overflow: hidden;
}
`);
let persistentlyHiddenItems = GM_getValue(HIDDEN_NEWS_GM_KEY, []);
let showPersistentlyHidden = false;
function getNewsIdsFromLink(newsItem) {
const linkElement = newsItem.querySelector(NEWS_LINK_SELECTOR);
if (linkElement) {
const href = linkElement.getAttribute('href');
const match = href.match(/\/news\/app\/(\d+)\/view\/(\d+)/);
if (match && match[1] && match[2]) {
return {
appID: match[1],
newsID: match[2]
};
}
}
return {
appID: null,
newsID: null
};
}
function getNewsItemDetails(newsItem) {
const {
appID,
newsID
} = getNewsIdsFromLink(newsItem);
let gameName = null;
let newsTitle = newsItem.querySelector(NEWS_TITLE_SELECTOR) ?.textContent.trim() || 'Без заголовка';
const appArea = newsItem.querySelector(NEWS_APP_AREA_SELECTOR);
if (appArea) {
gameName = appArea.querySelector(NEWS_APP_NAME_SELECTOR) ?.textContent.trim() || null;
}
return {
appID,
newsID,
gameName,
newsTitle
};
}
function addNewsCheckboxes(newsItems) {
newsItems.forEach(item => {
const {
newsID
} = getNewsItemDetails(item);
const hasExistingCheckboxArea = item.querySelector('.use-newsfilter-checkbox-area');
if (newsID && !hasExistingCheckboxArea) {
const checkboxArea = document.createElement('div');
checkboxArea.className = 'use-newsfilter-checkbox-area';
checkboxArea.title = 'Отметить для скрытия';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'use-newsfilter-checkbox';
checkbox.dataset.newsId = newsID;
checkboxArea.appendChild(checkbox);
checkboxArea.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
checkbox.checked = !checkbox.checked;
toggleTemporaryHide(item, checkbox.checked);
});
const imageContainer = item.querySelector(NEWS_IMAGE_CONTAINER_SELECTOR) || item.querySelector(NEWS_IMAGE_CONTAINER_FALLBACK_SELECTOR) || item;
if (imageContainer) {
if (getComputedStyle(imageContainer).position === 'static') {
imageContainer.style.position = 'relative';
}
imageContainer.appendChild(checkboxArea);
}
if (persistentlyHiddenItems.some(hidden => hidden.id === newsID)) {
applyPersistentHideStyle(item, newsID);
checkboxArea.style.display = 'none';
}
} else if (newsID && hasExistingCheckboxArea) {
if (persistentlyHiddenItems.some(hidden => hidden.id === newsID)) {
applyPersistentHideStyle(item, newsID);
hasExistingCheckboxArea.style.display = 'none';
} else {
hasExistingCheckboxArea.style.display = 'flex';
applyPersistentHideStyle(item, newsID);
}
}
});
}
function toggleTemporaryHide(newsItem, shouldHide) {
if (shouldHide) {
newsItem.classList.add('use-newsfilter-newsitem-selected');
} else {
newsItem.classList.remove('use-newsfilter-newsitem-selected');
}
updateSelectedCount();
}
function updateSelectedCount() {
const selectedItems = document.querySelectorAll(`${NEWS_ITEM_SELECTOR}.use-newsfilter-newsitem-selected`);
const confirmButton = document.getElementById('use-newsfilter-confirm-hide-button');
if (confirmButton) {
confirmButton.disabled = selectedItems.length === 0;
confirmButton.textContent = `Скрыть выбранные (${selectedItems.length})`;
}
}
function updatePersistentHiddenCountDisplay() {
const countElement = document.getElementById('use-newsfilter-storage-count-span');
if (countElement) {
countElement.textContent = persistentlyHiddenItems.length;
}
}
function createControls() {
if (document.getElementById('use-newsfilter-controls-container')) return;
const controlsContainer = document.createElement('div');
controlsContainer.id = 'use-newsfilter-controls-container';
controlsContainer.className = 'use-newsfilter-controls-container';
const confirmHideButton = document.createElement('button');
confirmHideButton.id = 'use-newsfilter-confirm-hide-button';
confirmHideButton.className = 'use-newsfilter-button';
confirmHideButton.textContent = 'Скрыть выбранные (0)';
confirmHideButton.disabled = true;
confirmHideButton.title = 'Переместить выбранные новости в список постоянно скрытых';
confirmHideButton.onclick = confirmAndHideSelectedNews;
const storageCountDisplay = document.createElement('div');
storageCountDisplay.className = 'use-newsfilter-storage-count';
storageCountDisplay.innerHTML = `В хранилище: <span id="use-newsfilter-storage-count-span">${persistentlyHiddenItems.length}</span>`;
controlsContainer.appendChild(storageCountDisplay);
const undoButton = document.createElement('button');
undoButton.id = 'use-newsfilter-undo-button';
undoButton.className = 'use-newsfilter-button';
undoButton.textContent = 'Отменить';
undoButton.style.display = 'none';
undoButton.title = 'Отменить последнее подтвержденное скрытие';
undoButton.onclick = undoLastPersistentHide;
controlsContainer.appendChild(undoButton);
const togglePersistentlyHiddenButton = document.createElement('button');
togglePersistentlyHiddenButton.id = 'use-newsfilter-toggle-persistent-button';
togglePersistentlyHiddenButton.className = 'use-newsfilter-button';
togglePersistentlyHiddenButton.textContent = showPersistentlyHidden ? 'Спрятать скрытое' : 'Показать скрытое';
togglePersistentlyHiddenButton.title = 'Показать/скрыть новости из списка постоянно скрытых';
togglePersistentlyHiddenButton.onclick = toggleShowPersistentlyHidden;
controlsContainer.appendChild(togglePersistentlyHiddenButton);
const manageHiddenButton = document.createElement('button');
manageHiddenButton.id = 'use-newsfilter-manage-button';
manageHiddenButton.className = 'use-newsfilter-button';
manageHiddenButton.textContent = 'Хранилище';
manageHiddenButton.title = 'Просмотреть и восстановить скрытые новости';
manageHiddenButton.onclick = showManageHiddenPanel;
controlsContainer.appendChild(manageHiddenButton);
controlsContainer.appendChild(confirmHideButton);
document.body.appendChild(controlsContainer);
createManageHiddenPanel();
}
function confirmAndHideSelectedNews() {
const selectedItems = document.querySelectorAll(`${NEWS_ITEM_SELECTOR}.use-newsfilter-newsitem-selected`);
if (selectedItems.length === 0) return;
lastHiddenItems = [];
const itemsToHideDetails = [];
selectedItems.forEach(item => {
const {
appID,
newsID,
gameName,
newsTitle
} = getNewsItemDetails(item);
if (newsID && !persistentlyHiddenItems.some(h => h.id === newsID)) {
const newItemData = {
id: newsID,
appID: appID,
gameName: gameName || "Неизвестная игра",
newsTitle: newsTitle,
dateHidden: Date.now()
};
itemsToHideDetails.push(newItemData);
}
item.classList.add('use-newsfilter-fading-out');
item.classList.remove('use-newsfilter-newsitem-selected');
const checkboxArea = item.querySelector('.use-newsfilter-checkbox-area');
if (checkboxArea) checkboxArea.style.display = 'none';
setTimeout(() => {
applyPersistentHideStyle(item, newsID);
item.classList.remove('use-newsfilter-fading-out');
}, 350);
});
if (itemsToHideDetails.length > 0) {
const newPersistentItems = itemsToHideDetails.filter(newItem =>
!persistentlyHiddenItems.some(existingItem => existingItem.id === newItem.id)
);
persistentlyHiddenItems.push(...newPersistentItems);
lastHiddenItems = newPersistentItems;
GM_setValue(HIDDEN_NEWS_GM_KEY, persistentlyHiddenItems);
updatePersistentHiddenCountDisplay();
}
updateSelectedCount();
const undoButton = document.getElementById('use-newsfilter-undo-button');
if (undoButton && lastHiddenItems.length > 0) {
undoButton.style.display = 'block';
setTimeout(() => {
undoButton.style.display = 'none';
}, 6000);
}
}
function undoLastPersistentHide() {
if (lastHiddenItems.length === 0) return;
const idsToRestore = lastHiddenItems.map(item => item.id);
persistentlyHiddenItems = persistentlyHiddenItems.filter(item => !idsToRestore.includes(item.id));
GM_setValue(HIDDEN_NEWS_GM_KEY, persistentlyHiddenItems);
updatePersistentHiddenCountDisplay();
document.querySelectorAll(NEWS_ITEM_SELECTOR).forEach(itemOnPage => {
const {
newsID
} = getNewsIdsFromLink(itemOnPage);
if (newsID && idsToRestore.includes(newsID)) {
itemOnPage.style.display = '';
itemOnPage.classList.remove('use-newsfilter-newsitem-persistently-hidden', 'use-newsfilter-fading-out');
const checkboxArea = itemOnPage.querySelector('.use-newsfilter-checkbox-area');
if (checkboxArea) checkboxArea.style.display = 'flex';
const checkbox = itemOnPage.querySelector('.use-newsfilter-checkbox');
if (checkbox) checkbox.checked = false;
toggleTemporaryHide(itemOnPage, false);
}
});
lastHiddenItems = [];
document.getElementById('use-newsfilter-undo-button').style.display = 'none';
if (isManagePanelOpen()) populateHiddenList();
updateSelectedCount();
}
function applyPersistentHideStyle(newsItem, newsID) {
const isHidden = persistentlyHiddenItems.some(h => h.id === newsID);
if (isHidden) {
newsItem.classList.add('use-newsfilter-newsitem-persistently-hidden');
if (!showPersistentlyHidden) {
newsItem.style.display = 'none';
} else {
newsItem.style.display = '';
}
} else {
newsItem.classList.remove('use-newsfilter-newsitem-persistently-hidden');
newsItem.style.display = '';
}
}
function toggleShowPersistentlyHidden() {
showPersistentlyHidden = !showPersistentlyHidden;
const button = document.getElementById('use-newsfilter-toggle-persistent-button');
if (button) {
button.textContent = showPersistentlyHidden ? 'Спрятать скрытое' : 'Показать скрытое';
}
document.querySelectorAll(NEWS_ITEM_SELECTOR).forEach(item => {
const {
newsID
} = getNewsIdsFromLink(item);
if (newsID) {
applyPersistentHideStyle(item, newsID);
const checkboxArea = item.querySelector('.use-newsfilter-checkbox-area');
if (checkboxArea) {
if (persistentlyHiddenItems.some(h => h.id === newsID)) {
checkboxArea.style.display = 'none';
} else {
checkboxArea.style.display = 'flex';
}
}
}
});
}
function applyInitialHide() {
persistentlyHiddenItems = GM_getValue(HIDDEN_NEWS_GM_KEY, []);
updatePersistentHiddenCountDisplay();
const newsItems = document.querySelectorAll(NEWS_ITEM_SELECTOR);
newsItems.forEach(item => {
const {
newsID
} = getNewsIdsFromLink(item);
if (newsID) {
applyPersistentHideStyle(item, newsID);
const checkboxArea = item.querySelector('.use-newsfilter-checkbox-area');
if (checkboxArea) {
if (persistentlyHiddenItems.some(h => h.id === newsID)) {
checkboxArea.style.display = 'none';
} else {
checkboxArea.style.display = 'flex';
}
}
}
});
}
function createManageHiddenPanel() {
if (document.getElementById('use-newsfilter-manage-panel')) return;
const panel = document.createElement('div');
panel.id = 'use-newsfilter-manage-panel';
panel.innerHTML = `
<h3>Хранилище скрытых новостей</h3>
<p style="font-size:11px; color: #808b96; text-align:center; margin-bottom: 10px;">Новости, ID которых есть в этом списке, не будут отображаться (если не включен режим "Показать скрытое").</p>
<ul id="use-newsfilter-hidden-list"></ul>
<div class="use-newsfilter-manage-buttons">
<button id="use-newsfilter-clear-all-hidden-btn" class="use-newsfilter-button" style="background-color: #c9302c; color: #DCEBF7;">Очистить хранилище</button>
<button id="use-newsfilter-close-manage-panel-btn" class="use-newsfilter-button" style="background-color: #4a5562;">Закрыть</button>
</div>
`;
document.body.appendChild(panel);
document.getElementById('use-newsfilter-clear-all-hidden-btn').onclick = () => {
if (confirm('Вы уверены, что хотите очистить хранилище скрытых новостей? Это действие нельзя будет отменить.')) {
clearAllPersistentHiddenNews();
}
};
document.getElementById('use-newsfilter-close-manage-panel-btn').onclick = hideManageHiddenPanel;
}
function showManageHiddenPanel() {
const panel = document.getElementById('use-newsfilter-manage-panel');
if (panel) {
populateHiddenList();
panel.style.display = 'flex';
}
}
function hideManageHiddenPanel() {
const panel = document.getElementById('use-newsfilter-manage-panel');
if (panel) {
panel.style.display = 'none';
}
}
function isManagePanelOpen() {
const panel = document.getElementById('use-newsfilter-manage-panel');
return panel && panel.style.display === 'flex';
}
function populateHiddenList() {
const listElement = document.getElementById('use-newsfilter-hidden-list');
if (!listElement) return;
listElement.innerHTML = '';
if (persistentlyHiddenItems.length === 0) {
listElement.innerHTML = '<li style="text-align:center; color:#808b96; padding: 10px;">Хранилище пусто.</li>';
return;
}
const sortedItems = [...persistentlyHiddenItems].sort((a, b) =>(b.dateHidden || 0) - (a.dateHidden || 0));
sortedItems.forEach(itemData => {
const listItem = document.createElement('li');
const textContainer = document.createElement('div');
textContainer.className = 'hidden-item-text';
const gameNameSpan = document.createElement('span');
gameNameSpan.className = 'game-name';
gameNameSpan.textContent = itemData.gameName ? `${itemData.gameName}` : 'Игра не указана';
const newsTitleSpan = document.createElement('span');
newsTitleSpan.className = 'news-title';
newsTitleSpan.textContent = itemData.newsTitle || `(Новость без заголовка)`;
newsTitleSpan.title = itemData.newsTitle || `Новость для AppID: ${itemData.appID}`;
const appIdSpan = document.createElement('span');
appIdSpan.className = 'app-id';
appIdSpan.textContent = `NewsID: ${itemData.id} (AppID: ${itemData.appID})`;
textContainer.appendChild(gameNameSpan);
textContainer.appendChild(newsTitleSpan);
textContainer.appendChild(appIdSpan);
listItem.appendChild(textContainer);
const restoreButton = document.createElement('button');
restoreButton.className = 'use-newsfilter-restore-btn';
restoreButton.textContent = 'Вернуть';
restoreButton.title = `Восстановить "${itemData.newsTitle || itemData.id}"`;
restoreButton.onclick = () => restoreNewsItem(itemData.id);
listItem.appendChild(restoreButton);
listElement.appendChild(listItem);
});
}
function restoreNewsItem(newsIDToRestore) {
persistentlyHiddenItems = persistentlyHiddenItems.filter(item => item.id !== newsIDToRestore);
GM_setValue(HIDDEN_NEWS_GM_KEY, persistentlyHiddenItems);
updatePersistentHiddenCountDisplay();
document.querySelectorAll(NEWS_ITEM_SELECTOR).forEach(itemOnPage => {
const {
newsID
} = getNewsIdsFromLink(itemOnPage);
if (newsID === newsIDToRestore) {
itemOnPage.style.display = '';
itemOnPage.classList.remove('use-newsfilter-newsitem-persistently-hidden');
const checkboxArea = itemOnPage.querySelector('.use-newsfilter-checkbox-area');
if (checkboxArea) checkboxArea.style.display = 'flex';
const checkbox = itemOnPage.querySelector('.use-newsfilter-checkbox');
if (checkbox) checkbox.checked = false;
toggleTemporaryHide(itemOnPage, false);
}
});
populateHiddenList();
updateSelectedCount();
}
function clearAllPersistentHiddenNews() {
persistentlyHiddenItems = [];
GM_setValue(HIDDEN_NEWS_GM_KEY, []);
updatePersistentHiddenCountDisplay();
document.querySelectorAll(NEWS_ITEM_SELECTOR).forEach(item => {
item.style.display = '';
item.classList.remove('use-newsfilter-newsitem-persistently-hidden', 'use-newsfilter-fading-out');
const checkboxArea = item.querySelector('.use-newsfilter-checkbox-area');
if (checkboxArea) checkboxArea.style.display = 'flex';
const checkbox = item.querySelector('.use-newsfilter-checkbox');
if (checkbox) checkbox.checked = false;
toggleTemporaryHide(item, false);
});
populateHiddenList();
updateSelectedCount();
}
function initNewsFilterEnhanced() {
applyInitialHide();
addNewsCheckboxes(document.querySelectorAll(NEWS_ITEM_SELECTOR));
createControls();
updateSelectedCount();
}
setTimeout(initNewsFilterEnhanced, 1500);
const newsObserver = new MutationObserver((mutations) => {
let processNewsItems = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
processNewsItems = true;
break;
}
}
}
if (processNewsItems) {
break;
}
}
if (processNewsItems) {
setTimeout(() => {
const allNewsItems = document.querySelectorAll(NEWS_ITEM_SELECTOR);
addNewsCheckboxes(allNewsItems);
applyInitialHide();
}, 100);
}
});
const newsFeedParentContainer = document.querySelector('div[class*="eventcalendar_EventBlockContainer"]');
if (newsFeedParentContainer) {
newsObserver.observe(newsFeedParentContainer, {
childList: true,
subtree: true
});
} else {
const observeBody = () => newsObserver.observe(document.body, {
childList: true,
subtree: true
});
if (document.readyState === "complete" || document.readyState === "interactive") {
observeBody();
} else {
window.addEventListener('DOMContentLoaded', observeBody);
}
}
})();
}
// Скрипт для показа годовых и исторических продаж предмета на торговой площадке Steam | https://steamcommunity.com/market/listings/*
if (scriptsConfig.Kaznachei && unsafeWindow.location.pathname.includes('/market/listings/')) {
async function fetchSalesInfo() {
const urlParts = unsafeWindow.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 | https://steamcommunity.com/my/
if (scriptsConfig.homeInfo && unsafeWindow.location.href.includes('steamcommunity.com') && unsafeWindow.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,
assets: item.assets,
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';
const headerUrl = data.assets?.header ? `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appId}/${data.assets.header}` : '';
const imageHtml = headerUrl ? `<div style="margin-bottom: 10px;"><img src="${headerUrl}" alt="${data.name}" style="width: 50%; height: auto;"></div>` : '';
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>
${imageHtml}
<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);
})();
}
//Скрипт для выбора случайной игры из ваших коллекций с помощью Stelicas и рулетки на странице вашей активности Steam | https://steamcommunity.com/my/
if (scriptsConfig.stelicasRoulette && unsafeWindow.location.href.includes('steamcommunity.com') && unsafeWindow.location.pathname.includes('/home')) {
(function() {
'use strict';
let sr2_games = [];
let sr2_filteredGames = [];
let sr2_categories = new Map();
let sr2_releaseYears = new Map();
let sr2_tags = new Map();
let sr2_languageSupportStats = { noRussianOrNoData: 0, subtitlesOrInterfaceOnly: 0, voice: 0 };
let sr2_spinning = false;
const SR2_CLONES_COUNT = 7;
let sr2_modal = null;
let sr2_currentViewMode = 'roulette';
let sr2_collectionSelectedGameAppId = null;
let sr2_toggleViewBtn = null;
let sr2_activeFilters = {
categories: ["Все"],
releaseYears: ["Все"],
tags: ["Все"],
language: ["Все"],
reviewCountMin: null,
reviewCountMax: null,
ratingMin: null,
ratingMax: null
};
function sr2_addRouletteBlock() {
const rightColumn = document.getElementById('friendactivity_right_column');
const friendsAddBlock = rightColumn ? rightColumn.querySelector('.friends_add_block') : null;
if (friendsAddBlock && !document.getElementById('sr2_stelicasRouletteBlock')) {
const rouletteBlock = document.createElement('div');
rouletteBlock.id = 'sr2_stelicasRouletteBlock';
rouletteBlock.className = 'friends_add_block panel';
rouletteBlock.style.marginTop = '12px';
rouletteBlock.style.padding = '10px';
const titleDiv = document.createElement('div');
titleDiv.className = 'profile_add_friends_title';
titleDiv.textContent = 'Рулетка Stelicas';
titleDiv.style.marginBottom = '10px';
rouletteBlock.appendChild(titleDiv);
const rouletteButton = document.createElement('div');
rouletteButton.className = 'btn_darkblue_white_innerfade btn_medium_tall';
rouletteButton.style.width = '100%';
rouletteButton.onclick = sr2_showRouletteModal;
const spanInsideButton = document.createElement('span');
spanInsideButton.textContent = 'Рулетка Stelicas';
rouletteButton.appendChild(spanInsideButton);
rouletteBlock.appendChild(rouletteButton);
friendsAddBlock.parentNode.insertBefore(rouletteBlock, friendsAddBlock.nextSibling);
}
}
function sr2_showRouletteModal() {
if (sr2_modal && document.body.contains(sr2_modal)) {
sr2_modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
return;
}
sr2_modal = document.createElement('div');
sr2_modal.id = 'sr2_stelicasRouletteModal';
sr2_modal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: #0D1117; color: #C9D1D9; z-index: 20000;
display: flex; flex-direction: column; overflow: hidden;
font-family: "Motiva Sans", Arial, sans-serif;
`;
const headerPanel = document.createElement('div');
headerPanel.id = 'sr2_headerPanel';
headerPanel.innerHTML = `
<h2 id="sr2_modalTitle">Рулетка Stelicas</h2>
<div id="sr2_headerControls">
<button id="sr2_toggleViewBtn" class="sr2_btn sr2_btnIcon" title="Посмотреть подборку" disabled>Посмотреть подборку</button>
<button id="sr2_helpBtn" class="sr2_btn sr2_btnIcon" title="Как пользоваться / Репозиторий">?</button>
<button id="sr2_closeBtn" class="sr2_btn sr2_btnIcon" title="Закрыть">×</button>
</div>
`;
sr2_modal.appendChild(headerPanel);
sr2_toggleViewBtn = headerPanel.querySelector('#sr2_toggleViewBtn');
const mainContainer = document.createElement('div');
mainContainer.id = 'sr2_mainContainer';
const leftControlsPanel = document.createElement('div');
leftControlsPanel.id = 'sr2_leftControlsPanel';
leftControlsPanel.innerHTML = `
<div class="sr2_controlSection">
<label for="sr2_csvFileTrigger" class="sr2_label">1. Загрузить данные:</label>
<input type="file" id="sr2_csvFile" accept=".csv" style="display:none;">
<button id="sr2_csvFileTrigger" class="sr2_btn sr2_btnBlock">Выбрать CSV от Stelicas</button>
<span id="sr2_fileNameDisplay" class="sr2_fileName">Файл не выбран</span>
</div>
<div class="sr2_controlSection">
<label class="sr2_label">2. Фильтры:</label>
<div class="sr2_filterBlock">
<h5 class="sr2_filterTitle">Категории</h5>
<div id="sr2_categoryList" class="sr2_filterList" style="max-height: 82px;"></div>
</div>
<div class="sr2_filterBlock">
<h5 class="sr2_filterTitle">Дата выхода</h5>
<div id="sr2_releaseYearList" class="sr2_filterList" style="max-height: 82px;"></div>
</div>
<div class="sr2_filterBlock">
<h5 class="sr2_filterTitle">Тэги</h5>
<input type="text" id="sr2_tagSearchInput" class="sr2_filterSearchInput" placeholder="Поиск тэга..." style="margin-bottom: 5px;">
<div id="sr2_tagList" class="sr2_filterList" style="max-height: 82px;"></div>
</div>
<div class="sr2_filterBlock">
<h5 class="sr2_filterTitle">Русский язык</h5>
<div id="sr2_languageList" class="sr2_filterList" style="max-height: 82px;"></div>
</div>
<div class="sr2_filterBlock">
<h5 class="sr2_filterTitle">Количество отзывов</h5>
<div class="sr2_inputRange">
<input type="number" id="sr2_reviewCountMin" class="sr2_filterInput" placeholder="От">
<span class="sr2_inputRangeSeparator">-</span>
<input type="number" id="sr2_reviewCountMax" class="sr2_filterInput" placeholder="До">
</div>
</div>
<div class="sr2_filterBlock">
<h5 class="sr2_filterTitle">Рейтинг (%)</h5>
<div class="sr2_inputRange">
<input type="number" id="sr2_ratingMin" class="sr2_filterInput" placeholder="От" min="0" max="100">
<span class="sr2_inputRangeSeparator">-</span>
<input type="number" id="sr2_ratingMax" class="sr2_filterInput" placeholder="До" min="0" max="100">
</div>
</div>
<div class="sr2_actionButtonsContainer">
<button id="sr2_applyFiltersBtn" class="sr2_btn sr2_btnApplyFilters" disabled>Применить фильтры</button>
<button id="sr2_resetAllFiltersBtn" class="sr2_btn sr2_btnResetFilters" title="Сбросить фильтры" disabled>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path 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>
</button>
</div>
</div>
<div class="sr2_controlSection">
<label for="sr2_priorityCheckbox" class="sr2_label sr2_priorityLabel">
<input type="checkbox" id="sr2_priorityCheckbox" class="sr2_checkbox">
Приоритет по отзывам
</label>
</div>
<div class="sr2_controlSection">
<button id="sr2_spinBtn" class="sr2_btn sr2_btnPrimary sr2_btnBlock" disabled>КРУТИТЬ!</button>
</div>
`;
mainContainer.appendChild(leftControlsPanel);
const rightContentArea = document.createElement('div');
rightContentArea.id = 'sr2_rightContentArea';
rightContentArea.innerHTML = `
<div id="sr2_viewFlipper">
<div id="sr2_flipper_content">
<div id="sr2_rouletteSection" class="sr2_flipper_face">
<div id="sr2_rouletteContainer">
<div id="sr2_roulette"></div>
<div id="sr2_selector"></div>
</div>
</div>
<div id="sr2_collectionViewWrapper" class="sr2_flipper_face">
</div>
</div>
</div>
<div id="sr2_result" class="sr2_result">
<div class="sr2_resultHeader">
<div class="sr2_gamePoster"><img id="sr2_resultPoster" src="" alt="Постер"></div>
<div class="sr2_gameInfoMain">
<h1 id="sr2_resultTitle" class="sr2_gameTitle"></h1>
<div id="sr2_resultRating" class="sr2_reviewRating"></div>
<a href="#" class="sr2_steamLink" target="_blank" id="sr2_resultSteamLink">
<svg class="sr2_icon" viewBox="0 0 496 512"><path fill="currentColor" d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 248.4 8 385.7 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
Страница в сообществе Steam
</a>
</div>
</div>
<div class="sr2_gameContent">
<div class="sr2_gameDescriptionSection">
<p id="sr2_resultDescription" class="sr2_gameDescription"></p>
<div id="sr2_resultTags" class="sr2_gameTags"></div>
<a href="#" class="sr2_btn sr2_btnPrimary sr2_launchButton" id="sr2_resultLaunchLink">
<svg class="sr2_icon" viewBox="0 0 448 512"><path fill="currentColor" d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z"/></svg>
Запустить игру
</a>
</div>
<div class="sr2_gameDetails">
<div class="sr2_detailItem"><div class="sr2_detailLabel">Дата выхода</div><div id="sr2_resultReleaseDate" class="sr2_detailValue"></div></div>
<div class="sr2_detailItem"><div class="sr2_detailLabel">Издатель</div><div id="sr2_resultPublisher" class="sr2_detailValue"></div></div>
<div class="sr2_detailItem"><div class="sr2_detailLabel">Разработчик</div><div id="sr2_resultDeveloper" class="sr2_detailValue"></div></div>
<div class="sr2_detailItem"><div class="sr2_detailLabel">Русский язык</div><div id="sr2_resultLanguages" class="sr2_detailValue"></div></div>
</div>
</div>
</div>
`;
mainContainer.appendChild(rightContentArea);
sr2_modal.appendChild(mainContainer);
document.body.appendChild(sr2_modal);
document.body.style.overflow = 'hidden';
document.getElementById('sr2_toggleViewBtn').onclick = sr2_toggleView;
document.getElementById('sr2_helpBtn').onclick = sr2_showHelpModal;
document.getElementById('sr2_closeBtn').onclick = sr2_hideRouletteModal;
document.getElementById('sr2_csvFileTrigger').onclick = () => document.getElementById('sr2_csvFile').click();
document.getElementById('sr2_csvFile').onchange = sr2_handleFileSelect;
document.getElementById('sr2_applyFiltersBtn').onclick = sr2_applyAllFiltersAndRouletteUpdate;
document.getElementById('sr2_resetAllFiltersBtn').onclick = sr2_confirmResetAllFilters;
document.getElementById('sr2_spinBtn').onclick = sr2_spin;
document.getElementById('sr2_tagSearchInput').oninput = sr2_filterTagList;
['sr2_reviewCountMin', 'sr2_reviewCountMax', 'sr2_ratingMin', 'sr2_ratingMax'].forEach(id => {
const el = document.getElementById(id);
if (el) el.oninput = sr2_handleRangeFilterChange;
});
sr2_modal._escHandler = (event) => {
if (event.key === "Escape") {
const helpModal = document.getElementById('sr2_stelicasRouletteHelpModal');
if (helpModal && helpModal.style.display !== 'none') {
helpModal.remove();
if(helpModal._escHandler) document.removeEventListener('keydown', helpModal._escHandler);
} else {
const confirmModal = document.getElementById('sr2_confirmResetModal');
if (confirmModal && confirmModal.style.display !== 'none') {
confirmModal.remove();
if(confirmModal._escHandler) document.removeEventListener('keydown', confirmModal._escHandler);
} else {
sr2_hideRouletteModal();
}
}
}
};
document.addEventListener('keydown', sr2_modal._escHandler);
}
function sr2_toggleView() {
const flipperContent = document.getElementById('sr2_flipper_content');
const spinBtn = document.getElementById('sr2_spinBtn');
const resultDiv = document.getElementById('sr2_result');
if (sr2_currentViewMode === 'roulette') {
sr2_currentViewMode = 'collection';
sr2_toggleViewBtn.textContent = 'Вернуться к рулетке';
sr2_toggleViewBtn.title = 'Вернуться к рулетке';
flipperContent.classList.add('flipped');
spinBtn.disabled = true;
sr2_populateCollectionView();
resultDiv.style.display = 'none';
sr2_collectionSelectedGameAppId = null;
} else {
sr2_currentViewMode = 'roulette';
sr2_toggleViewBtn.textContent = 'Посмотреть подборку';
sr2_toggleViewBtn.title = 'Посмотреть подборку';
flipperContent.classList.remove('flipped');
spinBtn.disabled = sr2_filteredGames.length === 0;
resultDiv.style.display = 'none';
}
}
function sr2_populateCollectionView() {
const collectionViewWrapper = document.getElementById('sr2_collectionViewWrapper');
if (!collectionViewWrapper) return;
collectionViewWrapper.innerHTML = '';
if (sr2_filteredGames.length === 0) {
collectionViewWrapper.innerHTML = '<div style="color: #8B949E; text-align: center; width: 100%; padding: 20px; align-self: center;">Нет игр по фильтрам для отображения в подборке.</div>';
return;
}
sr2_filteredGames.forEach(game => {
const card = document.createElement('div');
card.className = 'sr2_collectionGameCard';
card.dataset.gameId = game.game_id;
if (game.Pic) {
const img = document.createElement('img');
img.src = game.Pic;
img.alt = game.name || 'Game Poster';
img.loading = 'lazy';
img.onerror = function() { this.style.display='none'; card.insertAdjacentHTML('afterbegin', '<div style="height:120px; display:flex; align-items:center; justify-content:center; color:#666;">Нет постера</div>'); };
card.appendChild(img);
} else {
card.insertAdjacentHTML('afterbegin', '<div style="height:120px; display:flex; align-items:center; justify-content:center; color:#666;">Нет постера</div>');
}
const nameDiv = document.createElement('div');
nameDiv.className = 'sr2_collectionGameCardName';
nameDiv.textContent = game.name || 'Unnamed Game';
card.appendChild(nameDiv);
card.onclick = () => sr2_handleCollectionGameClick(game);
collectionViewWrapper.appendChild(card);
});
}
function sr2_handleCollectionGameClick(game) {
const resultDiv = document.getElementById('sr2_result');
if (sr2_collectionSelectedGameAppId === game.game_id && resultDiv.style.display === 'block') {
resultDiv.style.display = 'none';
sr2_collectionSelectedGameAppId = null;
} else {
sr2_showResult(game);
sr2_collectionSelectedGameAppId = game.game_id;
}
}
function sr2_showHelpModal() {
const helpModalId = 'sr2_stelicasRouletteHelpModal';
if (document.getElementById(helpModalId)) {
document.getElementById(helpModalId).style.display = 'flex';
return;
}
const helpModal = document.createElement('div');
helpModal.id = helpModalId;
helpModal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(13, 17, 23, 0.85); backdrop-filter: blur(4px);
z-index: 20002; display: flex;
align-items: center; justify-content: center; padding: 20px;
`;
const helpContent = document.createElement('div');
helpContent.style.cssText = `
background-color: #161B22; color: #C9D1D9; padding: 25px;
border-radius: 6px; border: 1px solid #30363D; width: 90%; max-width: 700px;
box-shadow: 0 8px 30px rgba(0,0,0,0.5); max-height: 85vh; overflow-y: auto;
font-family: "Motiva Sans", Arial, sans-serif; font-size: 14px;
`;
helpContent.innerHTML = `
<h3 style="color: #58A6FF; text-align: center; margin-top:0; margin-bottom: 20px; font-size: 18px;">Рулетка Stelicas - Инструкция</h3>
<p>Этот модуль поможет вам случайным образом выбрать игру для прохождения из вашей библиотеки Steam, используя данные, экспортированные из приложения <strong>Stelicas</strong>.</p>
<h4>Порядок действий:</h4>
<ol style="line-height: 1.6; margin-left: 20px; padding-left: 5px;">
<li style="margin-bottom: 0.8em;">
<strong>Подготовка CSV-файла:</strong>
<ul style="margin-top: 0.5em; margin-left: 15px; list-style-type: disc;">
<li style="margin-bottom: 0.3em;">Запустите приложение <a href="https://github.com/0wn3dg0d/Stelicas" target="_blank" style="color:#58A6FF;">Stelicas</a>. <strong>Важно:</strong> клиент Steam должен быть закрыт во время работы Stelicas.</li>
<li style="margin-bottom: 0.3em;">В Stelicas введите ваш AccountID (Steam3 ID) и выберите необходимые настройки языка и региона.</li>
<li style="margin-bottom: 0.3em;">Нажмите "Start". Программа соберет данные о ваших играх и категориях.</li>
<li style="margin-bottom: 0.3em;">После завершения, найдите файл <code>final_data.csv</code> в папке <code>output</code> (рядом с программой Stelicas). Этот файл понадобится для рулетки.</li>
</ul>
</li>
<li style="margin-bottom: 0.8em;"><strong>Загрузка CSV в рулетку:</strong> В левой панели текущего окна нажмите "Выбрать CSV от Stelicas" и укажите путь к файлу <code>final_data.csv</code>.</li>
<li style="margin-bottom: 0.8em;">
<strong>Выбор фильтров:</strong>
<ul style="margin-top: 0.5em; margin-left: 15px; list-style-type: square;">
<li><strong>Категории:</strong> Отметьте одну или несколько интересующих вас категорий. Опция "Все" (первая в списке) позволяет отметить или снять все галочки в секции "Категории" одновременно.</li>
<li><strong>Дата выхода:</strong> Выберите конкретные годы, "Без даты" или "Все". Опция "Все" также управляет всеми галочками в этой секции. Годы упорядочены от новых к старым.</li>
<li><strong>Тэги:</strong> Выберите тэги. Используйте поиск для быстрого нахождения нужного тэга. Тэги отсортированы по популярности. "Все" и "Без тэгов" также доступны, "Все" управляет всеми галочками тэгов.</li>
<li><strong>Русский язык:</strong> Укажите требуемый уровень локализации ("Без русского языка", "Русские субтитры/интерфейс", "Русская озвучка") или "Все" (управляет всеми опциями языка).</li>
<li><strong>Количество отзывов:</strong> Задайте диапазон (например, от 100 до 10000). Плейсхолдеры показывают доступные мин/макс значения с учётом других фильтров.</li>
<li><strong>Рейтинг:</strong> Задайте диапазон в процентах (например, от 70 до 100). Плейсхолдеры также динамические.</li>
</ul>
После выбора всех фильтров нажмите кнопку "Применить фильтры". Счётчики у фильтров обновляются в реальном времени при изменении других фильтров. Для сброса всех фильтров к состоянию по умолчанию используйте кнопку со значком возврата рядом с "Применить фильтры".
</li>
<li style="margin-bottom: 0.8em;"><strong>Настройка приоритетов (опционально):</strong> Если активировать опцию "Приоритет по отзывам", игры с более высоким рейтингом и большим количеством обзоров будут иметь больше шансов на выпадение (среди отфильтрованных).</li>
<li style="margin-bottom: 0.8em;"><strong>Запуск рулетки:</strong> Нажмите большую кнопку "КРУТИТЬ!".</li>
<li style="margin-bottom: 0.8em;">
<strong>Просмотр подборки:</strong>
<ul style="margin-top: 0.5em; margin-left: 15px; list-style-type: disc;">
<li style="margin-bottom: 0.3em;">Вы можете посмотреть игры из текущей подборки (с активным фильтром) с помощью кнопки "Посмотреть подборку" в заголовке модального окна.</li>
<li style="margin-bottom: 0.3em;">Нажатие на неё переключит правую панель в режим отображения всех отфильтрованных игр в виде карточек (изображение и название). Кнопка "КРУТИТЬ!" будет неактивна в этом режиме.</li>
<li style="margin-bottom: 0.3em;">Щелчок по карточке игры в подборке покажет подробную информацию о ней в блоке ниже (аналогично результату рулетки). Повторный щелчок по той же карточке скроет информацию.</li>
<li style="margin-bottom: 0.3em;">Чтобы вернуться к рулетке, нажмите кнопку "Вернуться к рулетке" в заголовке модального окна.</li>
</ul>
</li>
</ol>
<p>После остановки рулетки вы увидите подробную информацию о выбранной игре.</p>
`;
const closeHelpButton = document.createElement('button');
closeHelpButton.textContent = 'Понятно';
closeHelpButton.className = 'sr2_btn sr2_btnPrimary';
closeHelpButton.style.cssText = 'display: block; margin: 25px auto 0; padding: 10px 25px;';
closeHelpButton.onclick = () => {
helpModal.remove();
document.removeEventListener('keydown', helpModal._escHandler);
};
helpContent.appendChild(closeHelpButton);
helpModal.appendChild(helpContent);
document.body.appendChild(helpModal);
helpModal._escHandler = (event) => {
if (event.key === "Escape") {
helpModal.remove();
document.removeEventListener('keydown', helpModal._escHandler);
}
};
document.addEventListener('keydown', helpModal._escHandler);
helpContent.addEventListener('click', e => e.stopPropagation());
helpModal.addEventListener('click', function(event) {
if (event.target === helpModal) {
helpModal.remove();
document.removeEventListener('keydown', helpModal._escHandler);
}
});
}
function sr2_hideRouletteModal() {
if (sr2_modal) {
sr2_modal.style.display = 'none';
document.body.style.overflow = '';
if (sr2_modal._escHandler) {
document.removeEventListener('keydown', sr2_modal._escHandler);
delete sr2_modal._escHandler;
}
}
}
function sr2_resetFiltersToDefaultStateAndUI() {
sr2_activeFilters = {
categories: ["Все"], releaseYears: ["Все"], tags: ["Все"], language: ["Все"],
reviewCountMin: null, reviewCountMax: null, ratingMin: null, ratingMax: null
};
document.getElementById('sr2_reviewCountMin').value = '';
document.getElementById('sr2_reviewCountMax').value = '';
document.getElementById('sr2_ratingMin').value = '';
document.getElementById('sr2_ratingMax').value = '';
document.getElementById('sr2_tagSearchInput').value = '';
sr2_filterTagList();
if (sr2_currentViewMode === 'collection') {
sr2_currentViewMode = 'roulette';
document.getElementById('sr2_flipper_content').classList.remove('flipped');
if(sr2_toggleViewBtn) {
sr2_toggleViewBtn.textContent = 'Посмотреть подборку';
sr2_toggleViewBtn.title = 'Посмотреть подборку';
}
}
sr2_updateAllFilterCounts();
sr2_applyAllFiltersAndRouletteUpdate();
document.getElementById('sr2_result').style.display = 'none';
sr2_collectionSelectedGameAppId = null;
}
function sr2_handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
const fileNameDisplay = document.getElementById('sr2_fileNameDisplay');
if (fileNameDisplay) fileNameDisplay.textContent = file.name;
const reader = new FileReader();
reader.onload = function(e) {
sr2_parseCSV(e.target.result);
sr2_resetFiltersToDefaultStateAndUI();
document.getElementById('sr2_applyFiltersBtn').disabled = false;
document.getElementById('sr2_resetAllFiltersBtn').disabled = false;
sr2_applyAllFiltersAndRouletteUpdate();
};
reader.readAsText(file, 'UTF-8');
}
function sr2_parseCSV(data) {
const rows = data.split(/\r?\n/).slice(1);
sr2_games = [];
rows.forEach(row => {
if (!row.trim()) return;
const fields = row.split('\t');
if (fields.length < 17) return;
const game = {
game_id: fields[0]?.trim(),
name: fields[1]?.trim(),
categories: fields[2]?.trim().split(';').map(c => c.trim()).filter(c => c),
type: fields[3]?.trim(),
tags: fields[4]?.trim().split(';').map(t => t.trim()).filter(t => t),
release_date: fields[5]?.trim(),
review_percentage: parseInt(fields[6]) || 0,
review_count: parseInt(fields[7]) || 0,
is_free: fields[8]?.trim().toLowerCase() === 'true',
is_early_access: fields[9]?.trim().toLowerCase() === 'true',
publishers: fields[10]?.trim(),
developers: fields[11]?.trim(),
franchises: fields[12]?.trim(),
short_description: fields[13]?.trim().replace(/^"|"$/g, '').replace(/""/g, '"'),
supported_language: fields[14]?.trim(),
'Steam-Link': fields[15]?.trim(),
Pic: fields[16]?.trim(),
parsed_release_year: sr2_parseReleaseYear(fields[5]?.trim()),
parsed_language_support: sr2_parseGameLanguageSupport(fields[14]?.trim())
};
if (game.game_id && game.name) {
sr2_games.push(game);
}
});
sr2_filteredGames = [...sr2_games];
}
function sr2_parseReleaseYear(dateStr) {
if (!dateStr || dateStr.toLowerCase() === 'unknown' || dateStr.toLowerCase() === 'tbd' || dateStr.trim() === '') return 'Без даты';
const yearMatch = dateStr.match(/\b(\d{4})\b/);
return yearMatch ? yearMatch[1] : 'Без даты';
}
function sr2_parseGameLanguageSupport(langStr) {
const support = { hasRussian: false, interface: false, subtitles: false, voice: false, raw: langStr, noData: false };
if (!langStr || typeof langStr !== 'string' || langStr.trim() === '') {
support.noData = true; return support;
}
const cleaned = langStr.replace(/[{}]/g, '').trim().toLowerCase();
if (cleaned === 'true') {
support.hasRussian = true; support.interface = true; support.subtitles = true; support.voice = true;
} else if (cleaned === 'false') {
} else {
const parts = cleaned.split(';');
if (parts.length === 3) {
if (parts[0] === 'true') support.interface = true;
if (parts[1] === 'true') support.voice = true;
if (parts[2] === 'true') support.subtitles = true;
support.hasRussian = support.interface || support.subtitles || support.voice;
} else {
support.noData = true;
}
}
return support;
}
function sr2_populateFilterList(elementId, itemsMap, type) {
const listDiv = document.getElementById(elementId);
if (!listDiv) return;
listDiv.innerHTML = '';
const createCheckbox = (value, text, count) => {
const label = document.createElement('label');
label.className = 'sr2_filterItem';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = value;
checkbox.onchange = (event) => {
sr2_handleFilterChange(type, event);
};
label.appendChild(checkbox);
label.appendChild(document.createTextNode(` ${text} (${count})`));
return label;
};
let totalCountForFilterType = sr2_applyCurrentFiltersToGameList(sr2_games, { excludeFilter: type }).length;
listDiv.appendChild(createCheckbox('Все', 'Все', totalCountForFilterType));
if (type === 'releaseYears') {
listDiv.appendChild(createCheckbox('Без даты', 'Без даты', itemsMap.get('Без даты') || 0));
Array.from(itemsMap.keys()).filter(key => key !== 'Без даты').sort((a,b) => b-a).forEach(key => {
listDiv.appendChild(createCheckbox(key, key, itemsMap.get(key) || 0));
});
} else if (type === 'tags'){
listDiv.appendChild(createCheckbox('Без тэгов', 'Без тэгов', itemsMap.get('Без тэгов') || 0));
Array.from(itemsMap.entries())
.filter(([key]) => key !== 'Без тэгов')
.sort(([, countA], [, countB]) => countB - countA)
.forEach(([key, count]) => {
listDiv.appendChild(createCheckbox(key, key, count));
});
} else if (type === 'language') {
listDiv.appendChild(createCheckbox('noRussianOrNoData', 'Без русского языка', sr2_languageSupportStats.noRussianOrNoData));
listDiv.appendChild(createCheckbox('subtitlesOrInterfaceOnly', 'Русские субтитры/интерфейс', sr2_languageSupportStats.subtitlesOrInterfaceOnly));
listDiv.appendChild(createCheckbox('voice', 'Русская озвучка', sr2_languageSupportStats.voice));
} else {
Array.from(itemsMap.entries())
.sort((a, b) => {
if (a[0] === 'Избранное') return -1; if (b[0] === 'Избранное') return 1;
return a[0].localeCompare(b[0], 'ru');
})
.forEach(([key, count]) => {
listDiv.appendChild(createCheckbox(key, key, count));
});
}
}
function sr2_filterTagList() {
const searchTerm = document.getElementById('sr2_tagSearchInput').value.toLowerCase();
const tagListDiv = document.getElementById('sr2_tagList');
tagListDiv.querySelectorAll('.sr2_filterItem').forEach(item => {
const checkbox = item.querySelector('input[type="checkbox"]');
if (checkbox.value === "Все" || checkbox.value === "Без тэгов") {
item.style.display = 'block'; return;
}
item.style.display = checkbox.value.toLowerCase().includes(searchTerm) ? 'block' : 'none';
});
}
function sr2_updateAllFilterCounts() {
const tempFilteredGamesCategories = sr2_applyCurrentFiltersToGameList(sr2_games, { excludeFilter: 'categories'});
const tempFilteredGamesYears = sr2_applyCurrentFiltersToGameList(sr2_games, { excludeFilter: 'releaseYears'});
const tempFilteredGamesTags = sr2_applyCurrentFiltersToGameList(sr2_games, { excludeFilter: 'tags'});
const tempFilteredGamesLang = sr2_applyCurrentFiltersToGameList(sr2_games, { excludeFilter: 'language'});
const tempFilteredForRanges = sr2_applyCurrentFiltersToGameList(sr2_games, {});
sr2_calculateCategoryCounts(tempFilteredGamesCategories);
sr2_calculateReleaseYearCounts(tempFilteredGamesYears);
sr2_calculateTagCounts(tempFilteredGamesTags);
sr2_calculateLanguageCounts(tempFilteredGamesLang);
sr2_populateFilterList('sr2_categoryList', sr2_categories, 'categories');
sr2_populateFilterList('sr2_releaseYearList', sr2_releaseYears, 'releaseYears');
sr2_populateFilterList('sr2_tagList', sr2_tags, 'tags');
sr2_populateFilterList('sr2_languageList', null, 'language');
sr2_updateRangePlaceholders(tempFilteredForRanges);
sr2_restoreCheckboxStates();
}
function sr2_restoreCheckboxStates() {
['categories', 'releaseYears', 'tags', 'language'].forEach(type => {
const activeForType = sr2_activeFilters[type];
const listDiv = document.getElementById(sr2_getDivIdForFilterType(type));
if(!listDiv) return;
const allCb = listDiv.querySelector('input[value="Все"]');
const specificCheckboxes = Array.from(listDiv.querySelectorAll('input[type="checkbox"]:not([value="Все"])'));
if (!activeForType) return;
if (activeForType.includes("Все")) {
if (allCb) {
allCb.checked = true;
allCb.indeterminate = false;
}
specificCheckboxes.forEach(cb => cb.checked = true);
} else if (activeForType.length === 0) {
if (allCb) {
allCb.checked = false;
allCb.indeterminate = false;
}
specificCheckboxes.forEach(cb => cb.checked = false);
} else {
let allSpecificActuallyChecked = true;
let someSpecificActuallyChecked = false;
specificCheckboxes.forEach(cb => {
if (activeForType.includes(cb.value)) {
cb.checked = true;
someSpecificActuallyChecked = true;
} else {
cb.checked = false;
allSpecificActuallyChecked = false;
}
});
if (allCb) {
if (allSpecificActuallyChecked && specificCheckboxes.length > 0) {
allCb.checked = true;
allCb.indeterminate = false;
} else if (someSpecificActuallyChecked) {
allCb.checked = false;
allCb.indeterminate = true;
} else {
allCb.checked = false;
allCb.indeterminate = false;
}
}
}
});
}
function sr2_getDivIdForFilterType(type) {
switch(type) {
case 'categories': return 'sr2_categoryList';
case 'releaseYears': return 'sr2_releaseYearList';
case 'tags': return 'sr2_tagList';
case 'language': return 'sr2_languageList';
default: return '';
}
}
function sr2_handleFilterChange(filterType, event) {
const listDiv = document.getElementById(sr2_getDivIdForFilterType(filterType));
if (!listDiv) return;
const specificCheckboxes = Array.from(listDiv.querySelectorAll('input[type="checkbox"]:not([value="Все"])'));
const clickedValue = event.target.value;
const isChecked = event.target.checked;
if (clickedValue === "Все") {
sr2_activeFilters[filterType] = isChecked ? ["Все"] : [];
} else {
const selectedValues = [];
specificCheckboxes.forEach(cb => {
if (cb.value === clickedValue) {
if (isChecked) selectedValues.push(cb.value);
} else {
if (cb.checked) selectedValues.push(cb.value);
}
});
if (selectedValues.length === specificCheckboxes.length && specificCheckboxes.length > 0) {
sr2_activeFilters[filterType] = ["Все"];
} else if (selectedValues.length === 0) {
sr2_activeFilters[filterType] = [];
} else {
sr2_activeFilters[filterType] = selectedValues;
}
}
sr2_updateAllFilterCounts();
}
function sr2_handleRangeFilterChange() {
sr2_activeFilters.reviewCountMin = document.getElementById('sr2_reviewCountMin').value === '' ? null : parseInt(document.getElementById('sr2_reviewCountMin').value);
sr2_activeFilters.reviewCountMax = document.getElementById('sr2_reviewCountMax').value === '' ? null : parseInt(document.getElementById('sr2_reviewCountMax').value);
sr2_activeFilters.ratingMin = document.getElementById('sr2_ratingMin').value === '' ? null : parseInt(document.getElementById('sr2_ratingMin').value);
sr2_activeFilters.ratingMax = document.getElementById('sr2_ratingMax').value === '' ? null : parseInt(document.getElementById('sr2_ratingMax').value);
sr2_updateAllFilterCounts();
}
function sr2_confirmResetAllFilters() {
const confirmModalId = 'sr2_confirmResetModal';
if (document.getElementById(confirmModalId)) return;
const confirmModal = document.createElement('div');
confirmModal.id = confirmModalId;
confirmModal.className = 'sr2_confirmModal';
confirmModal.innerHTML = `
<div class="sr2_confirmModalContent">
<h4>Сбросить фильтры</h4>
<p>Вы уверены, что хотите сбросить все фильтры к значениям по умолчанию?</p>
<div class="sr2_confirmModalActions">
<button id="sr2_confirmResetYes" class="sr2_btn sr2_btnPrimary">Да</button>
<button id="sr2_confirmResetNo" class="sr2_btn">Нет</button>
</div>
</div>
`;
document.body.appendChild(confirmModal);
document.getElementById('sr2_confirmResetYes').onclick = () => {
sr2_resetFiltersToDefaultStateAndUI();
confirmModal.remove();
if(confirmModal._escHandler) document.removeEventListener('keydown', confirmModal._escHandler);
};
document.getElementById('sr2_confirmResetNo').onclick = () => {
confirmModal.remove();
if(confirmModal._escHandler) document.removeEventListener('keydown', confirmModal._escHandler);
};
confirmModal._escHandler = (event) => {
if (event.key === "Escape") {
confirmModal.remove();
document.removeEventListener('keydown', confirmModal._escHandler);
}
};
document.addEventListener('keydown', confirmModal._escHandler);
confirmModal.querySelector('.sr2_confirmModalContent').addEventListener('click', e => e.stopPropagation());
confirmModal.addEventListener('click', (event) => {
if(event.target === confirmModal) {
confirmModal.remove();
if(confirmModal._escHandler) document.removeEventListener('keydown', confirmModal._escHandler);
}
});
}
function sr2_calculateCategoryCounts(gamesToCount) {
sr2_categories.clear();
const tempCategories = new Map();
gamesToCount.forEach(game => {
game.categories.forEach(cat => {
if (cat) tempCategories.set(cat, (tempCategories.get(cat) || 0) + 1);
});
});
const sortedCategoriesArray = Array.from(tempCategories.entries()).sort((a, b) => {
const [catA] = a; const [catB] = b;
if (catA === 'Избранное') return -1; if (catB === 'Избранное') return 1;
return catA.localeCompare(catB, 'ru');
});
sr2_categories = new Map(sortedCategoriesArray);
}
function sr2_calculateReleaseYearCounts(gamesToCount) {
sr2_releaseYears.clear();
let noDateCount = 0;
gamesToCount.forEach(game => {
const year = game.parsed_release_year;
if (year === 'Без даты') {
noDateCount++;
} else if (year) {
sr2_releaseYears.set(year, (sr2_releaseYears.get(year) || 0) + 1);
}
});
if(noDateCount > 0 || gamesToCount.some(g => g.parsed_release_year === 'Без даты') || sr2_releaseYears.size === 0 && gamesToCount.length > 0) {
sr2_releaseYears.set('Без даты', noDateCount);
}
}
function sr2_calculateTagCounts(gamesToCount) {
sr2_tags.clear();
let noTagCount = 0;
gamesToCount.forEach(game => {
if (game.tags && game.tags.length > 0) {
game.tags.forEach(tag => {
if (tag) sr2_tags.set(tag, (sr2_tags.get(tag) || 0) + 1);
});
} else {
noTagCount++;
}
});
if(noTagCount > 0 || gamesToCount.some(g => !g.tags || g.tags.length === 0) || sr2_tags.size === 0 && gamesToCount.length > 0) {
sr2_tags.set('Без тэгов', noTagCount);
}
}
function sr2_calculateLanguageCounts(gamesToCount) {
sr2_languageSupportStats = { noRussianOrNoData: 0, subtitlesOrInterfaceOnly: 0, voice: 0 };
gamesToCount.forEach(game => {
const lang = game.parsed_language_support;
if (lang) {
if (lang.voice) {
sr2_languageSupportStats.voice++;
} else if (lang.interface || lang.subtitles) {
sr2_languageSupportStats.subtitlesOrInterfaceOnly++;
} else {
sr2_languageSupportStats.noRussianOrNoData++;
}
} else {
sr2_languageSupportStats.noRussianOrNoData++;
}
});
}
function sr2_updateRangePlaceholders(gamesToUpdateFrom) {
let minReviews = Infinity, maxReviews = 0, minRating = 101, maxRating = -1;
const validGamesForStats = gamesToUpdateFrom.filter(game => game.review_count > 0 || game.review_percentage > 0);
if (validGamesForStats.length > 0) {
validGamesForStats.forEach(game => {
minReviews = Math.min(minReviews, game.review_count);
maxReviews = Math.max(maxReviews, game.review_count);
if (game.review_count > 0) {
minRating = Math.min(minRating, game.review_percentage);
maxRating = Math.max(maxRating, game.review_percentage);
}
});
} else {
minReviews = 0; maxReviews = 0; minRating = 0; maxRating = 100;
}
minReviews = minReviews === Infinity ? 0 : minReviews;
minRating = minRating === 101 ? 0 : minRating;
maxRating = maxRating === -1 ? 100 : maxRating;
document.getElementById('sr2_reviewCountMin').placeholder = `От ${minReviews}`;
document.getElementById('sr2_reviewCountMax').placeholder = `До ${maxReviews}`;
document.getElementById('sr2_ratingMin').placeholder = `От ${minRating}`;
document.getElementById('sr2_ratingMax').placeholder = `До ${maxRating}`;
}
function sr2_gamePassesOtherFilters(game, options) {
const { excludeFilter } = options || {};
if (excludeFilter !== 'categories' && !sr2_activeFilters.categories.includes("Все")) {
if (sr2_activeFilters.categories.length === 0) return false;
if (!sr2_activeFilters.categories.some(cat => game.categories.includes(cat))) return false;
}
if (excludeFilter !== 'releaseYears' && !sr2_activeFilters.releaseYears.includes("Все")) {
if (sr2_activeFilters.releaseYears.length === 0) return false;
let yearMatch = false;
if (sr2_activeFilters.releaseYears.includes('Без даты') && game.parsed_release_year === 'Без даты') yearMatch = true;
if (!yearMatch && sr2_activeFilters.releaseYears.includes(game.parsed_release_year)) yearMatch = true;
if (!yearMatch) return false;
}
if (excludeFilter !== 'tags' && !sr2_activeFilters.tags.includes("Все")) {
if (sr2_activeFilters.tags.length === 0) return false;
let tagMatch = false;
const hasGameTags = game.tags && game.tags.length > 0;
if (sr2_activeFilters.tags.includes('Без тэгов') && !hasGameTags) tagMatch = true;
if (!tagMatch && hasGameTags && sr2_activeFilters.tags.some(tag => game.tags.includes(tag))) tagMatch = true;
if (!tagMatch) return false;
}
if (excludeFilter !== 'language' && !sr2_activeFilters.language.includes("Все")) {
if (sr2_activeFilters.language.length === 0) return false;
let langMatch = false;
const langSup = game.parsed_language_support;
if (sr2_activeFilters.language.includes('noRussianOrNoData') && (!langSup.hasRussian || langSup.noData)) langMatch = true;
if (!langMatch && sr2_activeFilters.language.includes('subtitlesOrInterfaceOnly') && (langSup.interface || langSup.subtitles) && !langSup.voice) langMatch = true;
if (!langMatch && sr2_activeFilters.language.includes('voice') && langSup.voice) langMatch = true;
if (!langMatch) return false;
}
if (excludeFilter !== 'reviewCount') {
if (sr2_activeFilters.reviewCountMin !== null && game.review_count < sr2_activeFilters.reviewCountMin) return false;
if (sr2_activeFilters.reviewCountMax !== null && game.review_count > sr2_activeFilters.reviewCountMax) return false;
}
if (excludeFilter !== 'rating') {
if (sr2_activeFilters.ratingMin !== null && game.review_percentage < sr2_activeFilters.ratingMin) return false;
if (sr2_activeFilters.ratingMax !== null && game.review_percentage > sr2_activeFilters.ratingMax) return false;
}
return true;
}
function sr2_applyCurrentFiltersToGameList(baseGameList, options) {
return baseGameList.filter(game => sr2_gamePassesOtherFilters(game, options));
}
function sr2_applyAllFiltersAndRouletteUpdate() {
sr2_filteredGames = sr2_applyCurrentFiltersToGameList(sr2_games, {});
const spinBtn = document.getElementById('sr2_spinBtn');
const resultDiv = document.getElementById('sr2_result');
if (sr2_currentViewMode === 'roulette') {
spinBtn.disabled = sr2_filteredGames.length === 0;
} else {
spinBtn.disabled = true;
sr2_populateCollectionView();
}
if(sr2_toggleViewBtn) sr2_toggleViewBtn.disabled = sr2_filteredGames.length === 0;
sr2_updateRoulette();
resultDiv.style.display = 'none';
sr2_collectionSelectedGameAppId = null;
}
function sr2_updateRoulette() {
const roulette = document.getElementById('sr2_roulette');
if (!roulette || !roulette.parentElement) return;
roulette.innerHTML = '';
if (sr2_filteredGames.length === 0) {
roulette.innerHTML = '<div class="sr2_gameItem" style="width: 100%; text-align: center; color: #8B949E; height:100%; display:flex; align-items:center; justify-content:center;">Нет игр по фильтрам</div>';
roulette.style.width = '100%';
return;
}
const fragment = document.createDocumentFragment();
const itemActualWidth = 164;
for (let i = 0; i < SR2_CLONES_COUNT; i++) {
sr2_filteredGames.forEach(game => {
const div = document.createElement('div');
div.className = 'sr2_gameItem';
if (game.Pic) {
const img = document.createElement('img');
img.src = game.Pic;
img.alt = game.name;
img.loading = 'lazy';
img.onerror = function() { this.style.display='none'; div.textContent = game.name || game.game_id; };
div.appendChild(img);
} else {
div.textContent = game.name || game.game_id;
}
fragment.appendChild(div);
});
}
roulette.appendChild(fragment);
roulette.style.transform = 'translateX(0)';
if (sr2_filteredGames.length > 0) {
roulette.style.width = `${itemActualWidth * sr2_filteredGames.length * SR2_CLONES_COUNT}px`;
} else {
roulette.style.width = '100%';
}
}
function sr2_spin() {
if (sr2_spinning || !sr2_filteredGames.length || sr2_currentViewMode === 'collection') return;
sr2_spinning = true;
document.getElementById('sr2_result').style.display = 'none';
sr2_collectionSelectedGameAppId = null;
const roulette = document.getElementById('sr2_roulette');
const itemActualWidth = 164;
const originalCount = sr2_filteredGames.length;
if (originalCount === 0) { sr2_spinning = false; return; }
let targetIndex;
const usePriorities = document.getElementById('sr2_priorityCheckbox').checked;
if (usePriorities && originalCount > 0) {
const weights = sr2_filteredGames.map(game => {
const percent = game.review_percentage || 0;
const count = game.review_count || 0;
return Math.max(1, (percent/100) * Math.log1p(count));
});
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
if (totalWeight > 0) {
const normalized = weights.map(w => w / totalWeight);
const random = Math.random();
let cumulative = 0;
for (let i = 0; i < normalized.length; i++) {
cumulative += normalized[i];
if (random <= cumulative) { targetIndex = i; break; }
}
targetIndex = (targetIndex === undefined) ? Math.floor(Math.random() * originalCount) : targetIndex;
} else {
targetIndex = Math.floor(Math.random() * originalCount);
}
} else {
targetIndex = Math.floor(Math.random() * originalCount);
}
const clonesBeforeTarget = Math.floor(SR2_CLONES_COUNT / 2);
const targetCloneIndex = clonesBeforeTarget * originalCount + targetIndex;
const rouletteContainer = document.getElementById('sr2_rouletteContainer');
const targetPosition = targetCloneIndex * itemActualWidth - (rouletteContainer.offsetWidth / 2) + (itemActualWidth / 2);
let currentTranslateX = 0;
const transformValue = roulette.style.transform;
if (transformValue && transformValue.startsWith('translateX(')) {
currentTranslateX = parseFloat(transformValue.replace('translateX(', '').replace('px)', ''));
}
const startPosition = -currentTranslateX;
const startTime = performance.now();
const duration = Math.max(3000, Math.min(7000, originalCount * 100));
function animate(timestamp) {
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = 1 - Math.pow(1 - progress, 4);
const currentX = startPosition + (targetPosition - startPosition) * easedProgress;
roulette.style.transform = `translateX(-${currentX}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
} else {
roulette.style.transition = 'none';
roulette.style.transform = `translateX(-${targetPosition}px)`;
setTimeout(() => { roulette.style.transition = ''; }, 50);
sr2_spinning = false;
sr2_showResult(sr2_filteredGames[targetIndex]);
}
}
requestAnimationFrame(animate);
}
function sr2_showResult(game) {
const resultDiv = document.getElementById('sr2_result');
if (!resultDiv || !game) { return; }
const steamLink = game['Steam-Link'] || `https://store.steampowered.com/app/${game.game_id}/`;
const launchLink = `steam://run/${game.game_id}`;
const posterImg = document.getElementById('sr2_resultPoster');
posterImg.src = game.Pic || ''; posterImg.alt = game.name || 'Постер игры';
posterImg.style.display = game.Pic ? 'block' : 'none';
document.getElementById('sr2_resultTitle').textContent = game.name || 'Без названия';
document.getElementById('sr2_resultSteamLink').href = steamLink;
document.getElementById('sr2_resultLaunchLink').href = launchLink;
const ratingElem = document.getElementById('sr2_resultRating');
let ratingClass = '';
if (game.review_percentage) {
const percent = game.review_percentage;
if (percent >= 70) ratingClass = 'positive';
else if (percent >= 40) ratingClass = 'mixed';
else if (percent > 0) ratingClass = 'negative';
else ratingClass = '';
ratingElem.innerHTML = percent > 0 ?
`<span>${percent}%</span><span> (${game.review_count || 0} отзывов)</span>` :
'Нет оценок';
ratingElem.className = `sr2_reviewRating ${ratingClass}`;
} else {
ratingElem.textContent = 'Нет оценок';
ratingElem.className = 'sr2_reviewRating';
}
document.getElementById('sr2_resultDescription').textContent = game.short_description || 'Описание отсутствует.';
const tagsContainer = document.getElementById('sr2_resultTags');
tagsContainer.innerHTML = '';
if (game.tags) {
game.tags.slice(0, 12).forEach(tag => {
if (tag.trim()) {
const span = document.createElement('span');
span.className = 'sr2_tag'; span.textContent = tag.trim();
tagsContainer.appendChild(span);
}
});
}
document.getElementById('sr2_resultReleaseDate').textContent = game.release_date || 'Неизвестно';
document.getElementById('sr2_resultPublisher').textContent = game.publishers || 'Не указан';
document.getElementById('sr2_resultDeveloper').textContent = game.developers || 'Не указан';
document.getElementById('sr2_resultLanguages').textContent = sr2_formatDisplayLanguage(game.parsed_language_support);
resultDiv.style.display = 'block';
setTimeout(() => {
resultDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
}
function sr2_formatDisplayLanguage(parsedSupport) {
if (!parsedSupport) return 'Нет данных';
if (parsedSupport.voice) return 'Полная локализация';
if (parsedSupport.interface && parsedSupport.subtitles) return 'Интерфейс + Субтитры';
if (parsedSupport.interface) return 'Только интерфейс';
if (parsedSupport.subtitles) return 'Только субтитры';
if (parsedSupport.noData) return 'Нет данных';
return 'Без русского языка';
}
function sr2_addModalStyles() {
const styleId = 'sr2-modal-styles';
if (document.getElementById(styleId)) return;
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
#sr2_stelicasRouletteModal * {
box-sizing: border-box;
}
#sr2_stelicasRouletteModal {
background-color: #0D1117;
color: #C9D1D9;
}
#sr2_headerPanel {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
background-color: #0B0F13;
border-bottom: 1px solid #21262D;
flex-shrink: 0;
}
#sr2_modalTitle {
margin: 0;
color: #58A6FF;
font-size: 22px;
font-weight: 500;
font-family: Arial;
font-weight: bold;
}
#sr2_headerControls {
display: flex;
gap: 10px;
}
.sr2_btn {
background-color: #21262D;
color: #C9D1D9;
border: 1px solid #30363D;
padding: 8px 15px;
border-radius: 6px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
transition: background-color 0.2s, border-color 0.2s;
font-family: "Motiva Sans", Arial, sans-serif;
}
.sr2_btn:hover:not(:disabled) {
background-color: #30363D;
border-color: #8B949E;
}
.sr2_btn:disabled {
background-color: #21262D;
color: #6a737d;
cursor: not-allowed;
opacity: 0.7;
}
.sr2_btnIcon {
padding: 6px 10px;
font-size: 16px;
line-height: 1;
}
#sr2_toggleViewBtn {
font-size: 11px;
}
#sr2_closeBtn {
font-size: 22px;
padding: 4px 10px;
background-color: transparent;
border: none;
color: #8B949E;
}
#sr2_closeBtn:hover {
color: #C9D1D9;
background-color: rgba(139, 148, 158, 0.1);
}
.sr2_btnPrimary {
background-color: #238636;
border-color: #2ea043;
color: #ffffff;
font-weight: bold;
}
.sr2_btnPrimary:hover:not(:disabled) {
background-color: #2ea043;
border-color: #3fb950;
}
.sr2_btnBlock {
display: block;
width: 100%;
margin-top: 8px;
}
.sr2_actionButtonsContainer {
display: flex;
gap: 8px;
margin-top: 10px;
}
.sr2_btnApplyFilters {
flex-grow: 3;
}
.sr2_btnResetFilters {
flex-grow: 1;
padding: 8px 10px;
display: flex;
align-items: center;
justify-content: center;
}
#sr2_mainContainer {
display: flex;
flex-grow: 1;
overflow: hidden;
}
#sr2_leftControlsPanel {
width: 300px;
padding: 15px;
background-color: #161B22;
border-right: 1px solid #21262D;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
flex-shrink: 0;
}
#sr2_leftControlsPanel::-webkit-scrollbar {
width: 6px;
}
#sr2_leftControlsPanel::-webkit-scrollbar-track {
background: #0D1117;
border-radius: 3px;
}
#sr2_leftControlsPanel::-webkit-scrollbar-thumb {
background-color: #30363D;
border-radius: 3px;
border: 1px solid #0D1117;
}
#sr2_leftControlsPanel::-webkit-scrollbar-thumb:hover {
background-color: #58A6FF;
}
.sr2_controlSection {
padding-bottom: 8px;
border-bottom: 1px solid #21262D;
}
.sr2_controlSection:last-child {
border-bottom: none;
padding-bottom: 0;
}
.sr2_label {
display: block;
margin-bottom: 6px;
color: #8B949E;
font-size: 13px;
font-weight: 500;
}
.sr2_fileName {
color: #58A6FF;
font-size: 12px;
margin-top: 6px;
display: block;
word-break: break-all;
}
.sr2_filterBlock {
margin-bottom: 8px;
}
.sr2_filterTitle {
font-size: 13px;
color: #58A6FF;
margin: 0 0 4px 0;
font-weight: 500;
}
.sr2_filterList {
border: 1px solid #30363D;
padding: 6px;
border-radius: 4px;
background-color: #0D1117;
overflow-y: auto;
}
.sr2_filterItem {
display: block;
margin-bottom: 4px;
color: #C9D1D9;
font-size: 12px;
cursor: pointer;
}
.sr2_filterItem input[type="checkbox"] {
margin-right: 6px;
accent-color: #58A6FF;
vertical-align: middle;
width: 14px;
height: 14px;
}
.sr2_filterSearchInput {
width: calc(100% - 12px);
padding: 4px 6px;
font-size: 12px;
background-color: #0D1117;
border: 1px solid #30363D;
color: #C9D1D9;
border-radius: 3px;
margin: 0 auto 5px auto;
display: block;
}
.sr2_inputRange {
display: flex;
gap: 5px;
align-items: center;
}
.sr2_filterInput {
width: calc(50% - 10px);
padding: 4px 6px;
font-size: 12px;
background-color: #0D1117;
border: 1px solid #30363D;
color: #C9D1D9;
border-radius: 3px;
text-align: center;
}
.sr2_inputRangeSeparator {
color: #8B949E;
}
.sr2_priorityLabel {
display: flex;
align-items: center;
font-size: 14px;
color: #C9D1D9;
cursor: pointer;
margin-top: 2px;
}
.sr2_checkbox {
margin-right: 8px;
width: 16px;
height: 16px;
accent-color: #58A6FF;
cursor: pointer;
background-color: #0D1117;
border: 1px solid #30363D;
border-radius: 3px;
}
#sr2_rightContentArea {
flex-grow: 1;
padding: 20px;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
#sr2_rightContentArea::-webkit-scrollbar {
width: 8px;
}
#sr2_rightContentArea::-webkit-scrollbar-track {
background: #0D1117;
border-radius: 4px;
}
#sr2_rightContentArea::-webkit-scrollbar-thumb {
background-color: #30363D;
border-radius: 4px;
border: 2px solid #0D1117;
}
#sr2_rightContentArea::-webkit-scrollbar-thumb:hover {
background-color: #58A6FF;
}
#sr2_viewFlipper {
min-height: 200px;
position: relative;
perspective: 1000px;
margin-bottom: 20px;
flex-grow: 1;
display: flex;
}
#sr2_flipper_content {
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.7s cubic-bezier(0.4, 0.0, 0.2, 1);
}
#sr2_flipper_content.flipped {
transform: rotateY(180deg);
}
.sr2_flipper_face {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backface-visibility: hidden;
display: flex;
flex-direction: column;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
background-color: #0B0F13;
overflow: hidden;
}
#sr2_rouletteSection {
align-items: center;
justify-content: flex-start;
padding-top: 15px;
padding-bottom: 15px;
}
#sr2_rouletteContainer {
width: 90%;
position: relative;
overflow: hidden;
height: 170px;
border: 1px solid #30363D;
border-radius: 6px;
background: #0D1117;
box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.7);
}
#sr2_roulette {
display: flex;
height: 100%;
position: relative;
}
.sr2_gameItem {
min-width: 164px;
width: 164px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid #21262D;
padding: 8px;
background-color: #161B22;
color: #8B949E;
text-align: center;
font-size: 12px;
}
.sr2_gameItem img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
}
#sr2_selector {
position: absolute;
left: 50%;
top: -5px;
bottom: -5px;
width: 4px;
background: #f9826c;
transform: translateX(-50%);
box-shadow: 0 0 10px 3px #f9826c;
border-radius: 2px;
animation: sr2_pulse 1.3s infinite ease-in-out;
}
@keyframes sr2_pulse {
0%,
100% {
opacity: 1;
transform: translateX(-50%) scaleY(1.05);
}
50% {
opacity: 0.7;
transform: translateX(-50%) scaleY(1);
}
}
#sr2_collectionViewWrapper {
transform: rotateY(180deg);
padding: 15px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
align-content: flex-start;
}
.sr2_collectionGameCard {
background-color: #161B22;
border: 1px solid #30363D;
border-radius: 6px;
padding: 10px;
cursor: pointer;
text-align: center;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 180px;
}
.sr2_collectionGameCard:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.3);
}
.sr2_collectionGameCard img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
}
.sr2_collectionGameCardName {
font-size: 13px;
color: #C9D1D9;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
margin-top: auto;
}
.sr2_result {
margin-top: 15px;
background: #161B22;
border-radius: 8px;
padding: 25px;
display: none;
border: 1px solid #30363D;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
max-height: calc(100vh - 400px);
overflow-y: auto;
}
.sr2_resultHeader {
display: flex;
align-items: flex-start;
gap: 25px;
margin-bottom: 25px;
}
.sr2_gamePoster {
width: 100%;
max-width: 320px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.5);
align-self: flex-start;
border: 1px solid #21262D;
}
.sr2_gamePoster img {
width: 100%;
height: auto;
display: block;
}
.sr2_gameInfoMain {
flex-grow: 1;
}
.sr2_gameTitle {
font-size: 26px;
color: #C9D1D9;
margin: 0 0 12px 0;
font-weight: 600;
line-height: 1.2;
}
.sr2_reviewRating {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(0, 0, 0, 0.3);
color: #C9D1D9;
padding: 6px 12px;
border-radius: 6px;
font-size: 14px;
margin-bottom: 15px;
border: 1px solid #30363D;
}
.sr2_reviewRating.positive {
background: rgba(35, 134, 54, 0.2);
color: #56d364;
border-color: rgba(56, 139, 253, 0.3);
}
.sr2_reviewRating.mixed {
background: rgba(187, 128, 9, 0.2);
color: #e3b341;
border-color: rgba(187, 128, 9, 0.4);
}
.sr2_reviewRating.negative {
background: rgba(248, 81, 73, 0.15);
color: #f85149;
border-color: rgba(248, 81, 73, 0.3);
}
.sr2_steamLink {
display: inline-flex;
align-items: center;
gap: 8px;
color: #58A6FF;
text-decoration: none;
font-size: 14px;
margin-top: 10px;
padding: 9px 14px;
border: 1px solid #388BFD;
border-radius: 6px;
transition: background-color 0.2s, color 0.2s;
font-weight: 500;
}
.sr2_steamLink:hover {
background-color: #388BFD;
color: #ffffff;
}
.sr2_icon {
width: 15px;
height: 15px;
margin-right: 6px;
}
.sr2_gameContent {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 25px;
margin-top: 20px;
}
.sr2_gameDescription {
line-height: 1.6;
font-size: 14px;
color: #8B949E;
margin-bottom: 20px;
max-height: 150px;
overflow-y: auto;
padding-right: 8px;
}
.sr2_gameDescription::-webkit-scrollbar {
width: 6px;
}
.sr2_gameDescription::-webkit-scrollbar-track {
background: #0D1117;
border-radius: 3px;
}
.sr2_gameDescription::-webkit-scrollbar-thumb {
background: #30363D;
border-radius: 3px;
}
.sr2_gameTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.sr2_tag {
background: rgba(88, 166, 255, 0.1);
color: #58A6FF;
padding: 5px 10px;
border-radius: 15px;
font-size: 12px;
border: 1px solid rgba(88, 166, 255, 0.2);
}
.sr2_launchButton {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 25px;
text-decoration: none;
margin-top: 15px;
font-weight: 600;
font-size: 16px;
}
.sr2_gameDetails {
background: #0D1117;
padding: 20px;
border-radius: 6px;
border: 1px solid #21262D;
}
.sr2_detailItem {
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #21262D;
}
.sr2_detailItem:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.sr2_detailLabel {
color: #58A6FF;
font-size: 13px;
margin-bottom: 5px;
display: block;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sr2_detailValue {
color: #C9D1D9;
font-size: 14px;
}
.sr2_confirmModal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 20003;
display: flex;
align-items: center;
justify-content: center;
}
.sr2_confirmModalContent {
background-color: #161B22;
padding: 20px;
border-radius: 6px;
border: 1px solid #30363D;
text-align: center;
}
.sr2_confirmModalContent h4 {
margin-top: 0;
margin-bottom: 15px;
color: #C9D1D9;
}
.sr2_confirmModalContent p {
margin-bottom: 20px;
font-size: 14px;
}
.sr2_confirmModalActions button {
margin: 0 5px;
}
@media (max-width: 900px) {
#sr2_mainContainer {
flex-direction: column;
}
#sr2_leftControlsPanel {
width: 100%;
border-right: none;
border-bottom: 1px solid #21262D;
max-height: 40vh;
overflow-y: auto;
}
#sr2_rightContentArea {
padding-top: 10px;
flex-grow: 1;
}
.sr2_gameContent {
grid-template-columns: 1fr;
}
#sr2_viewFlipper {
min-height: 250px;
}
}
@media (max-width: 600px) {
#sr2_headerPanel {
padding: 10px 15px;
}
#sr2_modalTitle {
font-size: 18px;
}
#sr2_leftControlsPanel,
#sr2_rightContentArea {
padding: 15px;
}
.sr2_resultHeader {
flex-direction: column;
align-items: center;
}
.sr2_gamePoster {
max-width: 100%;
}
.sr2_result {
max-height: calc(100vh - 350px);
}
#sr2_viewFlipper {
min-height: 200px;
}
}
#sr2_stelicasRouletteHelpModal {
background-color: rgba(13, 17, 23, 0.9);
backdrop-filter: blur(5px);
}
#sr2_stelicasRouletteHelpModal>div {
background-color: #161B22;
color: #C9D1D9;
border: 1px solid #30363D;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6);
}
#sr2_stelicasRouletteHelpModal h3 {
color: #58A6FF;
}
#sr2_stelicasRouletteHelpModal a {
color: #58A6FF;
}
#sr2_stelicasRouletteHelpModal button.sr2_btnPrimary {
background-color: #238636;
border-color: #2ea043;
color: #ffffff;
}
#sr2_stelicasRouletteHelpModal button.sr2_btnPrimary:hover:not(:disabled) {
background-color: #2ea043;
border-color: #3fb950;
}
`;
document.head.appendChild(style);
}
sr2_addModalStyles();
sr2_addRouletteBlock();
})();
}
// Скрипт "Наблюдатель": Отслеживание изменений дат/статуса игр (вишлист/библиотека) и показ календаря релизов
if (scriptsConfig.Sledilka) {
(function() {
'use strict';
function runDataMigration() {
const MIGRATION_FLAG = 'USE_Sledilka_migrated_v2_from_wishlistTracker';
if (GM_getValue(MIGRATION_FLAG, false)) {
return;
}
const OLD_KEYS = {
NOTIFICATIONS: 'USE_Wishlist_notifications',
GAME_DATA: 'USE_Wishlist_gameData',
LAST_UPDATE: 'USE_Wishlist_lastUpdate'
};
const NEW_KEYS = {
NOTIFICATIONS: 'USE_Sledilka_notifications',
WISHLIST_GAME_DATA: 'USE_Sledilka_wishlistGameData',
LAST_UPDATE_WISHLIST: 'USE_Sledilka_lastUpdateWishlist'
};
const oldGameData = GM_getValue(OLD_KEYS.GAME_DATA);
const newGameData = GM_getValue(NEW_KEYS.WISHLIST_GAME_DATA);
if (oldGameData !== undefined && newGameData !== undefined) {
GM_deleteValue(OLD_KEYS.GAME_DATA);
GM_deleteValue(OLD_KEYS.NOTIFICATIONS);
GM_deleteValue(OLD_KEYS.LAST_UPDATE);
GM_setValue(MIGRATION_FLAG, true);
return;
}
if (oldGameData !== undefined && newGameData === undefined) {
const oldNotifications = GM_getValue(OLD_KEYS.NOTIFICATIONS);
const oldLastUpdate = GM_getValue(OLD_KEYS.LAST_UPDATE);
GM_setValue(NEW_KEYS.WISHLIST_GAME_DATA, oldGameData);
if (oldNotifications !== undefined && Array.isArray(oldNotifications)) {
const migratedNotifications = oldNotifications.map(n => {
let newNotif = { ...n };
if (newNotif.hasOwnProperty('wtread')) {
newNotif.read = newNotif.wtread;
delete newNotif.wtread;
} else {
newNotif.read = false;
}
if (!newNotif.source) {
newNotif.source = 'wishlist';
}
return newNotif;
});
GM_setValue(NEW_KEYS.NOTIFICATIONS, migratedNotifications);
}
if (oldLastUpdate !== undefined) {
GM_setValue(NEW_KEYS.LAST_UPDATE_WISHLIST, oldLastUpdate);
}
GM_deleteValue(OLD_KEYS.GAME_DATA);
GM_deleteValue(OLD_KEYS.NOTIFICATIONS);
GM_deleteValue(OLD_KEYS.LAST_UPDATE);
GM_setValue(MIGRATION_FLAG, true);
return;
}
GM_setValue(MIGRATION_FLAG, true);
}
runDataMigration();
const SLEDILKA_PREFIX = 'USE_Sledilka_';
const STORAGE_KEYS = {
LAST_UPDATE_WISHLIST: SLEDILKA_PREFIX + 'lastUpdateWishlist',
LAST_UPDATE_LIBRARY: SLEDILKA_PREFIX + 'lastUpdateLibrary',
NOTIFICATIONS: SLEDILKA_PREFIX + 'notifications',
WISHLIST_GAME_DATA: SLEDILKA_PREFIX + 'wishlistGameData',
OWNED_APPS_DATA: SLEDILKA_PREFIX + 'ownedAppsData',
OWNED_CHECKED_V2: SLEDILKA_PREFIX + 'ownedCheckedV2',
UPDATE_SETTINGS: SLEDILKA_PREFIX + 'updateSettings'
};
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 storageIcon = `<svg width="20" height="20" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" xml:space="preserve"><style type="text/css">.st0_sledilka_storage{fill:#C6D4DF;}</style><g><path class="st0_sledilka_storage" d="M0,17.067V153.6h512V17.067H0z M110.933,110.925h-51.2v-51.2h51.2V110.925z"/><path class="st0_sledilka_storage" d="M0,324.267h512V187.733H0V324.267z M59.733,230.391h51.2v51.2h-51.2V230.391z"/><path class="st0_sledilka_storage" d="M0,494.933h512V358.4H0V494.933z M59.733,401.058h51.2v51.2h-51.2V401.058z"/></g></svg>`;
const settingsIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23-.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></svg>`;
const envelopeIcons = {
unread: `<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>`,
read: `<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>`
};
const BATCH_SIZE = 200;
const MILLISECONDS_IN_HOUR = 60 * 60 * 1000;
const API_RETRY_DELAY = 3000;
const MAX_API_RETRIES = 2;
let notifications = GM_getValue(STORAGE_KEYS.NOTIFICATIONS, []);
let isPanelOpen = false;
let updateInProgress = false;
let positionCheckInterval;
let mutationObserver;
function runOneTimeMigration() {
if (GM_getValue('USE_Sledilka_ownedChecked') !== undefined) {
GM_deleteValue('USE_Sledilka_ownedChecked');
}
if (GM_getValue('USE_Sledilka_lastUpdate') !== undefined) {
GM_deleteValue('USE_Sledilka_lastUpdate');
}
}
runOneTimeMigration();
GM_addStyle(`
.sledilka-container {
position: absolute;
visibility: hidden;
z-index: 999;
background-color: rgba(23, 26, 33, 0.9);
border-radius: 3px;
color: #c7d5e0;
font-family: "Motiva Sans", Sans-serif;
font-size: 13px;
line-height: 1.2;
}
.sledilka-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: 8px;
transition: all 0.2s ease;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.sledilka-button:hover {
background: rgba(103, 193, 245, 0.2);
}
.sledilka-notification-badge {
background: #67c1f5;
color: #1b2838;
border-radius: 3px;
padding: 3px 6px;
font-size: 14px;
font-weight: bold;
min-width: 20px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.sledilka-status-indicator-group {
display: flex;
gap: 4px;
}
.sledilka-status-indicator {
background: #4a5562;
color: #c6d4df;
border-radius: 3px;
padding: 3px 6px;
font-size: 12px;
font-weight: bold;
min-width: 40px;
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;
}
.sledilka-panel {
position: fixed;
right: 132px;
top: 50px;
background: #1b2838;
border: 1px solid #67c1f5;
width: 550px;
max-height: 70vh;
overflow-y: auto;
z-index: 9999;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
display: none;
flex-direction: column;
}
.sledilka-panel-header {
padding: 12px 15px;
background: #171a21;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
border-bottom: 1px solid #2a475e;
}
.sledilka-panel-title {
font-size: 16px;
font-weight: 500;
color: #67c1f5;
}
.sledilka-panel-controls {
display: flex;
gap: 5px;
align-items: center;
}
.sledilka-panel-controls button {
background: rgba(30, 45, 60, 0.7);
border: none;
color: #c6d4df;
padding: 6px 10px;
cursor: pointer;
border-radius: 2px;
font-size: 12px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: background 0.2s ease, box-shadow 0.2s ease;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sledilka-panel-controls button:hover:not(:disabled) {
background: rgba(40, 60, 80, 0.9);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
}
.sledilka-panel-controls button:active:not(:disabled) {
background: rgba(30, 45, 60, 0.6);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
transform: translateY(1px);
}
.sledilka-panel-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sledilka-panel-controls button svg {
width: 16px;
height: 16px;
}
.sledilka-settings-container {
position: relative;
}
.sledilka-settings-btn {
padding: 6px;
}
.sledilka-settings-dropdown {
display: none;
position: fixed;
background-color: #171a21;
border: 1px solid #2a475e;
border-radius: 3px;
padding: 10px;
z-index: 10000;
width: 250px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4);
}
.sledilka-settings-dropdown label {
display: block;
margin-bottom: 5px;
color: #c6d4df;
font-size: 13px;
cursor: pointer;
}
.sledilka-settings-dropdown label.disabled {
color: #5c626a;
cursor: default;
}
.sledilka-settings-dropdown input {
margin-right: 5px;
vertical-align: middle;
accent-color: #67c1f5;
}
#sledilkaLibrarySubSettings {
padding-left: 20px;
margin-top: 5px;
border-top: 1px solid #2a475e;
padding-top: 5px;
}
.sledilka-panel-content {
flex-grow: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #4b6f9c #1b2838;
}
.sledilka-panel-content::-webkit-scrollbar {
width: 6px;
}
.sledilka-panel-content::-webkit-scrollbar-track {
background: #1b2838;
}
.sledilka-panel-content::-webkit-scrollbar-thumb {
background-color: #4b6f9c;
border-radius: 3px;
}
.sledilka-notification-item {
padding: 12px 15px;
border-bottom: 1px solid #2a475e;
position: relative;
transition: opacity 0.3s;
}
.sledilka-notification-content {
display: flex;
gap: 12px;
}
.sledilka-notification-image {
width: 80px;
height: 45px;
object-fit: cover;
flex-shrink: 0;
border-radius: 2px;
}
.sledilka-notification-text {
flex-grow: 1;
padding-right: 30px;
font-size: 13px;
}
.sledilka-notification-game-title {
color: #66c0f4;
font-weight: bold;
text-decoration: none;
display: block;
margin-bottom: 4px;
font-size: 14px;
}
.sledilka-notification-type {
font-size: 11px;
color: #8f98a0;
margin-bottom: 4px;
text-transform: uppercase;
}
.sledilka-notification-details {
color: #c6d4df;
margin-bottom: 4px;
line-height: 1.4;
}
.sledilka-notification-timestamp {
font-size: 11px;
color: #556772;
}
.sledilka-notification-unread {
background: rgba(102, 192, 244, 0.1);
}
.sledilka-notification-controls {
position: absolute;
right: 10px;
top: 10px;
display: flex;
gap: 8px;
}
.sledilka-notification-control {
cursor: pointer;
width: 18px;
height: 18px;
opacity: 0.7;
transition: opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.sledilka-notification-control:hover {
opacity: 1;
}
.sledilka-delete-btn {
color: #6C7781;
font-size: 16px;
font-weight: bold;
line-height: 1;
transition: color 0.2s ease, transform 0.1s ease;
}
.sledilka-delete-btn:hover {
color: #8F98A0;
}
.sledilka-delete-btn:active {
color: #800000;
transform: scale(0.9);
}
.sledilka-loading-indicator,
.sledilka-error-indicator {
color: #67c1f5;
text-align: center;
padding: 10px;
font-size: 13px;
}
.sledilka-error-indicator {
color: #ff4747;
}
.sledilka-progress-bar {
height: 4px;
background-color: #2a475e;
width: 100%;
margin-top: -1px;
}
.sledilka-progress-bar-inner {
height: 100%;
width: 0%;
background-color: #67c1f5;
transition: width 0.3s ease;
}
.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;
scrollbar-width: thin;
scrollbar-color: #4b6f9c #1b2838;
}
.calendar-content::-webkit-scrollbar {
width: 6px;
}
.calendar-content::-webkit-scrollbar-track {
background: #1b2838;
}
.calendar-content::-webkit-scrollbar-thumb {
background-color: #4b6f9c;
border-radius: 3px;
}
.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 5px 5px 5px;
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;
flex-shrink: 0;
border-radius: 2px;
}
.calendar-game-title {
color: #c6d4df;
font-size: 13px;
line-height: 1.2;
}
.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;
}
.sledilka-storage-modal {
display: none;
position: fixed;
z-index: 100001;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(3px);
}
.sledilka-storage-modal-content {
background-color: #1f2c3a;
margin: 15% auto;
padding: 25px;
border: 1px solid #67c1f5;
width: 80%;
max-width: 500px;
border-radius: 5px;
text-align: center;
position: relative;
}
.sledilka-storage-modal-close {
color: #aaa;
position: absolute;
top: 10px;
right: 15px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.sledilka-storage-modal-close:hover {
color: #fff;
}
.sledilka-storage-modal h3 {
margin-top: 0;
color: #67c1f5;
font-size: 18px;
margin-bottom: 20px;
}
.sledilka-storage-modal button {
background-color: #4a5562;
color: #c6d4df;
border: 1px solid #67c1f5;
padding: 12px 20px;
margin: 10px;
cursor: pointer;
border-radius: 3px;
font-size: 14px;
transition: background-color 0.2s;
min-width: 200px;
}
.sledilka-storage-modal button:hover {
background-color: #5a6978;
}
`);
function retryPositionCheck() {
if (positionCheckInterval) clearInterval(positionCheckInterval);
positionCheckInterval = setInterval(createSledilkaUI.positionButtonSafely, 300);
setTimeout(() => {
if (positionCheckInterval) clearInterval(positionCheckInterval);
}, 10000);
}
function stopPositionTracking() {
if (positionCheckInterval) {
clearInterval(positionCheckInterval);
positionCheckInterval = null;
}
}
function setupMutationObserver(target) {
if (mutationObserver) mutationObserver.disconnect();
mutationObserver = new MutationObserver((mutations) => {
let needsUpdate = mutations.some(mutation => {
return mutation.type === 'attributes' ||
mutation.addedNodes.length > 0 ||
mutation.removedNodes.length > 0;
});
if (needsUpdate) {
if (typeof createSledilkaUI.positionButtonSafely === 'function') {
createSledilkaUI.positionButtonSafely();
}
}
});
if (target) {
mutationObserver.observe(target, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
}
}
function createSledilkaUI() {
const initialGlobalActionsContainer = document.getElementById('global_actions');
if (!initialGlobalActionsContainer) { return; }
const initialAvatarLink = initialGlobalActionsContainer.querySelector('a.user_avatar');
if (!initialAvatarLink) { return; }
if (document.querySelector('.sledilka-container')) { return; }
const updateSettings = GM_getValue(STORAGE_KEYS.UPDATE_SETTINGS, {
wishlist: true,
library: true,
recheckRussian: true,
recheckPartial: false
});
const container = $(`
<div class="sledilka-container">
<div class="sledilka-button">
<span>Наблюдатель</span>
<div class="sledilka-status-indicator-group">
<div class="sledilka-status-indicator sledilka-status-wishlist status-unknown" title="Время обновления списка желаемого">Ж:??</div>
<div class="sledilka-status-indicator sledilka-status-library status-unknown" title="Время обновления библиотеки">Б:??</div>
</div>
<div class="sledilka-notification-badge">${getUnreadCount()}</div>
</div>
<div class="sledilka-panel">
<div class="sledilka-panel-header">
<div class="sledilka-panel-title">Уведомления</div>
<div class="sledilka-panel-controls">
<div class="sledilka-settings-container">
<button class="sledilka-settings-btn" title="Настройки обновления">${settingsIcon}</button>
<div class="sledilka-settings-dropdown">
<label><input type="checkbox" id="sledilkaUpdateWishlist" ${updateSettings.wishlist ? 'checked' : ''}> Список желаемого</label>
<label><input type="checkbox" id="sledilkaUpdateLibrary" ${updateSettings.library ? 'checked' : ''}> Библиотека</label>
<div id="sledilkaLibrarySubSettings">
<label><input type="checkbox" id="sledilkaRecheckRussian" ${updateSettings.recheckRussian ? 'checked' : ''}> Перепроверять игры с русским</label>
<label id="sledilkaRecheckPartialLabel"><input type="checkbox" id="sledilkaRecheckPartial" ${updateSettings.recheckPartial ? 'checked' : ''}> ...только без полной локализации</label>
</div>
</div>
</div>
<button class="sledilka-refresh-btn">⟳ Обновить</button>
<button class="sledilka-clear-btn">× Очистить</button>
<button class="sledilka-calendar-btn">${calendarIcon}</button>
<button class="sledilka-storage-btn">${storageIcon}</button>
</div>
</div>
<div class="sledilka-progress-bar"><div class="sledilka-progress-bar-inner"></div></div>
<div class="sledilka-panel-content"></div>
</div>
</div>`);
initialGlobalActionsContainer.appendChild(container[0]);
function positionButtonSafely() {
const currentGlobalActionsContainer = document.getElementById('global_actions');
if (!currentGlobalActionsContainer || !container[0] || !container[0].isConnected) { stopPositionTracking(); return; }
const currentAvatarLink = currentGlobalActionsContainer.querySelector('a.user_avatar');
if (!currentAvatarLink) { retryPositionCheck(); return; }
const isVisible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
return !(rect.width === 0 && rect.height === 0 && element.offsetWidth === 0 && element.offsetHeight === 0);
};
if (!isVisible(currentAvatarLink)) { retryPositionCheck(); return; }
const avatarRect = currentAvatarLink.getBoundingClientRect();
const globalActionsRect = currentGlobalActionsContainer.getBoundingClientRect();
const sledilkaViewportTop = avatarRect.top;
const sledilkaViewportLeft = avatarRect.right + 45;
const sledilkaContainerTop = sledilkaViewportTop - globalActionsRect.top;
const sledilkaContainerLeft = sledilkaViewportLeft - globalActionsRect.left;
container.css({ top: sledilkaContainerTop + 'px', left: sledilkaContainerLeft + 'px', visibility: 'visible' });
setTimeout(() => {
if (container[0] && container[0].isConnected && currentAvatarLink && currentAvatarLink.isConnected) {
const postRect = container[0].getBoundingClientRect();
const liveAvatarRect = currentAvatarLink.getBoundingClientRect();
if (postRect.left < liveAvatarRect.right) {
container.css('left', (sledilkaContainerLeft + 50) + 'px');
}
}
}, 100);
stopPositionTracking();
setupMutationObserver(currentGlobalActionsContainer);
}
createSledilkaUI.positionButtonSafely = positionButtonSafely;
setTimeout(positionButtonSafely, 700);
const panel = container.find('.sledilka-panel');
container.find('.sledilka-button').on('click', function(e) { e.stopPropagation(); togglePanel(); });
container.find('.sledilka-refresh-btn').on('click', (e) => { e.stopPropagation(); if (!updateInProgress) updateData(); });
container.find('.sledilka-clear-btn').on('click', (e) => {
e.stopPropagation();
if (confirm("Вы уверены, что хотите очистить ВСЕ уведомления?")) {
notifications = [];
GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
updateNotificationPanel();
updateBadge();
showInfoIndicator("Все уведомления очищены.");
}
});
container.find('.sledilka-calendar-btn').on('click', (e) => { e.stopPropagation(); showCalendarModal(); });
container.find('.sledilka-storage-btn').on('click', (e) => { e.stopPropagation(); showStorageModal(); });
const settingsBtn = container.find('.sledilka-settings-btn');
const settingsDropdown = container.find('.sledilka-settings-dropdown');
const libCheck = $('#sledilkaUpdateLibrary');
const recheckRuCheck = $('#sledilkaRecheckRussian');
const recheckPartialCheck = $('#sledilkaRecheckPartial');
const recheckPartialLabel = $('#sledilkaRecheckPartialLabel');
const libSubSettings = $('#sledilkaLibrarySubSettings');
function toggleSubSettings() {
const libIsChecked = libCheck.is(':checked');
libSubSettings.toggle(libIsChecked);
if (!libIsChecked) {
recheckRuCheck.prop('disabled', true);
recheckPartialCheck.prop('disabled', true);
recheckPartialLabel.addClass('disabled');
} else {
recheckRuCheck.prop('disabled', false);
togglePartialRecheckOption();
}
}
function togglePartialRecheckOption() {
const recheckRuIsChecked = recheckRuCheck.is(':checked');
if (recheckRuIsChecked || !libCheck.is(':checked')) {
recheckPartialCheck.prop('disabled', true);
recheckPartialCheck.prop('checked', false);
recheckPartialLabel.addClass('disabled');
} else {
recheckPartialCheck.prop('disabled', false);
recheckPartialLabel.removeClass('disabled');
}
}
settingsBtn.on('click', function(e) {
e.stopPropagation();
if (settingsDropdown.is(':visible')) {
settingsDropdown.hide();
} else {
const btnRect = this.getBoundingClientRect();
const dropdownWidth = settingsDropdown.outerWidth();
settingsDropdown.css({
top: (btnRect.bottom + 5) + 'px',
left: (btnRect.right - dropdownWidth) + 'px'
}).show();
}
});
settingsDropdown.on('click', function(e) {
e.stopPropagation();
});
$('#sledilkaUpdateWishlist, #sledilkaUpdateLibrary, #sledilkaRecheckRussian, #sledilkaRecheckPartial').on('change', function() {
toggleSubSettings();
const newSettings = {
wishlist: $('#sledilkaUpdateWishlist').is(':checked'),
library: $('#sledilkaUpdateLibrary').is(':checked'),
recheckRussian: $('#sledilkaRecheckRussian').is(':checked'),
recheckPartial: $('#sledilkaRecheckPartial').is(':checked')
};
GM_setValue(STORAGE_KEYS.UPDATE_SETTINGS, newSettings);
});
toggleSubSettings();
updateNotificationPanel();
$(document).on('click', (e) => {
const $target = $(e.target);
if (isPanelOpen && panel.is(":visible")) {
if (!$target.closest('.sledilka-container').length) {
togglePanel(false);
}
}
if(settingsDropdown.is(':visible')) {
if (!$target.closest('.sledilka-settings-container').length) {
settingsDropdown.hide();
}
}
});
}
function togglePanel() {
updateStatusIndicator();
const panel = $('.sledilka-panel');
panel.toggle();
isPanelOpen = !isPanelOpen;
if (isPanelOpen) {
panel.css('display', 'flex');
updateBadge();
updateNotificationPanel();
}
}
function showLoadingIndicator(message = "Обновление данных...") {
const panelContent = $('.sledilka-panel-content');
panelContent.find('.sledilka-loading-indicator, .sledilka-error-indicator').remove();
panelContent.prepend($(`<div class="sledilka-loading-indicator">${message} <span class="spinner"></span></div>`));
}
function removeLoadingIndicator() {
$('.sledilka-panel-content .sledilka-loading-indicator').remove();
}
function showErrorIndicator(message) {
const panelContent = $('.sledilka-panel-content');
panelContent.find('.sledilka-loading-indicator, .sledilka-error-indicator').remove();
const error = $(`<div class="sledilka-error-indicator">${message}</div>`);
panelContent.prepend(error);
setTimeout(() => error.remove(), 8000);
}
function showInfoIndicator(message) {
const panelContent = $('.sledilka-panel-content');
panelContent.find('.sledilka-loading-indicator, .sledilka-error-indicator').remove();
const info = $(`<div class="sledilka-loading-indicator" style="color: #67c1f5;">${message}</div>`);
panelContent.prepend(info);
setTimeout(() => info.remove(), 4000);
}
function updateProgressBar(percentage) {
const progressBar = $('.sledilka-progress-bar');
const progressBarInner = progressBar.find('.sledilka-progress-bar-inner');
if (percentage > 0 && percentage <= 100) {
progressBar.show();
progressBarInner.css('width', `${percentage}%`);
} else {
progressBar.hide();
progressBarInner.css('width', '0%');
}
}
function getNotificationType(notification) {
if (notification.source === 'wishlist') {
if ('oldDate' in notification) return 'wishlist_date';
if (notification.changeType === 'ea_status') return 'wishlist_ea';
if (notification.changeType === 'ru_lang') return 'wishlist_ru';
}
if (notification.source === 'library') {
if (notification.changeType === 'ea_status') return 'owned_ea';
if (notification.changeType === 'ru_lang') return 'owned_ru';
}
if ('oldDate' in notification) return 'wishlist_date';
if (notification.changeType === 'ea_status') return 'owned_ea';
if (notification.changeType === 'ru_lang') return 'owned_ru';
return 'unknown';
}
function getNotificationTitle(type) {
switch (type) {
case 'wishlist_date': return 'Желаемое (Дата выхода)';
case 'wishlist_ea': return 'Желаемое (Ранний доступ)';
case 'wishlist_ru': return 'Желаемое (Русский язык)';
case 'owned_ea': return 'Библиотека (Ранний доступ)';
case 'owned_ru': return 'Библиотека (Русский язык)';
default: return 'Уведомление';
}
}
function getNotificationDetailsHTML(notification, type) {
if (type.endsWith('_ru')) {
const changeDetails = notification.ruChangeDetails;
if (!changeDetails) return 'Изменение в локализации.';
const added = [], removed = [];
const langMap = { supported: 'Интерфейс', full_audio: 'Озвучка', subtitles: 'Субтитры' };
for (const key in changeDetails.added) { if (changeDetails.added[key]) added.push(langMap[key]); }
for (const key in changeDetails.removed) { if (changeDetails.removed[key]) removed.push(langMap[key]); }
const oldHasAny = Object.values(changeDetails.oldState).some(v => v);
const newHasAny = Object.values(changeDetails.newState).some(v => v);
let parts = [];
if (added.length > 0 && removed.length > 0) {
parts.push(`Изменения в локализации:<br><b>Добавлено:</b> ${added.join(', ')}<br><b>Убрано:</b> ${removed.join(', ')}`);
} else if (added.length > 0) {
if (oldHasAny) {
parts.push(`К русскому языку добавлено:<br><b>${added.join(' + ')}</b>`);
} else {
parts.push(`Появился русский язык:<br><b>${added.join(' + ')}</b>`);
}
} else if (removed.length > 0) {
if (newHasAny) {
parts.push(`Локализация урезана:<br><b>Убрано: ${removed.join(', ')}</b>`);
} else {
parts.push(`<b>Русский язык удален из игры.</b>`);
}
}
return parts.join('<br>');
} else {
switch (type) {
case 'wishlist_date':
return `Дата выхода изменилась:<br>
<span class="old-date">${formatDate(notification.oldDate)}</span> →
<span class="new-date">${formatDate(notification.newDate)}</span>`;
case 'wishlist_ea':
case 'owned_ea':
return `Игра вышла из раннего доступа!`;
default:
return 'Детали неизвестны';
}
}
}
function updateNotificationPanel() {
const panelContent = $('.sledilka-panel-content');
if (!panelContent.length) return;
panelContent.find('.sledilka-notification-item, .sledilka-loading-indicator, .sledilka-error-indicator').remove();
$('.sledilka-panel-title').text(`Уведомления (${notifications.length})`);
notifications.sort((a, b) => {
if (a.read === b.read) {
return b.timestamp - a.timestamp;
}
return a.read ? 1 : -1;
});
if (notifications.length === 0) {
panelContent.append('<div class="sledilka-loading-indicator" style="color: #8f98a0;">Нет новых уведомлений</div>');
return;
}
notifications.slice(0, 5000).forEach((notification, index) => {
const notificationType = getNotificationType(notification);
const imageUrl = notification.header ?
`https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${notification.appid}/${notification.header}` :
`https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${notification.appid}/header.jpg`;
const item = $(`
<div class="sledilka-notification-item ${notification.read ? '' : 'sledilka-notification-unread'}" data-index="${index}">
<div class="sledilka-notification-controls">
<div class="sledilka-toggle-read-btn sledilka-notification-control" title="${notification.read ? 'Пометить как непрочитанное' : 'Пометить как прочитанное'}">
${notification.read ? envelopeIcons.read : envelopeIcons.unread}
</div>
<div class="sledilka-delete-btn sledilka-notification-control" title="Удалить уведомление">X</div>
</div>
<div class="sledilka-notification-content">
<a href="https://store.steampowered.com/app/${notification.appid}" target="_blank">
<img src="${imageUrl}"
class="sledilka-notification-image" loading="lazy"
onerror="this.onerror=null; this.src='https://via.placeholder.com/80x45?text=No+Img'; this.style.objectFit='contain';">
</a>
<div class="sledilka-notification-text">
<div class="sledilka-notification-type">${getNotificationTitle(notificationType)}</div>
<a href="https://store.steampowered.com/app/${notification.appid}"
class="sledilka-notification-game-title" target="_blank">
${notification.name || `Игра #${notification.appid}`}
</a>
<div class="sledilka-notification-details">
${getNotificationDetailsHTML(notification, notificationType)}
</div>
<div class="sledilka-notification-timestamp">
Обнаружено: ${new Date(notification.timestamp).toLocaleString()}
</div>
</div>
</div>
</div>
`);
item.find('.sledilka-delete-btn').on('click', (e) => {
e.stopPropagation();
const itemIndex = parseInt($(e.target).closest('.sledilka-notification-item').data('index'));
if (!isNaN(itemIndex) && itemIndex >= 0 && itemIndex < notifications.length) {
notifications.splice(itemIndex, 1);
GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
item.fadeOut(300, () => {
updateNotificationPanel();
updateBadge();
});
} else {
console.error("Ошибка удаления: неверный индекс", itemIndex);
}
});
item.find('.sledilka-toggle-read-btn').on('click', (e) => {
e.stopPropagation();
const itemIndex = parseInt($(e.target).closest('.sledilka-notification-item').data('index'));
if (!isNaN(itemIndex) && itemIndex >= 0 && itemIndex < notifications.length) {
notifications[itemIndex].read = !notifications[itemIndex].read;
GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
item.toggleClass('sledilka-notification-unread', !notifications[itemIndex].read);
$(e.currentTarget).html(notifications[itemIndex].read ? envelopeIcons.read : envelopeIcons.unread);
$(e.currentTarget).attr('title', notifications[itemIndex].read ? 'Пометить как непрочитанное' : 'Пометить как прочитанное');
updateBadge();
} else {
console.error("Ошибка чтения/нечтения: неверный индекс", itemIndex);
}
});
panelContent.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);
if (typeof ts === 'string') return ts;
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 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, 10);
}
function updateStatusIndicator() {
const updateTime = (key, selector, prefix) => {
const lastUpdate = GM_getValue(key, 0);
const indicator = $(selector);
indicator.removeClass('status-ok status-warning status-alert1 status-alert2 status-critical status-unknown');
if (!lastUpdate) {
indicator.text(`${prefix}:??`).addClass('status-unknown').attr('title', 'Данные ни разу не обновлялись');
return;
}
const hoursPassed = (Date.now() - lastUpdate) / MILLISECONDS_IN_HOUR;
const days = Math.floor(hoursPassed / 24);
const hours = Math.floor(hoursPassed % 24);
indicator.attr('title', `Данные не обновлялись: ${days} д. и ${hours} ч.`);
if (hoursPassed < 12) indicator.text(`${prefix}:OK`).addClass('status-ok');
else if (hoursPassed < 24) indicator.text(`${prefix}:OK?`).addClass('status-warning');
else if (hoursPassed < 48) indicator.text(`${prefix}:!`).addClass('status-alert1');
else if (hoursPassed < 72) indicator.text(`${prefix}:!!`).addClass('status-alert2');
else indicator.text(`${prefix}:!!!`).addClass('status-critical');
};
updateTime(STORAGE_KEYS.LAST_UPDATE_WISHLIST, '.sledilka-status-wishlist', 'Ж');
updateTime(STORAGE_KEYS.LAST_UPDATE_LIBRARY, '.sledilka-status-library', 'Б');
}
function updateBadge() {
$('.sledilka-notification-badge').text(getUnreadCount());
}
function getUnreadCount() {
return notifications.filter(n => !n.read).length;
}
async function getUserData() {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://store.steampowered.com/dynamicstore/userdata/?_t=' + Date.now(),
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
resolve({
wishlist: data.rgWishlist || [],
owned: data.rgOwnedApps || []
});
} catch (e) {
console.error("Ошибка парсинга UserData:", e);
resolve({
wishlist: [],
owned: []
});
}
},
onerror: function(error) {
console.error("Ошибка запроса UserData:", error);
resolve({
wishlist: [],
owned: []
});
}
});
});
}
async function fetchGameDetails(appIds, includeLanguages = false) {
if (!appIds || appIds.length === 0) return [];
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, includeLanguages);
allDetails.push(...details);
await new Promise(resolve => setTimeout(resolve, 500));
}
return allDetails;
}
async function fetchBatchDetails(appIds, includeLanguages = false, retries = MAX_API_RETRIES) {
const requestData = {
ids: appIds.map(appid => ({
appid
})),
context: {
language: 'russian',
country_code: 'RU',
steam_realm: 1
},
data_request: {
include_assets: true,
include_release: true,
include_basic_info: true
}
};
if (includeLanguages) {
requestData.data_request.include_supported_languages = true;
}
try {
return await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=${encodeURIComponent(JSON.stringify(requestData))}`,
timeout: 15000,
onload: function(response) {
try {
if (response.status >= 200 && response.status < 400) {
const data = JSON.parse(response.responseText);
resolve(data.response?.store_items || []);
} else {
console.warn(`API Request Failed (Status: ${response.status}) for batch:`, appIds);
reject(new Error(`API Status ${response.status}`));
}
} catch (e) {
console.error('Error parsing response:', e, response.responseText);
reject(new Error('Parse Error'));
}
},
onerror: (error) => {
console.error('API Request Network Error:', error);
reject(new Error('Network Error'));
},
ontimeout: () => {
console.warn('API Request Timeout for batch:', appIds);
reject(new Error('Timeout'));
}
});
});
} catch (error) {
if (retries > 0) {
console.log(`Retrying API request for batch (Retries left: ${retries})...`);
await new Promise(resolve => setTimeout(resolve, API_RETRY_DELAY));
return fetchBatchDetails(appIds, includeLanguages, retries - 1);
} else {
console.error(`API request failed after multiple retries for batch:`, appIds, error);
showErrorIndicator(`Ошибка API Steam после ${MAX_API_RETRIES+1} попыток.`);
return [];
}
}
}
function getWishlistReleaseInfo(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 getOwnedGameInfo(gameDetails) {
if (!gameDetails) return {
is_early_access: false,
ru_support: {
supported: false,
full_audio: false,
subtitles: false
}
};
const is_early_access = gameDetails.release?.is_early_access ?? false;
let ru_support = {
supported: false,
full_audio: false,
subtitles: false
};
const russianLangData = gameDetails.supported_languages?.find(lang => lang.elanguage === 8);
if (russianLangData) {
ru_support = {
supported: russianLangData.supported || false,
full_audio: russianLangData.full_audio || false,
subtitles: russianLangData.subtitles || false
};
}
return {
is_early_access,
ru_support
};
}
function compareOwnedGameInfo(oldInfo, newInfo) {
const changes = [];
if (oldInfo.is_early_access && !newInfo.is_early_access) {
changes.push({ type: 'ea_status', old: oldInfo.is_early_access, new: newInfo.is_early_access });
}
const oldSupport = oldInfo.ru_support || { supported: false, full_audio: false, subtitles: false };
const newSupport = newInfo.ru_support;
const added = {
supported: !oldSupport.supported && newSupport.supported,
full_audio: !oldSupport.full_audio && newSupport.full_audio,
subtitles: !oldSupport.subtitles && newSupport.subtitles
};
const removed = {
supported: oldSupport.supported && !newSupport.supported,
full_audio: oldSupport.full_audio && !newSupport.full_audio,
subtitles: oldSupport.subtitles && !newSupport.subtitles
};
const hasAdded = Object.values(added).some(v => v);
const hasRemoved = Object.values(removed).some(v => v);
if (hasAdded || hasRemoved) {
changes.push({
type: 'ru_lang',
details: {
oldState: oldSupport,
newState: newSupport,
added,
removed
}
});
}
return changes;
}
async function updateData() {
if (updateInProgress) {
showInfoIndicator("Обновление уже выполняется...");
return;
}
updateInProgress = true;
$('.sledilka-refresh-btn').prop('disabled', true);
const refreshBtnText = $('.sledilka-refresh-btn').contents().filter(function() { return this.nodeType === 3; });
if (refreshBtnText.length) refreshBtnText[0].nodeValue = ' Обновить...';
const updateSettings = GM_getValue(STORAGE_KEYS.UPDATE_SETTINGS, {
wishlist: true,
library: true,
recheckRussian: true,
recheckPartial: false
});
if (!updateSettings.wishlist && !updateSettings.library) {
showInfoIndicator("Не выбрано, что обновлять. Проверьте настройки (⚙️).");
updateInProgress = false;
$('.sledilka-refresh-btn').prop('disabled', false);
if(refreshBtnText.length) refreshBtnText[0].nodeValue = ' Обновить';
return;
}
showLoadingIndicator("Получение списков игр...");
updateProgressBar(1);
try {
const {
wishlist: currentWishlistAppIds,
owned: currentOwnedAppIds
} = await getUserData();
let allNewNotifications = [];
if(updateSettings.wishlist) {
const wishlistPreviousData = GM_getValue(STORAGE_KEYS.WISHLIST_GAME_DATA, {});
const currentWishlistDataToSave = { ...wishlistPreviousData };
const currentWishlistAppIdSet = new Set(currentWishlistAppIds);
const previouslyTrackedAppIds = Object.keys(wishlistPreviousData).map(id => parseInt(id, 10));
const removedAppIds = previouslyTrackedAppIds.filter(appid => !currentWishlistAppIdSet.has(appid));
removedAppIds.forEach(appid => {
delete currentWishlistDataToSave[String(appid)];
});
showLoadingIndicator(`Загрузка данных для ${currentWishlistAppIds.length} игр из желаемого...`);
updateProgressBar(5);
if (currentWishlistAppIds.length > 0) {
const wishlistDetails = await fetchGameDetails(currentWishlistAppIds, true);
updateProgressBar(25);
wishlistDetails.forEach(game => {
if (!game || !game.appid) return;
const appid = game.appid;
const prevGame = wishlistPreviousData[appid];
const currentRelease = getWishlistReleaseInfo(game.release);
const currentGameInfo = getOwnedGameInfo(game);
currentWishlistDataToSave[appid] = {
name: game.name,
rawRelease: game.release,
releaseInfo: currentRelease,
header: game.assets?.header || null,
is_early_access: currentGameInfo.is_early_access,
ru_support: currentGameInfo.ru_support
};
if (prevGame) {
if (currentRelease.date !== prevGame.releaseInfo?.date || currentRelease.type !== prevGame.releaseInfo?.type || currentRelease.displayType !== prevGame.releaseInfo?.displayType) {
allNewNotifications.push({ source: 'wishlist', appid: appid, name: game.name, header: game.assets?.header, oldDate: prevGame.releaseInfo ? { value: prevGame.releaseInfo.date, displayType: prevGame.releaseInfo.displayType } : { value: 'Не указана', displayType: null }, newDate: { value: currentRelease.date, displayType: currentRelease.displayType }, timestamp: Date.now(), read: false });
}
const hadOldStatusData = prevGame.hasOwnProperty('is_early_access') && prevGame.hasOwnProperty('ru_support');
if (hadOldStatusData) {
const oldGameInfo = { is_early_access: prevGame.is_early_access, ru_support: prevGame.ru_support };
const statusChanges = compareOwnedGameInfo(oldGameInfo, currentGameInfo);
statusChanges.forEach(change => {
allNewNotifications.push({ source: 'wishlist', changeType: change.type, appid: appid, name: game.name, header: game.assets?.header, ruChangeDetails: change.type === 'ru_lang' ? change.details : undefined, timestamp: Date.now(), read: false });
});
}
}
});
}
GM_setValue(STORAGE_KEYS.WISHLIST_GAME_DATA, currentWishlistDataToSave);
GM_setValue(STORAGE_KEYS.LAST_UPDATE_WISHLIST, Date.now());
}
if(updateSettings.library) {
const ownedPreviousData = GM_getValue(STORAGE_KEYS.OWNED_APPS_DATA, {});
const ownedCheckedSet = new Set(GM_getValue(STORAGE_KEYS.OWNED_CHECKED_V2, []));
const currentOwnedDataToSave = { ...ownedPreviousData };
let appsToCheckInLibrary = currentOwnedAppIds.filter(appid => !ownedCheckedSet.has(appid));
if (!updateSettings.recheckRussian) {
appsToCheckInLibrary = appsToCheckInLibrary.filter(appid => {
const prevData = ownedPreviousData[appid];
if (!prevData || !prevData.ru_support) {
return true;
}
const hasFullSupport = prevData.ru_support.supported && prevData.ru_support.full_audio && prevData.ru_support.subtitles;
const hasAnySupport = prevData.ru_support.supported || prevData.ru_support.full_audio || prevData.ru_support.subtitles;
if (updateSettings.recheckPartial) {
return !hasFullSupport;
} else {
return !hasAnySupport;
}
});
}
const totalOwnedToCheck = appsToCheckInLibrary.length;
showLoadingIndicator(`Проверка ${totalOwnedToCheck} игр из библиотеки...`);
if (totalOwnedToCheck > 0) {
for (let i = 0; i < totalOwnedToCheck; i += BATCH_SIZE) {
const batch = appsToCheckInLibrary.slice(i, i + BATCH_SIZE);
const progress = 50 + Math.round(((i) / (totalOwnedToCheck || 1)) * 50);
updateProgressBar(progress);
showLoadingIndicator(`Проверка библиотеки... (${i}/${totalOwnedToCheck})`);
const details = await fetchGameDetails(batch, true);
details.forEach(game => {
if (!game || !game.appid) return;
const appid = game.appid;
if (game.type !== 0 || game.visible === false) { ownedCheckedSet.add(appid); delete currentOwnedDataToSave[appid]; return; }
const name = game.name || ownedPreviousData[appid]?.name || `Игра #${appid}`;
if (!name || name.trim() === '' || name === `Игра #${appid}`) { ownedCheckedSet.add(appid); delete currentOwnedDataToSave[appid]; return; }
const prevGameData = ownedPreviousData[appid];
const currentGameInfo = getOwnedGameInfo(game);
currentOwnedDataToSave[appid] = { name: name, ...currentGameInfo };
if (prevGameData) {
const hadOldStatusData = prevGameData.hasOwnProperty('is_early_access') && prevGameData.hasOwnProperty('ru_support');
if(hadOldStatusData) {
const detectedChanges = compareOwnedGameInfo(prevGameData, currentGameInfo);
detectedChanges.forEach(change => allNewNotifications.push({ source: 'library', changeType: change.type, appid: appid, name: name, header: game.assets?.header, ruChangeDetails: change.type === 'ru_lang' ? change.details : undefined, timestamp: Date.now(), read: false }));
}
}
});
}
}
const currentOwnedAppIdSet = new Set(currentOwnedAppIds);
const previouslyTrackedAppIds = Object.keys(ownedPreviousData).map(id => parseInt(id, 10));
const removedAppIds = previouslyTrackedAppIds.filter(appid => !currentOwnedAppIdSet.has(appid));
removedAppIds.forEach(appid => { delete currentOwnedDataToSave[appid]; ownedCheckedSet.delete(appid); });
GM_setValue(STORAGE_KEYS.OWNED_APPS_DATA, currentOwnedDataToSave);
GM_setValue(STORAGE_KEYS.OWNED_CHECKED_V2, Array.from(ownedCheckedSet));
GM_setValue(STORAGE_KEYS.LAST_UPDATE_LIBRARY, Date.now());
}
if (allNewNotifications.length > 0) {
notifications = [...allNewNotifications, ...notifications];
notifications.sort((a, b) => b.timestamp - a.timestamp);
notifications = notifications.slice(0, 500);
GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
}
removeLoadingIndicator();
updateNotificationPanel();
updateBadge();
updateStatusIndicator();
updateProgressBar(100);
showInfoIndicator(`Обновление завершено. Новых уведомлений: ${allNewNotifications.length}.`);
} catch (e) {
console.error('Ошибка при обновлении данных:', e);
showErrorIndicator(`Ошибка обновления: ${e.message || 'Неизвестная ошибка'}`);
updateStatusIndicator();
} finally {
updateInProgress = false;
$('.sledilka-refresh-btn').prop('disabled', false);
if(refreshBtnText.length) refreshBtnText[0].nodeValue = ' Обновить';
setTimeout(() => updateProgressBar(0), 500);
}
}
function showCalendarModal() {
const gameData = GM_getValue(STORAGE_KEYS.WISHLIST_GAME_DATA, {});
const monthsData = getGamesByMonths(gameData);
const wtmodal = $(`
<div class="calendar-wtmodal">
<div class="calendar-header"> <div class="calendar-title">Календарь релизов (${Object.keys(gameData).length} игр в списке)</div> <div class="calendar-close">×</div> </div>
<div class="calendar-content"></div>
</div>`);
const clickHandler = (e) => {
if (!$(e.target).closest('.calendar-wtmodal').length) {
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();
if (monthsData.length === 0) {
content.append('<div style="text-align:center; padding: 30px; color: #8f98a0;">Нет игр с датой выхода в будущем в вашем списке желаемого.</div>');
return;
}
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 imageUrl = game.header ? `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.appid}/${game.header}` : `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.appid}/header.jpg`;
const gameElement = $(`<a href="https://store.steampowered.com/app/${game.appid}" target="_blank" class="calendar-game ${isApproximate ? 'calendar-game-approximate wt-tooltip' : ''}"> <img src="${imageUrl}" class="calendar-game-image" loading="lazy" onerror="this.onerror=null; this.src='https://via.placeholder.com/100x45?text=No+Img'; this.style.objectFit='contain';"> <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();
});
}
};
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 showStorageModal() {
const modalHtml = `
<div id="sledilkaStorageModal" class="sledilka-storage-modal">
<div class="sledilka-storage-modal-content">
<span id="sledilkaStorageCloseBtn" class="sledilka-storage-modal-close" title="Закрыть">×</span>
<h3>Управление хранилищем</h3>
<button id="clearWishlistDataBtn">Очистить данные из списка желаемого</button>
<br>
<button id="clearOwnedDataBtn">Очистить данные из библиотеки</button>
</div>
</div>`;
if ($('#sledilkaStorageModal').length === 0) {
$('body').append(modalHtml);
}
const modal = $('#sledilkaStorageModal');
modal.show();
$('#sledilkaStorageCloseBtn').off('click').on('click', () => modal.hide());
modal.off('click').on('click', (event) => {
if ($(event.target).is(modal)) {
modal.hide();
}
});
$('#clearWishlistDataBtn').off('click').on('click', () => {
if (confirm("Вы уверены, что хотите удалить сохраненные данные для Списка желаемого?\nЭто приведет к повторному сканированию при следующем обновлении.")) {
GM_deleteValue(STORAGE_KEYS.WISHLIST_GAME_DATA);
notifications = notifications.filter(n => !n.source || n.source !== 'wishlist');
GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
alert("Данные списка желаемого очищены.");
modal.hide();
updateNotificationPanel();
updateBadge();
}
});
$('#clearOwnedDataBtn').off('click').on('click', () => {
if (confirm("Вы уверены, что хотите удалить сохраненные данные для Библиотеки?\nЭто приведет к ПОЛНОМУ ПОВТОРНОМУ сканированию вашей библиотеки при следующем обновлении, что может занять время.")) {
GM_deleteValue(STORAGE_KEYS.OWNED_APPS_DATA);
GM_deleteValue(STORAGE_KEYS.OWNED_CHECKED_V2);
alert("Данные Библиотеки очищены. Потребуется повторное сканирование.");
modal.hide();
}
});
}
function initialize() {
createSledilkaUI();
updateStatusIndicator();
setupMutationObserver(document.getElementById('global_actions'));
}
if (typeof $ !== 'undefined') {
$(document).ready(initialize);
} else {
const checkJQuery = setInterval(() => {
if (typeof $ !== 'undefined') {
clearInterval(checkJQuery);
$(document).ready(initialize);
}
}, 100);
}
})();
}
// Скрипт для проверки возможности отправки подарка из списка желаемого друзьям в других странах | https://steamcommunity.com/my/wishlist/*
if (scriptsConfig.wishlistGiftHelper && unsafeWindow.location.pathname.includes('/wishlist/')) {
(function() {
'use strict';
const WGH_API_URL = "https://api.steampowered.com/IStoreBrowseService/GetItems/v1";
const WGH_CURRENCY_API_URL = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/';
const WGH_BATCH_SIZE = 200;
const WGH_INITIAL_DELAY_MS = 500;
const WGH_REQUEST_TIMEOUT_MS = 20000;
const WGH_GIFT_PRICE_DIFF_THRESHOLD = 0.10;
const WGH_IMAGE_BASE_URL = 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/';
const WGH_COUNTRY_CURRENCY_MAP = {
'US': {
name: 'U.S. Dollar',
code: 1,
iso: 'usd'
},
'EU': {
name: 'Euro',
code: 3,
iso: 'eur'
},
'AR': {
name: 'LATAM - U.S. Dollar',
code: 1,
iso: 'usd'
},
'AU': {
name: 'Australian Dollar',
code: 21,
iso: 'aud'
},
'BR': {
name: 'Brazilian Real',
code: 7,
iso: 'brl'
},
'GB': {
name: 'British Pound',
code: 2,
iso: 'gbp'
},
'CA': {
name: 'Canadian Dollar',
code: 20,
iso: 'cad'
},
'CL': {
name: 'Chilean Peso',
code: 25,
iso: 'clp'
},
'CN': {
name: 'Chinese Yuan',
code: 23,
iso: 'cny'
},
'AZ': {
name: 'CIS - U.S. Dollar',
code: 1,
iso: 'usd'
},
'CO': {
name: 'Colombian Peso',
code: 27,
iso: 'cop'
},
'CR': {
name: 'Costa Rican Colon',
code: 40,
iso: 'crc'
},
'HK': {
name: 'Hong Kong Dollar',
code: 29,
iso: 'hkd'
},
'IN': {
name: 'Indian Rupee',
code: 24,
iso: 'inr'
},
'ID': {
name: 'Indonesian Rupiah',
code: 10,
iso: 'idr'
},
'IL': {
name: 'Israeli New Shekel',
code: 35,
iso: 'ils'
},
'JP': {
name: 'Japanese Yen',
code: 8,
iso: 'jpy'
},
'KZ': {
name: 'Kazakhstani Tenge',
code: 37,
iso: 'kzt'
},
'KW': {
name: 'Kuwaiti Dinar',
code: 38,
iso: 'kwd'
},
'MY': {
name: 'Malaysian Ringgit',
code: 11,
iso: 'myr'
},
'MX': {
name: 'Mexican Peso',
code: 19,
iso: 'mxn'
},
'NZ': {
name: 'New Zealand Dollar',
code: 22,
iso: 'nzd'
},
'NO': {
name: 'Norwegian Krone',
code: 9,
iso: 'nok'
},
'PE': {
name: 'Peruvian Sol',
code: 26,
iso: 'pen'
},
'PH': {
name: 'Philippine Peso',
code: 12,
iso: 'php'
},
'PL': {
name: 'Polish Zloty',
code: 6,
iso: 'pln'
},
'QA': {
name: 'Qatari Riyal',
code: 39,
iso: 'qar'
},
'RU': {
name: 'Russian Ruble',
code: 5,
iso: 'rub'
},
'SA': {
name: 'Saudi Riyal',
code: 31,
iso: 'sar'
},
'SG': {
name: 'Singapore Dollar',
code: 13,
iso: 'sgd'
},
'ZA': {
name: 'South African Rand',
code: 28,
iso: 'zar'
},
'PK': {
name: 'South Asia - USD',
code: 1,
iso: 'usd'
},
'KR': {
name: 'South Korean Won',
code: 16,
iso: 'krw'
},
'CH': {
name: 'Swiss Franc',
code: 4,
iso: 'chf'
},
'TW': {
name: 'Taiwan Dollar',
code: 30,
iso: 'twd'
},
'TH': {
name: 'Thai Baht',
code: 14,
iso: 'thb'
},
'TR': {
name: 'MENA - U.S. Dollar',
code: 1,
iso: 'usd'
},
'AE': {
name: 'U.A.E. Dirham',
code: 32,
iso: 'aed'
},
'UA': {
name: 'Ukrainian Hryvnia',
code: 18,
iso: 'uah'
},
'UY': {
name: 'Uruguayan Peso',
code: 41,
iso: 'uyu'
},
'VN': {
name: 'Vietnamese Dong',
code: 15,
iso: 'vnd'
}
};
const WGH_CURRENCY_CODE_TO_COUNTRY = Object.fromEntries(Object.entries(WGH_COUNTRY_CURRENCY_MAP).map(([country, data]) => [data.code, country]));
const WGH_CURRENCY_CODE_TO_ISO = Object.fromEntries(Object.entries(WGH_COUNTRY_CURRENCY_MAP).map(([_, data]) => [data.code, data.iso]));
const WGH_CURRENCY_ISO_TO_CODE = Object.fromEntries(Object.entries(WGH_COUNTRY_CURRENCY_MAP).map(([_, data]) => [data.iso, data.code]));
const WGH_DEFAULT_SORT = {
field: 'price',
direction: 'asc'
};
let wgh_allAppIds = [];
let wgh_gameDataStore = {};
let wgh_wishlistOwnerSteamID = null;
let wgh_currentUserCountryCode = 'RU';
let wgh_currentUserCurrencyCode = 5;
let wgh_currentUserISOCurrencyCode = 'RUB';
let wgh_currentSort = {
...WGH_DEFAULT_SORT
};
let wgh_currentFriendCountryCode = null;
let wgh_exchangeRates = null;
let wgh_giftModeActive = false;
let wgh_showGiftableOnly = false;
let wgh_modal, wgh_closeBtn, wgh_analyzeBtn;
let wgh_resultsContainer, wgh_resultsDiv, wgh_statusDiv, wgh_progressBar;
let wgh_sortButtonsContainer;
let wgh_giftModeContainer, wgh_giftIconBtn, wgh_giftAccordion, wgh_friendRegionSelect, wgh_fetchFriendPricesBtn, wgh_giftProgressBar, wgh_giftableFilterCheckbox;
let wgh_myRegionDisplay;
function wgh_addAnalyzeButton() {
const titleBlock = document.querySelector('div.jfAmlCmNzHQ-');
const existingButton = document.getElementById('wghAnalyzeButton');
if (!titleBlock || existingButton) return;
wgh_analyzeBtn = document.createElement('button');
wgh_analyzeBtn.id = 'wghAnalyzeButton';
wgh_analyzeBtn.title = 'Помощник подарков';
wgh_analyzeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>`;
Object.assign(wgh_analyzeBtn.style, {
marginLeft: '15px',
background: 'rgba(103, 193, 245, 0.1)',
border: '1px solid rgba(103, 193, 245, 0.3)',
color: '#67c1f5',
borderRadius: '3px',
cursor: 'pointer',
padding: '5px 8px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
verticalAlign: 'middle'
});
wgh_analyzeBtn.onmouseover = () => {
wgh_analyzeBtn.style.background = 'rgba(103, 193, 245, 0.2)';
};
wgh_analyzeBtn.onmouseout = () => {
wgh_analyzeBtn.style.background = 'rgba(103, 193, 245, 0.1)';
};
wgh_analyzeBtn.onclick = wgh_showModal;
const h2Title = titleBlock.querySelector('h2');
if (h2Title) {
h2Title.style.display = 'inline-block';
h2Title.after(wgh_analyzeBtn);
} else {
titleBlock.appendChild(wgh_analyzeBtn);
}
}
function wgh_createModal() {
if (document.getElementById('wghModal')) return;
wgh_modal = document.createElement('div');
wgh_modal.id = 'wghModal';
wgh_modal.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(20, 20, 25, 0.95); backdrop-filter: blur(3px); z-index: 9999; display: none; color: #c6d4df; font-family: "Motiva Sans", Sans-serif, Arial; overflow: hidden; `;
const container = document.createElement('div');
container.id = 'wghContainer';
container.style.cssText = `height: 100%; display: flex; flex-direction: column; padding: 15px;`;
const header = document.createElement('div');
header.id = 'wghHeader';
header.style.cssText = `display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding-bottom: 10px; border-bottom: 1px solid #3a4f6a; margin-bottom: 10px; flex-shrink: 0; padding-right: 45px;`;
const collectBtn = document.createElement('button');
collectBtn.textContent = 'Собрать данные';
collectBtn.id = 'wghCollectBtn';
collectBtn.className = 'wghBtn wghPrimaryBtn';
collectBtn.onclick = wgh_collectData;
header.appendChild(collectBtn);
wgh_statusDiv = document.createElement('div');
wgh_statusDiv.id = 'wghStatus';
wgh_statusDiv.style.cssText = `flex-grow: 1; text-align: center; font-size: 14px; color: #aaa; min-height: 36px; display: flex; align-items: center; justify-content: center;`;
header.appendChild(wgh_statusDiv);
wgh_sortButtonsContainer = document.createElement('div');
wgh_sortButtonsContainer.id = 'wghSortButtons';
wgh_sortButtonsContainer.style.cssText = `display: flex; gap: 5px; align-items: center; margin-left: auto;`;
header.appendChild(wgh_sortButtonsContainer);
wgh_giftIconBtn = document.createElement('button');
wgh_giftIconBtn.id = 'wghGiftModeBtn';
wgh_giftIconBtn.className = 'wghBtn';
wgh_giftIconBtn.title = 'Режим помощника подарков';
wgh_giftIconBtn.innerHTML = '🎁';
wgh_giftIconBtn.onclick = wgh_toggleGiftMode;
header.appendChild(wgh_giftIconBtn);
container.appendChild(header);
wgh_giftModeContainer = document.createElement('div');
wgh_giftModeContainer.id = 'wghGiftAccordionContainer';
wgh_giftModeContainer.style.cssText = `display: none; padding: 10px 0; border-bottom: 1px solid #3a4f6a; margin-bottom: 10px; flex-shrink: 0;`;
container.appendChild(wgh_giftModeContainer);
wgh_resultsContainer = document.createElement('div');
wgh_resultsContainer.id = 'wghResultsContainer';
wgh_resultsContainer.style.cssText = ` flex-grow: 1; overflow-y: auto; overflow-x: hidden; scrollbar-color: #4b6f9c #17202d; scrollbar-width: thin; padding-right: 5px; `;
wgh_resultsContainer.innerHTML = `<style> #wghResultsContainer::-webkit-scrollbar { width: 8px; } #wghResultsContainer::-webkit-scrollbar-track { background: #17202d; border-radius: 4px; } #wghResultsContainer::-webkit-scrollbar-thumb { background-color: #4b6f9c; border-radius: 4px; border: 2px solid #17202d; } #wghResultsContainer::-webkit-scrollbar-thumb:hover { background-color: #67c1f5; } </style>`;
wgh_resultsDiv = document.createElement('div');
wgh_resultsDiv.id = 'wghResults';
wgh_resultsDiv.style.cssText = ` display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 15px; padding-top: 5px; `;
wgh_resultsContainer.appendChild(wgh_resultsDiv);
container.appendChild(wgh_resultsContainer);
wgh_progressBar = wgh_createProgressBar('wghMainProgress');
container.appendChild(wgh_progressBar);
wgh_giftProgressBar = wgh_createProgressBar('wghGiftProgress');
container.appendChild(wgh_giftProgressBar);
wgh_closeBtn = document.createElement('button');
wgh_closeBtn.id = 'wghCloseBtn';
wgh_closeBtn.innerHTML = '×';
wgh_closeBtn.onclick = wgh_hideModal;
wgh_closeBtn.style.cssText = ` position: absolute; top: 10px; right: 15px; font-size: 30px; color: #aaa; background: none; border: none; cursor: pointer; line-height: 1; z-index: 10002; padding: 5px; transition: color 0.2s, transform 0.2s; `;
wgh_closeBtn.onmouseover = () => {
wgh_closeBtn.style.color = '#fff';
wgh_closeBtn.style.transform = 'scale(1.1)';
};
wgh_closeBtn.onmouseout = () => {
wgh_closeBtn.style.color = '#aaa';
wgh_closeBtn.style.transform = 'scale(1)';
};
wgh_modal.appendChild(wgh_closeBtn);
wgh_modal.appendChild(container);
document.body.appendChild(wgh_modal);
wgh_createSortButtons();
wgh_createGiftAccordion();
wgh_updateSortButtonsState();
function handleEsc(event) {
if (event.key === 'Escape') wgh_hideModal();
}
document.addEventListener('keydown', handleEsc);
wgh_modal._escHandler = handleEsc;
}
function wgh_showModal() {
if (!wgh_modal) wgh_createModal();
wgh_updateStatus('Нажмите "Собрать данные" для анализа списка желаемого.');
wgh_resultsDiv.innerHTML = '';
wgh_gameDataStore = {};
wgh_hideGiftMode(true);
wgh_hideProgressBar(wgh_progressBar);
wgh_hideProgressBar(wgh_giftProgressBar);
document.body.style.overflow = 'hidden';
wgh_modal.style.display = 'block';
}
function wgh_hideModal() {
if (wgh_modal) {
wgh_modal.style.display = 'none';
if (wgh_modal._escHandler) {
document.removeEventListener('keydown', wgh_modal._escHandler);
delete wgh_modal._escHandler;
}
}
document.body.style.overflow = '';
}
function wgh_updateStatus(message, isLoading = false) {
if (wgh_statusDiv) {
wgh_statusDiv.innerHTML = message + (isLoading ? ' <span class="wghSpinner"></span>' : '');
}
const collectBtn = document.getElementById('wghCollectBtn');
if (collectBtn) collectBtn.disabled = isLoading;
if (wgh_fetchFriendPricesBtn) wgh_fetchFriendPricesBtn.disabled = isLoading;
}
async function wgh_collectData() {
wgh_updateStatus('Извлечение AppID...', true);
wgh_resultsDiv.innerHTML = '';
wgh_gameDataStore = {};
wgh_hideGiftMode(true);
wgh_showProgressBar(wgh_progressBar, 0);
try {
wgh_allAppIds = await wgh_extractAppIdsFromPage();
if (!wgh_allAppIds || wgh_allAppIds.length === 0) {
wgh_updateStatus('Не удалось найти игры в списке желаемого.');
wgh_hideProgressBar(wgh_progressBar);
return;
}
wgh_updateStatus(`Найдено ${wgh_allAppIds.length} игр. Запрос данных... (0/${Math.ceil(wgh_allAppIds.length / WGH_BATCH_SIZE)})`, true);
const totalBatches = Math.ceil(wgh_allAppIds.length / WGH_BATCH_SIZE);
let processedBatches = 0;
for (let i = 0; i < wgh_allAppIds.length; i += WGH_BATCH_SIZE) {
const batch = wgh_allAppIds.slice(i, i + WGH_BATCH_SIZE);
const batchData = await wgh_fetchBatchGameData(batch, wgh_currentUserCountryCode);
wgh_processBatchData(batchData, 'myData');
processedBatches++;
const progress = (processedBatches / totalBatches) * 100;
wgh_updateProgressBar(wgh_progressBar, progress);
wgh_updateStatus(`Запрос данных... (${processedBatches}/${totalBatches})`, true);
await new Promise(res => setTimeout(res, 200));
}
wgh_updateStatus(`Данные для ${Object.keys(wgh_gameDataStore).length} игр получены.`);
wgh_applySort(wgh_currentSort.field, wgh_currentSort.direction);
wgh_renderResults();
wgh_hideProgressBar(wgh_progressBar);
} catch (error) {
wgh_updateStatus(`Ошибка при сборе данных: ${error.message}`);
console.error('[WGH] Ошибка сбора данных:', error);
wgh_hideProgressBar(wgh_progressBar);
}
}
async function wgh_extractAppIdsFromPage() {
let appIds = [];
if (typeof unsafeWindow !== 'undefined' && unsafeWindow.SSR && unsafeWindow.SSR.renderContext && typeof unsafeWindow.SSR.renderContext.queryData === 'string') {
try {
const queryData = JSON.parse(unsafeWindow.SSR.renderContext.queryData);
if (queryData && Array.isArray(queryData.queries)) {
const wishlistQuery = queryData.queries.find(q =>
q && Array.isArray(q.queryKey) && q.queryKey[0] === 'WishlistSortedFiltered'
);
if (wishlistQuery && wishlistQuery.state && wishlistQuery.state.data && Array.isArray(wishlistQuery.state.data.items)) {
appIds = wishlistQuery.state.data.items.map(item => item.appid);
}
}
} catch (e) {
console.error("[WGH] Ошибка при разборе данных SSR:", e);
}
}
if (appIds.length === 0 && typeof unsafeWindow.g_rgWishlistData !== 'undefined' && Array.isArray(unsafeWindow.g_rgWishlistData)) {
console.warn("[WGH] Используется резервный метод g_rgWishlistData.");
appIds = unsafeWindow.g_rgWishlistData.map(item => item.appid).filter(id => id);
}
if (appIds.length === 0) {
throw new Error("Не удалось извлечь AppID. Возможно, структура страницы изменилась или список желаемого пуст.");
}
return [...new Set(appIds)];
}
function wgh_detectUserRegion() {
let found = false;
if (typeof unsafeWindow?.SSR?.renderContext?.queryData === 'string') {
try {
const queryData = JSON.parse(unsafeWindow.SSR.renderContext.queryData);
const walletInfoQuery = queryData?.queries?.find(q => q?.queryKey?.[0] === 'CurrentUserWalletDetails');
const walletInfoInSSR = walletInfoQuery?.state?.data;
if (walletInfoInSSR?.has_wallet && walletInfoInSSR?.currency_code && walletInfoInSSR?.wallet_country_code) {
wgh_currentUserCurrencyCode = walletInfoInSSR.currency_code;
wgh_currentUserCountryCode = walletInfoInSSR.wallet_country_code;
wgh_currentUserISOCurrencyCode = WGH_CURRENCY_CODE_TO_ISO[wgh_currentUserCurrencyCode] || null;
if (wgh_currentUserCountryCode && wgh_currentUserISOCurrencyCode) {
found = true;
console.log(`[WGH] Регион успешно определен через SSR/Wallet: ${wgh_currentUserCountryCode}`);
}
}
} catch(e) {
console.error('[WGH] Ошибка при разборе SSR для определения региона:', e);
}
}
if (!found && typeof unsafeWindow?.Config?.COUNTRY === 'string') {
const countryCode = unsafeWindow.Config.COUNTRY;
const currencyInfo = Object.entries(WGH_COUNTRY_CURRENCY_MAP).find(([key, data]) => key === countryCode);
if (currencyInfo) {
wgh_currentUserCountryCode = currencyInfo[0];
wgh_currentUserCurrencyCode = currencyInfo[1].code;
wgh_currentUserISOCurrencyCode = currencyInfo[1].iso;
found = true;
console.log(`[WGH] Регион определен через резервный метод window.Config: ${wgh_currentUserCountryCode}`);
}
}
if (!found && typeof unsafeWindow.g_rgWalletInfo !== 'undefined' && unsafeWindow.g_rgWalletInfo.wallet_currency) {
console.warn("[WGH] Используется самый старый резервный метод g_rgWalletInfo.");
wgh_currentUserCurrencyCode = unsafeWindow.g_rgWalletInfo.wallet_currency;
wgh_currentUserCountryCode = WGH_CURRENCY_CODE_TO_COUNTRY[wgh_currentUserCurrencyCode] || null;
wgh_currentUserISOCurrencyCode = WGH_CURRENCY_CODE_TO_ISO[wgh_currentUserCurrencyCode] || null;
if (wgh_currentUserCountryCode && wgh_currentUserISOCurrencyCode) {
found = true;
}
}
if (!found) {
console.warn('[WGH] Не удалось определить регион пользователя, используется значение по умолчанию: RU/RUB.');
wgh_currentUserCountryCode = 'RU';
wgh_currentUserCurrencyCode = 5;
wgh_currentUserISOCurrencyCode = 'RUB';
}
if (wgh_myRegionDisplay) {
wgh_myRegionDisplay.textContent = `${wgh_currentUserCountryCode || '??'} (${wgh_currentUserISOCurrencyCode || '???'})`;
}
}
async function wgh_fetchBatchGameData(appIdsBatch, countryCode) {
const inputJson = {
ids: appIdsBatch.map(appid => ({
appid
})),
context: {
language: "russian",
country_code: countryCode || 'RU',
steam_realm: 1
},
data_request: {
include_basic_info: true,
include_assets: true,
include_release: true,
include_reviews: true,
include_platforms: true,
include_all_purchase_options: true,
include_supported_languages: true
}
};
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: `${WGH_API_URL}?input_json=${encodeURIComponent(JSON.stringify(inputJson))}`,
timeout: WGH_REQUEST_TIMEOUT_MS,
onload: function(response) {
try {
if (response.status >= 200 && response.status < 400) {
const data = JSON.parse(response.responseText);
if (data?.response?.store_items) {
resolve(data.response.store_items);
} else {
console.warn(`[WGH] API вернул успех, но нет store_items для batch: ${appIdsBatch.join(',')}`, data);
resolve([]);
}
} else {
reject(new Error(`HTTP статус ${response.status} для батча`));
}
} catch (e) {
reject(new Error(`Ошибка парсинга JSON: ${e.message}`));
}
},
onerror: (error) => reject(new Error(`Сетевая ошибка: ${error?.finalUrl || WGH_API_URL}`)),
ontimeout: () => reject(new Error('Таймаут запроса к Steam API'))
});
});
}
function wgh_processBatchData(batchData, dataType = 'myData') {
if (!Array.isArray(batchData)) return;
batchData.forEach(item => {
if (!item || !item.id || item.success !== 1) return;
const appid = item.id;
if (!wgh_gameDataStore[appid]) {
wgh_gameDataStore[appid] = {
myData: null,
friendData: null
};
}
const headerFileName = item.assets?.header;
const imageUrl = headerFileName ? `${WGH_IMAGE_BASE_URL}${item.id}/${headerFileName}` : `${WGH_IMAGE_BASE_URL}${item.appid}/header_292x136.jpg`;
const extractedData = {
appid: item.appid,
name: item.name,
type: item.type,
imageUrl: imageUrl,
releaseDateTimestamp: item.release?.steam_release_date || null,
reviewScore: item.reviews?.summary_filtered?.review_score || 0,
reviewPercent: item.reviews?.summary_filtered?.percent_positive || 0,
reviewCount: item.reviews?.summary_filtered?.review_count || 0,
reviewDesc: item.reviews?.summary_filtered?.review_score_label || 'Нет отзывов',
platforms: {
windows: item.platforms?.windows || false,
mac: item.platforms?.mac || false,
linux: item.platforms?.steamos_linux || false,
},
canGift: item.best_purchase_option?.user_can_purchase_as_gift || false,
priceData: null
};
const purchaseOption = item.best_purchase_option;
if (purchaseOption) {
extractedData.priceData = {
formattedFinal: purchaseOption.formatted_final_price || 'N/A',
finalCents: purchaseOption.final_price_in_cents ? parseInt(purchaseOption.final_price_in_cents, 10) : null,
formattedOriginal: purchaseOption.formatted_original_price || null,
originalCents: purchaseOption.original_price_in_cents ? parseInt(purchaseOption.original_price_in_cents, 10) : null,
discountPercent: purchaseOption.discount_pct || 0
};
}
wgh_gameDataStore[appid][dataType] = extractedData;
});
}
function wgh_renderResults() {
if (!wgh_resultsDiv) return;
wgh_resultsDiv.innerHTML = '';
const fragment = document.createDocumentFragment();
const sortedAppIds = Object.keys(wgh_gameDataStore);
sortedAppIds.sort((idA, idB) => {
const a = wgh_gameDataStore[idA]?.myData;
const b = wgh_gameDataStore[idB]?.myData;
return wgh_compareItems(a, b, wgh_currentSort.field, wgh_currentSort.direction);
});
sortedAppIds.forEach(appid => {
const game = wgh_gameDataStore[appid];
if (game && game.myData) {
fragment.appendChild(wgh_createGameCard(appid, game));
}
});
wgh_resultsDiv.appendChild(fragment);
wgh_applyGiftFilter();
}
function wgh_createGameCard(appid, game) {
const myData = game.myData;
const friendData = game.friendData;
const card = document.createElement('div');
card.className = 'wghGameCard';
card.dataset.appid = appid;
const reviewClass = wgh_getReviewClass(myData.reviewPercent, myData.reviewCount);
const releaseDateStr = myData.releaseDateTimestamp ? new Date(myData.releaseDateTimestamp * 1000).toLocaleDateString('ru-RU') : 'Неизвестно';
let friendPriceStr = '';
let priceDiffStr = '';
let priceDiffClass = '';
card.dataset.giftablePrice = 'unknown';
card.dataset.canGiftApi = myData.canGift ? 'true' : 'false';
if (wgh_giftModeActive && friendData?.priceData && myData?.priceData && wgh_exchangeRates) {
const friendCents = friendData.priceData.finalCents;
const myCents = myData.priceData.finalCents;
if (friendCents !== null && myCents !== null) {
const friendPriceInMyCurrency = wgh_convertCurrency(friendCents / 100, wgh_currentFriendCountryCode, wgh_currentUserCountryCode);
if (friendPriceInMyCurrency !== null) {
const myPrice = myCents / 100;
const diff = friendPriceInMyCurrency - myPrice;
const diffPercent = myPrice > 0 ? diff / myPrice : (diff > 0 ? Infinity : -Infinity);
friendPriceStr = `Цена друга: ${friendPriceInMyCurrency.toLocaleString('ru-RU', { style: 'currency', currency: wgh_currentUserISOCurrencyCode, minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
if (Math.abs(diffPercent) <= WGH_GIFT_PRICE_DIFF_THRESHOLD) {
priceDiffClass = 'wghPriceDiffGood';
card.dataset.giftablePrice = 'true';
} else {
priceDiffClass = 'wghPriceDiffBad';
card.dataset.giftablePrice = 'false';
}
priceDiffStr = `Разница: ${diff > 0 ? '+' : ''}${diff.toLocaleString('ru-RU', { style: 'currency', currency: wgh_currentUserISOCurrencyCode, minimumFractionDigits: 0, maximumFractionDigits: 2 })} (${diffPercent === Infinity || diffPercent === -Infinity ? '∞' : (diffPercent * 100).toFixed(0)}%)`;
} else {
friendPriceStr = 'Цена друга: Ошибка конв.';
card.dataset.giftablePrice = 'false';
}
} else {
friendPriceStr = 'Цена друга: N/A';
card.dataset.giftablePrice = 'false';
}
} else if (wgh_giftModeActive) {
friendPriceStr = 'Цена друга: ...';
card.dataset.giftablePrice = 'false';
}
card.innerHTML = ` <a href="https://store.steampowered.com/app/${appid}" target="_blank" class="wghCardLink"> <div class="wghCardImageWrapper"> <img src="${myData.imageUrl}" alt="${myData.name}" loading="lazy" onerror="this.onerror=null;this.src='https://via.placeholder.com/292x136?text=No+Image';"> ${myData.priceData?.discountPercent > 0 ? `<div class="wghCardDiscountBadge">-${myData.priceData.discountPercent}%</div>` : ''} </div> <div class="wghCardContent"> <div class="wghCardTitle" title="${myData.name}">${myData.name}</div> <div class="wghCardPrice"> ${myData.priceData?.formattedOriginal ? `<span class="wghOriginalPrice">${myData.priceData.formattedOriginal}</span>` : ''} <span class="wghCurrentPrice">${myData.priceData?.formattedFinal || 'N/A'}</span> </div> <div class="wghCardReviews ${reviewClass}" title="${myData.reviewCount} отзывов"> ${myData.reviewDesc} (${myData.reviewPercent}%) </div> <div class="wghCardReleaseDate">Дата выхода: ${releaseDateStr}</div> ${myData.canGift ? '' : '<div class="wghCannotGift">Нельзя подарить</div>'} ${friendPriceStr ? `<div class="wghFriendPrice">${friendPriceStr}</div>` : ''} ${priceDiffStr ? `<div class="wghPriceDiff ${priceDiffClass}">${priceDiffStr}</div>` : ''} </div> </a> `;
return card;
}
function wgh_getReviewClass(percent, count) {
if (count === 0) return 'wghReviewNone';
if (percent >= 70) return 'wghReviewPositive';
if (percent >= 40) return 'wghReviewMixed';
return 'wghReviewNegative';
}
function wgh_createSortButtons() {
if (!wgh_sortButtonsContainer) return;
wgh_sortButtonsContainer.innerHTML = '';
const createBtn = (field, text) => {
const btn = document.createElement('button');
btn.className = 'wghBtn sortBtn';
btn.dataset.sort = field;
btn.textContent = text;
btn.onclick = () => wgh_handleSort(field);
wgh_sortButtonsContainer.appendChild(btn);
return btn;
};
createBtn('price', 'Цена');
createBtn('discountPercent', '% Скидки');
createBtn('name', 'Название');
createBtn('releaseDateTimestamp', 'Дата выхода');
createBtn('reviewPercent', '% Отзывов');
}
function wgh_handleSort(field) {
const defaultDirections = {
price: 'asc',
discountPercent: 'desc',
name: 'asc',
releaseDateTimestamp: 'desc',
reviewPercent: 'desc'
};
let newDirection;
if (wgh_currentSort.field === field) {
newDirection = wgh_currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
newDirection = defaultDirections[field] || 'asc';
}
wgh_currentSort.field = field;
wgh_currentSort.direction = newDirection;
wgh_applySort(field, newDirection);
wgh_renderResults();
wgh_updateSortButtonsState();
}
function wgh_applySort(field, direction) {
wgh_currentSort = {
field,
direction
};
}
function wgh_compareItems(a, b, field, direction) {
if (!a && !b) return 0;
if (!a) return direction === 'asc' ? 1 : -1;
if (!b) return direction === 'asc' ? -1 : 1;
const dirMultiplier = direction === 'asc' ? 1 : -1;
let valA, valB;
switch (field) {
case 'price':
valA = a.priceData?.finalCents ?? (direction === 'asc' ? Infinity : -Infinity);
valB = b.priceData?.finalCents ?? (direction === 'asc' ? Infinity : -Infinity);
break;
case 'discountPercent':
valA = a.priceData?.discountPercent ?? -1;
valB = b.priceData?.discountPercent ?? -1;
break;
case 'name':
valA = a.name?.toLowerCase() || '';
valB = b.name?.toLowerCase() || '';
return valA.localeCompare(valB) * dirMultiplier;
case 'releaseDateTimestamp':
valA = a.releaseDateTimestamp ?? (direction === 'asc' ? Infinity : 0);
valB = b.releaseDateTimestamp ?? (direction === 'asc' ? Infinity : 0);
break;
case 'reviewPercent':
valA = a.reviewPercent ?? -1;
valB = b.reviewPercent ?? -1;
break;
default:
return 0;
}
let comparisonResult = 0;
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 && field !== 'price') {
const priceA = a.priceData?.finalCents ?? Infinity;
const priceB = b.priceData?.finalCents ?? Infinity;
if (priceA < priceB) return -1;
if (priceA > priceB) return 1;
}
if (comparisonResult === 0 && field !== 'name') {
return (a.name?.toLowerCase() || '').localeCompare(b.name?.toLowerCase() || '');
}
return comparisonResult;
}
function wgh_updateSortButtonsState() {
if (!wgh_sortButtonsContainer) return;
const buttons = wgh_sortButtonsContainer.querySelectorAll('.sortBtn');
buttons.forEach(btn => {
const btnField = btn.dataset.sort;
const baseText = btn.textContent.replace(/ [▲▼]$/, '');
if (btnField === wgh_currentSort.field) {
const arrow = wgh_currentSort.direction === 'asc' ? ' ▲' : ' ▼';
btn.classList.add('active');
btn.textContent = baseText + arrow;
} else {
btn.classList.remove('active');
btn.textContent = baseText;
}
});
}
function wgh_createGiftAccordion() {
if (!wgh_giftModeContainer) return;
wgh_giftModeContainer.innerHTML = '';
const accordionContent = document.createElement('div');
accordionContent.id = 'wghGiftAccordionContent';
accordionContent.style.cssText = ` display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 10px; border: 1px solid #3a4f6a; border-radius: 4px; background-color: rgba(42, 71, 94, 0.2); `;
const myRegionDiv = document.createElement('div');
myRegionDiv.innerHTML = `Ваш регион: <strong id="wghMyRegionDisplay">${wgh_currentUserCountryCode || '??'} (${wgh_currentUserISOCurrencyCode || '???'})</strong>`;
myRegionDiv.style.marginRight = '15px';
accordionContent.appendChild(myRegionDiv);
wgh_myRegionDisplay = myRegionDiv.querySelector('strong');
const friendRegionLabel = document.createElement('label');
friendRegionLabel.textContent = 'Регион друга: ';
friendRegionLabel.style.marginRight = '5px';
accordionContent.appendChild(friendRegionLabel);
wgh_friendRegionSelect = document.createElement('select');
wgh_friendRegionSelect.id = 'wghFriendRegionSelect';
wgh_friendRegionSelect.className = 'wghSelect';
wgh_friendRegionSelect.innerHTML = '<option value="">-- Выберите --</option>';
Object.entries(WGH_COUNTRY_CURRENCY_MAP).sort(([, a], [, b]) => a.name.localeCompare(b.name)).forEach(([code, data]) => {
if (code !== wgh_currentUserCountryCode) {
const option = document.createElement('option');
option.value = code;
option.textContent = `${data.name} (${code})`;
wgh_friendRegionSelect.appendChild(option);
}
});
accordionContent.appendChild(wgh_friendRegionSelect);
wgh_fetchFriendPricesBtn = document.createElement('button');
wgh_fetchFriendPricesBtn.id = 'wghFetchFriendPricesBtn';
wgh_fetchFriendPricesBtn.className = 'wghBtn wghPrimaryBtn';
wgh_fetchFriendPricesBtn.textContent = 'Узнать цены';
wgh_fetchFriendPricesBtn.onclick = wgh_fetchFriendData;
accordionContent.appendChild(wgh_fetchFriendPricesBtn);
const giftFilterDiv = document.createElement('div');
giftFilterDiv.style.marginLeft = 'auto';
giftFilterDiv.innerHTML = ` <label title="Показать только игры с разницей цен +/- ${WGH_GIFT_PRICE_DIFF_THRESHOLD * 100}% и возможностью покупки в подарок"> <input type="checkbox" id="wghGiftableFilterCheckbox"> Можно подарить </label> `;
wgh_giftableFilterCheckbox = giftFilterDiv.querySelector('input');
wgh_giftableFilterCheckbox.onchange = wgh_handleGiftableFilterChange;
accordionContent.appendChild(giftFilterDiv);
wgh_giftModeContainer.appendChild(accordionContent);
}
function wgh_toggleGiftMode() {
wgh_giftModeActive = !wgh_giftModeActive;
wgh_giftModeContainer.style.display = wgh_giftModeActive ? 'block' : 'none';
if (wgh_giftIconBtn) wgh_giftIconBtn.classList.toggle('active', wgh_giftModeActive);
if (!wgh_giftModeActive) {
wgh_hideGiftMode(true);
}
wgh_renderResults();
}
function wgh_hideGiftMode(resetSelection = false) {
wgh_giftModeActive = false;
wgh_currentFriendCountryCode = null;
wgh_showGiftableOnly = false;
if (wgh_giftModeContainer) wgh_giftModeContainer.style.display = 'none';
if (wgh_giftIconBtn) wgh_giftIconBtn.classList.remove('active');
if (resetSelection && wgh_friendRegionSelect) wgh_friendRegionSelect.value = '';
if (wgh_giftableFilterCheckbox) wgh_giftableFilterCheckbox.checked = false;
wgh_hideProgressBar(wgh_giftProgressBar);
Object.values(wgh_gameDataStore).forEach(game => game.friendData = null);
wgh_renderResults();
}
async function wgh_fetchFriendData() {
wgh_currentFriendCountryCode = wgh_friendRegionSelect.value;
if (!wgh_currentFriendCountryCode) {
wgh_updateStatus('Выберите регион друга.');
return;
}
if (wgh_allAppIds.length === 0) {
wgh_updateStatus('Сначала соберите данные для своего списка желаемого.');
return;
}
wgh_updateStatus(`Запрос цен для региона ${wgh_currentFriendCountryCode}... (0/${Math.ceil(wgh_allAppIds.length / WGH_BATCH_SIZE)})`, true);
wgh_showProgressBar(wgh_giftProgressBar, 0);
Object.values(wgh_gameDataStore).forEach(game => game.friendData = null);
wgh_exchangeRates = null;
try {
const friendCurrencyInfo = WGH_COUNTRY_CURRENCY_MAP[wgh_currentFriendCountryCode];
const myCurrencyInfo = WGH_COUNTRY_CURRENCY_MAP[wgh_currentUserCountryCode];
if (friendCurrencyInfo && myCurrencyInfo && friendCurrencyInfo.code !== myCurrencyInfo.code) {
wgh_updateStatus(`Получение курса валют...`, true);
try {
wgh_exchangeRates = await wgh_fetchExchangeRates(friendCurrencyInfo.iso);
} catch (rateError) {
wgh_updateStatus(`Ошибка получения курса валют: ${rateError.message}. Сравнение цен будет неточным.`);
console.error("[WGH] Ошибка курса валют:", rateError);
await new Promise(res => setTimeout(res, 2000));
wgh_exchangeRates = {};
}
} else {
wgh_exchangeRates = {};
}
const totalBatches = Math.ceil(wgh_allAppIds.length / WGH_BATCH_SIZE);
let processedBatches = 0;
wgh_updateStatus(`Запрос цен для региона ${wgh_currentFriendCountryCode}... (0/${totalBatches})`, true);
for (let i = 0; i < wgh_allAppIds.length; i += WGH_BATCH_SIZE) {
const batch = wgh_allAppIds.slice(i, i + WGH_BATCH_SIZE);
const batchData = await wgh_fetchBatchGameData(batch, wgh_currentFriendCountryCode);
wgh_processBatchData(batchData, 'friendData');
processedBatches++;
const progress = (processedBatches / totalBatches) * 100;
wgh_updateProgressBar(wgh_giftProgressBar, progress);
wgh_updateStatus(`Запрос цен для региона ${wgh_currentFriendCountryCode}... (${processedBatches}/${totalBatches})`, true);
await new Promise(res => setTimeout(res, 200));
}
wgh_updateStatus(`Цены для региона ${wgh_currentFriendCountryCode} получены.`);
wgh_renderResults();
wgh_hideProgressBar(wgh_giftProgressBar);
} catch (error) {
wgh_updateStatus(`Ошибка при получении цен друга: ${error.message}`);
console.error('[WGH] Ошибка получения цен друга:', error);
wgh_hideProgressBar(wgh_giftProgressBar);
wgh_renderResults();
}
}
async function wgh_fetchExchangeRates(baseCurrencyIso) {
if (!baseCurrencyIso) throw new Error("Base currency ISO code not provided");
const apiUrl = `${WGH_CURRENCY_API_URL}${baseCurrencyIso.toLowerCase()}.json`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
responseType: 'json',
timeout: WGH_REQUEST_TIMEOUT_MS / 2,
onload: (response) => {
if (response.status >= 200 && response.status < 400 && response.response) {
const rates = response.response[baseCurrencyIso.toLowerCase()];
if (rates && typeof rates === 'object') {
resolve(rates);
} else {
reject(new Error(`Курсы для ${baseCurrencyIso} не найдены в ответе API`));
}
} else {
reject(new Error(`Ошибка API валют: статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка API валют')),
ontimeout: () => reject(new Error('Таймаут запроса API валют'))
});
});
}
function wgh_convertCurrency(amount, fromCountryCode, toCountryCode) {
if (fromCountryCode === toCountryCode) return amount;
if (!wgh_exchangeRates) {
console.warn('[WGH] Exchange rates not loaded for conversion');
return null;
}
const fromCurrencyInfo = WGH_COUNTRY_CURRENCY_MAP[fromCountryCode];
const toCurrencyInfo = WGH_COUNTRY_CURRENCY_MAP[toCountryCode];
if (!fromCurrencyInfo || !toCurrencyInfo) {
console.warn('[WGH] Unknown country code for conversion', fromCountryCode, toCountryCode);
return null;
}
if (fromCurrencyInfo.code === toCurrencyInfo.code) {
return amount;
}
const toCurrencyKey = toCurrencyInfo.iso?.toLowerCase();
if (!toCurrencyKey) {
console.warn('[WGH] Unknown target ISO currency code for conversion', toCountryCode);
return null;
}
if (wgh_exchangeRates[toCurrencyKey] === undefined || wgh_exchangeRates[toCurrencyKey] === null) {
console.warn(`[WGH] Exchange rate to ${toCurrencyKey} not available`);
return null;
}
const rate = wgh_exchangeRates[toCurrencyKey];
return parseFloat((amount * rate).toFixed(2));
}
function wgh_handleGiftableFilterChange() {
wgh_showGiftableOnly = wgh_giftableFilterCheckbox.checked;
wgh_applyGiftFilter();
}
function wgh_applyGiftFilter() {
if (!wgh_giftModeActive) {
document.querySelectorAll('.wghGameCard').forEach(card => {
card.style.display = 'flex';
});
return;
}
const cards = document.querySelectorAll('.wghGameCard');
cards.forEach(card => {
const isGiftableByPrice = card.dataset.giftablePrice === 'true';
const canGiftByApi = card.dataset.canGiftApi === 'true';
if (wgh_showGiftableOnly) {
if (isGiftableByPrice && canGiftByApi) {
card.style.display = 'flex';
} else {
card.style.display = 'none';
card.classList.add('wgh-filtered-out');
}
} else {
card.style.display = 'flex';
card.classList.remove('wgh-filtered-out');
}
});
}
function wgh_createProgressBar(id) {
const barContainer = document.createElement('div');
barContainer.id = id;
barContainer.style.cssText = ` width: 80%; max-width: 600px; height: 10px; background-color: #3a4f6a; border-radius: 5px; overflow: hidden; margin: 10px auto; display: none; flex-shrink: 0; `;
const barFill = document.createElement('div');
barFill.className = 'wghProgressBarFill';
barFill.style.cssText = ` width: 0%; height: 100%; background-color: #67c1f5; border-radius: 5px; transition: width 0.3s ease-out; `;
barContainer.appendChild(barFill);
return barContainer;
}
function wgh_showProgressBar(barElement, initialProgress = 0) {
if (!barElement) return;
const fill = barElement.querySelector('.wghProgressBarFill');
fill.style.width = `${initialProgress}%`;
barElement.style.display = 'block';
}
function wgh_updateProgressBar(barElement, progress) {
if (!barElement) return;
const fill = barElement.querySelector('.wghProgressBarFill');
fill.style.width = `${Math.min(100, Math.max(0, progress))}%`;
}
function wgh_hideProgressBar(barElement) {
if (!barElement) return;
barElement.style.display = 'none';
}
function wgh_addStyles() {
GM_addStyle(`
.wghBtn {
padding: 8px 14px;
font-size: 14px;
color: #c6d4df;
border: 1px solid #4b6f9c;
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: rgba(42, 71, 94, 0.8);
transition: background-color 0.2s, border-color 0.2s;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4);
}
.wghBtn:hover:not(:disabled) {
background-color: rgba(67, 103, 133, 0.9);
border-color: #67c1f5;
}
.wghBtn:disabled {
opacity: 0.6;
cursor: default;
}
.wghPrimaryBtn {
background-color: rgba(77, 136, 255, 0.8);
border-color: #4D88FF;
}
.wghPrimaryBtn:hover:not(:disabled) {
background-color: rgba(51, 102, 204, 0.9);
}
.wghBtn.sortBtn.active {
background-color: rgba(0, 123, 255, 0.8);
border-color: #007bff;
}
.wghBtn.sortBtn.active:hover {
background-color: rgba(0, 86, 179, 0.9);
}
#wghGiftModeBtn.active {
background-color: rgba(0, 123, 255, 0.8);
border-color: #007bff;
}
.wghSelect {
margin-left: 5px;
background-color: #333;
color: #eee;
border: 1px solid #555;
border-radius: 4px;
height: 36px;
padding: 0 8px;
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
outline: none;
max-width: 200px;
}
.wghSelect:focus {
border-color: #67c1f5;
}
.wghGameCard {
background-color: rgba(42, 46, 51, 0.85);
border-radius: 6px;
padding: 10px;
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.3);
color: #c6d4df;
font-size: 13px;
border: 1px solid #333941;
min-height: 360px;
}
.wghGameCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
border-color: #4b6f9c;
}
.wghCardLink {
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
height: 100%;
}
.wghCardImageWrapper {
position: relative;
width: 100%;
aspect-ratio: 292 / 136;
margin-bottom: 8px;
background-color: #111;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #333941;
}
.wghCardImageWrapper img {
display: block;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
.wghCardDiscountBadge {
position: absolute;
bottom: 5px;
right: 5px;
background-color: #e2004b;
color: white;
padding: 2px 6px;
font-size: 12px;
border-radius: 3px;
font-weight: 600;
z-index: 1;
}
.wghCardContent {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.wghCardTitle {
font-size: 14px;
font-weight: 500;
line-height: 1.3;
height: 2.6em;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 5px;
color: #e5e5e5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.wghCardPrice {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
min-height: 20px;
}
.wghCurrentPrice {
font-size: 16px;
font-weight: 600;
color: #a4d007;
}
.wghOriginalPrice {
font-size: 13px;
color: #8f98a0;
text-decoration: line-through;
}
.wghCardReviews {
font-size: 12px;
margin-bottom: 4px;
}
.wghReviewPositive {
color: #66c0f4;
}
.wghReviewMixed {
color: #a38b51;
}
.wghReviewNegative {
color: #c44c2c;
}
.wghReviewNone {
color: #8f98a0;
}
.wghCardReleaseDate {
font-size: 11px;
color: #8f98a0;
margin-bottom: 8px;
}
.wghCannotGift {
font-size: 11px;
color: #ff8080;
font-style: italic;
margin-bottom: 5px;
margin-top: auto;
padding-top: 5px;
}
.wghFriendPrice {
font-size: 12px;
color: #b0e0e6;
margin-top: auto;
padding-top: 5px;
}
.wghPriceDiff {
font-size: 12px;
font-weight: bold;
margin-top: 2px;
}
.wghPriceDiffGood {
color: #77dd77;
}
.wghPriceDiffBad {
color: #ff6961;
}
@keyframes wghSpin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.wghSpinner {
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
width: 1em;
height: 1em;
animation: wghSpin 1s linear infinite;
display: inline-block;
vertical-align: middle;
margin-left: 8px;
}
#wghGiftAccordionContent label {
display: flex;
align-items: center;
font-size: 14px;
color: #c6d4df;
cursor: pointer;
}
#wghGiftAccordionContent input[type="checkbox"] {
margin-right: 5px;
width: 16px;
height: 16px;
accent-color: #67c1f5;
cursor: pointer;
}
.wghGameCard.wgh-filtered-out {
display: none !important;
}
@media (max-width: 1600px) {
#wghResults {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}
@media (max-width: 1300px) {
#wghResults {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
}
@media (max-width: 900px) {
#wghResults {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
@media (max-width: 700px) {
#wghResults {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
#wghHeader {
flex-direction: column;
align-items: stretch;
}
#wghSortButtons {
justify-content: space-around;
margin-left: 0;
margin-top: 5px;
width: 100%;
order: 3;
}
#wghGiftModeContainer {
margin-top: 5px;
}
#wghGiftAccordionContent {
flex-direction: column;
align-items: flex-start;
}
#wghGiftAccordionContent label {
margin-bottom: 5px;
}
#wghGiftAccordionContent #wghFriendRegionSelect {
width: 100%;
margin-bottom: 10px;
}
#wghGiftAccordionContent #wghFetchFriendPricesBtn {
width: 100%;
margin-bottom: 10px;
}
#wghGiftAccordionContent div[style*='margin-left: auto'] {
width: 100%;
margin-left: 0 !important;
text-align: center;
}
#wghHeader>.wghBtn:first-child {
order: 1;
width: 100%;
margin-bottom: 5px;
}
#wghGiftModeBtn {
order: 2;
align-self: flex-end;
margin-bottom: 5px;
}
#wghStatus {
order: 0;
text-align: center;
justify-content: center;
margin-bottom: 5px;
}
}
`);
}
function wgh_initialize() {
wgh_detectUserRegion();
wgh_addStyles();
wgh_addAnalyzeButton();
}
setTimeout(wgh_initialize, WGH_INITIAL_DELAY_MS);
})();
}
// Скрипт для проверки возможности отправки подарка со страницы игры друзьям в других странах | https://store.steampowered.com/app/*
if (scriptsConfig.pageGiftHelper && unsafeWindow.location.pathname.includes('/app/')) {
(function() {
'use strict';
const PGH_API_URL = "https://api.steampowered.com/IStoreBrowseService/GetItems/v1";
const PGH_CURRENCY_API_URL = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/';
const PGH_REQUEST_TIMEOUT_MS = 15000;
const PGH_GIFT_PRICE_DIFF_THRESHOLD = 0.10;
const PGH_COUNTRY_CURRENCY_MAP = {
'US': {
name: 'U.S. Dollar',
code: 1,
iso: 'usd'
},
'EU': {
name: 'Euro',
code: 3,
iso: 'eur'
},
'AR': {
name: 'LATAM - U.S. Dollar',
code: 1,
iso: 'usd'
},
'AU': {
name: 'Australian Dollar',
code: 21,
iso: 'aud'
},
'BR': {
name: 'Brazilian Real',
code: 7,
iso: 'brl'
},
'GB': {
name: 'British Pound',
code: 2,
iso: 'gbp'
},
'CA': {
name: 'Canadian Dollar',
code: 20,
iso: 'cad'
},
'CL': {
name: 'Chilean Peso',
code: 25,
iso: 'clp'
},
'CN': {
name: 'Chinese Yuan',
code: 23,
iso: 'cny'
},
'AZ': {
name: 'CIS - U.S. Dollar',
code: 1,
iso: 'usd'
},
'CO': {
name: 'Colombian Peso',
code: 27,
iso: 'cop'
},
'CR': {
name: 'Costa Rican Colon',
code: 40,
iso: 'crc'
},
'HK': {
name: 'Hong Kong Dollar',
code: 29,
iso: 'hkd'
},
'IN': {
name: 'Indian Rupee',
code: 24,
iso: 'inr'
},
'ID': {
name: 'Indonesian Rupiah',
code: 10,
iso: 'idr'
},
'IL': {
name: 'Israeli New Shekel',
code: 35,
iso: 'ils'
},
'JP': {
name: 'Japanese Yen',
code: 8,
iso: 'jpy'
},
'KZ': {
name: 'Kazakhstani Tenge',
code: 37,
iso: 'kzt'
},
'KW': {
name: 'Kuwaiti Dinar',
code: 38,
iso: 'kwd'
},
'MY': {
name: 'Malaysian Ringgit',
code: 11,
iso: 'myr'
},
'MX': {
name: 'Mexican Peso',
code: 19,
iso: 'mxn'
},
'NZ': {
name: 'New Zealand Dollar',
code: 22,
iso: 'nzd'
},
'NO': {
name: 'Norwegian Krone',
code: 9,
iso: 'nok'
},
'PE': {
name: 'Peruvian Sol',
code: 26,
iso: 'pen'
},
'PH': {
name: 'Philippine Peso',
code: 12,
iso: 'php'
},
'PL': {
name: 'Polish Zloty',
code: 6,
iso: 'pln'
},
'QA': {
name: 'Qatari Riyal',
code: 39,
iso: 'qar'
},
'RU': {
name: 'Russian Ruble',
code: 5,
iso: 'rub'
},
'SA': {
name: 'Saudi Riyal',
code: 31,
iso: 'sar'
},
'SG': {
name: 'Singapore Dollar',
code: 13,
iso: 'sgd'
},
'ZA': {
name: 'South African Rand',
code: 28,
iso: 'zar'
},
'PK': {
name: 'South Asia - USD',
code: 1,
iso: 'usd'
},
'KR': {
name: 'South Korean Won',
code: 16,
iso: 'krw'
},
'CH': {
name: 'Swiss Franc',
code: 4,
iso: 'chf'
},
'TW': {
name: 'Taiwan Dollar',
code: 30,
iso: 'twd'
},
'TH': {
name: 'Thai Baht',
code: 14,
iso: 'thb'
},
'TR': {
name: 'MENA - U.S. Dollar',
code: 1,
iso: 'usd'
},
'AE': {
name: 'U.A.E. Dirham',
code: 32,
iso: 'aed'
},
'UA': {
name: 'Ukrainian Hryvnia',
code: 18,
iso: 'uah'
},
'UY': {
name: 'Uruguayan Peso',
code: 41,
iso: 'uyu'
},
'VN': {
name: 'Vietnamese Dong',
code: 15,
iso: 'vnd'
}
};
const PGH_ISO_TO_COUNTRY = Object.fromEntries(Object.entries(PGH_COUNTRY_CURRENCY_MAP).map(([country, data]) => [data.iso, country]));
const PGH_ISO_TO_CODE = Object.fromEntries(Object.entries(PGH_COUNTRY_CURRENCY_MAP).map(([_, data]) => [data.iso, data.code]));
let pgh_modal = null;
let pgh_resultDiv = null;
let pgh_fetchBtn = null;
let pgh_regionSelect = null;
let pgh_currentAppId = null;
let pgh_currentUserPrice = null;
let pgh_currentUserCurrencyISO = null;
let pgh_exchangeRates = null;
function pgh_addStyles() {
GM_addStyle(`
.pgh_button {
margin-left: 3px;
}
#pghModal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #1b2838;
color: #c6d4df;
padding: 20px;
border-radius: 5px;
border: 1px solid #67c1f5;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.7);
z-index: 10001;
display: none;
width: 400px;
font-family: "Motiva Sans", Sans-serif, Arial;
}
#pghModal h3 {
margin-top: 0;
margin-bottom: 15px;
color: #67c1f5;
text-align: center;
font-weight: 500;
font-size: 16px;
}
#pghModal label {
display: block;
margin-bottom: 5px;
font-size: 14px;
}
#pghRegionSelect {
width: 100%;
padding: 8px 10px;
margin-bottom: 15px;
background-color: #2a3f5a;
border: 1px solid #567d9c;
color: #ebebeb;
border-radius: 3px;
font-size: 14px;
cursor: pointer;
outline: none;
}
#pghRegionSelect:focus {
border-color: #67c1f5;
background-color: #314b6a;
}
#pghRegionSelect option {
background-color: #1b2838;
color: #c6d4df;
}
#pghFetchBtn {
display: block;
width: 100%;
padding: 10px;
background-color: #67c1f5;
color: #1b2838;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 15px;
font-weight: bold;
transition: background-color 0.2s;
margin-bottom: 15px;
}
#pghFetchBtn:hover:not(:disabled) {
background-color: #8ad3f7;
}
#pghFetchBtn:disabled {
background-color: #556772;
cursor: default;
}
#pghResult {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #3a4f6a;
font-size: 13px;
line-height: 1.5;
}
.pgh_status_loading {
text-align: center;
color: #8f98a0;
}
.pgh_status_error {
color: #ff6961;
font-weight: bold;
}
.pgh_result_block {
padding: 10px;
border-radius: 3px;
margin-top: 10px;
text-align: center;
font-weight: bold;
font-size: 15px;
}
.pgh_result_giftable {
background-color: rgba(119, 221, 119, 0.2);
border: 1px solid #77dd77;
color: #dff0d8;
}
.pgh_result_not_giftable {
background-color: rgba(255, 105, 97, 0.2);
border: 1px solid #ff6961;
color: #f2dede;
}
#pghCloseBtn {
position: absolute;
top: 8px;
right: 10px;
font-size: 24px;
color: #8f98a0;
background: none;
border: none;
cursor: pointer;
line-height: 1;
padding: 0 5px;
}
#pghCloseBtn:hover {
color: #ffffff;
}
`);
}
function pgh_parsePrice(priceStr) {
if (typeof priceStr !== 'string' && typeof priceStr !== 'number') return null;
const cleaned = String(priceStr).replace(/[^0-9.,]/g, '').replace(',', '.');
const price = parseFloat(cleaned);
return isNaN(price) ? null : price;
}
function pgh_getCurrentGameInfo() {
const appIdMatch = unsafeWindow.location.pathname.match(/\/app\/(\d+)/);
pgh_currentAppId = appIdMatch ? appIdMatch[1] : null;
const priceMeta = document.querySelector('meta[itemprop="price"]');
const currencyMeta = document.querySelector('meta[itemprop="priceCurrency"]');
pgh_currentUserPrice = priceMeta ? pgh_parsePrice(priceMeta.content) : null;
pgh_currentUserCurrencyISO = currencyMeta ? currencyMeta.content.toLowerCase() : null;
if (pgh_currentUserPrice === null || pgh_currentUserPrice === 0) {
const purchaseBlock = document.querySelector('.game_purchase_action .price[data-price-final], .discount_block .discount_final_price[data-price-final]');
if (purchaseBlock) {
pgh_currentUserPrice = pgh_parsePrice(purchaseBlock.dataset.priceFinal) / 100;
} else {
const simplePrice = document.querySelector('.game_purchase_action .price:not([data-price-final])');
if(simplePrice) {
pgh_currentUserPrice = pgh_parsePrice(simplePrice.textContent.trim());
}
}
}
if (!pgh_currentUserCurrencyISO) {
console.warn("[PGH] Не удалось определить валюту пользователя со страницы. Используем RUB по умолчанию.");
pgh_currentUserCurrencyISO = 'rub';
}
return pgh_currentAppId && pgh_currentUserPrice !== null && pgh_currentUserCurrencyISO;
}
function pgh_showModal() {
if (!pgh_getCurrentGameInfo()) {
alert("Не удалось получить информацию об игре и цене на этой странице.");
return;
}
if (!pgh_modal) {
pgh_modal = document.createElement('div');
pgh_modal.id = 'pghModal';
pgh_modal.innerHTML = `
<button id="pghCloseBtn" title="Закрыть">×</button>
<h3>Проверить возможность подарка</h3>
<label for="pghRegionSelect">Регион друга:</label>
<select id="pghRegionSelect">
<option value="">-- Выберите регион друга --</option>
${Object.entries(PGH_COUNTRY_CURRENCY_MAP)
.sort(([, a], [, b]) => a.name.localeCompare(b.name))
.map(([code, data]) => `<option value="${code}">${data.name} (${code})</option>`)
.join('')}
</select>
<button id="pghFetchBtn" disabled>Узнать</button>
<div id="pghResult"></div>
`;
document.body.appendChild(pgh_modal);
pgh_resultDiv = document.getElementById('pghResult');
pgh_fetchBtn = document.getElementById('pghFetchBtn');
pgh_regionSelect = document.getElementById('pghRegionSelect');
document.getElementById('pghCloseBtn').addEventListener('click', pgh_hideModal);
pgh_fetchBtn.addEventListener('click', pgh_fetchAndComparePrices);
pgh_regionSelect.addEventListener('change', () => {
pgh_fetchBtn.disabled = !pgh_regionSelect.value;
});
pgh_modal._outsideClickListener = (event) => {
if (!pgh_modal.contains(event.target) && pgh_modal.style.display === 'block') {
pgh_hideModal();
}
};
document.addEventListener('click', pgh_modal._outsideClickListener, true);
pgh_modal._escListener = (event) => {
if (event.key === 'Escape' && pgh_modal.style.display === 'block') {
pgh_hideModal();
}
};
document.addEventListener('keydown', pgh_modal._escListener);
}
pgh_regionSelect.value = "";
pgh_fetchBtn.disabled = true;
pgh_resultDiv.innerHTML = "";
pgh_modal.style.display = 'block';
}
function pgh_hideModal() {
if (pgh_modal) {
pgh_modal.style.display = 'none';
if (pgh_modal._outsideClickListener) {
document.removeEventListener('click', pgh_modal._outsideClickListener, true);
}
if (pgh_modal._escListener) {
document.removeEventListener('keydown', pgh_modal._escListener);
}
}
}
async function pgh_fetchFriendPrice(appId, friendCountryCode) {
const friendCurrencyInfo = PGH_COUNTRY_CURRENCY_MAP[friendCountryCode];
if (!friendCurrencyInfo) {
throw new Error("Неизвестный код страны друга.");
}
const inputJson = {
ids: [{ appid: parseInt(appId) }],
context: {
language: "russian",
country_code: friendCountryCode,
steam_realm: 1
},
data_request: {
include_basic_info: true,
include_all_purchase_options: true
}
};
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: `${PGH_API_URL}?input_json=${encodeURIComponent(JSON.stringify(inputJson))}`,
timeout: PGH_REQUEST_TIMEOUT_MS,
responseType: 'json',
onload: function(response) {
if (response.status >= 200 && response.status < 400 && response.response) {
const itemData = response.response.response?.store_items?.[0];
if (itemData?.success === 1 && itemData?.best_purchase_option) {
const priceData = itemData.best_purchase_option;
const friendPriceCents = priceData.final_price_in_cents ? parseInt(priceData.final_price_in_cents, 10) : null;
const canGift = priceData.user_can_purchase_as_gift || false;
if (friendPriceCents !== null) {
resolve({
price: friendPriceCents / 100,
currencyISO: friendCurrencyInfo.iso,
formatted: priceData.formatted_final_price || 'N/A',
canGift: canGift
});
} else {
resolve({
price: 0,
currencyISO: friendCurrencyInfo.iso,
formatted: priceData.formatted_final_price || 'Бесплатно',
canGift: canGift
});
}
} else if (itemData?.success === 1 && !itemData?.best_purchase_option){
reject(new Error(`Игра недоступна для покупки в регионе ${friendCountryCode}.`));
}
else {
reject(new Error("Не удалось получить данные о цене в регионе друга. Возможно, игра не найдена или недоступна."));
}
} else {
reject(new Error(`Ошибка API Steam: Статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка при запросе цены друга')),
ontimeout: () => reject(new Error('Таймаут при запросе цены друга'))
});
});
}
async function pgh_fetchExchangeRates(baseCurrencyIso) {
if (pgh_exchangeRates && pgh_exchangeRates[baseCurrencyIso]) {
return pgh_exchangeRates[baseCurrencyIso];
}
const apiUrl = `${PGH_CURRENCY_API_URL}${baseCurrencyIso.toLowerCase()}.json`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
responseType: 'json',
timeout: PGH_REQUEST_TIMEOUT_MS / 2,
onload: (response) => {
if (response.status >= 200 && response.status < 400 && response.response) {
const rates = response.response[baseCurrencyIso.toLowerCase()];
if (rates && typeof rates === 'object') {
if (!pgh_exchangeRates) pgh_exchangeRates = {};
pgh_exchangeRates[baseCurrencyIso] = rates;
resolve(rates);
} else {
reject(new Error(`Курсы для ${baseCurrencyIso} не найдены в ответе API`));
}
} else {
reject(new Error(`Ошибка API валют: статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка API валют')),
ontimeout: () => reject(new Error('Таймаут запроса API валют'))
});
});
}
async function pgh_fetchAndComparePrices() {
const selectedRegion = pgh_regionSelect.value;
if (!selectedRegion) return;
pgh_resultDiv.innerHTML = '<div class="pgh_status_loading">Загрузка данных...</div>';
pgh_fetchBtn.disabled = true;
pgh_regionSelect.disabled = true;
try {
const friendPriceData = await pgh_fetchFriendPrice(pgh_currentAppId, selectedRegion);
const friendPrice = friendPriceData.price;
const friendCurrencyISO = friendPriceData.currencyISO;
const canFriendReceiveGift = friendPriceData.canGift;
let friendPriceInUserCurrency = friendPrice;
let exchangeRateUsed = null;
if (pgh_currentUserCurrencyISO !== friendCurrencyISO) {
try {
const rates = await pgh_fetchExchangeRates(friendCurrencyISO);
const rate = rates[pgh_currentUserCurrencyISO];
if (rate === undefined || rate === null) {
throw new Error(`Нет курса для ${pgh_currentUserCurrencyISO.toUpperCase()}`);
}
friendPriceInUserCurrency = parseFloat((friendPrice * rate).toFixed(2));
exchangeRateUsed = rate;
} catch (rateError) {
console.error("[PGH] Ошибка получения/использования курса:", rateError);
pgh_resultDiv.innerHTML = `<div class="pgh_status_error">Ошибка конвертации валют: ${rateError.message}. Сравнение невозможно.</div>`;
return;
}
}
const userPrice = pgh_currentUserPrice;
const priceDifference = friendPriceInUserCurrency - userPrice;
const priceDifferencePercent = userPrice > 0 ? (priceDifference / userPrice) : (priceDifference > 0 ? Infinity : -Infinity);
const isWithinThreshold = Math.abs(priceDifferencePercent) <= PGH_GIFT_PRICE_DIFF_THRESHOLD;
const canGift = isWithinThreshold && canFriendReceiveGift;
let resultHTML = `
Ваша цена: ${userPrice.toLocaleString('ru-RU', { style: 'currency', currency: pgh_currentUserCurrencyISO.toUpperCase(), minimumFractionDigits: 0, maximumFractionDigits: 2 })}<br>
Цена друга: ${friendPrice.toLocaleString('ru-RU', { style: 'currency', currency: friendCurrencyISO.toUpperCase(), minimumFractionDigits: 0, maximumFractionDigits: 2 })}
`;
if (pgh_currentUserCurrencyISO !== friendCurrencyISO && exchangeRateUsed !== null) {
resultHTML += ` ≈ ${friendPriceInUserCurrency.toLocaleString('ru-RU', { style: 'currency', currency: pgh_currentUserCurrencyISO.toUpperCase(), minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
resultHTML += ` <small>(курс ≈ ${exchangeRateUsed.toFixed(4)})</small>`;
}
resultHTML += `<br>`;
if (userPrice > 0) {
resultHTML += `Разница: ${priceDifference > 0 ? '+' : ''}${priceDifference.toLocaleString('ru-RU', { style: 'currency', currency: pgh_currentUserCurrencyISO.toUpperCase(), minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
resultHTML += ` (${priceDifferencePercent === Infinity || priceDifferencePercent === -Infinity ? 'N/A' : (priceDifferencePercent * 100).toFixed(0)}%)`;
} else {
resultHTML += `Разница: N/A (ваша цена 0)`;
}
const resultClass = canGift ? 'pgh_result_giftable' : 'pgh_result_not_giftable';
const resultText = canGift ? '~Можно подарить~' : '~Нельзя подарить~';
resultHTML += `<div class="pgh_result_block ${resultClass}">${resultText}</div>`;
if (!canFriendReceiveGift && isWithinThreshold) {
resultHTML += `<small>Примечание: Steam не разрешает покупку этой игры в подарок для данного региона, несмотря на подходящую разницу цен.</small>`;
} else if (!isWithinThreshold) {
resultHTML += `<small>Примечание: Разница цен (${(priceDifferencePercent * 100).toFixed(0)}%) превышает допустимый порог Steam (${(PGH_GIFT_PRICE_DIFF_THRESHOLD * 100)}%).</small>`;
}
pgh_resultDiv.innerHTML = resultHTML;
} catch (error) {
console.error("[PGH] Ошибка при получении/сравнении цен:", error);
pgh_resultDiv.innerHTML = `<div class="pgh_status_error">Ошибка: ${error.message}</div>`;
} finally {
pgh_fetchBtn.disabled = !pgh_regionSelect.value;
pgh_regionSelect.disabled = false;
}
}
function pgh_addGiftButton() {
const targetArea = document.querySelector('.game_area_purchase_section .game_area_purchase_game_wrapper .game_purchase_action, #add_to_cart > .btn_addtocart > span');
const referenceButtonContainer = targetArea?.closest('.game_purchase_action') || document.getElementById('add_to_cart');
if (!referenceButtonContainer) {
console.warn('[PGH] Не найден контейнер для кнопки подарка.');
const smButton = document.querySelector('.salesMaster_button');
if (smButton) {
if (smButton.parentElement.querySelector('.pgh_button')) return;
const giftButton = pgh_createButtonElement();
smButton.insertAdjacentElement('afterend', giftButton);
console.log('[PGH] Кнопка подарка добавлена после SalesMaster.');
} else {
console.warn('[PGH] SalesMaster кнопка тоже не найдена.');
}
return;
}
if (referenceButtonContainer.parentElement.querySelector('.pgh_button')) return;
const giftButton = pgh_createButtonElement();
referenceButtonContainer.parentNode.insertBefore(giftButton, referenceButtonContainer);
console.log('[PGH] Кнопка подарка добавлена.');
}
function pgh_createButtonElement() {
const giftButtonContainer = document.createElement('div');
giftButtonContainer.className = 'pgh_button queue_control_button';
giftButtonContainer.style.marginLeft = '3px';
const giftButton = document.createElement('div');
giftButton.className = 'btnv6_blue_hoverfade btn_medium';
giftButton.style.cssText = 'height: 32px; padding: 0 0; font-size: 18px; display: flex; align-items: center; justify-content: center;';
giftButton.title = 'Проверить возможность подарка другу';
giftButton.innerHTML = '<span>GIFT</span>';
giftButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
pgh_showModal();
});
giftButtonContainer.appendChild(giftButton);
return giftButtonContainer;
}
function pgh_initialize() {
pgh_addStyles();
setTimeout(pgh_addGiftButton, 700);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.addedNodes.length > 0) {
if (document.querySelector('.game_area_purchase_section .game_area_purchase_game_wrapper') && !document.querySelector('.pgh_button')) {
pgh_addGiftButton();
}
}
});
});
const mainPage = document.querySelector('#game_area_purchase');
if (mainPage) {
observer.observe(mainPage, { childList: true, subtree: true });
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', pgh_initialize);
} else {
pgh_initialize();
}
})();
}
// Скрипт для страницы игры (salesMaster; %; агрегатор цен из разных магазинов) | https://store.steampowered.com/app/*
if (scriptsConfig.salesMaster && unsafeWindow.location.pathname.includes('/app/')) {
(function() {
'use strict';
// --- Конфигурация SalesMaster (SM) ---
const SM_STORAGE_PREFIX = 'salesMaster_v1_';
const SM_EXCLUSION_STORAGE_KEY = SM_STORAGE_PREFIX + 'exclusions';
const SM_FILTER_STORAGE_KEY = SM_STORAGE_PREFIX + 'filters';
const SM_SORT_STORAGE_KEY = SM_STORAGE_PREFIX + 'sort';
const SM_FILTER_DEBOUNCE_MS = 500;
const SM_REQUEST_TIMEOUT_MS = 15000; // 15 секунд на запрос к магазину
// --- Глобальные переменные SM ---
let sm_currentResults = [];
let sm_stores = {};
let sm_activeRequests = 0;
let sm_currentSort = GM_getValue(SM_SORT_STORAGE_KEY, {
field: 'price',
direction: 'asc'
});
let sm_exclusionKeywords = GM_getValue(SM_EXCLUSION_STORAGE_KEY, []);
let sm_currentFilters = GM_getValue(SM_FILTER_STORAGE_KEY, {
priceMin: '',
priceMax: '',
discountPercentMin: '',
discountPercentMax: '',
discountAmountMin: '',
discountAmountMax: '',
hasDiscount: false,
stores: {}
});
let sm_filterDebounceTimeout;
// --- Переменные для конвертации валют ---
const SM_CURRENCY_MODE_STORAGE_KEY = SM_STORAGE_PREFIX + 'currencyMode';
let sm_currentCurrencyMode = GM_getValue(SM_CURRENCY_MODE_STORAGE_KEY, 'RUB');
let sm_exchangeRates = {};
// --- DOM Элементы SM ---
let sm_modal, sm_closeBtn, sm_searchBtn;
let sm_resultsContainer, sm_resultsDiv, sm_statusDiv;
let sm_filtersPanel, sm_exclusionTagsDiv, sm_exclusionTagsListDiv, sm_excludeInput, sm_addExcludeBtn;
let sm_sortButtonsContainer;
let sm_filterStoreCheckboxesContainer;
// --- Вспомогательные функции SM ---
function sm_parsePrice(priceStr) {
if (!priceStr) return null;
const cleaned = String(priceStr).replace(/[^0-9.,]/g, '').replace(',', '.');
const price = parseFloat(cleaned);
return isNaN(price) ? null : price;
}
function sm_parsePercent(percentStr) {
if (!percentStr) return null;
const cleaned = String(percentStr).replace(/[^\d.]/g, '');
const percent = parseFloat(cleaned);
return isNaN(percent) ? null : percent;
}
function sm_calculateMissingValues(item) {
const price = item.currentPrice;
let original = item.originalPrice;
let percent = item.discountPercent;
let amount = item.discountAmount;
if (price === null) return item;
// 1. Есть цена и процент -> считаем оригинал и сумму скидки
if (price !== null && percent !== null && original === null) {
if (percent > 0 && percent < 100) {
original = price / (1 - percent / 100);
} else {
original = price;
}
}
// 2. Есть цена и оригинал -> считаем процент и сумму скидки
if (price !== null && original !== null && percent === null && original > price) {
percent = ((original - price) / original) * 100;
} else if (price !== null && original !== null && percent === null && original <= price) {
percent = 0;
}
// 3. Есть цена и сумма скидки -> считаем оригинал и процент
if (price !== null && amount !== null && original === null) {
original = price + amount;
}
if (price !== null && amount !== null && percent === null && original !== null && original > 0) {
percent = (amount / original) * 100;
}
// 4. Всегда считаем сумму скидки, если есть цена и оригинал
if (price !== null && original !== null && amount === null && original > price) {
amount = original - price;
} else if (price !== null && original !== null && amount === null && original <= price) {
amount = 0;
}
item.originalPrice = original !== null ? parseFloat(original.toFixed(2)) : null;
item.discountPercent = percent !== null ? parseFloat(percent.toFixed(1)) : null;
item.discountAmount = amount !== null ? parseFloat(amount.toFixed(2)) : null;
if (item.discountPercent !== null && item.discountPercent <= 0) {
item.discountPercent = 0;
item.discountAmount = 0;
if (item.originalPrice === null && item.currentPrice !== null) {
item.originalPrice = item.currentPrice;
}
}
if (item.originalPrice === null && item.currentPrice !== null) {
item.originalPrice = item.currentPrice;
item.discountPercent = 0;
item.discountAmount = 0;
}
return item;
}
function sm_getSteamGameName() {
const appNameElement = document.querySelector('#appHubAppName');
return appNameElement ? appNameElement.textContent.trim() : '';
}
function sm_logError(storeName, message, error = null) {
console.error(`[SalesMaster][${storeName}] ${message}`, error || '');
}
function sm_debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// --- Функция для получения курсов валют ---
async function sm_fetchExchangeRates(baseCurrency) {
const lowerBase = baseCurrency.toLowerCase();
if (sm_exchangeRates[lowerBase] && Object.keys(sm_exchangeRates[lowerBase]).length > 0) {
return sm_exchangeRates[lowerBase];
}
const apiUrl = `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${lowerBase}.json`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
responseType: 'json',
timeout: SM_REQUEST_TIMEOUT_MS / 2,
onload: (response) => {
if (response.status >= 200 && response.status < 400 && response.response) {
const rates = response.response[lowerBase];
if (rates && typeof rates === 'object') {
sm_exchangeRates[lowerBase] = rates;
resolve(rates);
} else {
reject(new Error(`Не найдены курсы для ${baseCurrency} в ответе API`));
}
} else {
reject(new Error(`Ошибка API валют: статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка API валют')),
ontimeout: () => reject(new Error('Таймаут запроса API валют'))
});
});
}
// --- Создание UI SalesMaster ---
function sm_updateCurrencyToggleButton() {
const toggleBtn = document.getElementById('smCurrencyToggleBtn');
if (!toggleBtn) return;
if (sm_currentCurrencyMode === 'USD') {
toggleBtn.textContent = 'RUB';
toggleBtn.title = 'Переключиться на рубли';
} else {
toggleBtn.textContent = 'USD';
toggleBtn.title = 'Переключиться на доллары США';
}
}
function sm_showUsdSwitchConfirmation() {
const hideWarning = GM_getValue('salesMaster_hideUsdWarning', false);
if (hideWarning) {
sm_switchToUsdMode();
return;
}
const dialog = document.createElement('div');
dialog.id = 'smUsdConfirmDialog';
Object.assign(dialog.style, {
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
backgroundColor: '#1f2c3a', color: '#c6d4df', padding: '25px', borderRadius: '5px',
boxShadow: '0 0 20px rgba(0,0,0,0.7)', zIndex: '10005', textAlign: 'left',
border: '1px solid #FFB300', maxWidth: '450px'
});
dialog.innerHTML = `
<h4 style="margin-top:0; color:#FFB300;">Переключение в режим USD</h4>
<p style="margin-bottom:20px; line-height:1.6;">Цены из всех магазинов будут конвертированы и отображены в долларах США (USD). Это может быть полезно для сравнения цен в международном контексте.<br><br>Продолжить?</p>
<div style="margin-bottom: 20px;"><label><input type="checkbox" id="smDontShowAgainUsd" style="margin-right:8px;">Больше не показывать</label></div>
<div style="text-align:right;">
<button id="smConfirmYes" class="salesMasterBtn" style="background-color:#FFB300; color:#1b2838; margin-right:10px;">Да</button>
<button id="smConfirmNo" class="salesMasterBtn">Отмена</button>
</div>
`;
document.body.appendChild(dialog);
document.getElementById('smConfirmYes').onclick = () => {
if (document.getElementById('smDontShowAgainUsd').checked) {
GM_setValue('salesMaster_hideUsdWarning', true);
}
sm_switchToUsdMode();
dialog.remove();
};
document.getElementById('smConfirmNo').onclick = () => dialog.remove();
}
function sm_switchToUsdMode() {
sm_currentCurrencyMode = 'USD';
GM_setValue(SM_CURRENCY_MODE_STORAGE_KEY, 'USD');
sm_updateCurrencyToggleButton();
sm_applySort(sm_currentSort.field, sm_currentSort.direction);
sm_renderResults();
sm_updateFilterPlaceholders();
sm_applyFilters();
sm_updateSortButtonsState();
}
function sm_switchToRubMode() {
sm_currentCurrencyMode = 'RUB';
GM_setValue(SM_CURRENCY_MODE_STORAGE_KEY, 'RUB');
sm_updateCurrencyToggleButton();
sm_applySort(sm_currentSort.field, sm_currentSort.direction);
sm_renderResults();
sm_updateFilterPlaceholders();
sm_applyFilters();
sm_updateSortButtonsState();
}
function sm_handleCurrencyToggle() {
if (sm_currentCurrencyMode === 'RUB') {
sm_showUsdSwitchConfirmation();
} else {
sm_switchToRubMode();
}
}
function sm_createModal() {
const existingModal = document.querySelector('#salesMasterModal');
if (existingModal) existingModal.remove();
sm_modal = document.createElement('div');
sm_modal.id = 'salesMasterModal';
const container = document.createElement('div');
container.id = 'salesMasterContainer';
const header = document.createElement('div');
header.id = 'salesMasterHeader';
// --- Левая часть шапки ---
sm_searchBtn = document.createElement('button');
sm_searchBtn.textContent = 'Обновить %';
sm_searchBtn.id = 'salesMasterSearchGoBtn';
sm_searchBtn.className = 'salesMasterBtn';
sm_searchBtn.title = 'Запросить цены с магазинов';
sm_searchBtn.onclick = sm_triggerSearch;
header.appendChild(sm_searchBtn);
const headerStatusDiv = document.createElement('div');
headerStatusDiv.id = 'salesMasterHeaderStatus';
header.appendChild(headerStatusDiv);
// --- Разделитель ---
const spacer = document.createElement('div');
spacer.style.flexGrow = '1';
header.appendChild(spacer);
// --- Правая часть шапки ---
const rightControls = document.createElement('div');
rightControls.style.display = 'flex';
rightControls.style.alignItems = 'center';
rightControls.style.gap = '10px';
const insertTitleBtn = document.createElement('button');
insertTitleBtn.id = 'smInsertTitleBtn';
insertTitleBtn.className = 'salesMasterBtn smInsertTitleBtn';
insertTitleBtn.textContent = 'Подставить название >';
insertTitleBtn.title = 'Подставить название текущей игры в фильтр';
insertTitleBtn.onclick = () => {
const gameName = sm_getSteamGameName();
const filterInput = document.getElementById('smTitleFilterInput');
if (gameName && filterInput) {
filterInput.value = gameName;
sm_applyFilters();
filterInput.focus();
}
};
rightControls.appendChild(insertTitleBtn);
const titleFilterInput = document.createElement('input');
titleFilterInput.type = 'text';
titleFilterInput.id = 'smTitleFilterInput';
titleFilterInput.placeholder = 'Фильтр по названию (слова через ;)';
titleFilterInput.addEventListener('input', sm_debounce(sm_applyFilters, SM_FILTER_DEBOUNCE_MS));
rightControls.appendChild(titleFilterInput);
const currencyToggleBtn = document.createElement('button');
currencyToggleBtn.id = 'smCurrencyToggleBtn';
currencyToggleBtn.className = 'salesMasterBtn';
currencyToggleBtn.onclick = sm_handleCurrencyToggle;
rightControls.appendChild(currencyToggleBtn);
sm_sortButtonsContainer = document.createElement('div');
sm_sortButtonsContainer.id = 'salesMasterSortButtons';
rightControls.appendChild(sm_sortButtonsContainer);
const resetSortBtn = document.createElement('button');
resetSortBtn.id = 'salesMasterResetSortBtn';
resetSortBtn.className = 'salesMasterBtn';
resetSortBtn.title = 'Сбросить сортировку (По Цене)';
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>`;
resetSortBtn.onclick = () => sm_resetSort(true);
sm_sortButtonsContainer.appendChild(resetSortBtn);
sm_createSortButton('price', 'Цена');
sm_createSortButton('discountPercent', '% Скидки');
sm_createSortButton('discountAmount', 'Скидка');
sm_createSortButton('name', 'Название');
header.appendChild(rightControls);
container.appendChild(header);
sm_resultsContainer = document.createElement('div');
sm_resultsContainer.id = 'salesMasterResultsContainer';
sm_resultsContainer.style.paddingTop = '0';
sm_resultsDiv = document.createElement('div');
sm_resultsDiv.id = 'salesMasterResults';
sm_resultsContainer.appendChild(sm_resultsDiv);
container.appendChild(sm_resultsContainer);
sm_filtersPanel = document.createElement('div');
sm_filtersPanel.id = 'salesMasterFiltersPanel';
sm_filtersPanel.innerHTML = `
<div class="smFilterGroup"><h4>Цена, ${sm_getCurrencySymbol()} ${sm_createResetButtonHTML('price')}</h4><div class="smFilterRangeInputs"><input type="number" id="smFilterPriceMin" placeholder="от" min="0"><input type="number" id="smFilterPriceMax" placeholder="до" min="0"></div></div>
<div class="smFilterGroup"><h4>Скидка, % ${sm_createResetButtonHTML('discountPercent')}</h4><div class="smFilterRangeInputs"><input type="number" id="smFilterDiscountPercentMin" placeholder="от" min="0" max="100"><input type="number" id="smFilterDiscountPercentMax" placeholder="до" min="0" max="100"></div></div>
<div class="smFilterGroup"><h4>Скидка, ${sm_getCurrencySymbol()} ${sm_createResetButtonHTML('discountAmount')}</h4><div class="smFilterRangeInputs"><input type="number" id="smFilterDiscountAmountMin" placeholder="от" min="0"><input type="number" id="smFilterDiscountAmountMax" placeholder="до" min="0"></div></div>
<div class="smFilterGroup"><h4>Опции ${sm_createResetButtonHTML('options')}</h4><div class="smFilterCheckbox"><label><input type="checkbox" id="smFilterHasDiscount"> Только со скидкой</label></div></div>
<div class="smFilterGroup">
<h4>Магазины ${sm_createResetButtonHTML('stores')}</h4>
<div class="smStoreSelectAllControls">
<span class="smStoreSelectAllLink" id="smSelectAllStores">Отметить всё</span>
<span class="smStoreSelectSeparator">|</span>
<span class="smStoreSelectAllLink" id="smDeselectAllStores">Снять всё</span>
</div>
<div id="smFilterStoreCheckboxes"></div>
</div>
<button id="smResetAllFiltersBtn" class="salesMasterBtn">Сбросить все фильтры</button>
`;
sm_modal.appendChild(sm_filtersPanel);
sm_exclusionTagsDiv = document.createElement('div');
sm_exclusionTagsDiv.id = 'salesMasterExclusionTags';
const exclusionInputGroup = document.createElement('div');
exclusionInputGroup.className = 'smExclusionInputGroup';
sm_excludeInput = document.createElement('input');
sm_excludeInput.type = 'text';
sm_excludeInput.id = 'salesMasterExcludeInput';
sm_excludeInput.placeholder = 'Исключить слово';
sm_excludeInput.onkeydown = (e) => { if (e.key === 'Enter') sm_addExclusionKeyword(); };
sm_addExcludeBtn = document.createElement('button');
sm_addExcludeBtn.id = 'salesMasterAddExcludeBtn';
sm_addExcludeBtn.innerHTML = `<svg viewBox="0 0 20 20"><path fill="currentColor" 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>`;
sm_addExcludeBtn.onclick = sm_addExclusionKeyword;
exclusionInputGroup.appendChild(sm_excludeInput);
exclusionInputGroup.appendChild(sm_addExcludeBtn);
sm_exclusionTagsDiv.appendChild(exclusionInputGroup);
const exclusionActionsDiv = document.createElement('div');
exclusionActionsDiv.className = 'smExclusionActions';
const exportBtn = document.createElement('button');
exportBtn.id = 'smExportExclusionsBtn';
exportBtn.className = 'salesMasterBtn smExclusionActionBtn';
exportBtn.title = 'Экспорт списка исключений';
exportBtn.innerHTML = '←'
exportBtn.onclick = sm_exportExclusions;
exclusionActionsDiv.appendChild(exportBtn);
const importBtn = document.createElement('button');
importBtn.id = 'smImportExclusionsBtn';
importBtn.className = 'salesMasterBtn smExclusionActionBtn';
importBtn.title = 'Импорт списка исключений';
importBtn.innerHTML = '→';
importBtn.onclick = sm_showImportModal;
exclusionActionsDiv.appendChild(importBtn);
sm_exclusionTagsDiv.appendChild(exclusionActionsDiv);
sm_exclusionTagsListDiv = document.createElement('div');
sm_exclusionTagsListDiv.id = 'salesMasterExclusionTagsList';
sm_exclusionTagsDiv.appendChild(sm_exclusionTagsListDiv);
sm_modal.appendChild(sm_exclusionTagsDiv);
sm_closeBtn = document.createElement('button');
sm_closeBtn.id = 'salesMasterCloseBtn';
sm_closeBtn.innerHTML = '×';
sm_closeBtn.onclick = sm_hideModal;
sm_modal.appendChild(sm_closeBtn);
sm_modal.appendChild(container);
document.body.appendChild(sm_modal);
document.getElementById('smSelectAllStores')?.addEventListener('click', sm_selectAllStores);
document.getElementById('smDeselectAllStores')?.addEventListener('click', sm_deselectAllStores);
sm_setupFilterEventListeners();
sm_applyLoadedFiltersToUI();
sm_renderExclusionTags();
sm_renderStoreCheckboxes();
sm_updateSortButtonsState();
sm_positionSidePanels();
function handleEsc(event) {
if (event.key === 'Escape') {
const importModal = document.getElementById('smImportModal');
if (importModal) { importModal.remove(); }
else { sm_hideModal(); }
}
}
document.addEventListener('keydown', handleEsc);
sm_modal._escHandler = handleEsc;
}
function sm_exportExclusions() {
const keywordsString = sm_exclusionKeywords.join(',');
if (!keywordsString) {
alert('Список исключений пуст.');
return;
}
try {
navigator.clipboard.writeText(keywordsString).then(() => {
const exportBtn = document.getElementById('smExportExclusionsBtn');
if (exportBtn) {
const originalContent = exportBtn.innerHTML;
exportBtn.innerHTML = 'Скопировано!';
exportBtn.disabled = true;
setTimeout(() => {
exportBtn.innerHTML = originalContent;
exportBtn.disabled = false;
}, 1500);
}
}, (err) => {
console.error('[SalesMaster] Не удалось скопировать в буфер обмена:', err);
prompt('Не удалось скопировать автоматически. Скопируйте вручную:', keywordsString);
});
} catch (err) {
console.error('[SalesMaster] Ошибка доступа к буферу обмена:', err);
prompt('Не удалось скопировать автоматически. Скопируйте вручную:', keywordsString);
}
}
function sm_showImportModal() {
const existingModal = document.getElementById('smImportModal');
if (existingModal) existingModal.remove();
const importModal = document.createElement('div');
importModal.id = 'smImportModal';
importModal.innerHTML = `
<div class="smImportModalContent">
<h4>Импорт списка исключений</h4>
<p>Вставьте список слов, разделенных запятыми:</p>
<textarea id="smImportTextarea" rows="6"></textarea>
<div class="smImportModalActions">
<button id="smImportAcceptBtn" class="salesMasterBtn">Принять</button>
<button id="smImportCancelBtn" class="salesMasterBtn">Отмена</button>
</div>
</div>
`;
document.body.appendChild(importModal);
document.getElementById('smImportAcceptBtn').onclick = sm_handleImport;
document.getElementById('smImportCancelBtn').onclick = () => importModal.remove();
document.getElementById('smImportTextarea').focus();
}
function sm_handleImport() {
const textarea = document.getElementById('smImportTextarea');
const importModal = document.getElementById('smImportModal');
if (!textarea || !importModal) return;
const text = textarea.value.trim();
if (text) {
const importedKeywords = text.split(',')
.map(k => k.trim().toLowerCase())
.filter(k => k.length > 0);
sm_exclusionKeywords = [...new Set(importedKeywords)];
GM_setValue(SM_EXCLUSION_STORAGE_KEY, sm_exclusionKeywords);
sm_renderExclusionTags();
sm_applyFilters();
console.log('[SalesMaster] Список исключений импортирован.');
} else {
alert("Поле ввода пустое. Импорт не выполнен.");
}
importModal.remove();
}
function sm_highlightErrorStores() {
if (!sm_filterStoreCheckboxesContainer) return;
sm_storeModules.filter(store => store && store.id).forEach(store => {
const checkboxContainer = sm_filterStoreCheckboxesContainer.querySelector(`#smStoreFilter-${store.id}`)?.closest('.smFilterCheckbox');
if (checkboxContainer) {
const storeStatus = sm_stores[store.id]?.status;
if (storeStatus === 'error') {
checkboxContainer.classList.add('sm-store-error');
} else {
checkboxContainer.classList.remove('sm-store-error');
}
}
});
}
// --- Функции для управления выбором магазинов ---
function sm_selectAllStores() {
const storeCheckboxes = document.querySelectorAll('#smFilterStoreCheckboxes input[type="checkbox"]');
if (!storeCheckboxes || storeCheckboxes.length === 0) return;
let changed = false;
storeCheckboxes.forEach(cb => {
if (!cb.checked) {
cb.checked = true;
if (cb.dataset.storeId) {
sm_currentFilters.stores[cb.dataset.storeId] = true;
}
changed = true;
}
});
if (changed) {
GM_setValue(SM_FILTER_STORAGE_KEY, sm_currentFilters);
sm_applyFilters();
}
}
function sm_deselectAllStores() {
const storeCheckboxes = document.querySelectorAll('#smFilterStoreCheckboxes input[type="checkbox"]');
if (!storeCheckboxes || storeCheckboxes.length === 0) return;
let changed = false;
storeCheckboxes.forEach(cb => {
if (cb.checked) {
cb.checked = false;
if (cb.dataset.storeId) {
sm_currentFilters.stores[cb.dataset.storeId] = false;
}
changed = true;
}
});
if (changed) {
GM_setValue(SM_FILTER_STORAGE_KEY, sm_currentFilters);
sm_applyFilters();
}
}
function sm_positionSidePanels() {
requestAnimationFrame(() => {
const header = document.getElementById('salesMasterHeader');
const resultsContainer = document.getElementById('salesMasterResultsContainer');
if (!header || !resultsContainer || !sm_filtersPanel || !sm_exclusionTagsDiv) return;
const headerRect = header.getBoundingClientRect();
const headerHeight = header.offsetHeight;
const topOffset = headerRect.top + headerHeight + 15;
const bottomOffset = 20;
const availableHeight = `calc(100vh - ${topOffset}px - ${bottomOffset}px)`;
sm_filtersPanel.style.position = 'fixed';
sm_filtersPanel.style.left = `15px`;
sm_filtersPanel.style.top = `${topOffset}px`;
sm_filtersPanel.style.maxHeight = availableHeight;
sm_filtersPanel.style.visibility = 'visible';
sm_exclusionTagsDiv.style.position = 'fixed';
sm_exclusionTagsDiv.style.right = `15px`;
sm_exclusionTagsDiv.style.top = `${topOffset}px`;
sm_exclusionTagsDiv.style.maxHeight = availableHeight;
sm_exclusionTagsDiv.style.visibility = 'visible';
const filterPanelWidth = sm_filtersPanel.offsetWidth;
const exclusionPanelWidth = sm_exclusionTagsDiv.offsetWidth;
const contentSidePadding = 25;
header.style.paddingLeft = `${filterPanelWidth + contentSidePadding}px`;
header.style.paddingRight = `${exclusionPanelWidth + contentSidePadding}px`;
resultsContainer.style.paddingLeft = `${filterPanelWidth + contentSidePadding}px`;
resultsContainer.style.paddingRight = `${exclusionPanelWidth + contentSidePadding}px`;
resultsContainer.style.paddingTop = `0`;
resultsContainer.style.paddingBottom = `20px`;
resultsContainer.style.height = `calc(100% - ${headerHeight}px)`;
resultsContainer.style.overflowY = 'auto';
resultsContainer.style.scrollbarColor = '#4b6f9c #17202d';
resultsContainer.style.scrollbarWidth = 'thin';
});
}
function sm_createSortButton(field, text) {
const btn = document.createElement('button');
btn.className = 'salesMasterBtn sortBtn';
btn.dataset.sort = field;
btn.textContent = text;
btn.onclick = () => sm_handleSort(field);
sm_sortButtonsContainer.appendChild(btn);
}
function sm_createResetButtonHTML(filterKey) {
return `<button class="smFilterResetBtn" title="Сбросить фильтр" data-filter-key="${filterKey}"><svg viewBox="0 0 24 24"><path fill="currentColor" 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 sm_renderStoreCheckboxes() {
sm_filterStoreCheckboxesContainer = document.getElementById('smFilterStoreCheckboxes');
if (!sm_filterStoreCheckboxesContainer) return;
sm_filterStoreCheckboxesContainer.innerHTML = '';
sm_storeModules.filter(store => store && typeof store.id === 'string').forEach(store => {
const div = document.createElement('div');
div.className = 'smFilterCheckbox';
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `smStoreFilter-${store.id}`;
checkbox.dataset.storeId = store.id;
checkbox.checked = sm_currentFilters.stores[store.id] !== false;
checkbox.addEventListener('change', sm_handleStoreFilterChange);
label.appendChild(checkbox);
label.appendChild(document.createTextNode(` ${store.name}`));
div.appendChild(label);
sm_filterStoreCheckboxesContainer.appendChild(div);
});
}
function sm_handleStoreFilterChange(event) {
const storeId = event.target.dataset.storeId;
const isChecked = event.target.checked;
sm_currentFilters.stores[storeId] = isChecked;
GM_setValue(SM_FILTER_STORAGE_KEY, sm_currentFilters);
sm_applyFilters();
}
async function sm_showModal() {
if (!sm_modal) sm_createModal();
try {
sm_updateStatus('Загрузка курсов валют...', true);
await sm_fetchExchangeRates('rub');
sm_updateStatus('Нажмите "Обновить %" для поиска цен...');
} catch (e) {
sm_updateStatus('Ошибка загрузки курсов валют!', false);
console.error("[SalesMaster] Не удалось загрузить курсы валют при открытии модального окна:", e);
}
if (sm_currentResults.length > 0 || sm_resultsDiv.innerHTML !== '') {
sm_resultsDiv.innerHTML = '';
sm_currentResults = [];
}
const titleFilterInput = document.getElementById('smTitleFilterInput');
if (titleFilterInput) titleFilterInput.value = '';
document.body.style.overflow = 'hidden';
sm_modal.style.display = 'block';
sm_modal.scrollTop = 0;
sm_renderExclusionTags();
sm_applyLoadedFiltersToUI();
sm_updateSortButtonsState();
sm_renderStoreCheckboxes();
sm_positionSidePanels();
sm_updateCurrencyToggleButton();
sm_applyFilters();
}
function sm_hideModal() {
if (sm_modal) {
sm_modal.style.display = 'none';
if (sm_modal._escHandler) {
document.removeEventListener('keydown', sm_modal._escHandler);
delete sm_modal._escHandler;
}
}
document.body.style.overflow = '';
}
// --- Обновление статуса ---
function sm_updateStatus(message, isLoading = false) {
const headerStatusDiv = document.getElementById('salesMasterHeaderStatus');
if (headerStatusDiv) {
headerStatusDiv.innerHTML = message + (isLoading ? ' <span class="spinner"></span>' : '');
}
if (sm_searchBtn) {
if (isLoading) {
sm_searchBtn.disabled = true;
} else {
sm_searchBtn.disabled = false;
sm_searchBtn.textContent = 'Обновить %';
}
}
}
// --- Запуск поиска ---
async function sm_triggerSearch() {
const gameName = sm_getSteamGameName();
if (!gameName) {
sm_updateStatus('Не удалось определить название игры на странице Steam.');
return;
}
const titleFilterInput = document.getElementById('smTitleFilterInput');
if (titleFilterInput) titleFilterInput.value = '';
sm_currentResults = [];
sm_resultsDiv.innerHTML = '';
sm_stores = {};
sm_highlightErrorStores();
sm_updateStatus(`Поиск "${gameName}"...`, true);
sm_activeRequests = 0;
const promises = [];
let totalStoresToCheck = 0;
sm_storeModules.filter(m => m && typeof m.fetch === 'function').forEach(storeModule => {
if (sm_currentFilters.stores[storeModule.id] !== false) {
totalStoresToCheck++;
sm_activeRequests++;
sm_stores[storeModule.id] = {
name: storeModule.name,
status: 'pending',
error: null
};
promises.push(
storeModule.fetch(gameName)
.then(results => {
sm_stores[storeModule.id].status = 'success';
return results;
})
.catch(error => {
sm_stores[storeModule.id].status = 'error';
sm_stores[storeModule.id].error = error.message || 'Неизвестная ошибка';
sm_logError(storeModule.name, `Ошибка при запросе: ${sm_stores[storeModule.id].error}`, error);
return [];
})
.finally(() => {
sm_activeRequests--;
sm_updateLoadingProgress(totalStoresToCheck);
})
);
} else {
sm_stores[storeModule.id] = {
name: storeModule.name,
status: 'skipped',
error: null
};
}
});
if (promises.length === 0) {
sm_updateStatus('Нет активных магазинов для поиска.');
return;
}
const resultsArrays = await Promise.all(promises);
sm_currentResults = resultsArrays.flat();
sm_updateLoadingProgress(totalStoresToCheck);
if (sm_currentResults.length > 0) {
sm_applySort(sm_currentSort.field, sm_currentSort.direction);
sm_renderResults();
sm_updateFilterPlaceholders();
} else {
sm_applyFilters();
}
}
function sm_updateLoadingProgress(totalStores) {
const completedStores = Object.values(sm_stores).filter(s => s.status !== 'pending').length;
const skippedStores = Object.values(sm_stores).filter(s => s.status === 'skipped').length;
const successStores = Object.values(sm_stores).filter(s => s.status === 'success').length;
const errorStores = Object.values(sm_stores).filter(s => s.status === 'error');
const searchedCompletedCount = completedStores - skippedStores;
if (sm_activeRequests > 0) {
sm_updateStatus(`Загрузка... (${searchedCompletedCount}/${totalStores})`, true);
} else {
let statusMessage = '';
if (sm_currentResults.length > 0) {
statusMessage = `Найдено ${sm_currentResults.length} предложений. `;
} else {
const gameName = sm_getSteamGameName();
if (gameName) {
statusMessage = `Предложений не найдено. `;
} else {
statusMessage = `Введите запрос или обновите для поиска.`;
}
}
if (errorStores.length > 0) {
statusMessage += `Ошибки в магазинах: ${errorStores.map(s => s.name).join(', ')}.`;
}
if (sm_currentResults.length === 0 && errorStores.length === 0 && sm_activeRequests === 0 && sm_getSteamGameName()) {
statusMessage = `По запросу "${sm_getSteamGameName()}" ничего не найдено в выбранных магазинах.`;
}
sm_updateStatus(statusMessage.trim(), false);
sm_highlightErrorStores();
const anyFilterActive = (parseFloat(sm_currentFilters.priceMin) || 0) > 0 || (parseFloat(sm_currentFilters.priceMax) || Infinity) < Infinity ||
(parseFloat(sm_currentFilters.discountPercentMin) || 0) > 0 || (parseFloat(sm_currentFilters.discountPercentMax) || 100) < 100 ||
(parseFloat(sm_currentFilters.discountAmountMin) || 0) > 0 || (parseFloat(sm_currentFilters.discountAmountMax) || Infinity) < Infinity ||
sm_currentFilters.hasDiscount || sm_exclusionKeywords.length > 0 ||
Object.values(sm_currentFilters.stores).some(v => v === false) ||
(document.getElementById('smTitleFilterInput')?.value.trim() || '').length > 0;
const visibleItems = sm_resultsDiv.querySelectorAll('.salesMasterItem:not(.hidden-by-filter)').length;
if (visibleItems === 0 && sm_currentResults.length > 0 && anyFilterActive) {
const statusDivInHeader = document.getElementById('salesMasterHeaderStatus');
if (statusDivInHeader) {
let currentStatus = statusDivInHeader.textContent.replace(' Нет товаров, соответствующих фильтрам.', '');
statusDivInHeader.textContent = currentStatus.trim() + ' Нет товаров, соответствующих фильтрам.';
}
}
}
}
// --- Сортировка ---
function sm_handleSort(field) {
const defaultDirections = {
price: 'asc',
discountPercent: 'desc',
discountAmount: 'desc',
name: 'asc'
};
let newDirection;
if (sm_currentSort.field === field) {
newDirection = sm_currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
newDirection = defaultDirections[field] || 'asc';
}
sm_currentSort.field = field;
sm_currentSort.direction = newDirection;
GM_setValue(SM_SORT_STORAGE_KEY, sm_currentSort);
sm_applySort(field, newDirection);
sm_renderResults();
sm_updateSortButtonsState();
}
function sm_applySort(field, direction) {
const dirMultiplier = direction === 'asc' ? 1 : -1;
sm_currentResults.sort((a, b) => {
let valA, valB;
switch (field) {
case 'price':
valA = a.currentPrice ?? (direction === 'asc' ? Infinity : -Infinity);
valB = b.currentPrice ?? (direction === 'asc' ? Infinity : -Infinity);
break;
case 'discountPercent':
valA = a.discountPercent ?? -1;
valB = b.discountPercent ?? -1;
break;
case 'discountAmount':
const amountA = a.discountAmount;
const amountB = b.discountAmount;
if (amountA === null && amountB === null) valA = valB = 0;
else if (amountA === null) valA = direction === 'desc' ? -Infinity : Infinity;
else if (amountB === null) valB = direction === 'desc' ? -Infinity : Infinity;
else {
valA = amountA;
valB = amountB;
}
break;
case 'name':
valA = a.productName?.toLowerCase() || '';
valB = b.productName?.toLowerCase() || '';
return valA.localeCompare(valB) * dirMultiplier;
default:
return 0;
}
let comparisonResult = 0;
if (valA < valB) comparisonResult = -1;
else if (valA > valB) comparisonResult = 1;
comparisonResult *= dirMultiplier;
if (comparisonResult === 0 && field !== 'price') {
const priceA = a.currentPrice ?? Infinity;
const priceB = b.currentPrice ?? Infinity;
if (priceA < priceB) return -1;
if (priceA > priceB) return 1;
}
if (comparisonResult === 0 && field !== 'name') {
return (a.productName?.toLowerCase() || '').localeCompare(b.productName?.toLowerCase() || '');
}
return comparisonResult;
});
}
function sm_updateSortButtonsState() {
if (!sm_sortButtonsContainer) return;
const buttons = sm_sortButtonsContainer.querySelectorAll('.sortBtn');
buttons.forEach(btn => {
const btnField = btn.dataset.sort;
let baseText = '';
switch (btnField) {
case 'price':
baseText = 'Цена';
break;
case 'discountPercent':
baseText = '% Скидки';
break;
case 'discountAmount':
baseText = `Скидка ${sm_getCurrencySymbol()}`;
break;
case 'name':
baseText = 'Название';
break;
}
if (btnField === sm_currentSort.field) {
const arrow = sm_currentSort.direction === 'asc' ? ' ▲' : ' ▼';
btn.classList.add('active');
btn.textContent = baseText + arrow;
} else {
btn.classList.remove('active');
btn.textContent = baseText;
}
});
const resetBtn = sm_sortButtonsContainer.querySelector('#salesMasterResetSortBtn');
if (resetBtn) {
if (sm_currentSort.field === 'price' && sm_currentSort.direction === 'asc') {
resetBtn.classList.add('active');
} else {
resetBtn.classList.remove('active');
}
}
}
function sm_resetSort(render = true) {
sm_currentSort = {
field: 'price',
direction: 'asc'
};
GM_setValue(SM_SORT_STORAGE_KEY, sm_currentSort);
sm_updateSortButtonsState();
if (render) {
sm_applySort(sm_currentSort.field, sm_currentSort.direction);
sm_renderResults();
}
}
// --- Управление фильтрами ---
function sm_getFilterStorageKey(key) {
return `${SM_FILTER_STORAGE_KEY}_${key}`;
}
function sm_saveFilter(key, value) {
sm_currentFilters[key] = value;
GM_setValue(SM_FILTER_STORAGE_KEY, sm_currentFilters);
}
function sm_applyLoadedFiltersToUI() {
if (!sm_filtersPanel) return;
document.getElementById('smFilterPriceMin').value = sm_currentFilters.priceMin || '';
document.getElementById('smFilterPriceMax').value = sm_currentFilters.priceMax || '';
document.getElementById('smFilterDiscountPercentMin').value = sm_currentFilters.discountPercentMin || '';
document.getElementById('smFilterDiscountPercentMax').value = sm_currentFilters.discountPercentMax || '';
document.getElementById('smFilterDiscountAmountMin').value = sm_currentFilters.discountAmountMin || '';
document.getElementById('smFilterDiscountAmountMax').value = sm_currentFilters.discountAmountMax || '';
document.getElementById('smFilterHasDiscount').checked = sm_currentFilters.hasDiscount || false;
if (sm_filterStoreCheckboxesContainer) {
sm_filterStoreCheckboxesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => {
const storeId = cb.dataset.storeId;
cb.checked = sm_currentFilters.stores[storeId] !== false;
});
}
sm_updateFilterPlaceholders();
}
function sm_setupFilterEventListeners() {
if (!sm_filtersPanel) return;
const debouncedApply = sm_debounce(sm_applyFilters, SM_FILTER_DEBOUNCE_MS);
['smFilterPriceMin', 'smFilterPriceMax', 'smFilterDiscountPercentMin', 'smFilterDiscountPercentMax', 'smFilterDiscountAmountMin', 'smFilterDiscountAmountMax'].forEach(id => {
const input = document.getElementById(id);
const filterKey = id.replace('smFilter', '').charAt(0).toLowerCase() + id.replace('smFilter', '').slice(1);
if (input) {
input.addEventListener('input', (e) => {
sm_saveFilter(filterKey, e.target.value);
debouncedApply();
});
}
});
const hasDiscountCheckbox = document.getElementById('smFilterHasDiscount');
if (hasDiscountCheckbox) {
hasDiscountCheckbox.addEventListener('change', (e) => {
sm_saveFilter('hasDiscount', e.target.checked);
sm_applyFilters();
});
}
const resetAllBtn = document.getElementById('smResetAllFiltersBtn');
if (resetAllBtn) resetAllBtn.addEventListener('click', () => sm_resetAllFilters(true));
sm_filtersPanel.querySelectorAll('.smFilterResetBtn').forEach(btn => {
btn.addEventListener('click', (event) => sm_handleFilterReset(event));
});
}
function sm_handleFilterReset(event) {
const filterKey = event.currentTarget.dataset.filterKey;
sm_resetFilterByKey(filterKey, true);
}
function sm_resetFilterByKey(key, apply = true) {
const defaults = {
priceMin: '',
priceMax: '',
discountPercentMin: '',
discountPercentMax: '',
discountAmountMin: '',
discountAmountMax: '',
hasDiscount: false,
stores: {}
};
switch (key) {
case 'price':
sm_saveFilter('priceMin', defaults.priceMin);
if (document.getElementById('smFilterPriceMin')) document.getElementById('smFilterPriceMin').value = defaults.priceMin;
sm_saveFilter('priceMax', defaults.priceMax);
if (document.getElementById('smFilterPriceMax')) document.getElementById('smFilterPriceMax').value = defaults.priceMax;
break;
case 'discountPercent':
sm_saveFilter('discountPercentMin', defaults.discountPercentMin);
if (document.getElementById('smFilterDiscountPercentMin')) document.getElementById('smFilterDiscountPercentMin').value = defaults.discountPercentMin;
sm_saveFilter('discountPercentMax', defaults.discountPercentMax);
if (document.getElementById('smFilterDiscountPercentMax')) document.getElementById('smFilterDiscountPercentMax').value = defaults.discountPercentMax;
break;
case 'discountAmount':
sm_saveFilter('discountAmountMin', defaults.discountAmountMin);
if (document.getElementById('smFilterDiscountAmountMin')) document.getElementById('smFilterDiscountAmountMin').value = defaults.discountAmountMin;
sm_saveFilter('discountAmountMax', defaults.discountAmountMax);
if (document.getElementById('smFilterDiscountAmountMax')) document.getElementById('smFilterDiscountAmountMax').value = defaults.discountAmountMax;
break;
case 'options':
sm_saveFilter('hasDiscount', defaults.hasDiscount);
if (document.getElementById('smFilterHasDiscount')) document.getElementById('smFilterHasDiscount').checked = defaults.hasDiscount;
break;
case 'stores':
const storeCheckboxes = document.querySelectorAll('#smFilterStoreCheckboxes input[type="checkbox"]');
let updatedStores = {};
storeCheckboxes.forEach(cb => {
cb.checked = true;
updatedStores[cb.dataset.storeId] = true;
});
sm_currentFilters.stores = updatedStores;
GM_setValue(SM_FILTER_STORAGE_KEY, sm_currentFilters);
break;
}
if (apply) sm_applyFilters();
}
function sm_resetAllFilters(apply = true) {
const filterKeys = ['price', 'discountPercent', 'discountAmount', 'options', 'stores'];
filterKeys.forEach(key => sm_resetFilterByKey(key, false));
if (apply) sm_applyFilters();
}
function sm_getCurrencySymbol() {
return sm_currentCurrencyMode === 'USD' ? '$' : '₽';
}
function sm_updateFilterPlaceholders() {
if (!sm_filtersPanel) return;
const currencySymbol = sm_getCurrencySymbol();
const resultsToScan = sm_currentResults || [];
const priceHeader = sm_filtersPanel.querySelector('.smFilterGroup h4:first-child');
if (priceHeader) priceHeader.innerHTML = `Цена, ${currencySymbol} ${sm_createResetButtonHTML('price')}`;
const amountHeader = sm_filtersPanel.querySelector('.smFilterGroup:nth-child(3) h4');
if (amountHeader) amountHeader.innerHTML = `Скидка, ${currencySymbol} ${sm_createResetButtonHTML('discountAmount')}`;
sm_filtersPanel.querySelectorAll('.smFilterResetBtn').forEach(btn => {
btn.removeEventListener('click', sm_handleFilterReset);
btn.addEventListener('click', sm_handleFilterReset);
});
if (resultsToScan.length === 0) {
['smFilterPriceMin', 'smFilterPriceMax', 'smFilterDiscountPercentMin', 'smFilterDiscountPercentMax', 'smFilterDiscountAmountMin', 'smFilterDiscountAmountMax'].forEach(id => {
const el = document.getElementById(id);
if (el) el.placeholder = '-';
});
return;
}
let minPrice = Infinity, maxPrice = -Infinity;
let minDiscountPercent = 101, maxDiscountPercent = -1;
let minDiscountAmount = Infinity, maxDiscountAmount = -Infinity;
const rubToUsdRate = sm_exchangeRates?.rub?.usd || null;
const isUsdMode = sm_currentCurrencyMode === 'USD';
resultsToScan.forEach(item => {
let currentPrice = item.currentPrice;
let discountAmount = item.discountAmount;
if(isUsdMode && rubToUsdRate) {
if (currentPrice !== null) currentPrice *= rubToUsdRate;
if (discountAmount !== null) discountAmount *= rubToUsdRate;
}
if (currentPrice !== null) {
if (currentPrice < minPrice) minPrice = currentPrice;
if (currentPrice > maxPrice) maxPrice = currentPrice;
}
if (item.discountPercent !== null) {
if (item.discountPercent < minDiscountPercent) minDiscountPercent = item.discountPercent;
if (item.discountPercent > maxDiscountPercent) maxDiscountPercent = item.discountPercent;
}
if (discountAmount !== null) {
if (discountAmount < minDiscountAmount) minDiscountAmount = discountAmount;
if (discountAmount > maxDiscountAmount) maxDiscountAmount = discountAmount;
}
});
const setPlaceholder = (id, prefix, value, suffix = '', formatFn = Math.round) => {
const el = document.getElementById(id);
if (el) {
el.placeholder = (value === Infinity || value === -Infinity || value === 101 || value === -1) ? '-' : `${prefix} ${formatFn(value)}${suffix}`;
}
};
setPlaceholder('smFilterPriceMin', 'от', minPrice, '', Math.floor);
setPlaceholder('smFilterPriceMax', 'до', maxPrice, '', Math.ceil);
setPlaceholder('smFilterDiscountPercentMin', 'от', minDiscountPercent, '%', v => Math.max(0, Math.floor(v)));
setPlaceholder('smFilterDiscountPercentMax', 'до', maxDiscountPercent, '%', v => Math.min(100, Math.ceil(v)));
setPlaceholder('smFilterDiscountAmountMin', 'от', minDiscountAmount, '', Math.floor);
setPlaceholder('smFilterDiscountAmountMax', 'до', maxDiscountAmount, '', Math.ceil);
}
function sm_applyFilters() {
if (!sm_resultsDiv || !sm_currentResults) return;
// Получаем фильтр по названию из поля ввода
const titleFilterInput = document.getElementById('smTitleFilterInput');
const rawTitleFilterText = titleFilterInput ? titleFilterInput.value.trim() : '';
// Разбиваем на отдельные слова/фразы по ";" и приводим к нижнему регистру
const titleFilterTerms = rawTitleFilterText
.split(';')
.map(term => term.trim().toLowerCase())
.filter(term => term.length > 0);
// Получаем ключевые слова для исключения и фильтры
const keywords = sm_exclusionKeywords.map(k => k.toLowerCase());
const pMin = parseFloat(sm_currentFilters.priceMin) || 0;
const pMax = parseFloat(sm_currentFilters.priceMax) || Infinity;
const dpMin = parseFloat(sm_currentFilters.discountPercentMin) || 0;
const dpMax = parseFloat(sm_currentFilters.discountPercentMax) || 100;
const daMin = parseFloat(sm_currentFilters.discountAmountMin) || 0;
const daMax = parseFloat(sm_currentFilters.discountAmountMax) || Infinity;
const hasDiscountFilter = sm_currentFilters.hasDiscount || false;
const activeStoreFilters = sm_currentFilters.stores;
let visibleCount = 0;
const items = sm_resultsDiv.querySelectorAll('.salesMasterItem');
items.forEach(itemElement => {
const index = Array.from(sm_resultsDiv.children).indexOf(itemElement);
if (index < 0 || index >= sm_currentResults.length) {
itemElement.classList.add('hidden-by-filter');
return;
}
const itemData = sm_currentResults[index];
if (!itemData) {
itemElement.classList.add('hidden-by-filter');
return;
}
const titleElement = itemElement.querySelector('.sm-title');
const itemTitle = titleElement ? titleElement.textContent.trim().toLowerCase() : '';
let hideByTitleFilter = false;
if (titleFilterTerms.length > 0 && !titleFilterTerms.some(term => itemTitle.includes(term))) {
hideByTitleFilter = true;
}
let shouldHide = false;
// 1. Фильтр по магазину
if (activeStoreFilters[itemData.storeId] === false) {
shouldHide = true;
}
// 2. Фильтр по ключевым словам (исключения)
if (!shouldHide && keywords.length > 0) {
let textToSearch = itemTitle;
if (itemData.storeId === 'platimarket' && itemData.sellerName) {
textToSearch += ' ' + itemData.sellerName.toLowerCase();
}
if (keywords.some(keyword => textToSearch.includes(keyword))) {
shouldHide = true;
}
}
// 3. Фильтр по цене
if (!shouldHide && itemData.currentPrice !== null) {
if (itemData.currentPrice < pMin || itemData.currentPrice > pMax) {
shouldHide = true;
}
} else if (!shouldHide && itemData.currentPrice === null && (pMin > 0 || pMax < Infinity)) {
if (!(pMin === 0 && pMax === Infinity)) {
shouldHide = true;
}
}
// 4. Фильтр по % скидки
if (!shouldHide) {
const discountP = itemData.discountPercent ?? 0;
if (discountP < dpMin || discountP > dpMax) {
shouldHide = true;
}
}
// 5. Фильтр по сумме скидки
if (!shouldHide) {
const discountA = itemData.discountAmount ?? 0;
if (discountA < daMin || discountA > daMax) {
shouldHide = true;
}
}
// 6. Фильтр "Только со скидкой"
if (!shouldHide && hasDiscountFilter) {
if (!itemData.discountPercent || itemData.discountPercent <= 0) {
shouldHide = true;
}
}
// Применяем класс скрытия, если сработал любой фильтр ИЛИ фильтр по названию
if (shouldHide || hideByTitleFilter) {
itemElement.classList.add('hidden-by-filter');
} else {
itemElement.classList.remove('hidden-by-filter');
visibleCount++;
}
});
// Обновляем статус в шапке
const totalLoadedCount = sm_currentResults.length;
const anyFilterActive = pMin > 0 || pMax < Infinity || dpMin > 0 || dpMax < 100 || daMin > 0 || daMax < Infinity || hasDiscountFilter || keywords.length > 0 || Object.values(activeStoreFilters).some(v => v === false) || titleFilterTerms.length > 0;
const errorStoresCount = Object.values(sm_stores).filter(s => s.status === 'error').length;
let statusMessage = '';
if (sm_activeRequests === 0) {
if (totalLoadedCount > 0) {
if (anyFilterActive) {
statusMessage = `Показано ${visibleCount} из ${totalLoadedCount} предложений. `;
} else {
statusMessage = `Найдено ${totalLoadedCount} предложений. `;
}
} else {
const gameName = sm_getSteamGameName();
if (gameName) {
statusMessage = `Предложений не найдено. `;
} else {
statusMessage = `Введите запрос или обновите для поиска.`;
}
}
if (errorStoresCount > 0) {
statusMessage += `(${errorStoresCount} маг. с ошибками).`;
}
sm_updateStatus(statusMessage.trim(), false);
} else {
}
if (visibleCount === 0 && totalLoadedCount > 0 && anyFilterActive && sm_activeRequests === 0) {
const statusDivInHeader = document.getElementById('salesMasterHeaderStatus');
if (statusDivInHeader) {
let currentStatus = statusDivInHeader.textContent.replace(' Нет товаров, соответствующих фильтрам.', '');
statusDivInHeader.textContent = currentStatus.trim() + ' Нет товаров, соответствующих фильтрам.';
}
}
}
// --- Фильтрация исключений ---
function sm_addExclusionKeyword() {
const keyword = sm_excludeInput.value.trim().toLowerCase();
if (keyword && !sm_exclusionKeywords.includes(keyword)) {
sm_exclusionKeywords.push(keyword);
GM_setValue(SM_EXCLUSION_STORAGE_KEY, sm_exclusionKeywords);
sm_excludeInput.value = '';
sm_renderExclusionTags();
sm_applyFilters();
}
}
function sm_removeExclusionKeyword(keywordToRemove) {
sm_exclusionKeywords = sm_exclusionKeywords.filter(k => k !== keywordToRemove);
GM_setValue(SM_EXCLUSION_STORAGE_KEY, sm_exclusionKeywords);
sm_renderExclusionTags();
sm_applyFilters();
}
function sm_renderExclusionTags() {
if (!sm_exclusionTagsListDiv) return;
sm_exclusionTagsListDiv.innerHTML = '';
sm_exclusionKeywords.forEach(keyword => {
const tag = document.createElement('span');
tag.className = 'smExclusionTag';
tag.textContent = keyword;
tag.title = `Удалить "${keyword}"`;
tag.onclick = () => sm_removeExclusionKeyword(keyword);
sm_exclusionTagsListDiv.appendChild(tag);
});
}
// --- Рендеринг результатов ---
function sm_renderResults() {
if (!sm_resultsDiv) return;
sm_resultsDiv.innerHTML = '';
if (sm_currentResults.length === 0 && sm_activeRequests === 0) {
sm_applyFilters();
return;
}
const fragment = document.createDocumentFragment();
const isUsdMode = sm_currentCurrencyMode === 'USD';
const rubToUsdRate = sm_exchangeRates['rub']?.usd || null;
if (isUsdMode && !rubToUsdRate) {
sm_updateStatus('Не удалось загрузить курс RUB/USD для конвертации.', false);
}
sm_currentResults.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.className = 'salesMasterItem';
itemDiv.dataset.store = item.storeId;
if (item.storeId === 'steam_current_page') {
itemDiv.classList.add('steam-page-offer');
}
const link = document.createElement('a');
link.href = item.productUrl || item.storeUrl || '#';
link.target = '_blank';
link.rel = 'noopener noreferrer nofollow';
const imageWrapper = document.createElement('div');
imageWrapper.className = 'sm-card-image-wrapper';
const img = document.createElement('img');
let imgSrc = item.imageUrl;
if (imgSrc && !imgSrc.startsWith('http') && !imgSrc.startsWith('//')) {
try {
const storeBaseUrl = new URL(item.storeUrl || sm_storeModules.find(s => s.id === item.storeId)?.baseUrl || unsafeWindow.location.origin);
imgSrc = new URL(imgSrc, storeBaseUrl.origin).href;
} catch (e) {
imgSrc = 'https://via.placeholder.com/150x80?text=No+Image';
}
} else if (!imgSrc) {
imgSrc = 'https://via.placeholder.com/150x80?text=No+Image';
}
img.src = imgSrc;
img.alt = item.productName || 'Изображение товара';
img.loading = 'lazy';
img.onerror = function() {
this.onerror = null; this.src = 'https://via.placeholder.com/150x80?text=Load+Error'; this.style.objectFit = 'contain';
};
imageWrapper.appendChild(img);
link.appendChild(imageWrapper);
const priceDiv = document.createElement('div');
priceDiv.className = 'sm-price-container';
const currentPriceSpan = document.createElement('span');
currentPriceSpan.className = 'sm-current-price';
if (isUsdMode) {
if (item.currentPrice !== null && rubToUsdRate) {
const usdPrice = item.currentPrice * rubToUsdRate;
currentPriceSpan.textContent = `$${usdPrice.toFixed(2)}`;
} else {
currentPriceSpan.textContent = item.currentPrice === null ? 'Нет цены' : 'Нет курса';
}
} else {
currentPriceSpan.textContent = item.currentPrice !== null ? `${item.currentPrice.toLocaleString('ru-RU')} ₽` : 'Нет цены';
}
priceDiv.appendChild(currentPriceSpan);
if (item.discountPercent && item.discountPercent > 0) {
const discountBadge = document.createElement('span');
discountBadge.className = 'sm-discount-badge';
discountBadge.textContent = `-${Math.round(item.discountPercent)}%`;
priceDiv.appendChild(discountBadge);
if (item.originalPrice !== null) {
const originalPriceSpan = document.createElement('span');
originalPriceSpan.className = 'sm-original-price';
if (isUsdMode && rubToUsdRate) {
const usdOriginalPrice = item.originalPrice * rubToUsdRate;
originalPriceSpan.textContent = `$${usdOriginalPrice.toFixed(2)}`;
} else {
originalPriceSpan.textContent = `${item.originalPrice.toLocaleString('ru-RU')} ₽`;
}
priceDiv.appendChild(originalPriceSpan);
}
}
link.appendChild(priceDiv);
const titleDiv = document.createElement('div');
titleDiv.className = 'sm-title';
titleDiv.textContent = item.productName || 'Без названия';
titleDiv.title = item.productName || 'Без названия';
link.appendChild(titleDiv);
const storeInfoContainer = document.createElement('div');
storeInfoContainer.className = 'sm-store-info-container';
const storeDiv = document.createElement('div');
storeDiv.className = 'sm-store-name';
storeDiv.textContent = item.storeName || 'Неизвестный магазин';
storeDiv.title = `Магазин: ${item.storeName}`;
storeInfoContainer.appendChild(storeDiv);
if (item.storeId === 'platimarket' && item.sellerId && item.sellerName) {
const sellerLink = document.createElement('a');
sellerLink.className = 'sm-seller-link';
sellerLink.textContent = `Продавец: ${item.sellerName}`;
sellerLink.title = `Перейти к продавцу: ${item.sellerName}`;
try {
const safeSellerName = encodeURIComponent(item.sellerName.replace(/[^a-zA-Z0-9_\-.~]/g, '-')).replace(/%2F/g, '/');
sellerLink.href = `https://plati.market/seller/${safeSellerName}/${item.sellerId}`;
sellerLink.target = '_blank';
sellerLink.rel = 'noopener noreferrer nofollow';
sellerLink.onclick = (e) => { e.stopPropagation(); };
storeInfoContainer.appendChild(sellerLink);
} catch (e) {
const sellerText = document.createElement('div'); sellerText.className = 'sm-seller-link no-link'; sellerText.textContent = `Продавец: ${item.sellerName}`; storeInfoContainer.appendChild(sellerText);
}
} else if (item.storeId === 'ggsel' && item.sellerId && item.sellerName) {
const sellerLink = document.createElement('a');
sellerLink.className = 'sm-seller-link';
sellerLink.textContent = `Продавец: ${item.sellerName}`;
sellerLink.title = `Перейти к продавцу: ${item.sellerName}`;
sellerLink.href = `https://ggsel.net/sellers/${item.sellerId}`;
sellerLink.target = '_blank';
sellerLink.rel = 'noopener noreferrer nofollow';
sellerLink.onclick = (e) => { e.stopPropagation(); };
storeInfoContainer.appendChild(sellerLink);
}
link.appendChild(storeInfoContainer);
const buyButtonDiv = document.createElement('div');
buyButtonDiv.className = 'sm-buyButton';
buyButtonDiv.textContent = 'Перейти';
link.appendChild(buyButtonDiv);
itemDiv.appendChild(link);
fragment.appendChild(itemDiv);
});
sm_resultsDiv.appendChild(fragment);
sm_applyFilters();
}
// --- Добавление кнопки SalesMaster ---
function sm_addSalesMasterButton() {
const actionsContainer = document.querySelector('#queueActionsCtn');
// Ищем кнопку Plati как ориентир
const referenceButton = actionsContainer?.querySelector('.plati_price_button, .vgt_price_button');
const ignoreButtonContainer = actionsContainer?.querySelector('#ignoreBtn');
if (!actionsContainer || (!referenceButton && !ignoreButtonContainer)) {
// Если нет ни Plati ни Ignore, попробуем вставить просто в actionsContainer
if (!actionsContainer) {
console.warn('SalesMaster Button: Could not find actions container.');
return;
}
console.warn('SalesMaster Button: Could not find reference button, appending to container.');
}
if (actionsContainer.querySelector('.salesMaster_button')) return;
const smContainer = document.createElement('div');
smContainer.className = 'salesMaster_button queue_control_button';
smContainer.style.marginLeft = '3px';
smContainer.innerHTML = `<div class="btnv6_blue_hoverfade btn_medium" style="height: 32px; padding: 0 5px; font-size: 18px; font-weight: bold;" title="Поиск скидок в других магазинах"><span>%</span></div>`;
smContainer.querySelector('div').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
sm_showModal();
});
// Логика вставки: после Plati, или после Ignore, или в конец actionsContainer
if (referenceButton) {
referenceButton.insertAdjacentElement('afterend', smContainer);
} else if (ignoreButtonContainer) {
ignoreButtonContainer.insertAdjacentElement('afterend', smContainer);
} else if (actionsContainer) {
actionsContainer.appendChild(smContainer);
}
}
// --- Стили SalesMaster ---
function sm_addStyles() {
GM_addStyle(`
#salesMasterModal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(20, 20, 25, 0.9);
backdrop-filter: blur(3px);
z-index: 9999;
display: none;
color: #c6d4df;
font-family: "Motiva Sans", Sans-serif, Arial;
}
#salesMasterModal * {
box-sizing: border-box;
}
#salesMasterContainer {
padding-top: 0;
height: 100%;
display: flex;
flex-direction: column;
}
#salesMasterCloseBtn {
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;
}
#salesMasterCloseBtn:hover {
color: #fff;
transform: scale(1.1);
}
/* --- Шапка --- */
#salesMasterHeader {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
position: relative;
z-index: 1001;
background-color: rgba(27, 40, 56, 0.95);
backdrop-filter: blur(5px);
padding: 10px 15px;
border-bottom: 1px solid #3a4f6a;
border-radius: 0;
margin-left: 0;
margin-right: 0;
transition: padding-left 0.2s ease-out, padding-right 0.2s ease-out;
flex-shrink: 0;
}
#salesMasterHeaderStatus {
text-align: left;
font-size: 14px;
color: #aaa;
padding: 0 10px 0 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 36px;
flex-shrink: 0;
}
#salesMasterHeaderStatus .spinner {
margin-left: 8px;
}
#smTitleFilterInput {
width: 250px;
height: 36px;
padding: 6px 12px;
font-size: 14px;
background-color: rgba(10, 10, 15, 0.7);
border: 1px solid #3a4f6a;
color: #c6d4df;
border-radius: 3px;
outline: none;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
margin-left: 5px;
flex-shrink: 0;
}
#smTitleFilterInput:focus {
border-color: #67c1f5;
background-color: rgba(0, 0, 0, 0.8);
}
#smTitleFilterInput::placeholder {
color: #777;
font-style: italic;
font-size: 13px;
}
.smInsertTitleBtn {
padding: 0 10px;
font-size: 12px;
}
#salesMasterSortButtons {
display: flex;
gap: 5px;
align-items: center;
margin-left: 10px;
}
/* --- Кнопки --- */
.salesMasterBtn {
padding: 0 12px;
font-size: 13px;
color: #c6d4df;
border: 1px solid #4b6f9c;
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: rgba(42, 71, 94, 0.8);
transition: background-color 0.2s, border-color 0.2s;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4);
}
.salesMasterBtn:hover:not(:disabled) {
background-color: rgba(67, 103, 133, 0.9);
border-color: #67c1f5;
}
.salesMasterBtn:disabled {
opacity: 0.6;
cursor: default;
background-color: rgba(42, 71, 94, 0.5);
border-color: #3a4f6a;
}
#salesMasterSearchGoBtn {
background-color: rgba(77, 136, 255, 0.8);
border-color: #4D88FF;
}
#salesMasterSearchGoBtn:hover:not(:disabled) {
background-color: rgba(51, 102, 204, 0.9);
}
.salesMasterBtn.sortBtn.active {
background-color: rgba(0, 123, 255, 0.8);
border-color: #007bff;
}
.salesMasterBtn.sortBtn.active:hover {
background-color: rgba(0, 86, 179, 0.9);
}
.sortBtn span {
margin-left: 5px;
font-size: 12px;
line-height: 1;
}
#salesMasterResetSortBtn {
background-color: rgba(119, 119, 119, 0.8);
border-color: #777;
padding: 0 8px;
}
#salesMasterResetSortBtn:hover {
background-color: rgba(136, 136, 136, 0.9);
}
#salesMasterResetSortBtn svg {
width: 14px;
height: 14px;
fill: currentColor;
}
#salesMasterResetSortBtn.active {
background-color: rgba(0, 123, 255, 0.8);
border-color: #007bff;
}
/* --- Боковые панели ("плавающие") --- */
#salesMasterFiltersPanel,
#salesMasterExclusionTags {
position: fixed;
top: 60px;
max-height: calc(100vh - 80px);
overflow-y: auto;
z-index: 1000;
padding: 15px;
scrollbar-width: thin;
scrollbar-color: #555 #2a2a30;
background-color: transparent;
backdrop-filter: none;
border-radius: 6px;
box-shadow: none;
border: none;
transition: top 0.2s ease-in-out, max-height 0.2s ease-in-out;
visibility: hidden;
}
#salesMasterFiltersPanel::before,
#salesMasterExclusionTags::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(23, 26, 33, 0.85);
backdrop-filter: blur(4px);
border-radius: 6px;
z-index: -1;
}
#salesMasterFiltersPanel::-webkit-scrollbar,
#salesMasterExclusionTags::-webkit-scrollbar {
width: 5px;
}
#salesMasterFiltersPanel::-webkit-scrollbar-track,
#salesMasterExclusionTags::-webkit-scrollbar-track {
background: rgba(42, 42, 48, 0.5);
border-radius: 3px;
}
#salesMasterFiltersPanel::-webkit-scrollbar-thumb,
#salesMasterExclusionTags::-webkit-scrollbar-thumb {
background-color: rgba(85, 85, 85, 0.7);
border-radius: 3px;
}
#salesMasterFiltersPanel {
left: 15px;
width: 240px;
}
#salesMasterExclusionTags {
right: 15px;
width: 260px;
}
.smFilterGroup {
margin-bottom: 20px;
}
.smFilterGroup h4 {
font-size: 15px;
color: #67c1f5;
margin-bottom: 10px;
padding-bottom: 5px;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 500;
border-bottom: 1px solid #3a4f6a;
}
.smFilterResetBtn {
font-size: 12px;
color: #8f98a0;
background: none;
border: none;
cursor: pointer;
padding: 0 3px;
line-height: 1;
}
.smFilterResetBtn:hover {
color: #c6d4df;
}
.smFilterResetBtn svg {
width: 14px;
height: 14px;
vertical-align: middle;
fill: currentColor;
}
.smFilterRangeInputs {
display: flex;
gap: 8px;
align-items: center;
}
.smFilterRangeInputs input[type="number"] {
width: calc(50% - 4px);
padding: 8px 10px;
font-size: 14px;
background-color: rgba(10, 10, 15, 0.7);
border: 1px solid #3a4f6a;
color: #c6d4df;
border-radius: 3px;
height: 34px;
text-align: center;
-moz-appearance: textfield;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
outline: none;
}
.smFilterRangeInputs input[type="number"]:focus {
border-color: #67c1f5;
background-color: rgba(0, 0, 0, 0.8);
}
.smFilterRangeInputs input[type="number"]::-webkit-outer-spin-button,
.smFilterRangeInputs input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.smFilterRangeInputs input[type="number"]::placeholder {
color: #777;
font-size: 12px;
text-align: center;
}
.smFilterCheckbox {
margin-bottom: 10px;
}
.smFilterCheckbox label {
display: flex;
align-items: center;
font-size: 14px;
cursor: pointer;
color: #c6d4df;
}
.smFilterCheckbox input[type="checkbox"] {
margin-right: 8px;
width: 18px;
height: 18px;
accent-color: #67c1f5;
cursor: pointer;
flex-shrink: 0;
}
.smFilterCheckbox.sm-store-error label {
background-color: rgba(139, 0, 0, 0.35);
border: 1px solid rgba(255, 100, 100, 0.3);
border-radius: 3px;
padding: 1px 4px;
margin: -1px -4px;
}
#smFilterStoreCheckboxes {
max-height: 315px;
padding-right: 5px;
overflow-y: auto;
}
#smResetAllFiltersBtn {
width: 100%;
margin-top: 15px;
padding: 10px 15px;
height: auto;
font-size: 14px;
background-color: rgba(108, 117, 125, 0.6);
border: 1px solid #5a6268;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4);
color: #c6d4df;
}
#smResetAllFiltersBtn:hover {
background-color: rgba(90, 98, 104, 0.8);
border-color: #8f98a0;
}
.smExclusionInputGroup {
display: flex;
align-items: stretch;
border: 1px solid #3a4f6a;
border-radius: 4px;
background-color: rgba(10, 10, 15, 0.7);
overflow: hidden;
height: 36px;
flex-shrink: 0;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
margin-bottom: 10px;
}
#salesMasterExcludeInput {
padding: 6px 12px;
font-size: 14px;
background-color: transparent;
border: none;
color: #c6d4df;
outline: none;
border-radius: 0;
flex-grow: 1;
width: auto;
height: auto;
}
#salesMasterExcludeInput:focus {
box-shadow: none;
}
#salesMasterAddExcludeBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
background-color: #4b6f9c;
border: none;
border-left: 1px solid #3a4f6a;
cursor: pointer;
border-radius: 0;
color: #c6d4df;
height: auto;
}
#salesMasterAddExcludeBtn:hover {
background-color: #67c1f5;
color: #fff;
}
#salesMasterAddExcludeBtn svg {
width: 26px;
height: 26px;
fill: currentColor;
padding-right: 14px;
}
#salesMasterExclusionTagsList {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: flex-start;
gap: 10px;
overflow-y: auto;
flex-grow: 1;
}
.smExclusionTag {
display: inline-block;
background-color: rgba(75, 111, 156, 0.7);
color: #c6d4df;
padding: 6px 12px;
border-radius: 15px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
border: 1px solid #4b6f9c;
white-space: nowrap;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
.smExclusionTag:hover {
background-color: rgba(220, 53, 69, 0.8);
border-color: rgba(255, 80, 90, 0.9);
color: #fff;
}
.smExclusionTag::after {
content: ' ×';
font-weight: bold;
margin-left: 4px;
font-size: 12px;
}
.smExclusionActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #3a4f6a;
}
.smExclusionActionBtn {
padding: 0 8px;
height: 30px;
width: 40px;
background-color: rgba(75, 111, 156, 0.7);
border-color: #4b6f9c;
font-size: 14px;
font-weight: bold;
line-height: 1;
}
#smImportModal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 10003;
display: flex;
align-items: center;
justify-content: center;
}
.smImportModalContent {
background-color: #1b2838;
padding: 25px;
border-radius: 5px;
border: 1px solid #67c1f5;
width: 90%;
max-width: 500px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
}
.smImportModalContent h4 {
margin-top: 0;
margin-bottom: 15px;
color: #67c1f5;
font-size: 16px;
text-align: center;
}
.smImportModalContent p {
margin-bottom: 10px;
font-size: 14px;
color: #c6d4df;
}
#smImportTextarea {
width: 100%;
padding: 10px;
font-size: 14px;
background-color: rgba(10, 10, 15, 0.7);
border: 1px solid #3a4f6a;
color: #c6d4df;
border-radius: 3px;
margin-bottom: 20px;
min-height: 100px;
resize: vertical;
outline: none;
}
#smImportTextarea:focus {
border-color: #67c1f5;
}
.smImportModalActions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.smImportModalActions .salesMasterBtn {
padding: 8px 20px;
height: auto;
font-size: 14px;
}
#smImportAcceptBtn {
background-color: rgba(77, 136, 255, 0.8);
border-color: #4D88FF;
}
#smImportAcceptBtn:hover {
background-color: rgba(51, 102, 204, 0.9);
}
#smImportCancelBtn {
background-color: rgba(108, 117, 125, 0.6);
border: 1px solid #5a6268;
}
#smImportCancelBtn:hover {
background-color: rgba(90, 98, 104, 0.8);
border-color: #8f98a0;
}
#salesMasterExclusionTagsList {
margin-top: 0;
}
/* --- Контейнер и статус результатов --- */
#salesMasterResultsContainer {
position: relative;
flex-grow: 1;
padding-top: 15px;
transition: padding-left 0.2s ease-out, padding-right 0.2s ease-out;
overflow-y: auto;
scrollbar-color: #4b6f9c #17202d;
scrollbar-width: thin;
}
#salesMasterResultsContainer::-webkit-scrollbar {
width: 8px;
}
#salesMasterResultsContainer::-webkit-scrollbar-track {
background: #17202d;
border-radius: 4px;
}
#salesMasterResultsContainer::-webkit-scrollbar-thumb {
background-color: #4b6f9c;
border-radius: 4px;
border: 2px solid #17202d;
}
#salesMasterResultsContainer::-webkit-scrollbar-thumb:hover {
background-color: #67c1f5;
}
#salesMasterResultsStatus {
display: none !important;
}
#salesMasterResults {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 20px;
padding-top: 15px;
padding-bottom: 20px;
}
/* --- Класс для скрытия по фильтру названий --- */
.salesMasterItem.hidden-by-filter {
display: none !important;
}
/* --- Карточка товара --- */
.salesMasterItem {
background-color: rgba(42, 46, 51, 0.85);
backdrop-filter: blur(4px);
border-radius: 4px;
padding: 15px;
display: flex;
flex-direction: column;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4);
position: relative;
color: #c6d4df;
font-size: 14px;
min-height: 380px;
border: 1px solid #333941;
}
.salesMasterItem:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
background-color: rgba(50, 55, 61, 0.9);
border-color: #67c1f5;
}
.salesMasterItem a {
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
height: 100%;
}
/* --- Стили для выделения предложений со страницы Steam --- */
.salesMasterItem.steam-page-offer {
background-color: #202c24;
border: 1px solid #354f3a;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.4);
color: #c6d4df;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
/* Кнопка в приглушенном зеленом стиле, похожем на стандартную синюю */
.salesMasterItem.steam-page-offer .sm-buyButton {
background-color: #5c9d4f;
color: #1a2f1f;
font-weight: 600;
border: none;
transition: background-color 0.2s, color 0.2s;
}
.salesMasterItem.steam-page-offer .sm-buyButton:hover {
background-color: #6ebf5f;
color: #0f1a0f;
}
.salesMasterItem.steam-page-offer:hover {
background-color: #304035;
border-color: #4a784d;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
transform: translateY(-3px);
}
.sm-card-image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
margin-bottom: 12px;
background-color: #111;
border-radius: 3px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #333941;
}
.sm-card-image-wrapper img {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border-radius: 3px;
}
.sm-price-container {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 5px 10px;
margin-bottom: 10px;
min-height: 26px;
}
.sm-current-price {
font-size: 18px;
font-weight: 700;
color: #66c0f4;
line-height: 1;
}
.sm-original-price {
font-size: 14px;
color: #8f98a0;
text-decoration: line-through;
line-height: 1;
}
.sm-discount-badge {
background-color: #e2004b;
color: white;
padding: 3px 7px;
font-size: 13px;
border-radius: 3px;
font-weight: 600;
line-height: 1;
}
.sm-title {
font-size: 15px;
font-weight: 500;
line-height: 1.4;
height: 4.2em;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 10px;
color: #e5e5e5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
/* --- Контейнер для магазина/продавца --- */
.sm-store-info-container {
margin-top: auto;
padding-top: 10px;
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
}
/* --- Стиль названия магазина --- */
.sm-store-name {
font-size: 12px;
color: #8f98a0;
text-align: right;
}
/* --- Стили для управления выбором магазинов --- */
.smStoreSelectAllControls {
margin-top: -5px;
margin-bottom: 10px;
padding-top: 5px;
border-bottom: 1px solid #3a4f6a;
text-align: center;
}
.smStoreSelectAllLink {
font-size: 12px;
color: #8f98a0;
cursor: pointer;
text-decoration: none;
transition: color 0.2s;
padding: 0 5px;
}
.smStoreSelectAllLink:hover {
color: #c6d4df;
text-decoration: underline;
}
.smStoreSelectSeparator {
color: #5a6268;
margin: 0 3px;
font-size: 12px;
}
/* --- Стиль ссылки продавца --- */
.sm-seller-link {
font-size: 12px;
color: #8f98a0;
text-align: right;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
transition: color 0.2s;
}
.sm-seller-link:not(.no-link):hover {
color: #c6d4df;
text-decoration: underline;
}
.sm-buyButton {
display: block;
text-align: center;
padding: 10px;
margin-top: 12px;
background-color: #67c1f5;
color: #1b2838;
border-radius: 3px;
font-size: 14px;
font-weight: 600;
transition: background-color 0.2s, color 0.2s;
margin-top: auto;
border: none;
}
.sm-buyButton:hover {
background-color: #8ad3f7;
color: #0e141b;
}
/* --- Адаптивность SM --- */
@media (max-width: 1400px) {
#salesMasterResults {
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
}
}
@media (max-width: 1100px) {
#salesMasterResults {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
#smTitleFilterInput {
max-width: 200px;
}
}
@media (max-width: 850px) {
#salesMasterFiltersPanel,
#salesMasterExclusionTags {
display: none;
}
#salesMasterHeader,
#salesMasterResultsContainer {
padding-left: 15px;
padding-right: 15px;
}
#salesMasterResults {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
#salesMasterHeader {
justify-content: space-between;
}
#smTitleFilterInput {
max-width: 180px;
margin-left: 5px;
margin-right: 5px;
}
.smInsertTitleBtn {
display: none;
}
}
@media (max-width: 600px) {
#salesMasterContainer {
width: 95%;
margin: 10px auto;
min-height: calc(100vh - 20px);
}
#salesMasterHeader {
flex-direction: column;
align-items: stretch;
padding-bottom: 5px;
}
#salesMasterHeaderStatus {
order: -2;
min-height: 25px;
padding: 5px 0;
font-size: 13px;
max-width: 100%;
text-align: center;
justify-content: center;
margin-bottom: 5px;
}
#smTitleFilterInput {
order: -1;
max-width: 100%;
margin: 0 0 10px 0;
}
.smInsertTitleBtn {
display: block;
order: -1;
margin: 0 0 5px 0;
width: 100%;
}
/* Показываем кнопку подстановки и делаем на всю ширину */
#salesMasterSortButtons {
width: 100%;
justify-content: space-around;
margin-top: 5px;
margin-left: 0;
}
.salesMasterBtn {
flex-grow: 1;
font-size: 13px;
padding: 8px 5px;
height: 36px;
}
#salesMasterResetSortBtn {
flex-grow: 0;
width: auto;
padding: 0 8px;
}
#salesMasterResults {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.salesMasterItem {
min-height: 320px;
font-size: 13px;
}
.sm-current-price {
font-size: 15px;
}
.sm-title {
font-size: 13px;
height: 3.9em;
-webkit-line-clamp: 3;
}
.sm-store-name {
font-size: 11px;
}
.sm-buyButton {
font-size: 13px;
padding: 8px;
}
}
/* --- Кнопка % на странице --- */
.salesMaster_button .btnv6_blue_hoverfade {
margin: 0;
padding: 0 10px;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: filter 0.2s;
}
.salesMaster_button .btnv6_blue_hoverfade:hover {
filter: brightness(1.1);
}
/* Спиннер */
@keyframes salesMasterSpin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.spinner {
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
width: 1em;
height: 1em;
animation: salesMasterSpin 1s linear infinite;
display: inline-block;
vertical-align: middle;
margin-left: 5px;
line-height: 1;
}
`);
}
// --- Модули магазинов ---
const sm_storeModules = [
{ // --- Модуль Steam ---
id: 'steam_current_page',
name: 'Steam',
baseUrl: 'https://store.steampowered.com',
searchUrlTemplate: '',
isEnabled: true,
fetch: async function(query) {
const storeModule = this;
const results = [];
const currencySymbol = sm_getCurrencySymbol();
const currencyMeta = document.querySelector('meta[itemprop="priceCurrency"]');
const pageCurrency = currencyMeta ? currencyMeta.content.toUpperCase() : 'RUB';
let exchangeRate = 1.0;
if (pageCurrency !== 'RUB') {
try {
const rates = await sm_fetchExchangeRates(pageCurrency);
if (rates && rates['rub']) {
exchangeRate = parseFloat(rates['rub']);
if (isNaN(exchangeRate) || exchangeRate <= 0) {
sm_logError(storeModule.name, `Invalid exchange rate for ${pageCurrency} -> RUB: ${rates['rub']}`);
exchangeRate = 1.0;
}
} else {
sm_logError(storeModule.name, `Could not get RUB rate for ${pageCurrency}`);
}
} catch (error) {
sm_logError(storeModule.name, `Error fetching exchange rates for ${pageCurrency}: ${error.message}`, error);
}
}
const headerImageElement = document.querySelector('#gameHeaderImageCtn img.game_header_image_full');
const mainImageUrl = headerImageElement ? headerImageElement.src : null;
const purchaseWrappers = document.querySelectorAll('.game_area_purchase_game_wrapper');
purchaseWrappers.forEach(wrapper => {
try {
const gamePurchaseDiv = wrapper.querySelector('.game_area_purchase_game, .game_area_purchase_game_dropdown_subscription');
if (!gamePurchaseDiv) return;
const titleElement = gamePurchaseDiv.querySelector('[id^="game_area_purchase_section_add_to_cart_title_"], [id^="bundle_purchase_label_"]');
let productName = null;
if (titleElement) {
let cleanedText = titleElement.textContent.trim();
cleanedText = cleanedText.replace(/^(Купить|Buy)[\s\u00A0]+/, '');
cleanedText = cleanedText.replace(/\s*(—\s*НАБОР|BUNDLE)\s*\(\?\)\s*$/, '');
productName = cleanedText.trim();
}
if (!productName) return;
let currentPrice = null;
let originalPrice = null;
let discountPercent = 0;
let imageUrl = mainImageUrl;
const priceSimpleElement = gamePurchaseDiv.querySelector('.game_purchase_price.price[data-price-final]');
const discountBlockElement = gamePurchaseDiv.querySelector('.discount_block.game_purchase_discount');
if (discountBlockElement) {
const finalPriceText = discountBlockElement.querySelector('.discount_final_price')?.textContent;
const originalPriceText = discountBlockElement.querySelector('.discount_original_price')?.textContent;
const discountPercentText = discountBlockElement.querySelector('.discount_pct')?.textContent;
const dataPriceFinal = discountBlockElement.dataset.priceFinal;
const dataDiscount = discountBlockElement.dataset.discount;
const dataBundleDiscount = discountBlockElement.dataset.bundlediscount;
if (finalPriceText) {
currentPrice = sm_parsePrice(finalPriceText);
} else if (dataPriceFinal) {
currentPrice = parseFloat(dataPriceFinal) / 100;
if (isNaN(currentPrice)) currentPrice = null;
}
if (originalPriceText) {
originalPrice = sm_parsePrice(originalPriceText);
}
if (discountPercentText) {
discountPercent = sm_parsePercent(discountPercentText) || 0;
} else if (dataDiscount) {
discountPercent = parseFloat(dataDiscount) || 0;
}
if (discountPercent === 0 && dataBundleDiscount) {
const bundleDiscountVal = parseFloat(dataBundleDiscount);
if (bundleDiscountVal > 0) {
discountPercent = bundleDiscountVal;
if (originalPrice === null && currentPrice !== null) {
originalPrice = currentPrice / (1 - discountPercent / 100);
}
}
}
} else if (priceSimpleElement) {
const dataPriceFinal = priceSimpleElement.dataset.priceFinal;
if (dataPriceFinal) {
currentPrice = parseFloat(dataPriceFinal) / 100;
if (isNaN(currentPrice)) currentPrice = null;
} else {
currentPrice = sm_parsePrice(priceSimpleElement.textContent);
}
originalPrice = currentPrice;
discountPercent = 0;
}
if (currentPrice !== null) {
const finalCurrentPrice = parseFloat((currentPrice * exchangeRate).toFixed(2));
const finalOriginalPrice = originalPrice !== null ? parseFloat((originalPrice * exchangeRate).toFixed(2)) : null;
const effectiveOriginalPrice = (finalOriginalPrice === null || isNaN(finalOriginalPrice)) ? finalCurrentPrice : finalOriginalPrice;
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: unsafeWindow.location.href,
imageUrl: imageUrl,
currentPrice: finalCurrentPrice,
originalPrice: effectiveOriginalPrice,
discountPercent: discountPercent > 0 ? discountPercent : null,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
}
} catch (e) {
sm_logError(storeModule.name, `Error parsing purchase block for "${productName || 'unnamed element'}"`, e);
}
});
return results;
}
},
{ // --- Модуль SteamBuy ---
id: 'steambuy',
name: 'SteamBuy',
baseUrl: 'https://steambuy.com',
searchUrlTemplate: 'https://steambuy.com/ajax/_get.php?a=search&q={query}',
isEnabled: true,
fetch: async function(query) {
const searchUrl = this.searchUrlTemplate.replace('{query}', encodeURIComponent(query));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
responseType: 'json',
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest'
},
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400 && response.response) {
const data = response.response;
if (data.status === 'success' && typeof data.html === 'string') {
resolve(this.parseHtml(data.html, this));
} else if (data.status === 'false' && data.message && data.message.includes("ничего не найдено")) {
resolve([]);
} else if (data.status === 'empty') {
resolve([]);
} else if (data.status === 'success' && !data.html) {
resolve([]);
} else {
reject(new Error(`API вернул неожиданный ответ: Статус ${data.status}, Сообщение: ${data.message || 'Нет сообщения'}`));
}
} else {
reject(new Error(`HTTP статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка')),
ontimeout: () => reject(new Error('Таймаут запроса'))
});
});
},
parseHtml: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const items = doc.querySelectorAll('.search-result__item');
items.forEach(item => {
try {
const linkElement = item.querySelector('.search-result__link');
const imgElement = item.querySelector('.search-result__img img');
const titleElement = item.querySelector('.search-result__title');
const priceElement = item.querySelector('.search-result__cost');
const discountElement = item.querySelector('.search-result__discount');
const productName = titleElement?.textContent?.trim() || null;
const productUrlRaw = linkElement?.getAttribute('href') || null;
const currentPriceText = priceElement?.innerHTML.replace(/<span[^>]*>.*<\/span>/i, '').replace('р', '').trim();
const currentPrice = sm_parsePrice(currentPriceText);
let discountPercent = 0;
const discountText = discountElement?.textContent?.trim();
if (discountText && discountText !== ' ') {
const parsedPercent = sm_parsePercent(discountText);
if (parsedPercent !== null) {
discountPercent = parsedPercent;
}
}
const imageUrl = imgElement?.getAttribute('src') || null;
if (productName && productUrlRaw && currentPrice !== null) {
const fullProductUrl = productUrlRaw.startsWith('/') ? storeModule.baseUrl + productUrlRaw : productUrlRaw;
const productUrl = fullProductUrl + '?partner=234029';
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: null,
discountPercent: discountPercent,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
} else {}
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента из AJAX HTML', e);
}
});
return results;
}
}, // --- Конец модуля SteamBuy ---
{ // --- Модуль Playo ---
id: 'playo',
name: 'Playo',
baseUrl: 'https://playo.ru',
searchUrlTemplate: 'https://playo.ru/search/{query}/?search={query}',
isEnabled: true,
fetch: async function(query) {
const urlEncodedQuery = encodeURIComponent(query).replace(/%20/g, '+');
const pathEncodedQuery = encodeURIComponent(query);
const searchUrl = this.searchUrlTemplate
.replace('{query}', pathEncodedQuery)
.replace('{query}', urlEncodedQuery);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
resolve(this.parseHtml(response.responseText, this));
} else {
reject(new Error(`HTTP статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка')),
ontimeout: () => reject(new Error('Таймаут запроса'))
});
});
},
parseHtml: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const items = doc.querySelectorAll('.preview_list .preview_it');
items.forEach(item => {
try {
const linkElement = item.querySelector('a.link_preview');
const imgElement = item.querySelector('.img_prev img');
const titleElement = item.querySelector('.inf');
const priceElement = item.querySelector('.price');
const oldPriceElement = item.querySelector('.old_price');
const discountPercentElement = item.querySelector('.gmlst_dscnt_lbl');
const discountAmountElement = item.querySelector('.gmlst_dsnt_val_text');
const productUrlRaw = linkElement ? linkElement.getAttribute('href') : null;
const imageUrlRaw = imgElement ? imgElement.getAttribute('src') : null;
let productName = null;
if (titleElement) {
const clonedTitle = titleElement.cloneNode(true);
const economySpan = clonedTitle.querySelector('.gmlst_dsnt_val_text');
if (economySpan) economySpan.remove();
productName = clonedTitle.textContent.replace(/\s+/g, ' ').trim();
}
const currentPrice = priceElement ? sm_parsePrice(priceElement.textContent) : null;
const originalPrice = oldPriceElement ? sm_parsePrice(oldPriceElement.textContent) : null;
const discountPercent = discountPercentElement ? sm_parsePercent(discountPercentElement.textContent) : null;
const discountAmount = discountAmountElement ? sm_parsePrice(discountAmountElement.textContent) : null;
if (productName && productUrlRaw && currentPrice !== null) {
const fullProductUrl = productUrlRaw.startsWith('/') ? storeModule.baseUrl + productUrlRaw : productUrlRaw;
const productUrl = fullProductUrl + '?s=n3j6y08f';
const imageUrl = imageUrlRaw.startsWith('/') ? storeModule.baseUrl + imageUrlRaw : imageUrlRaw;
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: originalPrice,
discountPercent: discountPercent,
discountAmount: discountAmount,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
}
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента', e);
}
});
return results;
}
},
{ // --- Модуль SteamPay ---
id: 'steampay',
name: 'SteamPay',
baseUrl: 'https://steampay.com',
searchUrlTemplate: 'https://steampay.com/search?q={query}',
isEnabled: true,
fetch: async function(query) {
const searchUrl = this.searchUrlTemplate.replace('{query}', encodeURIComponent(query));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
resolve(this.parseHtml(response.responseText, this));
} else {
reject(new Error(`HTTP статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка')),
ontimeout: () => reject(new Error('Таймаут запроса'))
});
});
},
parseHtml: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const items = doc.querySelectorAll('.catalog-item');
items.forEach(item => {
try {
const linkElement = item;
const imgElement = item.querySelector('.catalog-item__img img');
const nameElement = item.querySelector('.catalog-item__name');
const priceSpanElement = item.querySelector('.catalog-item__price-span');
const discountElement = item.querySelector('.catalog-item__discount');
const currentPriceText = priceSpanElement?.textContent?.trim();
const currentPrice = sm_parsePrice(currentPriceText);
if (currentPrice === null) {
return;
}
let productName = null;
if (nameElement) {
const nameClone = nameElement.cloneNode(true);
const infoDiv = nameClone.querySelector('.catalog-item__info');
if (infoDiv) infoDiv.remove();
productName = nameClone.textContent?.trim();
}
const productUrl = linkElement?.getAttribute('href');
const imageUrl = imgElement?.getAttribute('src');
const discountPercent = discountElement ? sm_parsePercent(discountElement.textContent) : 0;
if (productName && productUrl) {
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl.startsWith('/') ? storeModule.baseUrl + productUrl : productUrl,
imageUrl: imageUrl?.startsWith('/') ? storeModule.baseUrl + imageUrl : imageUrl,
currentPrice: currentPrice,
originalPrice: null,
discountPercent: discountPercent,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
}
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента', e);
}
});
return results;
}
}, // --- Конец модуля SteamPay ---
{ // --- Модуль Gabestore ---
id: 'gabestore',
name: 'Gabestore',
baseUrl: 'https://gabestore.ru',
searchUrlTemplate: 'https://gabestore.ru/result?ProductFilter%5Bsearch%5D={query}',
isEnabled: true,
fetch: async function(query) {
const searchUrl = this.searchUrlTemplate.replace('{query}', encodeURIComponent(query));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
resolve(this.parseHtml(response.responseText, this));
} else {
reject(new Error(`HTTP статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка')),
ontimeout: () => reject(new Error('Таймаут запроса'))
});
});
},
parseHtml: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const itemsContainer = doc.querySelector('.js-load-container');
const items = itemsContainer ? itemsContainer.querySelectorAll('.shop-item') : [];
items.forEach(item => {
try {
const nameLinkElement = item.querySelector('a.shop-item__name');
const imageLinkElement = item.querySelector('a.shop-item__image');
const imgElement = imageLinkElement?.querySelector('img');
const priceElement = item.querySelector('.shop-item__price-current');
const discountElement = item.querySelector('.shop-item__price-discount');
const productName = nameLinkElement?.textContent?.trim();
const productUrlRaw = nameLinkElement?.getAttribute('href') || imageLinkElement?.getAttribute('href');
const imageUrl = imgElement?.getAttribute('src');
const currentPrice = priceElement ? sm_parsePrice(priceElement.textContent) : null;
const discountPercent = discountElement ? sm_parsePercent(discountElement.textContent) : 0;
if (!productName || !productUrlRaw || currentPrice === null) {
return;
}
const fullOriginalUrl = productUrlRaw.startsWith('/') ? storeModule.baseUrl + productUrlRaw : productUrlRaw;
const referralPrefix = 'https://codeaven.com/g/om6s6jfc50c1442ace4b215ab801b9/?erid=2bL9aMPo2e49hMef4peVT3sy3u&ulp=';
const productUrl = referralPrefix + encodeURIComponent(fullOriginalUrl);
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: null,
discountPercent: discountPercent,
discountAmount: null,
currency: 'RUB',
isAvailable: !item.querySelector('.btn--empty-item')
};
if (data.isAvailable) {
results.push(sm_calculateMissingValues(data));
}
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента', e);
}
});
return results;
}
}, // --- Конец модуля Gabestore ---
{ // --- Модуль GamerBase ---
id: 'gamerbase',
name: 'GamersBase',
baseUrl: 'https://gamersbase.store',
searchUrlTemplate: 'https://gamersbase.store/ru/search/?isFullTextSearch=true&searchQuery={query}',
isEnabled: true,
fetch: async function(query) {
const storeModule = this;
const searchByName = () => {
const searchUrl = storeModule.searchUrlTemplate.replace('{query}', encodeURIComponent(query));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
resolve(storeModule.parseHtml(response.responseText, storeModule));
} else {
reject(new Error(`[Fallback] HTTP статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('[Fallback] Сетевая ошибка')),
ontimeout: () => reject(new Error('[Fallback] Таймаут запроса'))
});
});
};
const steamAppIdMatch = unsafeWindow.location.pathname.match(/\/app\/(\d+)/);
if (!steamAppIdMatch || !steamAppIdMatch[1]) {
return searchByName();
}
const currentAppId = steamAppIdMatch[1];
const xmlFeedUrl = "https://coreplatform.blob.core.windows.net/products-content/steam_pages_feed.xml";
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: xmlFeedUrl,
responseType: 'text',
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
try {
const parser = new DOMParser();
const xml = parser.parseFromString(response.responseText, "application/xml");
const match = Array.from(xml.querySelectorAll("game")).find(game => game.querySelector("steam_id")?.textContent === currentAppId);
if (match) {
const isAvailable = match.querySelector("available")?.textContent === "True";
const price = sm_parsePrice(match.querySelector("price")?.textContent);
const gameCode = match.querySelector("code_gb")?.textContent;
if (isAvailable && price !== null && gameCode) {
const headerImageElement = document.querySelector('#gameHeaderImageCtn img.game_header_image_full');
const mainImageUrl = headerImageElement ? headerImageElement.src : null;
const fullOriginalUrl = `https://gamersbase.store/game/${gameCode}`;
const referralPrefix = 'https://lsuix.com/g/nzstwno2sac1442ace4bb0de1ddd64/?erid=2bL9aMPo2e49hMef4pfVDVxtYh&ulp=';
const productUrl = referralPrefix + encodeURIComponent(fullOriginalUrl);
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: query,
productUrl: productUrl,
imageUrl: mainImageUrl,
currentPrice: price,
originalPrice: null,
discountPercent: null,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
return resolve([sm_calculateMissingValues(data)]);
}
}
searchByName().then(resolve).catch(reject);
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга XML, переход на поиск по названию.', e);
searchByName().then(resolve).catch(reject);
}
} else {
sm_logError(storeModule.name, `XML-фид недоступен (статус ${response.status}), переход на поиск по названию.`);
searchByName().then(resolve).catch(reject);
}
},
onerror: (error) => {
sm_logError(storeModule.name, 'Сетевая ошибка XML, переход на поиск по названию.', error);
searchByName().then(resolve).catch(reject);
},
ontimeout: () => {
sm_logError(storeModule.name, 'Таймаут XML, переход на поиск по названию.');
searchByName().then(resolve).catch(reject);
}
});
});
},
parseHtml: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const items = doc.querySelectorAll('.js-products-container .ui.cover');
items.forEach(item => {
try {
const linkElement = item.querySelector('a.cover-holder');
const imgElement = item.querySelector('.image img');
const buyButton = item.querySelector('.js-add-product');
const productDataJson = linkElement?.dataset.product || buyButton?.dataset.product;
if (!productDataJson) return;
const productData = JSON.parse(productDataJson);
if (!productData?.name || !productData?.priceData) return;
const productName = productData.name;
const productUrlRaw = linkElement?.getAttribute('href');
const imageUrl = imgElement?.getAttribute('src');
const currentPrice = sm_parsePrice(productData.priceData.actualPriceFormatted);
const originalPrice = sm_parsePrice(productData.priceData.standardPriceFormatted);
const discountPercent = productData.priceData.discountPercent || 0;
const currency = productData.priceData.currency || 'RUB';
const isAvailable = item.querySelector('.js-add-product.available-true') !== null;
if (productName && productUrlRaw && currentPrice !== null) {
let fullOriginalUrl = productUrlRaw.startsWith('/') ? storeModule.baseUrl + productUrlRaw : productUrlRaw;
const urlObject = new URL(fullOriginalUrl);
if (urlObject.pathname.startsWith('/ru/')) {
urlObject.pathname = urlObject.pathname.substring(3);
fullOriginalUrl = urlObject.toString();
}
const referralPrefix = 'https://lsuix.com/g/nzstwno2sac1442ace4bb0de1ddd64/?erid=2bL9aMPo2e49hMef4pfVDVxtYh&ulp=';
const productUrl = referralPrefix + encodeURIComponent(fullOriginalUrl);
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: originalPrice,
discountPercent: discountPercent,
discountAmount: null,
currency: currency,
isAvailable: isAvailable
};
if (data.isAvailable) {
results.push(sm_calculateMissingValues(data));
}
}
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента или JSON в data-product', e);
}
});
return results;
}
}, // --- Конец модуля GamerBase ---
{ // --- Модуль Igromagaz ---
id: 'igromagaz',
name: 'Igromagaz',
baseUrl: 'https://www.igromagaz.ru',
searchUrlTemplate: 'https://www.igromagaz.ru/search/?q={query}&quantity_in=Y',
isEnabled: true,
fetch: async function(query) {
const searchUrl = this.searchUrlTemplate.replace('{query}', encodeURIComponent(query));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
resolve(this.parseHtml(response.responseText, this));
} else {
reject(new Error(`HTTP статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка')),
ontimeout: () => reject(new Error('Таймаут запроса'))
});
});
},
parseHtml: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const items = doc.querySelectorAll('.product-card');
items.forEach(item => {
try {
const notAvailableElement = item.querySelector('.product-availability--not-available');
const notifyButton = item.querySelector('.button-notify-js');
if (notAvailableElement || notifyButton) {
return;
}
const titleLinkElement = item.querySelector('a.product-title');
const imageLinkElement = item.querySelector('a.product-img');
const imgElement = imageLinkElement?.querySelector('img');
const priceElement = item.querySelector('.product-price__standart');
const oldPriceElement = item.querySelector('.product-price__fail');
const discountElement = item.querySelector('.sale-label');
const productName = titleLinkElement?.textContent?.trim();
const productUrl = titleLinkElement?.getAttribute('href') || imageLinkElement?.getAttribute('href');
const imageUrl = imgElement?.getAttribute('src');
const currentPrice = priceElement ? sm_parsePrice(priceElement.textContent) : null;
const originalPrice = oldPriceElement ? sm_parsePrice(oldPriceElement.textContent) : null;
const discountPercent = discountElement ? sm_parsePercent(discountElement.textContent) : null;
if (!productName || !productUrl || currentPrice === null) {
return;
}
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl.startsWith('/') ? storeModule.baseUrl + productUrl : productUrl,
imageUrl: imageUrl?.startsWith('/') ? storeModule.baseUrl + imageUrl : imageUrl,
currentPrice: currentPrice,
originalPrice: originalPrice,
discountPercent: discountPercent,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента', e);
}
});
return results;
}
}, // --- Конец модуля Igromagaz ---
{ // --- Модуль GamesForFarm ---
id: 'gamesforfarm',
name: 'GamesForFarm',
baseUrl: 'https://gamesforfarm.com',
searchUrlTemplate: 'https://gamesforfarm.com/?search={query}',
isEnabled: true,
fetch: async function(query) {
const searchUrl = this.searchUrlTemplate.replace('{query}', encodeURIComponent(query));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
resolve(this.parseHtml(response.responseText, this));
} else {
reject(new Error(`HTTP статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка')),
ontimeout: () => reject(new Error('Таймаут запроса'))
});
});
},
parseHtml: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const container = doc.querySelector('#gamesCatalog');
if (!container) return results;
const items = container.querySelectorAll('.product__item');
items.forEach(item => {
try {
const linkElement = item.querySelector('.product__box-title a');
const imgElement = item.querySelector('.product__box-image img');
const priceElement = item.querySelector('.product__box-price');
const discountElement = item.querySelector('.product__box-prop.prop--discount');
let currentPrice = null;
if (priceElement) {
const priceClone = priceElement.cloneNode(true);
const currencySpan = priceClone.querySelector('span.sc-ru3bl');
if (currencySpan) currencySpan.remove();
currentPrice = sm_parsePrice(priceClone.textContent);
}
const productName = linkElement?.textContent?.trim();
const productUrl = linkElement?.getAttribute('href');
const imageUrl = imgElement?.dataset.src || imgElement?.getAttribute('src');
const discountPercent = discountElement ? sm_parsePercent(discountElement.textContent) : 0;
if (!productName || !productUrl || currentPrice === null) {
return;
}
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl.startsWith('/') ? storeModule.baseUrl + productUrl : productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: null,
discountPercent: discountPercent,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента', e);
}
});
return results;
}
}, // --- Конец модуля GamesForFarm ---
{ // --- Модуль Gamazavr ---
id: 'gamazavr',
name: 'Gamazavr',
baseUrl: 'https://gamazavr.ru',
searchUrlTemplate: 'https://gamazavr.ru/search/?query={query}',
isEnabled: true,
fetch: async function(query) {
const searchUrl = this.searchUrlTemplate.replace('{query}', encodeURIComponent(query));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
resolve(this.parseHtml(response.responseText, this));
} else {
reject(new Error(`HTTP статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка')),
ontimeout: () => reject(new Error('Таймаут запроса'))
});
});
},
parseHtml: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const container = doc.querySelector('.productsList');
if (!container) {
return results;
}
const items = container.querySelectorAll('.item');
items.forEach(item => {
try {
const descriptionLink = item.querySelector('.description a');
const imageLink = item.querySelector('a.img');
const imgElement = imageLink?.querySelector('img');
const priceElement = item.querySelector('.price');
const currentPriceElement = priceElement?.querySelector('b');
const originalPriceElement = priceElement?.querySelector('s');
const productName = descriptionLink?.querySelector('b')?.textContent?.trim();
const productUrlRaw = descriptionLink?.getAttribute('href');
const imageUrlRaw = imgElement?.getAttribute('src');
const currentPrice = currentPriceElement ? sm_parsePrice(currentPriceElement.textContent) : null;
const originalPrice = originalPriceElement ? sm_parsePrice(originalPriceElement.textContent) : null;
if (!productName || !productUrlRaw || currentPrice === null) {
return;
}
const fullProductUrl = productUrlRaw.startsWith('/') ? storeModule.baseUrl + productUrlRaw : productUrlRaw;
const productUrl = fullProductUrl + '?partner=8293ebf587779da6';
const imageUrl = imageUrlRaw?.startsWith('/') ? storeModule.baseUrl + imageUrlRaw : imageUrlRaw;
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: originalPrice,
discountPercent: null,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента Gamazavr', e);
}
});
return results;
}
}, // --- Конец модуля Gamazavr ---
{ // --- Модуль GameRay ---
id: 'gameray',
name: 'GameRay',
baseUrl: 'https://gameray.ru',
searchUrlTemplate: 'https://gameray.ru/search/index.php?q={query}',
isEnabled: true,
fetch: async function(query) {
const searchUrl = this.searchUrlTemplate.replace('{query}', encodeURIComponent(query));
let initialResults = [];
// --- Шаг 1: Получаем список игр со страницы поиска ---
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error('Таймаут запроса (поиск)')),
});
});
if (response.status >= 200 && response.status < 400) {
initialResults = this.parseSearchPage(response.responseText, this);
} else {
throw new Error(`HTTP статус ${response.status} (поиск)`);
}
} catch (error) {
sm_logError(this.name, `Ошибка на шаге 1 (поиск): ${error.message}`, error);
return [];
}
if (initialResults.length === 0) {
return [];
}
// --- Шаг 2: Запрашиваем каждую страницу товара для деталей ---
const detailPromises = initialResults.map(initialData =>
new Promise(async (resolve) => {
try {
const productResponse = await new Promise((resolveFetch, rejectFetch) => {
GM_xmlhttpRequest({
method: "GET",
url: initialData.fullProductUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: resolveFetch,
onerror: rejectFetch,
ontimeout: () => rejectFetch(new Error(`Таймаут запроса (${initialData.productName})`)),
});
});
if (productResponse.status >= 200 && productResponse.status < 400) {
resolve(this.parseProductPage(productResponse.responseText, initialData, this));
} else {
sm_logError(this.name, `Ошибка загрузки страницы товара ${initialData.productName} (Статус: ${productResponse.status})`);
resolve(null);
}
} catch (error) {
sm_logError(this.name, `Ошибка загрузки страницы товара ${initialData.productName}: ${error.message}`, error);
resolve(null);
}
})
);
const detailedResults = await Promise.allSettled(detailPromises);
const finalResults = detailedResults
.filter(result => result.status === 'fulfilled' && result.value !== null)
.map(result => result.value);
return finalResults;
},
parseSearchPage: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const container = doc.querySelector('.search-page') || doc.body;
const items = container.querySelectorAll('a.ec-clicker');
items.forEach(item => {
try {
const productName = item.dataset.name?.trim();
const productUrlRaw = item.getAttribute('href');
const imgElement = item.querySelector('img');
const imageUrlRaw = imgElement?.getAttribute('src');
if (productName && productUrlRaw && imageUrlRaw) {
const fullProductUrl = productUrlRaw.startsWith('/') ? storeModule.baseUrl + productUrlRaw : productUrlRaw;
const imageUrl = imageUrlRaw.startsWith('/') ? storeModule.baseUrl + imageUrlRaw : imageUrlRaw;
results.push({
productName: productName,
fullProductUrl: fullProductUrl,
imageUrl: imageUrl
});
}
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента на странице поиска', e);
}
});
return results;
},
parseProductPage: function(htmlString, initialData, storeModule) {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const pricingBlock = doc.querySelector('div.pricing');
if (!pricingBlock) {
sm_logError(storeModule.name, `Блок .pricing не найден для: ${initialData.productName}`);
return null;
}
const buyButton = pricingBlock.querySelector('a.buy-button');
const isAvailable = buyButton !== null;
if (!isAvailable) {
return null;
}
const priceElement = pricingBlock.querySelector('strong.price span[itemprop="price"]');
const originalPriceElement = pricingBlock.querySelector('strike.price_old');
const currentPrice = priceElement ? sm_parsePrice(priceElement.textContent) : null;
const originalPrice = originalPriceElement ? sm_parsePrice(originalPriceElement.textContent) : null;
if (currentPrice === null) {
sm_logError(storeModule.name, `Не найдена цена в блоке .pricing для: ${initialData.productName}`);
return null;
}
const productUrlWithRef = initialData.fullProductUrl + '?partner=93';
let finalData = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: initialData.productName,
productUrl: productUrlWithRef,
imageUrl: initialData.imageUrl,
currentPrice: currentPrice,
originalPrice: originalPrice,
discountPercent: null,
discountAmount: null,
currency: 'RUB',
isAvailable: isAvailable
};
return sm_calculateMissingValues(finalData);
}
}, // --- Конец модуля GameRay ---
{ // --- Модуль Kupikod ---
id: 'kupikod',
name: 'KupiKod',
baseUrl: 'https://kupikod.com',
apiGamesUrlTemplate: 'https://explorer.kupikod.com/backend/api/games?name={query}',
apiShopUrlTemplate: 'https://explorer.kupikod.com/backend/api/shop/products-list?name={query}',
isEnabled: true,
// Список суффиксов регионов для исключения (в нижнем регистре)
excludedRegionSuffixes: [
'-eu', '-us', '-arg', '-tr', '-no-ru-no-rb', '-no-ru-no-cis',
'-no-ru', '-euus', '-cis', '-uk', '-in', '-eg'
],
// Список ключевых слов платформ для исключения (в нижнем регистре)
excludedPlatformKeywords: [
'-xbox-', '-origin-', '-uplay-', '-gog-', '-rockstar-',
'-battlestate-', '-nintendo-'
],
fetch: async function(query) {
const storeModule = this;
const encodedQuery = encodeURIComponent(query);
const gamesUrl = storeModule.apiGamesUrlTemplate.replace('{query}', encodedQuery);
const shopUrl = storeModule.apiShopUrlTemplate.replace('{query}', encodedQuery);
const fetchPromise = (url) => new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
responseType: 'json',
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400 && response.response) {
resolve(response.response);
} else {
sm_logError(storeModule.name, `HTTP статус ${response.status} для ${url}`);
resolve(null);
}
},
onerror: (error) => {
sm_logError(storeModule.name, `Сетевая ошибка для ${url}`, error);
resolve(null);
},
ontimeout: () => {
sm_logError(storeModule.name, `Таймаут запроса для ${url}`);
resolve(null);
}
});
});
const [gamesResult, shopResult] = await Promise.allSettled([
fetchPromise(gamesUrl),
fetchPromise(shopUrl)
]);
let finalResults = [];
if (gamesResult.status === 'fulfilled' && gamesResult.value?.data) {
try {
finalResults = finalResults.concat(storeModule.parseGamesApi(gamesResult.value.data, storeModule));
} catch(e) {
sm_logError(storeModule.name, 'Ошибка парсинга ответа Games API', e);
}
} else if (gamesResult.status === 'rejected') {
}
if (shopResult.status === 'fulfilled' && shopResult.value?.data) {
try {
finalResults = finalResults.concat(storeModule.parseShopApi(shopResult.value.data, storeModule));
} catch(e) {
sm_logError(storeModule.name, 'Ошибка парсинга ответа Shop API', e);
}
} else if (shopResult.status === 'rejected') {
}
return finalResults;
},
// Парсер для ответа от /api/games (Steam-гифты)
parseGamesApi: function(items, storeModule) {
const results = [];
if (!Array.isArray(items)) {
sm_logError(storeModule.name, 'Games API response data is not an array', items);
return results;
}
const referralBase = "https://yknhc.com/g/lfofiog4lqc1442ace4b294cb5928a/";
const referralParams = "?erid=2bL9aMPo2e49hMef4phUQVF5W8&ulp=";
items.forEach(item => {
try {
const productName = item.name?.trim();
const slug = item.slug;
const currentPrice = sm_parsePrice(item.min_price?.rub ?? null);
const originalPriceRaw = sm_parsePrice(item.min_old_price?.rub ?? null);
const originalPrice = (originalPriceRaw !== null && currentPrice !== null && originalPriceRaw > currentPrice) ? originalPriceRaw : null;
const imageUrl = item.external_data?.header_image;
if (!productName || !slug || currentPrice === null || !imageUrl) {
return;
}
const originalProductUrl = `https://steam.kupikod.com/ru-ru/games/${slug}`;
const productUrl = referralBase + referralParams + encodeURIComponent(originalProductUrl);
let data = {
storeId: storeModule.id,
storeName: storeModule.name + " (Гифты)",
storeUrl: "https://steam.kupikod.com/",
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: originalPrice,
discountPercent: null,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента Games API', e);
}
});
return results;
},
// Парсер для ответа от /api/shop/products-list (Ключи)
parseShopApi: function(items, storeModule) {
const results = [];
if (!Array.isArray(items)) {
sm_logError(storeModule.name, 'Shop API response data is not an array', items);
return results;
}
const referralBase = "https://yknhc.com/g/lfofiog4lqc1442ace4b294cb5928a/";
const referralParams = "?erid=2bL9aMPo2e49hMef4phUQVF5W8&ulp=";
items.forEach(item => {
try {
const productName = item.h1_title?.trim();
const slug = item.slug?.toLowerCase();
const currentPrice = sm_parsePrice(item.price ?? null);
const originalPriceRaw = sm_parsePrice(item.old_price ?? null);
const originalPrice = (originalPriceRaw !== null && originalPriceRaw > 0 && currentPrice !== null && originalPriceRaw > currentPrice) ? originalPriceRaw : null;
const imageUrl = item.picture_url;
if (!imageUrl || typeof imageUrl !== 'string' || imageUrl.includes('/apps//')) {
return;
}
if (!productName || !slug || currentPrice === null) {
return;
}
if (storeModule.excludedRegionSuffixes.some(suffix => slug.endsWith(suffix))) {
return;
}
if (storeModule.excludedPlatformKeywords.some(keyword => slug.includes(keyword))) {
return;
}
const originalProductUrl = `${storeModule.baseUrl}/shop/${item.slug}`;
const productUrl = referralBase + referralParams + encodeURIComponent(originalProductUrl);
let data = {
storeId: storeModule.id,
storeName: storeModule.name + " (Ключи)",
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: originalPrice,
discountPercent: null,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента Shop API', e);
}
});
return results;
}
}, // --- Конец модуля Kupikod ---
{ // --- Модуль KeysForGamers ---
id: 'keysforgamers',
name: 'KeysForGamers',
baseUrl: 'https://keysforgamers.com',
apiUrl: 'https://keysforgamers.com/ru/product/search',
isEnabled: true,
fetch: async function(query) {
const storeModule = this;
let searchQuery = query;
const containsCyrillic = /[а-яё]/i.test(query);
if (containsCyrillic) {
sm_logError(storeModule.name, `Обнаружена кириллица в запросе "${query}". Пытаемся получить английское название...`);
const steamAppIdMatch = unsafeWindow.location.pathname.match(/\/app\/(\d+)/);
if (steamAppIdMatch && steamAppIdMatch[1]) {
const currentAppId = steamAppIdMatch[1];
const apiUrl = `https://store.steampowered.com/api/appdetails?appids=${currentAppId}&l=english`;
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
responseType: 'json',
timeout: SM_REQUEST_TIMEOUT_MS,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error('Таймаут запроса к Steam API (AppDetails)')),
});
});
if (response.status === 200 && response.response && response.response[currentAppId]?.success) {
const englishName = response.response[currentAppId]?.data?.name;
if (englishName && englishName.trim()) {
searchQuery = englishName.trim();
sm_logError(storeModule.name, `Используем английское название для поиска: "${searchQuery}"`);
} else {
sm_logError(storeModule.name, `Steam API вернул успех, но английское имя не найдено для AppID ${currentAppId}. Используем оригинальный запрос.`);
}
} else {
sm_logError(storeModule.name, `Запрос к Steam API не удался или неверный ответ для AppID ${currentAppId} (Status: ${response.status}). Используем оригинальный запрос.`);
}
} catch (error) {
sm_logError(storeModule.name, `Ошибка при получении английского названия из Steam API: ${error.message}. Используем оригинальный запрос.`, error);
}
} else {
sm_logError(storeModule.name, 'Не удалось получить Steam AppID со страницы для запроса английского названия. Используем оригинальный запрос.');
}
}
let csrfToken = '';
try {
const mainPageResponse = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: storeModule.baseUrl + '/ru/',
timeout: SM_REQUEST_TIMEOUT_MS,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error('Таймаут запроса (CSRF)')),
});
});
if (mainPageResponse.status >= 200 && mainPageResponse.status < 400) {
const parser = new DOMParser();
const doc = parser.parseFromString(mainPageResponse.responseText, 'text/html');
const csrfMetaTag = doc.querySelector('meta[name="csrf-token"]');
if (!csrfMetaTag) throw new Error('Мета-тег csrf-token не найден!');
csrfToken = csrfMetaTag.getAttribute('content');
if (!csrfToken) throw new Error('Не удалось получить значение csrf-token!');
} else {
throw new Error(`HTTP статус ${mainPageResponse.status} при получении CSRF`);
}
} catch (error) {
sm_logError(storeModule.name, `Ошибка получения CSRF токена: ${error.message}`, error);
throw error;
}
let allItems = [];
let currentPage = 1;
let totalPages = 1;
do {
const requestPayload = {
productTypes: [{ value: "6", id: "category-6" }],
regionData: [
{ value: "1", id: "region-1" },
{ value: "85", id: "region-85" },
{ value: "6", id: "region-6" }
],
searchData: [{ value: searchQuery, id: "product-search" }],
sortData: [{ value: "4", id: "search_sort" }],
priceRange: [{ value: ["0.00", "99999.00"], id: ["min_price", "max_price"] }],
page: currentPage,
perPage: 24,
switchData: [],
marketplaceData: [],
otherTypesData: [],
hashData: [],
showMorePages: 0,
isMinPriceChanged: false,
isMaxPriceChanged: true,
minPriceValue: 0,
maxPriceValue: 99999.00
};
const requestHeaders = {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'X-Csrf-Token': csrfToken,
'X-Requested-With': 'XMLHttpRequest'
};
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: storeModule.apiUrl,
headers: requestHeaders,
data: JSON.stringify(requestPayload),
responseType: 'json',
timeout: SM_REQUEST_TIMEOUT_MS,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error(`Таймаут запроса (page: ${currentPage})`)),
});
});
if (response.status >= 200 && response.status < 400 && response.response) {
const data = response.response;
if (data.catalogBody && typeof data.catalogBody === 'string') {
const pageItems = storeModule.parseKFGHtml(data.catalogBody, storeModule);
allItems = allItems.concat(pageItems);
}
totalPages = data.pages ?? totalPages;
if (data.pages === undefined && currentPage === 1) totalPages = 1;
} else {
throw new Error(`HTTP статус ${response.status} (page: ${currentPage})`);
}
} catch (error) {
sm_logError(storeModule.name, `Ошибка загрузки страницы ${currentPage}: ${error.message}`, error);
throw error;
}
currentPage++;
if (currentPage <= totalPages) await new Promise(res => setTimeout(res, 150));
} while (currentPage <= totalPages);
return allItems;
},
parseKFGHtml: function(htmlString, storeModule) {
const items = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const productElements = doc.querySelectorAll('.items-list .product-item');
productElements.forEach(element => {
try {
const titleElement = element.querySelector('.catalog-card__item-title');
const priceElement = element.querySelector('.catalog-card__price');
const linkElement = element.querySelector('.catalog-card__img-link, .product-card__link, .catalog-card__item-title a');
const imgElement = element.querySelector('.catalog-card__img img, .product-card img');
const productName = titleElement?.textContent?.trim();
const priceText = priceElement?.textContent?.trim();
const productUrlRaw = linkElement?.getAttribute('href');
const imageUrlRaw = imgElement?.getAttribute('src');
if (!productName || !priceText || !productUrlRaw || !imageUrlRaw) {
return;
}
const cleanedPriceText = priceText.replace(/[₽$,]/g, '');
const currentPrice = sm_parsePrice(cleanedPriceText);
if (currentPrice === null) {
sm_logError(storeModule.name, `Не удалось распарсить очищенную цену: ${cleanedPriceText}`, element.innerHTML);
return;
}
const productUrl = productUrlRaw.startsWith('/') ? storeModule.baseUrl + productUrlRaw : productUrlRaw;
const imageUrl = imageUrlRaw.startsWith('/') ? storeModule.baseUrl + imageUrlRaw : imageUrlRaw;
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: null,
discountPercent: null,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
items.push(data);
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга HTML элемента KeysForGamers', e);
}
});
return items;
}
}, // --- Конец модуля KeysForGamers ---
{ // --- Модуль Zaka-zaka ---
id: 'zakazaka',
name: 'Zaka-zaka',
baseUrl: 'https://zaka-zaka.com',
searchUrlTemplate: 'https://zaka-zaka.com/search/ask/{query}/sort/price.asc',
isEnabled: true,
fetch: async function(query) {
const searchUrl = this.searchUrlTemplate.replace('{query}', encodeURIComponent(query));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: searchUrl,
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
resolve(this.parseHtml(response.responseText, this));
} else {
reject(new Error(`HTTP статус ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка')),
ontimeout: () => reject(new Error('Таймаут запроса'))
});
});
},
parseHtml: function(htmlString, storeModule) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
const items = doc.querySelectorAll('.search-results .game-block');
items.forEach(item => {
try {
const linkElement = item;
const imageDiv = item.querySelector('.game-block-image');
const nameElement = item.querySelector('.game-block-name');
const priceElement = item.querySelector('.game-block-price');
const discountElement = item.querySelector('.game-block-discount');
const discountAmountElement = item.querySelector('.game-block-discount-sum');
const productName = nameElement?.textContent?.trim();
const productUrlRaw = linkElement?.getAttribute('href');
const currentPrice = priceElement ? sm_parsePrice(priceElement.textContent) : null;
const discountPercent = discountElement ? sm_parsePercent(discountElement.textContent) : 0;
const discountAmount = discountAmountElement ? Math.abs(sm_parsePrice(discountAmountElement.textContent) ?? 0) : null;
let imageUrl = null;
if (imageDiv?.style?.backgroundImage) {
const match = imageDiv.style.backgroundImage.match(/url\("?(.+?)"?\)/);
if (match && match[1]) {
imageUrl = match[1].startsWith('/') ? storeModule.baseUrl + match[1] : match[1];
}
}
if (!productName || !productUrlRaw || currentPrice === null) {
return;
}
const fullOriginalUrl = productUrlRaw.startsWith('/') ? storeModule.baseUrl + productUrlRaw : productUrlRaw;
const referralPrefix = 'https://bednari.com/g/momptkjep9c1442ace4b02770293ab/?erid=2bL9aMPo2e49hMef4pgUXYbxvv&ulp=';
const productUrl = referralPrefix + encodeURIComponent(fullOriginalUrl);
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: null,
discountPercent: discountPercent,
discountAmount: discountAmount,
currency: 'RUB',
isAvailable: true
};
results.push(sm_calculateMissingValues(data));
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента', e);
}
});
return results;
}
}, // --- Конец модуля Zaka-zaka ---
{ // --- Модуль Buka ---
id: 'buka',
name: 'Buka',
baseUrl: 'https://shop.buka.ru',
apiUrl: 'https://shop.buka.ru/api/f/v2/search/get-page',
isEnabled: true,
fetch: async function(query) {
let allItems = [];
let pageIndex = 0;
let hasNext = true;
const storeModule = this;
async function fetchBukaPage(currentIndex) {
const requestPayload = {
pageIndex: currentIndex,
filter: {
term: query,
area_id: 100001,
channel: "WEB"
}
};
const requestHeaders = {
'Accept': '*/*',
'Content-Type': 'application/json'
};
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: storeModule.apiUrl,
headers: requestHeaders,
data: JSON.stringify(requestPayload),
responseType: 'json',
timeout: SM_REQUEST_TIMEOUT_MS,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error(`Таймаут запроса (pageIndex: ${currentIndex})`)),
});
});
if (response.status >= 200 && response.status < 400 && response.response) {
const data = response.response;
const pageInfo = data.page;
if (pageInfo && Array.isArray(pageInfo.rows)) {
const processedItems = pageInfo.rows
.map(item => storeModule.parseApiItem(item, storeModule))
.filter(item => item !== null);
allItems = allItems.concat(processedItems);
}
hasNext = pageInfo?.hasNext ?? false;
if (hasNext) {
await fetchBukaPage(currentIndex + 1);
}
} else {
throw new Error(`HTTP статус ${response.status} (pageIndex: ${currentIndex})`);
}
} catch (error) {
sm_logError(storeModule.name, `Ошибка загрузки страницы ${currentIndex}: ${error.message}`, error);
hasNext = false;
}
}
await fetchBukaPage(pageIndex);
return allItems;
},
parseApiItem: function(item, storeModule) {
try {
// --- Фильтрация ---
// 1. Проверяем тип (нужен цифровой, обычно type: 3)
if (item.type !== 3) return null;
// 2. Проверяем платформу (нужен PC)
const platformFilter = item.filters?.find(f => f.field === 'platform');
const isPC = platformFilter?.values?.some(v => v.title === 'PC');
if (!isPC) return null;
// 3. Проверяем статус продажи (доступен или предзаказ)
const saleState = item.saleState;
if (saleState !== 'available' && saleState !== 'pre-order') {
return null;
}
const productName = item.title?.trim();
const productUrlRaw = item.alias ? `/item/${item.alias}` : null;
const imageUrl = item.img;
const currentPrice = item.price?.actual ? sm_parsePrice(item.price.actual) : null;
const originalPrice = item.price?.old ? sm_parsePrice(item.price.old) : (currentPrice !== null ? currentPrice : null);
const discountPercent = item.price?.discount ? parseFloat(item.price.discount) : 0;
if (!productName || !productUrlRaw || !imageUrl || currentPrice === null) {
sm_logError(storeModule.name, 'Недостаточно данных в API ответе для элемента', item);
return null;
}
const fullProductUrl = storeModule.baseUrl + productUrlRaw;
const productUrlWithRef = fullProductUrl + '?ref=zoneofgames';
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrlWithRef,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: originalPrice === currentPrice ? null : originalPrice,
discountPercent: discountPercent > 0 ? discountPercent : null,
discountAmount: null,
currency: 'RUB',
isAvailable: true
};
return sm_calculateMissingValues(data);
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента API Buka', e);
return null;
}
}
},
{ // --- Модуль GGSEL ---
id: 'ggsel',
name: 'GGSEL',
baseUrl: 'https://ggsel.net',
apiUrl: 'https://api4.ggsel.com/elastic/goods/query',
isEnabled: true,
fetch: async function(query) {
let allItems = [];
let searchAfter = [];
const limit = 60;
let hasMore = true;
let fetchedCount = 0;
const maxFetches = 5;
let fetchAttempts = 0;
const storeModule = this;
async function fetchGGSELPage(currentIndex) {
fetchAttempts++;
const requestPayload = {
search_term: query,
limit: limit,
search_after: searchAfter,
is_preorders: false,
with_filters: true,
with_categories: false,
sort: "sortByPriceUp",
content_type_ids: [48, 2],
with_forbidden: false,
min_price: "",
max_price: "",
currency: "wmr",
lang: "ru",
platforms: ["Steam"]
};
const requestHeaders = {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json'
};
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: storeModule.apiUrl,
headers: requestHeaders,
data: JSON.stringify(requestPayload),
responseType: 'json',
timeout: SM_REQUEST_TIMEOUT_MS,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error(`Таймаут запроса (pageIndex: ${currentIndex})`)),
});
});
if (response.status >= 200 && response.status < 400 && response.response?.data) {
const data = response.response.data;
if (data.items && Array.isArray(data.items)) {
const processedItems = data.items
.map(item => storeModule.parseApiItem(item, storeModule))
.filter(item => item !== null);
allItems = allItems.concat(processedItems);
fetchedCount += data.items.length;
if (data.items.length < limit || !data.last_sort || fetchedCount >= (data.total ?? fetchedCount)) {
hasMore = false;
} else {
searchAfter = data.last_sort;
}
} else {
hasMore = false;
}
} else {
throw new Error(`HTTP статус ${response.status} (pageIndex: ${currentIndex})`);
}
} catch (error) {
sm_logError(storeModule.name, `Ошибка загрузки страницы ${currentIndex}: ${error.message}`, error);
hasMore = false;
}
if (hasMore && fetchAttempts < maxFetches) {
await new Promise(res => setTimeout(res, 150));
await fetchGGSELPage(currentIndex + 1);
}
}
await fetchGGSELPage(0);
if (fetchAttempts >= maxFetches && hasMore) {
sm_logError(storeModule.name, `Достигнут лимит запросов пагинации (${maxFetches}). Возможно, показаны не все результаты.`);
}
return allItems;
},
parseApiItem: function(item, storeModule) {
try {
if (item.forbidden_type !== 0 || item.hidden_from_search || item.hidden_from_parents) {
return null;
}
if (item.content_type_id !== 48 && item.content_type_id !== 2) {
return null;
}
const productName = item.name?.trim();
const productUrlRaw = `${storeModule.baseUrl}/catalog/product/${item.id_goods}`;
const productUrl = `${productUrlRaw}?ai=234029`;
const imageUrl = item.images ? `https://img.ggsel.ru/${item.id_goods}/original/250x250/${item.images}` : null;
const currentPrice = item.price_wmr ? sm_parsePrice(item.price_wmr) : null;
const potentialOriginalPrice = item.category_discount ? sm_parsePrice(item.category_discount) : null;
const originalPrice = (potentialOriginalPrice && currentPrice !== null && potentialOriginalPrice > currentPrice) ? potentialOriginalPrice : null;
const sellerId = item.id_seller;
const sellerName = item.seller_name;
if (!productName || currentPrice === null || !imageUrl) {
sm_logError(storeModule.name, 'Недостаточно данных в элементе API GGSEL (после проверок)', item);
return null;
}
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: originalPrice,
discountPercent: null,
discountAmount: null,
currency: 'RUB',
isAvailable: true,
sellerId: sellerId,
sellerName: sellerName
};
return sm_calculateMissingValues(data);
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента API GGSEL', e);
return null;
}
}
}, // --- Конец модуля GGSEL ---
{ // --- Модуль Plati.Market ---
id: 'platimarket',
name: 'Plati.Market',
baseUrl: 'https://plati.market',
apiUrlBase: 'https://api.digiseller.com/api/products/search2',
isEnabled: true,
fetch: async function(query) {
const MAX_RESULTS_PER_REQUEST = 500;
// --- Шаг 1: Узнаем общее количество товаров ---
const initialUrl = `${this.apiUrlBase}?query=${encodeURIComponent(query)}&searchmode=10&sortmode=2&pagesize=1`;
let totalItems = 0;
try {
const initialResponse = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: initialUrl,
responseType: 'json',
timeout: SM_REQUEST_TIMEOUT_MS,
onload: (response) => {
if (response.status >= 200 && response.status < 400 && response.response?.result?.total !== undefined) {
resolve(response.response);
} else {
sm_logError(this.name, `Не удалось получить total_pages. Status: ${response.status}`, response);
reject(new Error(`API Error: Status ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка (initial request)')),
ontimeout: () => reject(new Error('Таймаут запроса (initial request)'))
});
});
totalItems = parseInt(initialResponse.result.total, 10);
} catch (error) {
sm_logError(this.name, 'Ошибка на шаге 1 (получение total)', error);
return [];
}
if (totalItems === 0) {
return [];
}
// --- Шаг 2: Запрашиваем все (или до MAX_RESULTS_PER_REQUEST) товары ---
const resultsToFetch = Math.min(totalItems, MAX_RESULTS_PER_REQUEST);
if (resultsToFetch <= 0) return [];
const finalUrl = `${this.apiUrlBase}?query=${encodeURIComponent(query)}&searchmode=10&sortmode=2&pagesize=${resultsToFetch}`;
try {
const finalResponse = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: finalUrl,
responseType: 'json',
timeout: SM_REQUEST_TIMEOUT_MS * 2,
onload: (response) => {
if (response.status >= 200 && response.status < 400 && response.response?.items?.item) {
resolve(response.response);
} else {
sm_logError(this.name, `Не удалось получить items. Status: ${response.status}`, response);
reject(new Error(`API Error: Status ${response.status}`));
}
},
onerror: (error) => reject(new Error('Сетевая ошибка (final request)')),
ontimeout: () => reject(new Error('Таймаут запроса (final request)'))
});
});
return this.parseApiResponse(finalResponse.items.item, this);
} catch (error) {
sm_logError(this.name, 'Ошибка на шаге 2 (получение items)', error);
return [];
}
},
parseApiResponse: function(items, storeModule) {
const results = [];
if (!Array.isArray(items)) {
sm_logError(storeModule.name, 'Ответ API не содержит массив items', items);
return results;
}
items.forEach(item => {
try {
const productName = item.name;
const productUrlRaw = item.url;
const currentPrice = sm_parsePrice(item.price_rur);
const currency = 'RUB';
const sellerId = item.seller_id;
const sellerName = item.seller_name;
if (!productName || !productUrlRaw || currentPrice === null) {
return;
}
const productUrl = productUrlRaw + '?ai=234029';
const imageUrl = `https://graph.digiseller.ru/img.ashx?id_d=${item.id}&w=150&h=80`;
let data = {
storeId: storeModule.id,
storeName: storeModule.name,
storeUrl: storeModule.baseUrl,
productName: productName,
productUrl: productUrl,
imageUrl: imageUrl,
currentPrice: currentPrice,
originalPrice: currentPrice,
discountPercent: 0,
discountAmount: 0,
currency: currency,
isAvailable: true,
sellerId: sellerId,
sellerName: sellerName
};
results.push(sm_calculateMissingValues(data));
} catch (e) {
sm_logError(storeModule.name, 'Ошибка парсинга элемента API', e);
}
});
return results;
}
} // --- Конец модуля Plati.Market ---
// --- Сюда другие модули ---
];
// --- Инициализация модуля SalesMaster ---
sm_addStyles();
const steamAppIdCheckSM = unsafeWindow.location.pathname.match(/\/app\/(\d+)/);
if (steamAppIdCheckSM && steamAppIdCheckSM[1]) {
setTimeout(sm_addSalesMasterButton, 500);
sm_currentFilters = GM_getValue(SM_FILTER_STORAGE_KEY, {
priceMin: '',
priceMax: '',
discountPercentMin: '',
discountPercentMax: '',
discountAmountMin: '',
discountAmountMax: '',
hasDiscount: false,
stores: Object.fromEntries(sm_storeModules.map(s => [s.id, true]))
});
sm_storeModules.forEach(store => {
if (!(store.id in sm_currentFilters.stores)) {
sm_currentFilters.stores[store.id] = true;
}
});
}
})();
}
// Скрипт для страницы игры (Plati; отображение цен с Plati.Market) | https://store.steampowered.com/app/*
if (scriptsConfig.platiSales && unsafeWindow.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;
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>`;
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) {
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);
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();
}
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' };
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');
const baseUrl = item.url || `https://plati.market/itm/${item.id}`;
link.href = baseUrl + '?ai=234029';
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');
if (!actionsContainer || actionsContainer.querySelector('.plati_price_button')) {
return;
}
const ignoreButtonContainer = actionsContainer.querySelector('#ignoreBtn');
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; padding: 0 5px;" title="Найти на Plati.Market"><span>Plati</span></div>`;
platiContainer.querySelector('div').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showPlatiModal();
});
if (ignoreButtonContainer) {
ignoreButtonContainer.insertAdjacentElement('afterend', platiContainer);
} else {
actionsContainer.appendChild(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 = unsafeWindow.location.pathname.match(/\/app\/(\d+)/);
if (steamAppIdCheck && steamAppIdCheck[1]) {
addPlatiButton();
}
})();
}
}
})();