// ==UserScript== // @name Ultimate Steam Enhancer // @namespace https://store.steampowered.com/ // @version 2.0.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/chart.js@4.4.1/dist/chart.umd.min.js // @require https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/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 // @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 // @downloadURL none // ==/UserScript== (function() { 'use strict'; const scriptsConfig = { // Основные скрипты gamePage: true, // Скрипт для страницы игры (индикаторы о наличии русского перевода; получение дополнительных обзоров) | https://store.steampowered.com/app/* hltbData: true, // Скрипт для страницы игры (HLTB; получение сведений о времени прохождения) | https://store.steampowered.com/app/* friendsPlaytime: true, // Скрипт для страницы игры (Время друзей & Достижения) | https://store.steampowered.com/app/* earlyaccdata: true, // Скрипт для страницы игры (Ранний доступ) | https://store.steampowered.com/app/* zogInfo: true, // Скрипт для страницы игры (ZOG; получение сведение о наличии русификаторов) | https://store.steampowered.com/app/* 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 = { 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: `

Что делает:

  1. Отображает значки-индикаторы наличия русского языка (интерфейс, озвучка, субтитры) прямо на странице игры.
  2. Добавляет под стандартными обзорами блок с расширенной статистикой (загружается по щелчку или автоматически, если включена опция):
  3. Пример индикаторов и обзоров 1
  4. Модальные окна:

⚠️ Важное замечание о зависимостях:

Отключение этого модуля приведет к автоматическому отключению или нарушению корректной работы модулей «Время прохождения (HLTB)», «Русификаторы (ZOG)» и «Время друзей / Глобальные достижения», так как они критически зависят от его функционала по отображению элементов на странице игры.

` }, RuRegionalPriceAnalyzer: { category: 'gamePage', label: "Анализатор цен", title: "Анализатор цен", details: `

Что делает: Добавляет кнопку "Анализатор цен" на страницу игры. Этот инструмент позволяет анализировать региональные цены двумя способами: в рублях (по умолчанию) и в долларах США.

После нажатия кнопки "Сбор данных" в специальном окне, модуль выполняет следующее в зависимости от выбранного режима:

Режим Рублей (по умолчанию):

Режим Долларов США (переключаемый):

В обоих режимах, если игра в США бесплатна или цена не найдена, возможности анализа могут быть ограничены. Вся собранная информация представляется в модальном окне.

⚠️ Важная информация:

Каждый полный сбор данных подразумевает отправку ~41 запроса к серверам Steam (количество зависит от числа доступных регионов). Пожалуйста, используйте эту функцию обдуманно. Частое нажатие кнопки на разных играх в течение короткого периода времени может привести к временному ограничению доступа к API Steam (обычно на 5-15 минут).

Пример интерфейса анализатора цен ` }, hltbData: { category: 'gamePage', label: "Время прохождения (HLTB)", title: "Время прохождения HLTB", details: `

Что делает: Добавляет компактный блок с информацией о времени прохождения игры, полученной с популярного сайта HowLongToBeat.com.

Показывает среднее время для разных стилей:

Рядом со временем указывается количество игроков, на чьих данных основана статистика. Поиск игры в базе HLTB идет по названию, при неоднозначности предлагается выбор из похожих вариантов.

Пример HLTB 1 ` }, platiSales: { category: 'gamePage', label: "Поиск цен Plati.Market", title: "Поиск цен на Plati.Market", details: `

Что делает: Добавляет кнопку "Plati" рядом с кнопкой "В желаемое" на странице игры. Нажатие открывает полноэкранное окно для поиска предложений по этой игре на торговой площадке Plati.Market.

Возможности окна поиска:

Используются официальные API Plati.Market.

Кнопка Plati Окно Plati ` }, zogInfo: { category: 'gamePage', label: "Русификаторы (ZOG)", title: "Информация о наличии переводов с ZOG (ZoneOfGames)", details: `

Что делает: Добавляет блок с информацией о наличии русификаторов для игры на сайте ZoneOfGames.ru.

В блоке отображается:

Поиск происходит в реальном времени. Скрипт автоматически определяет название игры, выполняет поиск по алфавитному указателю на ZoneOfGames.ru и предлагает вам выбрать наиболее точное совпадение.

Пример ZOG 21 ` }, salesMaster: { category: 'gamePage', label: "Агрегатор цен (%)", title: "Агрегатор цен (%)", details: `

Что делает: Добавляет кнопку "%" рядом с кнопкой "В желаемое" на странице игры. Нажатие открывает модальное окно с ценами на эту игру из различных цифровых магазинов.

Возможности окна агрегатора:

Использует различные методы для получения цен (API, парсинг HTML).

Пример Агрегатора 1 Пример Агрегатора 2 ` }, friendsPlaytime: { category: 'gamePage', label: "Время друзей / Глобальные достижения", title: "Информация о времени друзей и статистике достижений", details: `

Что делает: Отображает блок с информацией о времени, которое ваши друзья провели в этой игре, а также о статистике глобальных достижений Steam.

Время друзей:

Глобальные достижения:

Данные загружаются при раскрытии блока.

Пример Время друзей / Ачивки ` }, pageGiftHelper: { category: 'gamePage', label: "Доступность подарков (страница игры)", title: "Доступность подарков (страница игры)", details: `

Что делает: Добавляет кнопку "GIFT" в блок с кнопкой "В желаемое" на странице игры.

Нажатие открывает окно, где можно:

Использует те же механизмы получения цен и курсов валют, что и помощник для списка желаемого.

Пример PageGiftHelper 1 ` }, earlyaccdata: { category: 'gamePage', label: "Индикатор раннего доступа", title: "Индикатор раннего доступа", details: `

Что делает: Показывает небольшую плашку над изображением игры с информацией о статусе раннего доступа (Early Access).

Расчет времени динамический. Использует даты со страницы Steam, а также может подтягивать дату старта раннего доступа из собственной базы для вышедших игр, если Steam ее не показывает.

` }, // --- Каталог --- catalogInfo: { category: 'catalog', label: "Доп. инфо / Фильтры", title: "Дополнительная информация и фильтрация в каталоге поиска", details: `

Что делает: Расширяет функционал страницы поиска по каталогу Steam (store.steampowered.com/search/).

При наведении:


Фильтры (панель справа):

Фильтры применяются динамически по мере получения данных от API.

` }, catalogHider: { category: 'catalog', label: "Скрытие игр", title: "Система скрытия игр в каталоге поиска", details: `

Что делает: Добавляет инструменты для массового скрытия неинтересующих игр прямо со страницы поиска по каталогу.

Элементы интерфейса:

Принцип работы:

  1. Отмечаете чекбоксами игры, которые хотите скрыть.
  2. Нажимаете "Скрыть выбранное".
  3. Скрипт добавляет эти игры в ваш официальный список игнорируемых в Steam и удаляет их элементы со страницы.

В отличие от стандартного механизма Steam, элементы полностью удаляются из DOM, что улучшает производительность при работе с большим количеством результатов.

Внимание: Рекомендуется использовать только при необходимости массового скрытия. Для обычного просмотра каталога лучше отключать эту опцию.

Пример Скрытия Игр ` }, // --- Сообщество / Активность --- homeInfo: { category: 'community', label: "Доп. инфо в ленте активности", title: "Дополнительная информация в ленте активности Steam", details: `

Что делает: Добавляет всплывающую подсказку при наведении на название игры в вашей ленте активности Steam (steamcommunity.com/my/home).

Подсказка содержит подробную информацию об игре, аналогичную той, что показывается в каталоге поиска:

Данные загружаются через API Steam.

Пример Инфо в Ленте ` }, stelicasRoulette: { category: 'community', label: "Рулетка Stelicas", title: "Рулетка Stelicas - Случайный выбор игры из ваших коллекций", details: `

Что делает: Добавляет блок "Рулетка Stelicas" на страницу вашей активности Steam (steamcommunity.com/my/home). Позволяет загрузить CSV-файл, сгенерированный приложением Stelicas, применить к нему разнообразные фильтры и случайным образом выбрать игру из вашей коллекции.

Возможности:

Как пользоваться:

Примечание: Качество работы и полнота информации в рулетке напрямую зависят от корректности и актуальности данных в предоставленном CSV-файле из Stelicas.

Пример модального окна Рулетки Stelicas с фильтрами ` }, // --- Торговая площадка --- Kaznachei: { category: 'market', label: "Продажи предмета", title: "Информация об исторических продажах на торговой площадке Steam", details: `

Что делает: Добавляет информационный блок на страницу предмета на торговой площадке Steam (steamcommunity.com/market/).

Блок содержит:

Данные загружаются через API истории цен Steam.

Пример Продаж 1 ` }, // --- Новости / Список желаемого --- Sledilka: { category: 'news_wishlist', label: "Наблюдатель (Желаемое/Библиотека)", title: "Наблюдатель: Отслеживание изменений в списке желаемого и библиотеке", details: `

Что делает: Отслеживает изменения в вашем списке желаемого Steam и в вашей библиотеке игр, отображает календарь релизов.

Основные функции:

  1. В правом верхнем углу страниц Steam появляется кнопка "Наблюдатель".
  2. Индикаторы статуса (Ж/Б): Показывают, как давно обновлялись данные для Желаемого и Библиотеки.
  3. Счетчик уведомлений: Показывает количество новых (непрочитанных) изменений.
  4. Панель уведомлений (по щелчку на кнопку):
  5. Пример Трекера 1
  6. Календарь релизов (по щелчку на кнопку "Календарь"):
  7. Пример Календаря
  8. Хранилище (по щелчку на кнопку "Хранилище"):
  9. Пример Хранилища

Требует авторизации. Обработка больших списков/библиотек может занять время. Используйте новые опции в настройках для ускорения сканирования библиотеки.

` }, newsFilter: { category: 'news_wishlist', label: "Фильтр новостей", title: "Система скрытия новостей в новостном центре", details: `

Что делает: Позволяет гибко управлять отображением новостей в новостном центре Steam (store.steampowered.com/news/), скрывая неинтересные материалы.

Основные возможности и использование:

  1. Выбор новостей для скрытия:
  2. Панель управления (справа вверху):
  3. Пример интерфейса фильтра новостей
  4. Панель "Хранилище скрытых новостей":
` }, wishlistGiftHelper: { category: 'news_wishlist', label: "Доступность подарков (список желаемого)", title: "Доступность подарков (список желаемого)", details: `

Что делает: Добавляет кнопку со значком лупы на страницу списка желаемого, позволяющую определить, какие игры можно подарить друзьям в других регионах.

Основные функции:

Это помогает легко найти подходящие и экономически целесообразные подарки для друзей за границей.

*Примечание: Скорость загрузки данных зависит от размера списка желаемого.

Пример WishlistGiftHelper 1` }, // --- Дополнительные --- autoExpandHltb: { category: 'additional', label: "Авто-раскрытие HLTB", title: "Автоматически раскрывать спойлер HLTB", details: "

Если включено, блок с информацией о времени прохождения (HLTB) на странице игры будет автоматически раскрываться при загрузке страницы (если основной модуль HLTB включен).

Удобно, если вы всегда хотите видеть эту информацию без лишнего щелчка.

" }, autoLoadReviews: { category: 'additional', label: "Авто-загрузка доп. обзоров", title: "Автоматически загружать дополнительные обзоры", details: "

Если включено, блок с дополнительными обзорами (Тотальные, Безкитайские, Русские) на странице игры будет загружаться автоматически при загрузке страницы (если основной модуль 'Индикаторы/Обзоры' включен).

Экономит щелчок, если вам всегда нужна эта статистика.

" }, toggleEnglishLangInfo: { category: 'additional', label: "Показ инфо об англ. языке", title: "Отображать данные об английском языке", details: "

Функция для переводчиков и интересующихся. Если включено, в блоках дополнительной информации (в каталоге при наведении и в ленте активности при наведении) будет также отображаться информация о поддержке английского языка (интерфейс, озвучка, субтитры), аналогично русскому.

По умолчанию эта информация скрыта для экономии места.

" } }; 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(``, russianSupport.interface, 'интерфейс'); const plum = createFruitIndicator(``, russianSupport.voice, 'озвучка'); const apricot = createFruitIndicator(``, 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('

Ошибка загрузки обзоров.

'); } } catch (e) { console.error('Error parsing Russian reviews HTML:', e); callback('

Ошибка обработки ответа.

'); } } else { console.error(`Failed to fetch Russian reviews HTML. Status: ${response.status}`); callback('

Ошибка сети при загрузке обзоров.

'); } }, onerror: function(error) { console.error('Network error fetching Russian reviews HTML:', error); callback('

Ошибка сети.

'); }, ontimeout: function() { console.error('Timeout fetching Russian reviews HTML'); callback('

Таймаут загрузки обзоров.

'); } }); } 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 = `
Доп. обзоры:
Загрузить статистику
`; 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 = `
${title}:
${errorText || summaryText}
`; 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 = `
Безкитайские:
${summaryText}
`; 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 = `
×

Русскоязычные обзоры

Загрузка...
`; 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 = 'Нажмите "Собрать", чтобы загрузить данные.'; } 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 = ` `; 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 = 'Ошибка: Не найдены элементы интерфейса. Попробуйте закрыть и снова открыть окно.'; return; } collectBtn.disabled = true; tableBody.innerHTML = 'Загрузка данных... '; 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 = 'Нет обзоров ни на одном языке или не удалось загрузить данные.'; return; } else if (validLanguageData.length === 0 && languagesWithErrors.length > 0) { tableBody.innerHTML = 'Не удалось загрузить данные об обзорах.'; } 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 = ` ${index + 1} ${data.langName} ${formatNumber(reviewCount)} ${percentTotal}% ${reviewCount > 0 ? percentPositive + '%' : '-'} `; 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("HowLongToBeat - 404")) { 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}: ${timeText} (${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 statsBlock = document.createElement('div'); statsBlock.style.position = 'absolute'; statsBlock.style.top = '0px'; statsBlock.style.left = '406px'; statsBlock.style.width = '30px'; statsBlock.style.height = '30px'; statsBlock.style.background = 'rgba(27, 40, 56, 0.95)'; statsBlock.style.padding = '15px'; statsBlock.style.borderRadius = '4px'; statsBlock.style.border = '1px solid #3c3c3c'; statsBlock.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)'; statsBlock.style.zIndex = '1'; statsBlock.style.fontFamily = 'Arial, sans-serif'; statsBlock.style.overflow = 'hidden'; statsBlock.style.transition = 'all 0.3s ease'; const triangle = document.createElement('div'); triangle.style.position = 'absolute'; triangle.style.bottom = '5px'; triangle.style.left = '50%'; triangle.style.transform = 'translateX(-50%)'; triangle.style.width = '0'; triangle.style.height = '0'; triangle.style.borderLeft = '5px solid transparent'; triangle.style.borderRight = '5px solid transparent'; triangle.style.borderTop = '5px solid #67c1f5'; triangle.style.cursor = 'pointer'; statsBlock.appendChild(triangle); const title = document.createElement('div'); title.style.display = 'flex'; title.style.alignItems = 'center'; title.style.marginBottom = '7px'; title.style.cursor = 'pointer'; const combinedImg = document.createElement('div'); combinedImg.style.width = '29px'; combinedImg.style.height = '29px'; combinedImg.style.backgroundImage = 'url(https://gist.githubusercontent.com/0wn3dg0d/9c259eebc40a1e97397ccf3da7ee7bd6/raw/SUEftach.png)'; combinedImg.style.backgroundSize = 'contain'; combinedImg.style.backgroundPosition = 'center'; title.appendChild(combinedImg); statsBlock.appendChild(title); const content = document.createElement('div'); content.style.fontSize = '14px'; content.style.color = '#c6d4df'; content.style.display = 'none'; content.style.padding = '0'; statsBlock.appendChild(content); const toggleBlock = async () => { if (content.style.display === 'none') { statsBlock.style.width = '250px'; statsBlock.style.height = '60px'; content.style.display = 'block'; content.textContent = 'Загрузка...'; triangle.style.borderTop = 'none'; triangle.style.borderBottom = '5px solid #67c1f5'; try { const friendsData = await loadFriendsData(); const achievementsData = await loadAchievementsData(); content.innerHTML = ''; const friendsTitle = document.createElement('div'); friendsTitle.style.fontSize = '12px'; friendsTitle.style.fontWeight = 'bold'; friendsTitle.style.color = '#67c1f5'; friendsTitle.style.marginBottom = '5px'; friendsTitle.textContent = 'ВРЕМЯ ДРУЗЕЙ'; content.appendChild(friendsTitle); if (friendsData.length > 0) { const maxHours = Math.max(...friendsData.map(f => f.hours)); const minHours = Math.min(...friendsData.map(f => f.hours)); const avgHours = friendsData.reduce((sum, f) => sum + f.hours, 0) / friendsData.length; const maxPlayers = friendsData.filter(f => f.hours === maxHours); const maxEl = document.createElement('div'); maxEl.style.marginBottom = '4px'; maxEl.innerHTML = `Макс: ${maxHours.toFixed(1)} ч.`; if (maxPlayers.length > 0) { maxEl.innerHTML += ` (${maxPlayers.map(p => `${p.name}` ).join(', ')})`; } const avgEl = document.createElement('div'); avgEl.style.marginBottom = '4px'; avgEl.innerHTML = `Среднее: ${avgHours.toFixed(1)} ч. (${friendsData.length} чел.)`; const minEl = document.createElement('div'); minEl.innerHTML = `Минимальное: ${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 = `Платина: ${achievementsData.platinumPercent}%`; const averageEl = document.createElement('div'); averageEl.innerHTML = `Средний прогресс: ${achievementsData.averageAdjustedPercent}%`; content.append(platinumEl, averageEl); } else { const noAch = document.createElement('div'); noAch.textContent = achievementsData.error === 'Нет достижений' ? 'Достижений нет' : achievementsData.error; noAch.style.marginBottom = '12px'; content.appendChild(noAch); } statsBlock.style.height = `${content.scrollHeight + 38}px`; } catch (error) { content.textContent = 'Ошибка загрузки'; statsBlock.style.height = '60px'; } } else { content.style.display = 'none'; statsBlock.style.height = '30px'; statsBlock.style.width = '30px'; triangle.style.borderBottom = 'none'; triangle.style.borderTop = '5px solid #67c1f5'; } }; async function loadFriendsData() { try { const friendsLink = document.querySelector('.recommendation_reasons a[href*="friendsthatplay"]'); if (!friendsLink) return []; const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: friendsLink.href, onload: resolve, onerror: reject, timeout: 5000 }); }); const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, 'text/html'); return Array.from(doc.querySelectorAll('.friendBlockContent')) .map(block => { const timeText = block.querySelector('.friendSmallText')?.textContent; const hoursMatch = timeText?.match(/(\d+[,.]?\d*)\s*ч/); return { name: block.firstChild.textContent.trim(), hours: hoursMatch ? parseFloat(hoursMatch[1].replace(',', '.')) : 0, profile: block.closest('.friendBlock').querySelector('a').href }; }) .filter(f => f.hours > 0); } catch (error) { return []; } } async function loadAchievementsData() { try { const appIdMatch = unsafeWindow.location.pathname.match(/\/app\/(\d+)/); if (!appIdMatch) return { hasAchievements: false, error: 'Не найден App ID' }; const appId = appIdMatch[1]; const achievementsUrl = `https://steamcommunity.com/stats/${appId}/achievements/`; const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: achievementsUrl, onload: resolve, onerror: reject, timeout: 8000 }); }); if (response.status !== 200) return { hasAchievements: false, error: 'Ошибка загрузки страницы' }; const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, 'text/html'); if (doc.querySelector('.no_achievements_message')) { return { hasAchievements: false, error: 'Достижения отсутствуют' }; } const percentElements = doc.querySelectorAll('.achievePercent'); if (percentElements.length === 0) return { hasAchievements: false, error: 'Достижения отсутствуют' }; const percents = Array.from(percentElements) .map(el => { const text = el.textContent.trim(); return parseFloat(text.replace('%', '')) || 0; }) .filter(p => p > 0); if (percents.length === 0) return { hasAchievements: false, error: 'Нет данных' }; const maxPercent = Math.max(...percents); const minPercent = Math.min(...percents); const adjustment = 100 - maxPercent; const adjustedPercents = percents.map(p => p + adjustment); const averageAdjusted = adjustedPercents.reduce((sum, p) => sum + p, 0) / adjustedPercents.length; return { hasAchievements: true, platinumPercent: (minPercent + adjustment).toFixed(1), averageAdjustedPercent: averageAdjusted.toFixed(1), }; } catch (error) { return { hasAchievements: false, error: 'Ошибка соединения' }; } } title.addEventListener('click', toggleBlock); triangle.addEventListener('click', toggleBlock); document.querySelector('#gameHeaderImageCtn').appendChild(statsBlock); if (scriptsConfig.autoExpandFriends) { toggleBlock(); } })(); } // Скрипт для страницы игры (Ранний доступ) | https://store.steampowered.com/app/* if (window.location.pathname.includes('/app/') && scriptsConfig.earlyaccdata) { (function() { 'use strict'; const EAORDATE_STORAGE_KEY = 'USE_EarlyAccess_ordateData'; const EAORDATE_URL = 'https://gist.githubusercontent.com/0wn3dg0d/58a8e35f3d34014ea749a22d02f7e203/raw/eaordate.json'; const CACHE_DURATION = 180 * 24 * 60 * 60 * 1000; // 6 месяцев const getYearForm = (n) => { n = Math.abs(n) % 100; const n1 = n % 10; if (n > 10 && n < 20) return 'лет'; if (n1 === 1) return 'год'; if (n1 >= 2 && n1 <= 4) return 'года'; return 'лет'; }; const getMonthForm = (n) => { n = Math.abs(n) % 100; const n1 = n % 10; if (n > 10 && n < 20) return 'месяцев'; if (n1 === 1) return 'месяц'; if (n1 >= 2 && n1 <= 4) return 'месяца'; return 'месяцев'; }; const parseSteamDate = (dateStr) => { const numericParts = dateStr.split('.'); if (numericParts.length === 3) { const day = parseInt(numericParts[0], 10); const month = parseInt(numericParts[1], 10) - 1; const year = parseInt(numericParts[2], 10); return new Date(year, month, day); } const monthsMap = { 'янв': 0, 'фев': 1, 'мар': 2, 'апр': 3, 'мая': 4, 'июн': 5, 'июл': 6, 'авг': 7, 'сен': 8, 'окт': 9, 'ноя': 10, 'дек': 11 }; const cleanedStr = dateStr.replace(/\./g, ''); const [day, monthNameRaw, year] = cleanedStr.split(' '); const monthName = monthNameRaw.substring(0, 3); return new Date(parseInt(year), monthsMap[monthName], parseInt(day)); }; const fetchOrdateData = async () => { const cachedData = GM_getValue(EAORDATE_STORAGE_KEY, null); if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) { return cachedData.data; } return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: EAORDATE_URL, onload: function(response) { try { const data = JSON.parse(response.responseText); GM_setValue(EAORDATE_STORAGE_KEY, { timestamp: Date.now(), data: data }); resolve(data); } catch (e) { console.error('Error parsing EAOrdate data:', e); resolve(cachedData?.data || []); } }, onerror: () => resolve(cachedData?.data || []) }); }); }; const getAppId = () => { const match = 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 = `
${isReleased ? '➡️' : '⏳'} ${message}
`; 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 = `

Click "Fetch Data" to begin.

`; 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 = '

Нажмите "Сбор данных" для начала.

'; 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 = 'Вы переходите в режим долларов. Данный режим предназначен для получения цен в долларах США и может быть полезен для оценки ценовой политики при общении с разработчиками/издателями.

Продолжить?'; 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 = ``; 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 = ``; 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 = `

${initialMsg}

`; 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} ` : 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 = `

${rpa_currentDisplayMode === 'USD' ? 'Summary:' : 'Сводная информация:'}

`; 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 += `
Base US Price:${baseUsdPrice.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
`; 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 += `
Базовая цена в США (для расчета):$${baseUsdPrice.toFixed(2)}
`; const recommendedRubPriceVal = calculateRecommendedRubPrice(baseUsdPrice); if (recommendedRubPriceVal === null || typeof recommendedRubPriceVal === 'string') { summaryDiv.innerHTML += `
Рекомендуемая цена Steam в РФ:${recommendedRubPriceVal || 'Не удалось рассчитать'}
`; } else { summaryDiv.innerHTML += `
Рекомендуемая цена Steam в РФ:${recommendedRubPriceVal.toLocaleString('ru-RU')} руб.
`; } 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 += `
Cheapest (USD):${cheapestRegion.priceInUsd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} (${cheapestRegion.regionName})
`; summaryDiv.innerHTML += `
Most Expensive (USD):${mostExpensiveRegion.priceInUsd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} (${mostExpensiveRegion.regionName})
`; const usEntryIndex = regionalPrices.findIndex(r => r.cc === 'US'); if (usEntryIndex !== -1) { summaryDiv.innerHTML += `
US Price Rank:${usEntryIndex + 1} of ${regionalPrices.length}
`; } generatePriceTable(regionalPrices, baseUsdPrice, resultsDiv); } else { resultsDiv.innerHTML = `

Could not retrieve regional prices for comparison.

`; } } 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 = `Цена в РФ не найдена/неконвертируема.`; } ruComparisonDiv.innerHTML += `
Фактическая цена в РФ:${factPriceValue}${factPriceSubValue ? `${factPriceSubValue}` : ''}
`; } else if (typeof recommendedRubPriceVal === 'number') { ruComparisonDiv.innerHTML += `
Фактическая цена в РФ:Не найдена.
`; } 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 += `
Место РФ в мировом рейтинге цен:${rankText}
`; generatePriceTable(regionalPrices, ruActualPriceInRub, resultsDiv); } else { resultsDiv.innerHTML = `

Не удалось получить региональные цены для сравнения.

`; } } 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 = '
'; 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 += "
Интерфейс: ✔ "; if (data.language_support_russian.full_audio) languageSupportRussianText += "
Озвучка: ✔ "; if (data.language_support_russian.subtitles) languageSupportRussianText += "
Субтитры: ✔"; 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 += "
Интерфейс: ✔ "; if (data.language_support_english.full_audio) languageSupportEnglishText += "
Озвучка: ✔ "; if (data.language_support_english.subtitles) languageSupportEnglishText += "
Субтитры: ✔"; 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 => `
${tag}
` ).join(''); tooltipContent.innerHTML = `
Издатели: ${data.publishers || "Нет данных"}
Разработчики: ${data.developers || "Нет данных"}
Серия игр: ${data.franchises || "Нет данных"}
Отзывы: ${data.review_count || "0"} (${data.percent_positive || "0"}% положительных)
Ранний доступ: ${data.is_early_access ? "Да" : "Нет"}
Русский язык: ${languageSupportRussianText}
${scriptsConfig.toggleEnglishLangInfo ? `
Английский язык: ${languageSupportEnglishText}
` : ''}
Метки:
${tagsHtml}
Описание: ${data.short_description || "Нет данных"}
`; 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 = `
Русский перевод
Только текст
Озвучка
Без перевода
`; const dlcFilterBlock = document.createElement('div'); dlcFilterBlock.className = 'block search_collapse_block'; dlcFilterBlock.innerHTML = `
DLC
Только ваши DLC
`; 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 = `В хранилище: ${persistentlyHiddenItems.length}`; 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 = `

Хранилище скрытых новостей

Новости, ID которых есть в этом списке, не будут отображаться (если не включен режим "Показать скрытое").

`; 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 = '
  • Хранилище пусто.
  • '; 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 = 'Информация о продажах '; 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 = 'ГодСумма продаж за годУшло разработчикуУшло Valve'; yearlySalesTable.appendChild(yearlySalesHeader); for (const year in yearlySales) { const row = document.createElement('tr'); row.innerHTML = `${year}${yearlySales[year].total.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.${yearlySales[year].developerShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.${yearlySales[year].valveShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.`; 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 = '
    '; 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 += "
    Интерфейс: ✔ "; if (data.language_support_russian.full_audio) languageSupportRussianText += "
    Озвучка: ✔ "; if (data.language_support_russian.subtitles) languageSupportRussianText += "
    Субтитры: ✔"; 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 += "
    Интерфейс: ✔ "; if (data.language_support_english.full_audio) languageSupportEnglishText += "
    Озвучка: ✔ "; if (data.language_support_english.subtitles) languageSupportEnglishText += "
    Субтитры: ✔"; 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 ? `
    ${data.name}
    ` : ''; 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 => `
    ${tag}
    ` ).join(''); tooltipContent.innerHTML = `
    Название: ${data.name || "Нет данных"}
    ${imageHtml}
    Дата выхода: ${data.release_date}
    Издатели: ${data.publishers || "Нет данных"}
    Разработчики: ${data.developers || "Нет данных"}
    Серия игр: ${data.franchises || "Нет данных"}
    Отзывы: ${data.review_count || "0"} (${data.percent_positive || "0"}% положительных)
    Ранний доступ: ${data.is_early_access ? "Да" : "Нет"}
    Русский язык: ${languageSupportRussianText}
    ${scriptsConfig.toggleEnglishLangInfo ? `
    Английский язык: ${languageSupportEnglishText}
    ` : ''}
    Метки:
    ${tagsHtml}
    Описание: ${data.short_description || "Нет данных"}
    `; 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 = `

    Рулетка Stelicas

    `; 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 = `
    Файл не выбран
    Категории
    Дата выхода
    Тэги
    Русский язык
    Количество отзывов
    -
    Рейтинг (%)
    -
    `; mainContainer.appendChild(leftControlsPanel); const rightContentArea = document.createElement('div'); rightContentArea.id = 'sr2_rightContentArea'; rightContentArea.innerHTML = `
    Постер
    Дата выхода
    Издатель
    Разработчик
    Русский язык
    `; 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 = '
    Нет игр по фильтрам для отображения в подборке.
    '; 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', '
    Нет постера
    '); }; card.appendChild(img); } else { card.insertAdjacentHTML('afterbegin', '
    Нет постера
    '); } 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 = `

    Рулетка Stelicas - Инструкция

    Этот модуль поможет вам случайным образом выбрать игру для прохождения из вашей библиотеки Steam, используя данные, экспортированные из приложения Stelicas.

    Порядок действий:

    1. Подготовка CSV-файла:
    2. Загрузка CSV в рулетку: В левой панели текущего окна нажмите "Выбрать CSV от Stelicas" и укажите путь к файлу final_data.csv.
    3. Выбор фильтров: После выбора всех фильтров нажмите кнопку "Применить фильтры". Счётчики у фильтров обновляются в реальном времени при изменении других фильтров. Для сброса всех фильтров к состоянию по умолчанию используйте кнопку со значком возврата рядом с "Применить фильтры".
    4. Настройка приоритетов (опционально): Если активировать опцию "Приоритет по отзывам", игры с более высоким рейтингом и большим количеством обзоров будут иметь больше шансов на выпадение (среди отфильтрованных).
    5. Запуск рулетки: Нажмите большую кнопку "КРУТИТЬ!".
    6. Просмотр подборки:

    После остановки рулетки вы увидите подробную информацию о выбранной игре.

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

    Сбросить фильтры

    Вы уверены, что хотите сбросить все фильтры к значениям по умолчанию?

    `; 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 = '
    Нет игр по фильтрам
    '; 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 ? `${percent}% (${game.review_count || 0} отзывов)` : 'Нет оценок'; 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 = ``; const storageIcon = ``; const settingsIcon = ``; const envelopeIcons = { unread: ``, read: `` }; 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 = $(`
    Наблюдатель
    Ж:??
    Б:??
    ${getUnreadCount()}
    Уведомления
    `); 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($(`
    ${message}
    `)); } 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 = $(`
    ${message}
    `); 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 = $(`
    ${message}
    `); 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(`Изменения в локализации:
    Добавлено: ${added.join(', ')}
    Убрано: ${removed.join(', ')}`); } else if (added.length > 0) { if (oldHasAny) { parts.push(`К русскому языку добавлено:
    ${added.join(' + ')}`); } else { parts.push(`Появился русский язык:
    ${added.join(' + ')}`); } } else if (removed.length > 0) { if (newHasAny) { parts.push(`Локализация урезана:
    Убрано: ${removed.join(', ')}`); } else { parts.push(`Русский язык удален из игры.`); } } return parts.join('
    '); } else { switch (type) { case 'wishlist_date': return `Дата выхода изменилась:
    ${formatDate(notification.oldDate)}${formatDate(notification.newDate)}`; 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('
    Нет новых уведомлений
    '); 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 = $(`
    ${notification.read ? envelopeIcons.read : envelopeIcons.unread}
    X
    ${getNotificationTitle(notificationType)}
    ${notification.name || `Игра #${notification.appid}`}
    ${getNotificationDetailsHTML(notification, notificationType)}
    Обнаружено: ${new Date(notification.timestamp).toLocaleString()}
    `); 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 = $(`
    Календарь релизов (${Object.keys(gameData).length} игр в списке)
    ×
    `); 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('
    Нет игр с датой выхода в будущем в вашем списке желаемого.
    '); 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 = $(`
    ${monthName} ${year}
    `); const grid = monthBlock.find('.calendar-grid'); grid.append('
    Пн
    Вт
    Ср
    Чт
    Пт
    Сб
    Вс
    '); for (let i = 0; i < adjustedFirstDay; i++) { grid.append('
    '); } 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 = $(`
    ${day}
    `); 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 = $(`
    ${game.name}
    ${isApproximate ? `
    Приблизительная дата: ${getApproximateDateText(game.releaseInfo)}
    ` : ''}
    `); dayElement.append(gameElement); }); grid.append(dayElement); } content.append(monthBlock); }); if (visibleMonths < monthsData.length) { content.append(`
    `); 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 = `
    ×

    Управление хранилищем


    `; 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 = ``; 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 = ``; 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 ? ' ' : ''); } 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 (!found && typeof unsafeWindow !== 'undefined' && unsafeWindow.Config?.COUNTRY && unsafeWindow.UserConfig?.accountid) { try { const countryCode = unsafeWindow.Config.COUNTRY; const currencyInfo = Object.values(WGH_COUNTRY_CURRENCY_MAP).find(c => c.iso === unsafeWindow.Config.STORE_BASE_URL.match(/\.com\/(\w{2})\//)?.[1]?.toLowerCase()); const walletInfoInSSR = JSON.parse(unsafeWindow.SSR.renderContext.queryData) .queries.find(q => q?.queryKey?.[0] === 'CurrentUserWalletDetails')?.state?.data; if (walletInfoInSSR?.currency_code) { wgh_currentUserCurrencyCode = walletInfoInSSR.currency_code; wgh_currentUserCountryCode = walletInfoInSSR.wallet_country_code || countryCode; wgh_currentUserISOCurrencyCode = WGH_CURRENCY_CODE_TO_ISO[wgh_currentUserCurrencyCode] || null; if (wgh_currentUserCountryCode && wgh_currentUserISOCurrencyCode) { found = true; console.log(`[WGH] Регион успешно определен через SSR/Config: ${wgh_currentUserCountryCode}`); } } } catch(e) { console.error('[WGH] Ошибка при разборе SSR/Config для определения региона:', e); } } 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 = `
    ${myData.name} ${myData.priceData?.discountPercent > 0 ? `
    -${myData.priceData.discountPercent}%
    ` : ''}
    ${myData.name}
    ${myData.priceData?.formattedOriginal ? `${myData.priceData.formattedOriginal}` : ''} ${myData.priceData?.formattedFinal || 'N/A'}
    ${myData.reviewDesc} (${myData.reviewPercent}%)
    Дата выхода: ${releaseDateStr}
    ${myData.canGift ? '' : '
    Нельзя подарить
    '} ${friendPriceStr ? `
    ${friendPriceStr}
    ` : ''} ${priceDiffStr ? `
    ${priceDiffStr}
    ` : ''}
    `; 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 = `Ваш регион: ${wgh_currentUserCountryCode || '??'} (${wgh_currentUserISOCurrencyCode || '???'})`; 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 = ''; 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 = ` `; 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 = `

    Проверить возможность подарка

    `; 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 = '
    Загрузка данных...
    '; 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 = `
    Ошибка конвертации валют: ${rateError.message}. Сравнение невозможно.
    `; 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 })}
    Цена друга: ${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 += ` (курс ≈ ${exchangeRateUsed.toFixed(4)})`; } resultHTML += `
    `; 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 += `
    ${resultText}
    `; if (!canFriendReceiveGift && isWithinThreshold) { resultHTML += `Примечание: Steam не разрешает покупку этой игры в подарок для данного региона, несмотря на подходящую разницу цен.`; } else if (!isWithinThreshold) { resultHTML += `Примечание: Разница цен (${(priceDifferencePercent * 100).toFixed(0)}%) превышает допустимый порог Steam (${(PGH_GIFT_PRICE_DIFF_THRESHOLD * 100)}%).`; } pgh_resultDiv.innerHTML = resultHTML; } catch (error) { console.error("[PGH] Ошибка при получении/сравнении цен:", error); pgh_resultDiv.innerHTML = `
    Ошибка: ${error.message}
    `; } 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 = 'GIFT'; 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; // --- 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 apiUrl = `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${baseCurrency.toLowerCase()}.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[baseCurrency.toLowerCase()]; if (rates && typeof rates === 'object') { 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_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 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(); } }; header.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)); header.appendChild(titleFilterInput); sm_sortButtonsContainer = document.createElement('div'); sm_sortButtonsContainer.id = 'salesMasterSortButtons'; header.appendChild(sm_sortButtonsContainer); const resetSortBtn = document.createElement('button'); resetSortBtn.id = 'salesMasterResetSortBtn'; resetSortBtn.className = 'salesMasterBtn'; resetSortBtn.title = 'Сбросить сортировку (По Цене)'; resetSortBtn.innerHTML = ``; resetSortBtn.onclick = () => sm_resetSort(true); sm_sortButtonsContainer.appendChild(resetSortBtn); sm_createSortButton('price', 'Цена'); sm_createSortButton('discountPercent', '% Скидки'); sm_createSortButton('discountAmount', `Скидка ${sm_getCurrencySymbol()}`); sm_createSortButton('name', 'Название'); 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 = `

    Цена, ${sm_getCurrencySymbol()} ${sm_createResetButtonHTML('price')}

    Скидка, % ${sm_createResetButtonHTML('discountPercent')}

    Скидка, ${sm_getCurrencySymbol()} ${sm_createResetButtonHTML('discountAmount')}

    Опции ${sm_createResetButtonHTML('options')}

    Магазины ${sm_createResetButtonHTML('stores')}

    Отметить всё | Снять всё
    `; 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 = ``; 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); const selectAllStoresBtn = document.getElementById('smSelectAllStores'); const deselectAllStoresBtn = document.getElementById('smDeselectAllStores'); if (selectAllStoresBtn) { selectAllStoresBtn.addEventListener('click', sm_selectAllStores); } if (deselectAllStoresBtn) { deselectAllStoresBtn.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 = `

    Импорт списка исключений

    Вставьте список слов, разделенных запятыми:

    `; 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; Object.values(sm_storeModules).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 ``; } function sm_renderStoreCheckboxes() { sm_filterStoreCheckboxesContainer = document.getElementById('smFilterStoreCheckboxes'); if (!sm_filterStoreCheckboxesContainer) return; sm_filterStoreCheckboxesContainer.innerHTML = ''; Object.values(sm_storeModules).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(); } // --- Управление модальным окном --- function sm_showModal() { if (!sm_modal) sm_createModal(); sm_updateStatus('Нажмите "Обновить %" для поиска цен...'); if (sm_currentResults.length > 0 || sm_resultsDiv.innerHTML !== '') { sm_resultsDiv.innerHTML = ''; sm_currentResults = []; } const titleFilterInput = document.getElementById('smTitleFilterInput'); if (titleFilterInput) titleFilterInput.value = ''; const gameName = sm_getSteamGameName(); 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_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 ? ' ' : ''); } 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; Object.values(sm_storeModules).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; const baseText = btn.textContent.replace(/ [▲▼]$/, ''); if (btnField === sm_currentSort.field) { const arrow = sm_currentSort.direction === 'asc' ? ' ▲' : ' ▼'; btn.classList.add('active'); btn.textContent = baseText + arrow; } else { btn.classList.remove('active'); const defaultDirections = { price: 'asc', discountPercent: 'desc', discountAmount: 'desc', name: 'asc' }; const defaultArrow = (defaultDirections[btnField] || 'asc') === 'asc' ? ' ▲' : ' ▼'; 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 '₽'; } 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; resultsToScan.forEach(item => { if (item.currentPrice !== null) { if (item.currentPrice < minPrice) minPrice = item.currentPrice; if (item.currentPrice > maxPrice) maxPrice = item.currentPrice; } if (item.discountPercent !== null) { if (item.discountPercent < minDiscountPercent) minDiscountPercent = item.discountPercent; if (item.discountPercent > maxDiscountPercent) maxDiscountPercent = item.discountPercent; } if (item.discountAmount !== null) { if (item.discountAmount < minDiscountAmount) minDiscountAmount = item.discountAmount; if (item.discountAmount > maxDiscountAmount) maxDiscountAmount = item.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 currencySymbol = sm_getCurrencySymbol(); 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'; console.warn(`[SalesMaster][${item.storeName}] Invalid image URL base for: ${item.imageUrl}`); } } 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'; currentPriceSpan.textContent = item.currentPrice !== null ? `${item.currentPrice.toLocaleString('ru-RU')} ${currencySymbol}` : 'Нет цены'; priceDiv.appendChild(currentPriceSpan); if (item.discountPercent && item.discountPercent > 0 && item.originalPrice !== null) { const discountBadge = document.createElement('span'); discountBadge.className = 'sm-discount-badge'; discountBadge.textContent = `-${Math.round(item.discountPercent)}%`; priceDiv.appendChild(discountBadge); const originalPriceSpan = document.createElement('span'); originalPriceSpan.className = 'sm-original-price'; originalPriceSpan.textContent = `${item.originalPrice.toLocaleString('ru-RU')} ${currencySymbol}`; 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); // --- Ссылка на продавца (Plati.Market) --- 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) { console.error("[SalesMaster] Error creating Plati seller link:", e); const sellerText = document.createElement('div'); sellerText.className = 'sm-seller-link no-link'; sellerText.textContent = `Продавец: ${item.sellerName}`; storeInfoContainer.appendChild(sellerText); } } // --- Ссылка на продавца (GGSEL) --- 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 = `
    %
    `; 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>/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 = ``; 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} `; 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 = `

    Цена (${ps_currentCurrency}) ${ps_createResetButtonHTML('price')}

    Продажи ${ps_createResetButtonHTML('sales')}

    Рейтинг продавца ${ps_createResetButtonHTML('rating')}

    Опции ${ps_createResetButtonHTML('options')}

    Дата добавления ${ps_createResetButtonHTML('date')}

    `; 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 = ``; 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 ``; } // --- Управление модальным окном --- 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 ? ' ' : ''); 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} ${arrow}`).attr('data-dir', activeDirection); } else { const defaultDir = ps_advancedSorts[itemField].defaultDir; const defaultArrow = defaultDir === 'asc' ? ' ▲' : ' ▼'; $item.removeClass('active').html(`${baseText} ${defaultArrow}`).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 = `Рейт: ${ratingVal > 0 ? ratingVal.toLocaleString('ru-RU', {maximumFractionDigits: 0}) : 'N/A'}Отз: ${goodRev}${badRev > 0 ? '/' + badRev + '' : ''}Возв: ${returns}`; infoRow2.innerHTML = `Прод: ${salesCount > 0 ? salesCount.toLocaleString('ru-RU') : '0'}Доб: ${formatDateString(itemDate)}`; infoContainer.appendChild(infoRow1); infoContainer.appendChild(infoRow2); const sellerLink = document.createElement('a'); sellerLink.className = 'sellerLink'; sellerLink.textContent = `Продавец: ${item.seller_name || 'N/A'}`; sellerLink.title = `Перейти к продавцу: ${item.seller_name || 'N/A'}`; if (item.seller_id && item.seller_name) { const safeSellerName = encodeURIComponent(item.seller_name.replace(/[^a-zA-Z0-9_\-.~]/g, '-')).replace(/%2F/g, '/'); sellerLink.href = `https://plati.market/seller/${safeSellerName}/${item.seller_id}/`; sellerLink.target = '_blank'; sellerLink.rel = 'noopener noreferrer nofollow'; sellerLink.onclick = (e) => { e.stopPropagation(); }; } else { sellerLink.style.pointerEvents = 'none'; } infoContainer.appendChild(sellerLink); link.appendChild(infoContainer); const buyButtonDiv = document.createElement('div'); buyButtonDiv.className = 'buyButton'; buyButtonDiv.textContent = 'Перейти'; link.appendChild(buyButtonDiv); itemDiv.appendChild(link); fragment.appendChild(itemDiv); }); ps_resultsDiv.appendChild(fragment); ps_applyFilters(); } // --- Обработчики UI --- function ps_handleCurrencyChange() { ps_currentCurrency = ps_currencySelect.value.toUpperCase(); GM_setValue(PS_CURRENCY_STORAGE_KEY, ps_currentCurrency); applyLoadedFiltersToUI(); ps_updateFilterPlaceholders(); if (ps_currentSort.field === 'price') { ps_applySort(ps_currentSort.field, ps_currentSort.direction); } ps_renderResults(); } // --- Добавление кнопки Plati --- function addPlatiButton() { const actionsContainer = document.querySelector('#queueActionsCtn'); const ignoreButtonContainer = actionsContainer?.querySelector('#ignoreBtn'); if (!actionsContainer || !ignoreButtonContainer || actionsContainer.querySelector('.plati_price_button')) return; const platiContainer = document.createElement('div'); platiContainer.className = 'plati_price_button queue_control_button'; platiContainer.style.marginLeft = '3px'; platiContainer.innerHTML = `
    Plati
    `; platiContainer.querySelector('div').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); showPlatiModal(); }); ignoreButtonContainer.insertAdjacentElement('afterend', platiContainer); } // --- Стили --- function addPlatiStyles() { GM_addStyle(` /* Стили спиннера */ @keyframes platiSpin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .spinner { border: 3px solid rgba(255, 255, 255, 0.3); border-radius: 50%; border-top-color: #fff; width: 1em; height: 1em; animation: platiSpin 1s linear infinite; display: inline-block; vertical-align: middle; margin-left: 5px; } .platiSearchBtn .spinner { width: 0.8em; height: 0.8em; border-width: 2px; } /* Стили модального окна */ #platiSearchModal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(20, 20, 25, 0.98); z-index: 9999; display: none; color: #eee; font-family: "Motiva Sans", Sans-serif, Arial; overflow-y: auto; scrollbar-color: #67c1f5 #17202d; scrollbar-width: thin; } #platiSearchModal::-webkit-scrollbar { width: 8px; } #platiSearchModal::-webkit-scrollbar-track { background: #17202d; border-radius: 4px; } #platiSearchModal::-webkit-scrollbar-thumb { background-color: #4b6f9c; border-radius: 4px; border: 2px solid #17202d; } #platiSearchModal::-webkit-scrollbar-thumb:hover { background-color: #67c1f5; } #platiSearchModal * { box-sizing: border-box; } #platiSearchContainer { max-width: 1350px; margin: 0 auto; padding: 15px ${PS_SIDE_PANEL_HORIZONTAL_PADDING}px; position: relative; min-height: 100%; } #platiSearchCloseBtn { position: fixed; top: 15px; right: 20px; font-size: 35px; color: #aaa; background: none; border: none; cursor: pointer; line-height: 1; z-index: 10002; padding: 5px; transition: color 0.2s, transform 0.2s; } #platiSearchCloseBtn:hover { color: #fff; transform: scale(1.1); } /* Шапка */ #platiSearchHeader { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; position: relative; z-index: 5; border-bottom: 1px solid #444; padding-bottom: 15px; padding-left: ${PS_CONTENT_PADDING_LEFT}px; padding-right: ${PS_CONTENT_PADDING_RIGHT}px; margin-left: -${PS_CONTENT_PADDING_LEFT}px; margin-right: -${PS_CONTENT_PADDING_RIGHT}px; flex-shrink: 0; } .platiSearchInputContainer { position: relative; flex-grow: 0.7; min-width: 200px; flex-basis: 350px; } #platiSearchInput { width: 100%; padding: 10px 15px; font-size: 16px; background-color: #333; border: 1px solid #555; color: #eee; border-radius: 4px; height: 40px; outline: none; } #platiSearchInput:focus { border-color: #67c1f5; } #platiSearchSuggestions { position: absolute; top: 100%; left: 0; right: 0; background-color: #3a3a40; border: 1px solid #555; border-top: none; border-radius: 0 0 4px 4px; max-height: 300px; overflow-y: auto; z-index: 10000; display: none; } .suggestionItem { padding: 8px 15px; cursor: pointer; color: #eee; font-size: 14px; border-bottom: 1px solid #4a4a50; } .suggestionItem:last-child { border-bottom: none; } .suggestionItem:hover { background-color: #4a4a55; } /* Кнопки в шапке */ .platiSearchBtn { padding: 10px 15px; font-size: 14px; color: white; border: none; border-radius: 4px; cursor: pointer; white-space: nowrap; height: 40px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; background-color: #555; transition: background-color 0.2s; } .platiSearchBtn:hover:not(:disabled) { background-color: #666; } .platiSearchBtn:disabled { opacity: 0.6; cursor: default; } #platiSearchGoBtn { background-color: #4D88FF; } #platiSearchGoBtn:hover { background-color: #3366CC; } .platiSearchBtn.sortBtn.active { background-color: #007bff; } .platiSearchBtn.sortBtn.active:hover { background-color: #0056b3; } #platiResetSortBtn { background-color: #777; margin-right: 5px; padding: 0 10px; } #platiResetSortBtn:hover { background-color: #888; } #platiResetSortBtn svg { width: 16px; height: 16px; fill: currentColor; } #platiResetSortBtn.active { background-color: #007bff; } #platiSearchAdvSortBtnContainer { position: relative; flex-shrink: 0; width: ${PS_ADV_SORT_CONTAINER_WIDTH}px; display: flex; justify-content: center; } #platiSearchAdvSortBtn { width: 100%; justify-content: center; overflow: hidden; text-overflow: ellipsis; } #platiSearchCurrencySelect { margin-left: 10px; background-color: #333; color: #eee; border: 1px solid #555; border-radius: 4px; height: 40px; padding: 0 8px; font-size: 14px; cursor: pointer; flex-shrink: 0; outline: none; } #platiSearchCurrencySelect:focus { border-color: #67c1f5; } /* Меню доп сортировки */ #platiSearchAdvSortMenu { display: none; position: absolute; top: 100%; left: 0; background-color: #3a3a40; border: 1px solid #555; border-radius: 4px; min-width: 100%; z-index: 10001; padding: 5px 0; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); } #platiSearchAdvSortBtnContainer:hover #platiSearchAdvSortMenu { display: block; } .platiSearchSortMenuItem { display: block; padding: 8px 15px; color: #eee; font-size: 14px; cursor: pointer; white-space: nowrap; transition: background-color 0.1s; } .platiSearchSortMenuItem:hover { background-color: #4a4a55; } .platiSearchSortMenuItem.active { background-color: #007bff; color: white; } .platiSearchSortMenuItem .sortArrow { display: inline-block; margin-left: 5px; font-size: 12px; } /* Боковые панели */ #platiSearchFiltersPanel, #platiSearchExclusionTags { position: fixed; top: ${PS_TOP_OFFSET_FOR_SIDE_PANELS}px; max-height: calc(100vh - ${PS_TOP_OFFSET_FOR_SIDE_PANELS}px - ${PS_BOTTOM_OFFSET_FOR_SIDE_PANELS}px); overflow-y: auto; z-index: 1000; padding: 10px; padding-right: 15px; scrollbar-width: thin; scrollbar-color: #555 #2a2a30; background-color: transparent; transition: top 0.2s ease-in-out; } #platiSearchFiltersPanel::-webkit-scrollbar, #platiSearchExclusionTags::-webkit-scrollbar { width: 5px; } #platiSearchFiltersPanel::-webkit-scrollbar-track, #platiSearchExclusionTags::-webkit-scrollbar-track { background: rgba(42, 42, 48, 0.5); border-radius: 3px; } #platiSearchFiltersPanel::-webkit-scrollbar-thumb, #platiSearchExclusionTags::-webkit-scrollbar-thumb { background-color: rgba(85, 85, 85, 0.7); border-radius: 3px; } #platiSearchFiltersPanel { left: ${PS_SIDE_PANEL_HORIZONTAL_PADDING}px; width: ${PS_FILTER_PANEL_WIDTH}px; } #platiSearchExclusionTags { right: ${PS_SIDE_PANEL_HORIZONTAL_PADDING}px; width: ${PS_EXCLUSION_PANEL_WIDTH}px; display: flex; flex-direction: column; gap: 10px; } /* Фильтры */ .filterGroup { margin-bottom: 18px; } .filterGroup h4 { font-size: 15px; color: #ddd; margin-bottom: 8px; padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); font-weight: 500; } .filterResetBtn { font-size: 12px; color: #aaa; background: none; border: none; cursor: pointer; padding: 0 3px; line-height: 1; } .filterResetBtn:hover { color: #fff; } .filterResetBtn svg { width: 14px; height: 14px; vertical-align: middle; fill: currentColor; } .filterRangeInputs { display: flex; gap: 8px; align-items: center; } .filterRangeInputs input[type="number"] { width: calc(50% - 4px); padding: 6px 8px; font-size: 13px; background-color: rgba(51, 51, 51, 0.85); border: 1px solid #666; color: #eee; border-radius: 3px; height: 30px; text-align: center; -moz-appearance: textfield; box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3); outline: none; } .filterRangeInputs input[type="number"]:focus { border-color: #67c1f5; } .filterRangeInputs input[type="number"]::-webkit-outer-spin-button, .filterRangeInputs input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .filterRangeInputs input[type="number"]::placeholder { color: #999; font-size: 11px; text-align: center; } .filterCheckbox { margin-bottom: 8px; } .filterCheckbox label { display: flex; align-items: center; font-size: 14px; cursor: pointer; color: #ccc; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); } .filterCheckbox input[type="checkbox"] { margin-right: 8px; width: 16px; height: 16px; accent-color: #007bff; cursor: pointer; flex-shrink: 0; } .filterSelect select { width: 100%; padding: 6px 8px; font-size: 13px; background-color: rgba(51, 51, 51, 0.85); border: 1px solid #666; color: #eee; border-radius: 3px; height: 30px; box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3); outline: none; } .filterSelect select:focus { border-color: #67c1f5; } #psResetAllFiltersBtn { width: 100%; margin-top: 10px; padding: 8px 10px; height: auto; background-color: rgba(108, 117, 125, 0.8); border: 1px solid #888; text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4); } #psResetAllFiltersBtn:hover { background-color: rgba(90, 98, 104, 0.9); } /* Исключения */ .exclusionInputGroup { display: flex; align-items: stretch; border: 1px solid #555; border-radius: 4px; background-color: rgba(51, 51, 51, 0.85); overflow: hidden; height: 34px; flex-shrink: 0; box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3); } .exclusionInputGroup #platiSearchExcludeInput { padding: 6px 10px; font-size: 13px; background-color: transparent; border: none; color: #eee; outline: none; border-radius: 0; flex-grow: 1; width: auto; height: auto; } .exclusionInputGroup #platiSearchExcludeInput:focus { box-shadow: none; } .exclusionInputGroup #platiSearchAddExcludeBtn { display: flex; align-items: center; justify-content: center; padding: 0 10px; background-color: #555; border: none; border-left: 1px solid #555; cursor: pointer; border-radius: 0; color: #eee; height: auto; } .exclusionInputGroup #platiSearchAddExcludeBtn:hover { background-color: #666; } .exclusionInputGroup #platiSearchAddExcludeBtn svg { width: 16px; height: 16px; fill: currentColor; } #platiExclusionTagsList { display: flex; flex-direction: row; flex-wrap: wrap; align-content: flex-start; gap: 8px; overflow-y: auto; flex-grow: 1; } .exclusionTag { display: inline-block; background-color: rgba(70, 70, 80, 0.9); color: #ddd; padding: 5px 10px; border-radius: 15px; font-size: 13px; cursor: pointer; transition: background-color 0.2s; border: 1px solid rgba(100, 100, 110, 0.9); white-space: nowrap; text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); } .exclusionTag:hover { background-color: rgba(220, 53, 69, 0.9); border-color: rgba(200, 40, 50, 0.95); color: #fff; } .exclusionTag::after { content: ' ×'; font-weight: bold; margin-left: 4px; } /* Результаты */ #platiSearchResultsContainer { position: relative; padding-left: ${PS_CONTENT_PADDING_LEFT}px; padding-right: ${PS_CONTENT_PADDING_RIGHT}px; margin-left: -${PS_CONTENT_PADDING_LEFT}px; margin-right: -${PS_CONTENT_PADDING_RIGHT}px; } #platiSearchResultsStatus { width: 100%; text-align: center; font-size: 18px; color: #aaa; padding: 50px 0; display: none; min-height: 100px; display: flex; align-items: center; justify-content: center; flex-direction: column; } #platiSearchResults { display: flex; flex-wrap: wrap; gap: 15px; justify-content: flex-start; padding-top: 10px; } /* Карточка товара */ .platiSearchItem { background-color: #2a2a30; border-radius: 8px; padding: 10px; width: calc(20% - 12px); min-width: 170px; display: flex; flex-direction: column; transition: transform 0.2s ease, box-shadow 0.2s ease; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); position: relative; color: #ccc; font-size: 13px; min-height: 340px; border: 1px solid transparent; } .platiSearchItem:hover { transform: translateY(-3px); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4); border-color: #4b6f9c; } .platiSearchItem.hidden-by-filter { display: none !important; } .platiSearchItem a { text-decoration: none; color: inherit; display: flex; flex-direction: column; height: 100%; } .platiSearchItem .card-image-wrapper { position: relative; width: 100%; aspect-ratio: 1 / 1; margin-bottom: 8px; background-color: #444; border-radius: 6px; overflow: hidden; } .platiSearchItem img { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; border-radius: 6px; } .newItemBadge { position: absolute; top: 4px; left: 4px; background-color: #f54848; color: white; padding: 1px 5px; font-size: 10px; border-radius: 3px; font-weight: bold; z-index: 1; text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3); } .platiSearchItem .price { font-size: 16px; font-weight: 700; color: #a4d007; margin-bottom: 5px; } .platiSearchItem .title { font-size: 13px; font-weight: 500; line-height: 1.3; height: 3.9em; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; color: #eee; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; } .cardInfoContainer { margin-top: auto; padding-top: 6px; } .cardInfoRow1, .cardInfoRow2 { display: flex; justify-content: space-between; flex-wrap: nowrap; gap: 8px; font-size: 12px; color: #bbb; margin-bottom: 4px; } .cardInfoRow1 span, .cardInfoRow2 span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; } .cardInfoRow1 span:first-child, .cardInfoRow2 span:first-child { flex-shrink: 0; margin-right: auto; } .reviewsGood { color: #6cff5c; font-weight: bold; } .reviewsBad { color: #f54848; margin-left: 2px; font-weight: bold; } .sales { font-weight: bold; color: #eee; } .sellerLink { display: block; font-size: 12px; color: #bbb; text-decoration: none; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color 0.2s; } .sellerLink:hover { color: #ddd; text-decoration: underline; } .platiSearchItem .buyButton { display: block; text-align: center; padding: 8px; margin-top: 8px; background-color: #007bff; color: white; border-radius: 4px; font-size: 13px; font-weight: 600; transition: background-color 0.2s; } .platiSearchItem .buyButton:hover { background-color: #0056b3; } /* Адаптивность */ @media (max-width: 1650px) { .platiSearchItem { width: calc(20% - 12px); } } @media (max-width: 1400px) { .platiSearchItem { width: calc(25% - 12px); } } @media (max-width: 1100px) { .platiSearchItem { width: calc(33.33% - 10px); } } @media (max-width: 850px) { #platiSearchFiltersPanel, #platiSearchExclusionTags { display: none; } #platiSearchHeader, #platiSearchResultsContainer { padding-left: 15px; padding-right: 15px; margin-left: 0; margin-right: 0; } .platiSearchItem { width: calc(50% - 8px); } #platiSearchHeader { justify-content: center; } } @media (max-width: 600px) { .platiSearchItem { width: 100%; min-height: auto; } #platiSearchHeader { gap: 5px; } .platiSearchInputContainer { flex-basis: 100%; order: -1; } .platiSearchBtn, #platiSearchCurrencySelect, #platiSearchAdvSortBtnContainer { width: calc(33.3% - 4px); font-size: 13px; padding: 8px 5px; height: 36px; } #platiSearchAdvSortBtnContainer { width: calc(33.3% - 4px); } #platiSearchAdvSortBtn { width: 100%; } #platiSearchAdvSortMenu { min-width: 200px; left: 50%; transform: translateX(-50%); } #platiResetSortBtn { width: auto; padding: 0 8px; } } /* Стили для кнопки Plati на странице Steam */ .plati_price_button .btnv6_blue_hoverfade { margin: 0; padding: 0 15px; font-size: 15px; display: flex; align-items: center; transition: filter 0.2s; } .plati_price_button .btnv6_blue_hoverfade:hover { filter: brightness(1.1); } `); } addPlatiStyles(); const steamAppIdCheck = unsafeWindow.location.pathname.match(/\/app\/(\d+)/); if (steamAppIdCheck && steamAppIdCheck[1]) { addPlatiButton(); } })(); } })();