SteamDB - Sales; Ultimate Enhancer

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

  1. // ==UserScript==
  2. // @name SteamDB - Sales; Ultimate Enhancer
  3. // @namespace https://steamdb.info/
  4. // @version 1.4
  5. // @description Комплексное улучшение для SteamDB: фильтры по языкам, спискам, дате, РРЦ, конвертация валют, расширенная информация об играх, калькулятор желаемого, фильтры по % от ист. минимума.
  6. // @author 0wn3df1x
  7. // @license MIT
  8. // @include https://steamdb.info/sales/*
  9. // @include https://steamdb.info/stats/mostfollowed/*
  10. // @include https://steamdb.info/stats/pricesnotavailable/*
  11. // @grant GM_xmlhttpRequest
  12. // @grant GM_setValue
  13. // @grant GM_getValue
  14. // @connect api.steampowered.com
  15. // ==/UserScript==
  16.  
  17. (function() {
  18. 'use strict';
  19.  
  20. const scriptsConfig = {
  21. toggleEnglishLangInfo: false
  22. };
  23. const API_URL = "https://api.steampowered.com/IStoreBrowseService/GetItems/v1";
  24. const BATCH_SIZE = 200;
  25. const HOVER_DELAY = 300;
  26. const REQUEST_DELAY = 200;
  27. const DEFAULT_EXCHANGE_RATE = 0.19;
  28. const PAGE_RELOAD_DELAY = 3000;
  29.  
  30. let collectedAppIds = new Set();
  31. let tooltip = null;
  32. let hoverTimer = null;
  33. let gameData = {};
  34. let activeLanguageFilter = null;
  35. let totalGamesOnPage = 0;
  36. let processedRuGames = 0;
  37. let processedUsGames = 0;
  38. let processedSingleStageGames = 0;
  39.  
  40. let progressContainer = null;
  41. let requestQueue = [];
  42. let isProcessingQueue = false;
  43. let currentExchangeRate = DEFAULT_EXCHANGE_RATE;
  44. let activeListFilter = false;
  45. let activeDateFilterTimestamp = null;
  46. let isProcessingStarted = false;
  47. let processButton = null;
  48. let currentProcessingStage = '';
  49.  
  50. let isRuModeActive = false;
  51. let activeRrcFilters = {
  52. lower: false,
  53. equal: false,
  54. higher: false
  55. };
  56.  
  57. let activeShowDiscountFilters = { blue: false, green: false, purple: false };
  58. let activeHideDiscountFilters = { blue: false, green: false, purple: false };
  59.  
  60. let activeShowAtlPercentFilters = { cheaper: false, equal: false, more_expensive: false };
  61. let activeHideAtlPercentFilters = { cheaper: false, equal: false, more_expensive: false };
  62.  
  63. let reviewFilters = {
  64. minCount: null,
  65. maxCount: null,
  66. minRating: null,
  67. maxRating: null
  68. };
  69. let earlyAccessFilter = 'none';
  70. let isTotalSortEnabled = false;
  71. let debounceTimer;
  72.  
  73. const PROCESS_BUTTON_TEXT = {
  74. idle: "Обработать игры",
  75. processing_ru: "Сбор RU данных...",
  76. processing_us: "Сбор US данных...",
  77. processing_single: "Сбор данных...",
  78. done: "Обработка завершена",
  79. calculate_wishlist_idle: "Высчитать"
  80. };
  81.  
  82. const STATUS_TEXT = {
  83. ready_to_process: "Нажмите обработать игры для начала работы",
  84. processing_ru: "Идет сбор RU данных...",
  85. processing_us: "Идет сбор US данных...",
  86. processing_single: "Идет сбор данных...",
  87. processing_rrc: "Расчет РРЦ...",
  88. done: "Обработка завершена. Фильтры применены.",
  89. done_no_rrc: "Обработка данных завершена.",
  90. rrc_disabled: "Нажмите обработать игры для начала работы (РРЦ анализ доступен только для российской валюты)",
  91. error: "Произошла ошибка.",
  92. changing_entries_prefix: "Меняем на All... ",
  93. calculating_wishlist: "Анализ цен желаемого..."
  94. };
  95.  
  96. const styles = `
  97. .steamdb-enhancer * { box-sizing: border-box; margin: 0; padding: 0; }
  98. .steamdb-enhancer { background: #16202d; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.25); padding: 12px; width: auto; margin-top: 5px; margin-bottom: 15px; max-width: 900px; }
  99. .enhancer-header { display: flex; align-items: center; gap: 12px; margin-bottom: 15px; flex-wrap: wrap; }
  100. .row-layout { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; margin-bottom: 12px; }
  101. .row-layout.compact { gap: 8px; margin-bottom: 0; }
  102. .control-group { background: #1a2635; border-radius: 6px; padding: 10px; margin: 6px 0; }
  103. .group-title { color: #66c0f4; font-size: 12px; font-weight: 600; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.5px; }
  104. .btn-group { display: flex; flex-wrap: wrap; gap: 5px; }
  105. .btn { background: #2a3a4d; border: 1px solid #354658; border-radius: 4px; color: #c6d4df; cursor: pointer; font-size: 12px; padding: 5px 10px; transition: all 0.2s ease; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
  106. .btn:hover { background: #31455b; border-color: #3d526b; }
  107. .btn.active { background: #66c0f4 !important; border-color: #66c0f4 !important; color: #1b2838 !important; }
  108. .btn-icon { width: 12px; height: 12px; fill: currentColor; }
  109. .progress-container { background: #1a2635; border-radius: 4px; height: 6px; overflow: hidden; margin: 10px 0 5px; }
  110. .progress-text { display: flex; justify-content: space-between; color: #8f98a0; font-size: 11px; margin: 4px 2px 0; }
  111. .progress-count { flex: 1; text-align: left; }
  112. .progress-percent { flex: 1; text-align: right; }
  113. .progress-bar { height: 100%; background: linear-gradient(90deg, #66c0f4 0%, #4d9cff 100%); transition: width 0.3s ease; }
  114. .converter-group { display: flex; gap: 6px; flex: 1; }
  115. .input-field { background: #1a2635; border: 1px solid #2a3a4d; border-radius: 4px; color: #c6d4df; font-size: 12px; padding: 5px 8px; min-width: 60px; width: 80px; }
  116. .date-picker { background: #1a2635; border: 1px solid #2a3a4d; border-radius: 4px; color: #c6d4df; font-size: 12px; padding: 5px; width: 120px; }
  117. .status-indicator { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 5px 8px; border-radius: 4px; color: #8f98a0;}
  118. .status-indicator.status-active { color: #66c0f4; }
  119. .steamdb-tooltip { position: absolute; background: #1b2838; color: #c6d4df; padding: 15px; border-radius: 3px; width: 320px; font-size: 14px; line-height: 1.5; box-shadow: 0 0 12px rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s; pointer-events: none; z-index: 9999; display: none; }
  120. .tooltip-arrow { position: absolute; width: 0; height: 0; border-top: 10px solid transparent; border-bottom: 10px solid transparent; }
  121. .group-top { margin-bottom: 8px; }
  122. .group-middle { margin-bottom: 12px; }
  123. .group-bottom { margin-bottom: 15px; }
  124. .tooltip-row { margin-bottom: 4px; }
  125. .tooltip-row.compact { margin-bottom: 2px; }
  126. .tooltip-row.spaced { margin-bottom: 10px; }
  127. .tooltip-row.language { margin-bottom: 8px; }
  128. .tooltip-row.description { margin-top: 15px; padding-top: 10px; border-top: 1px solid #2a3a4d; color: #8f98a0; font-style: italic; }
  129. .positive { color: #66c0f4; }
  130. .mixed { color: #997a00; }
  131. .negative { color: #a74343; }
  132. .no-reviews { color: #929396; }
  133. .language-yes { color: #66c0f4; }
  134. .language-no { color: #a74343; }
  135. .early-access-yes { color: #66c0f4; }
  136. .early-access-no { color: #929396; }
  137. .no-data { color: #929396; }
  138. tr.app td:first-child { position: relative; }
  139.  
  140. .rrc-display-container {
  141. position: absolute;
  142. width: 100px;
  143. left: -100px;
  144. top: -1px;
  145. height: 100%;
  146. box-sizing: border-box;
  147. background-color: var(--body-bg-color, #161920);
  148. border-top: 1px solid var(--border-color-2, hsl(216, 25%, 16%));
  149. border-left: none;
  150. border-right: none;
  151. border-radius: 0;
  152. display: flex;
  153. flex-direction: column;
  154. justify-content: center;
  155. align-items: center;
  156. padding: 0;
  157. font-size: 11px;
  158. color: var(--body-color, #ddd);
  159. text-align: center;
  160. white-space: normal;
  161. overflow: hidden;
  162. z-index: 3;
  163. pointer-events: none;
  164. }
  165. .rrc-display-container .rrc-content-wrapper { padding: 1px 3px; }
  166. .rrc-display-container .rrc-text { font-weight: 700; font-style: normal; padding: 1px 5px; border-radius: 3px; display: inline-block; line-height: 1.2; margin-bottom: 2px; }
  167. .rrc-display-container .rrc-text.equal { background-color: #4c6b22 !important; color: #c0ef15 !important; }
  168. .rrc-display-container .rrc-text.higher { background-color: #cb2431 !important; color: #fde2e4 !important; }
  169. .rrc-display-container .rrc-text.lower { background-color: #1566b7 !important; color: #d1e5fa !important; }
  170. .rrc-display-container .rrc-details { color: var(--muted-color, #999); font-size: 10px; line-height: 1.1; display: block; }
  171. .rrc-display-container .rrc-no-data { color: var(--muted-color, #999); font-style: italic; font-size: 11px; padding: 2px 0; }
  172. #rrc-filter-group.disabled-filter { opacity: 0.5; pointer-events: none; }
  173. #rrc-filter-group.disabled-filter .btn { cursor: not-allowed; }
  174.  
  175. .steamdb-custom-discount-filters { display: flex; flex-direction: column; gap: 0px; margin-top: 0px; }
  176. .steamdb-custom-discount-filters .filter-block-title { color: #c6d4df; font-size: 14px; font-weight: 500; margin-bottom: 8px; padding-bottom: 5px; border-bottom: 1px solid #2a3f5a;}
  177. .steamdb-custom-discount-filters .filter-block-subtitle { color: #a0b0c0; font-size: 13px; font-weight: 400; margin-top: 10px; margin-bottom: 6px; }
  178.  
  179. .discount-filter-row-steamdb {
  180. display: grid;
  181. grid-template-columns: auto 1fr auto;
  182. align-items: center;
  183. gap: 8px;
  184. padding: 1px 0;
  185. font-size: 13px;
  186. min-height: 24px;
  187. }
  188. .discount-filter-row-steamdb .steamy-checkbox-control {
  189. display: inline-flex;
  190. align-items: center;
  191. gap: 5px;
  192. color: #9fbbcb;
  193. cursor: pointer;
  194. font-weight: normal;
  195. padding: 0;
  196. white-space: nowrap;
  197. }
  198. .discount-filter-row-steamdb .steamy-checkbox-control:hover,
  199. .discount-filter-row-steamdb .steamy-checkbox-control:focus-within {
  200. color: #fff;
  201. }
  202. .discount-filter-row-steamdb .steamy-checkbox-control input[type="checkbox"] {
  203. margin: 0 4px 0 0;
  204. vertical-align: middle;
  205. }
  206. .discount-filter-row-steamdb .steamy-checkbox-control:first-of-type {
  207. justify-self: start;
  208. padding-left: 0;
  209. }
  210. .discount-filter-label-text-steamdb {
  211. text-align: left;
  212. padding-left: 5px;
  213. color: #c6d4df;
  214. white-space: nowrap;
  215. overflow: hidden;
  216. text-overflow: ellipsis;
  217. line-height: normal;
  218. vertical-align: middle;
  219. }
  220. .discount-filter-row-steamdb .steamy-checkbox-control:last-of-type {
  221. justify-self: end;
  222. }
  223. .tooltipped-blue input[type="checkbox"]:checked { accent-color: #1566b7; }
  224. .tooltipped-green input[type="checkbox"]:checked { accent-color: #4c6b22; }
  225. .tooltipped-purple input[type="checkbox"]:checked { accent-color: #74002d; }
  226.  
  227. .steamy-checkbox-control.active { }
  228. #wishlist-calculator-group .group-title { font-size: 11px; color: #a0a0a0; text-transform: none; margin-bottom: 6px; }
  229. #wishlist-calculator-group .btn { width: 100%; justify-content: center; }
  230.  
  231. .atl-percent-text { padding: 1px 3px; border-radius: 2px; font-weight: bold; }
  232. .atl-percent-text.cheaper { background-color: #1566b7 !important; color: #d1e5fa !important; }
  233. .atl-percent-text.equal { background-color: #4c6b22 !important; color: #c0ef15 !important; }
  234. .atl-percent-text.more_expensive { background-color: #74002d !important; color: #dfccff !important; }
  235. `;
  236.  
  237. function extractAndDisplayGameDataSteamStyle() {
  238. const sourceTable = document.querySelector('table.table-sales.dataTable, table#DataTables_Table_0');
  239. if (!sourceTable) {
  240. alert('Исходная таблица не найдена!');
  241. return;
  242. }
  243.  
  244. const rows = sourceTable.querySelectorAll('tbody tr.app');
  245. const gamesData = [];
  246. let totalApproximateFullPriceSum = 0;
  247. let totalCalculatedBestPriceSum = 0;
  248. let currentSortConfig = { columnKey: null, direction: 'asc' };
  249.  
  250. function parseRawPrice(priceString) {
  251. if (typeof priceString !== 'string' && typeof priceString !== 'number') return null;
  252. let cleanedString = String(priceString).replace(/[^0-9,.]/g, '').replace(',', '.');
  253. if (cleanedString === '') return null;
  254. let price = parseFloat(cleanedString);
  255. return isNaN(price) ? null : price;
  256. }
  257.  
  258. function formatPriceDisplay(value) {
  259. if (value === null || typeof value === 'undefined') {
  260. return 'N/A';
  261. }
  262. return Number(value).toFixed(2);
  263. }
  264.  
  265. function extractPriceAfterLabel(text, label) {
  266. if (typeof text !== 'string' || typeof label !== 'string') return null;
  267. const labelIndex = text.toLowerCase().indexOf(label.toLowerCase());
  268. if (labelIndex === -1) return null;
  269.  
  270. let potentialPriceText = text.substring(labelIndex + label.length).trim();
  271. const atIndex = potentialPriceText.search(/\s+\(at|\s+at\s+|\s+-?\d+%/);
  272. if (atIndex !== -1) {
  273. potentialPriceText = potentialPriceText.substring(0, atIndex).trim();
  274. }
  275. potentialPriceText = potentialPriceText.replace(/\s+\([\s\S]*?\)$/, '').trim();
  276. return parseRawPrice(potentialPriceText);
  277. }
  278.  
  279.  
  280. rows.forEach(row => {
  281. const cells = row.cells;
  282. if (cells.length < 5) return;
  283.  
  284. const appId = row.dataset.appid;
  285. const nameElement = cells[2].querySelector('a.b');
  286. const name = nameElement ? nameElement.innerText.trim() : 'N/A';
  287.  
  288. let currentPriceTextToParse = cells[4].dataset.originalPrice || cells[4].innerText.trim();
  289. const nonPriceValues = ["free", "—", "tba", "soon", "n/a", "бесплатно"];
  290. if (nonPriceValues.some(val => currentPriceTextToParse.toLowerCase().includes(val)) || !/\d/.test(currentPriceTextToParse)) {
  291. if (nonPriceValues.some(val => currentPriceTextToParse.toLowerCase().includes(val) && (val === "free" || val === "бесплатно"))) {
  292. } else {
  293. return;
  294. }
  295. }
  296.  
  297. let currentPriceNum;
  298. if (nonPriceValues.some(val => currentPriceTextToParse.toLowerCase().includes(val) && (val === "free" || val === "бесплатно"))) {
  299. currentPriceNum = 0;
  300. } else {
  301. currentPriceNum = parseRawPrice(currentPriceTextToParse);
  302. }
  303.  
  304. if (currentPriceNum === null && !nonPriceValues.some(val => currentPriceTextToParse.toLowerCase().includes(val) && (val === "free" || val === "бесплатно"))) {
  305. return;
  306. }
  307. if (currentPriceNum === null) currentPriceNum = 0;
  308.  
  309.  
  310. const currentDiscountString = cells[3].innerText.trim();
  311. let approximateFullPrice = currentPriceNum;
  312. let discountPercentage = 0;
  313.  
  314. if (currentDiscountString && currentDiscountString.startsWith('-') && currentDiscountString.endsWith('%')) {
  315. const discountMatch = currentDiscountString.match(/-(\d+)%/);
  316. if (discountMatch && discountMatch[1]) {
  317. discountPercentage = parseFloat(discountMatch[1]);
  318. if (!isNaN(discountPercentage) && discountPercentage > 0 && discountPercentage < 100) {
  319. if (currentPriceNum > 0) {
  320. approximateFullPrice = currentPriceNum / (1 - (discountPercentage / 100));
  321. } else {
  322. approximateFullPrice = 0;
  323. }
  324. } else {
  325. discountPercentage = 0;
  326. }
  327. }
  328. }
  329. approximateFullPrice = Math.round(approximateFullPrice * 100) / 100;
  330.  
  331. let allTimeLowPrice = null;
  332. let twoYearLowPrice = null;
  333.  
  334. const subinfoElement = cells[2].querySelector('div.subinfo');
  335. if (subinfoElement) {
  336. const highestDiscountSpan = subinfoElement.querySelector('span.highest-discount');
  337. if (highestDiscountSpan) {
  338. const highestDiscountText = highestDiscountSpan.innerText;
  339. if (highestDiscountText.toLowerCase().includes('all-time low:')) {
  340. allTimeLowPrice = extractPriceAfterLabel(highestDiscountText, 'All-time low:');
  341. } else if (highestDiscountText.toLowerCase().includes('2-year low:')) {
  342. twoYearLowPrice = extractPriceAfterLabel(highestDiscountText, '2-year low:');
  343. } else if (highestDiscountText.toLowerCase().includes('current 2-year low')) {
  344. twoYearLowPrice = currentPriceNum;
  345. }
  346. }
  347. const newHistoricalLowSpan = subinfoElement.querySelector('span.highest-discount-major');
  348. if (newHistoricalLowSpan && newHistoricalLowSpan.innerText.toLowerCase().includes('new historical low')) {
  349. if (allTimeLowPrice === null || currentPriceNum < allTimeLowPrice) {
  350. allTimeLowPrice = currentPriceNum;
  351. }
  352. }
  353. }
  354.  
  355. let bestPriceForCalculation = currentPriceNum;
  356. if (allTimeLowPrice !== null) {
  357. bestPriceForCalculation = allTimeLowPrice;
  358. } else if (twoYearLowPrice !== null) {
  359. bestPriceForCalculation = twoYearLowPrice;
  360. }
  361.  
  362. gamesData.push({
  363. appId,
  364. name,
  365. currentPriceNum: currentPriceNum,
  366. currentDiscountText: currentDiscountString || 'N/A',
  367. approximateFullPrice: approximateFullPrice,
  368. allTimeLowPrice: allTimeLowPrice,
  369. twoYearLowPrice: twoYearLowPrice,
  370. bestPriceForCalculation: bestPriceForCalculation
  371. });
  372.  
  373. totalApproximateFullPriceSum += approximateFullPrice;
  374. totalCalculatedBestPriceSum += bestPriceForCalculation;
  375. });
  376.  
  377. if (gamesData.length === 0) {
  378. alert('Не найдено игр с указанными ценами для обработки.');
  379. return;
  380. }
  381.  
  382. let modal = document.getElementById('steamTableModal');
  383. let modalContentElement;
  384. if (!modal) {
  385. modal = document.createElement('div');
  386. modal.id = 'steamTableModal';
  387. modal.innerHTML = `
  388. <div id="steamTableModalContent">
  389. <span id="steamTableModalClose">&times;</span>
  390. <h1>Отчет по ценам на игры</h1>
  391. <div id="steamTableTotalsContainerPlaceholder"></div>
  392. <table>
  393. <thead></thead>
  394. <tbody></tbody>
  395. </table>
  396. </div>
  397. `;
  398. document.body.appendChild(modal);
  399.  
  400. const styleSheet = document.createElement("style");
  401. styleSheet.id = "steamTableModalStyles";
  402. styleSheet.textContent = `
  403. #steamTableModal {
  404. position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
  405. background-color: rgba(23, 26, 33, 0.9);
  406. z-index: 10000; display: none; justify-content: center; align-items: center;
  407. font-family: "Motiva Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
  408. }
  409. #steamTableModalContent {
  410. background-color: #1b2838; color: #c7d5e0;
  411. padding: 20px; border-radius: 4px; width: 95%; max-width: 1600px; height: 90%;
  412. overflow: auto; box-shadow: 0 0 30px rgba(0,0,0,0.7); position: relative;
  413. border: 1px solid #000;
  414. }
  415. #steamTableModalClose {
  416. position: absolute; top: 10px; right: 15px; font-size: 32px; color: #5c6b7c;
  417. cursor: pointer; font-weight: bold; line-height: 1; user-select: none;
  418. }
  419. #steamTableModalClose:hover { color: #66c0f4; }
  420. #steamTableModalContent h1 {
  421. color: #66c0f4; text-align: center; margin-top: 0; margin-bottom: 15px;
  422. border-bottom: 1px solid #2a3f5a; padding-bottom: 10px; font-weight: 500;
  423. }
  424. #steamTableModalContent table { border-collapse: collapse; width: 100%; font-size: 13px; }
  425. #steamTableModalContent th, #steamTableModalContent td {
  426. border: 1px solid #2a3f5a; padding: 8px 10px; text-align: left;
  427. }
  428. #steamTableModalContent th {
  429. background-color: #2a475e; color: #c7d5e0; font-weight: normal;
  430. cursor: pointer; user-select: none; position: relative;
  431. }
  432. #steamTableModalContent th:hover { background-color: #3a5f7e; }
  433. #steamTableModalContent th .sort-arrow {
  434. position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
  435. font-size: 0.9em; color: #66c0f4;
  436. }
  437. #steamTableModalContent tr:nth-child(even) td { background-color: #203142; }
  438. #steamTableModalContent tr:hover td { background-color: #2c3e50; }
  439. #steamTableModalContent .price { text-align: right; white-space: nowrap; }
  440. #steamTableModalContent .discount { text-align: center; }
  441. #steamTableModalContent .na { color: #7d8a96; font-style: italic; }
  442. #steamTableModalContent ::-webkit-scrollbar { width: 10px; }
  443. #steamTableModalContent ::-webkit-scrollbar-track { background: #2a3f5a; }
  444. #steamTableModalContent ::-webkit-scrollbar-thumb { background: #5c6b7c; border-radius: 4px;}
  445. #steamTableModalContent ::-webkit-scrollbar-thumb:hover { background: #66c0f4; }
  446. #steamTableTotalsContainer {
  447. margin-bottom: 15px; padding: 10px; background-color: #171a21;
  448. color: #c7d5e0; border: 1px solid #2a3f5a; border-radius: 3px;
  449. }
  450. #steamTableTotalsContainer .total-item {
  451. margin-bottom: 12px;
  452. }
  453. #steamTableTotalsContainer .total-item:last-child {
  454. margin-bottom: 0;
  455. }
  456. #steamTableTotalsContainer .total-label {
  457. font-weight: bold;
  458. display: block;
  459. margin-bottom: 4px;
  460. line-height: 1.3;
  461. }
  462. #steamTableTotalsContainer .total-value {
  463. font-weight: bold;
  464. color: #66c0f4;
  465. display: block;
  466. font-size: 1.1em;
  467. }
  468. `;
  469. document.head.appendChild(styleSheet);
  470.  
  471. document.getElementById('steamTableModalClose').addEventListener('click', () => {
  472. modal.style.display = 'none';
  473. });
  474. }
  475.  
  476. modalContentElement = document.getElementById('steamTableModalContent');
  477. modal.style.display = 'flex';
  478.  
  479. const totalsContainerPlaceholder = document.getElementById('steamTableTotalsContainerPlaceholder');
  480. const totalsContainer = document.createElement('div');
  481. totalsContainer.id = 'steamTableTotalsContainer';
  482. totalsContainer.innerHTML = `
  483. <div class="total-item">
  484. <div class="total-label">Итого, если купить все игры по ~ПОЛНЫМ (расчетным) ценам:</div>
  485. <div class="total-value">${formatPriceDisplay(totalApproximateFullPriceSum)}</div>
  486. </div>
  487. <div class="total-item">
  488. <div class="total-label">Итого, если купить игры по ЛУЧШИМ доступным ценам (All-time/2-year/Текущая со скидкой):</div>
  489. <div class="total-value">${formatPriceDisplay(totalCalculatedBestPriceSum)}</div>
  490. </div>
  491. `;
  492. if (totalsContainerPlaceholder) {
  493. totalsContainerPlaceholder.replaceWith(totalsContainer);
  494. }
  495.  
  496.  
  497. const theadElement = modalContentElement.querySelector('table thead');
  498. theadElement.innerHTML = `
  499. <tr>
  500. <th data-sort-key="appId">AppID <span class="sort-arrow"></span></th>
  501. <th data-sort-key="name">Название <span class="sort-arrow"></span></th>
  502. <th data-sort-key="currentDiscountText" class="discount">Текущая скидка <span class="sort-arrow"></span></th>
  503. <th data-sort-key="currentPriceNum" class="price">Текущая цена <span class="sort-arrow"></span></th>
  504. <th data-sort-key="approximateFullPrice" class="price">~Полная цена <span class="sort-arrow"></span></th>
  505. <th data-sort-key="allTimeLowPrice" class="price">All-time Low <span class="sort-arrow"></span></th>
  506. <th data-sort-key="twoYearLowPrice" class="price">2-year Low <span class="sort-arrow"></span></th>
  507. <th data-sort-key="bestPriceForCalculation" class="price">Цена для расчета <span class="sort-arrow"></span></th>
  508. </tr>
  509. `;
  510.  
  511. theadElement.querySelectorAll('th').forEach(th => {
  512. th.addEventListener('click', () => {
  513. const columnKey = th.dataset.sortKey;
  514. sortAndRenderData(columnKey);
  515. });
  516. });
  517.  
  518. function renderTableBody(dataToRender) {
  519. let tbodyHtml = '';
  520. dataToRender.forEach(game => {
  521. tbodyHtml += `
  522. <tr>
  523. <td>${game.appId}</td>
  524. <td>${game.name}</td>
  525. <td class="discount">${game.currentDiscountText}</td>
  526. <td class="price">${formatPriceDisplay(game.currentPriceNum)}</td>
  527. <td class="price">${formatPriceDisplay(game.approximateFullPrice)}</td>
  528. <td class="price">${game.allTimeLowPrice !== null ? formatPriceDisplay(game.allTimeLowPrice) : '<span class="na">N/A</span>'}</td>
  529. <td class="price">${game.twoYearLowPrice !== null ? formatPriceDisplay(game.twoYearLowPrice) : '<span class="na">N/A</span>'}</td>
  530. <td class="price">${formatPriceDisplay(game.bestPriceForCalculation)}</td>
  531. </tr>
  532. `;
  533. });
  534. modalContentElement.querySelector('table tbody').innerHTML = tbodyHtml;
  535. }
  536.  
  537. function updateSortArrows() {
  538. theadElement.querySelectorAll('th').forEach(th => {
  539. let arrowSpan = th.querySelector('.sort-arrow');
  540. if (!arrowSpan) {
  541. arrowSpan = document.createElement('span');
  542. arrowSpan.className = 'sort-arrow';
  543. th.appendChild(arrowSpan);
  544. }
  545. if (th.dataset.sortKey === currentSortConfig.columnKey) {
  546. arrowSpan.innerHTML = currentSortConfig.direction === 'asc' ? ' ▲' : ' ▼';
  547. } else {
  548. arrowSpan.innerHTML = '';
  549. }
  550. });
  551. }
  552.  
  553. function sortAndRenderData(columnKey) {
  554. const sortOrder = (currentSortConfig.columnKey === columnKey && currentSortConfig.direction === 'asc') ? 'desc' : 'asc';
  555. currentSortConfig = { columnKey: columnKey, direction: sortOrder };
  556.  
  557. gamesData.sort((a, b) => {
  558. let valA = a[columnKey];
  559. let valB = b[columnKey];
  560.  
  561. if (columnKey === 'currentDiscountText') {
  562. const parseDiscountVal = (text) => {
  563. if (text === 'N/A' || !text.includes('%')) return 0;
  564. const num = parseInt(text.replace('-', '').replace('%', ''), 10);
  565. return isNaN(num) ? 0 : num;
  566. };
  567. valA = parseDiscountVal(a.currentDiscountText);
  568. valB = parseDiscountVal(b.currentDiscountText);
  569. } else if (typeof valA === 'string' && typeof valB === 'string') {
  570. valA = valA.toLowerCase();
  571. valB = valB.toLowerCase();
  572. }
  573.  
  574. if (valA === null || typeof valA === 'undefined') valA = sortOrder === 'asc' ? Infinity : -Infinity;
  575. if (valB === null || typeof valB === 'undefined') valB = sortOrder === 'asc' ? Infinity : -Infinity;
  576.  
  577. if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
  578. if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
  579. return 0;
  580. });
  581.  
  582. renderTableBody(gamesData);
  583. updateSortArrows();
  584. }
  585.  
  586. renderTableBody(gamesData);
  587. updateSortArrows();
  588.  
  589. const tfootElement = modalContentElement.querySelector('table tfoot');
  590. if (tfootElement) tfootElement.innerHTML = '';
  591. }
  592.  
  593.  
  594. function isRuCurrencySelected() {
  595. const currencySelector = document.querySelector('details#js-select-cc');
  596. if (currencySelector) {
  597. const checkedRadio = currencySelector.querySelector('input[name="cc"]:checked');
  598. if (checkedRadio) { return checkedRadio.value === 'ru'; }
  599. return currencySelector.dataset.default === 'ru';
  600. }
  601. const priceHeader = document.querySelector('th[data-name="price"] img[src*="/ru.svg"]');
  602. if (priceHeader) return true;
  603. const urlParams = new URLSearchParams(window.location.search);
  604. return urlParams.get('cc') === 'ru';
  605. }
  606.  
  607. function calculateRecommendedRubPrice(pUSD) {
  608. if (typeof pUSD !== 'number' || isNaN(pUSD)) return null;
  609. 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;
  610. return null;
  611. }
  612.  
  613. function getPriceInCents(purchaseOption) {
  614. if (!purchaseOption) return null;
  615. if (purchaseOption.discount_pct > 0 && purchaseOption.original_price_in_cents) {
  616. return parseInt(purchaseOption.original_price_in_cents, 10);
  617. }
  618. if (purchaseOption.final_price_in_cents) {
  619. return parseInt(purchaseOption.final_price_in_cents, 10);
  620. }
  621. return null;
  622. }
  623.  
  624. function createRrcDisplayElement(appId) {
  625. const container = document.createElement('div');
  626. container.className = 'rrc-display-container';
  627. const data = gameData[appId];
  628. let rrcStatus = 'no_data';
  629. let htmlContent = `<div class="rrc-content-wrapper"><span class="rrc-no-data">Нет данных РРЦ</span></div>`;
  630.  
  631. if (isRuModeActive && data && typeof data.price_us_initial_cents === 'number' && typeof data.price_ru_initial_cents === 'number') {
  632. const pUSD = data.price_us_initial_cents / 100;
  633. const actualRubPrice = data.price_ru_initial_cents / 100;
  634. const recommendedRubPrice = calculateRecommendedRubPrice(pUSD);
  635.  
  636. if (recommendedRubPrice !== null) {
  637. const diff = actualRubPrice - recommendedRubPrice;
  638. const diffPercent = recommendedRubPrice !== 0 ? (diff / recommendedRubPrice) * 100 : (diff > 0 ? Infinity : (actualRubPrice === 0 && recommendedRubPrice === 0 ? 0 : -Infinity));
  639. let textClass = 'equal';
  640. let symbol = '=';
  641. if (diff > 0.01) {
  642. textClass = 'higher'; symbol = '>'; rrcStatus = 'higher';
  643. } else if (diff < -0.01) {
  644. textClass = 'lower'; symbol = '<'; rrcStatus = 'lower';
  645. } else {
  646. rrcStatus = 'equal';
  647. }
  648. htmlContent = `
  649. <div class="rrc-content-wrapper">
  650. <span class="rrc-text ${textClass}">${symbol} РРЦ</span>
  651. <span class="rrc-details">(${diffPercent !== Infinity && diffPercent !== -Infinity ? diffPercent.toFixed(0) + '%' : (diffPercent > 0 ? '>~' : '<~') }, ${diff.toFixed(0)} ₽)</span>
  652. </div>`;
  653. } else {
  654. rrcStatus = 'no_rec_price';
  655. htmlContent = `<div class="rrc-content-wrapper"><span class="rrc-no-data">Нет данных РРЦ (USD?)</span></div>`;
  656. }
  657. } else if (isRuModeActive && data && (!data.price_us_initial_cents || !data.price_ru_initial_cents)) {
  658. rrcStatus = 'no_price_data';
  659. }
  660. if (!isRuModeActive) rrcStatus = 'not_applicable';
  661. if (data) gameData[appId].rrc_status = rrcStatus;
  662. container.innerHTML = htmlContent;
  663. return container;
  664. }
  665.  
  666. function injectRrcDisplay(row) {
  667. if (!isRuModeActive) return;
  668. const appId = row.dataset.appid;
  669. if (!appId) return;
  670. const targetCell = row.querySelector('td:first-child');
  671. if (!targetCell) return;
  672. let displayElement = targetCell.querySelector('.rrc-display-container');
  673. if (displayElement) displayElement.remove();
  674. displayElement = createRrcDisplayElement(appId);
  675. targetCell.prepend(displayElement);
  676. }
  677.  
  678. function injectAllRrcDisplays() {
  679. if (!isRuModeActive) {
  680. document.querySelectorAll('.rrc-display-container').forEach(el => el.remove());
  681. return;
  682. }
  683. document.querySelectorAll('tr.app[data-appid]').forEach(row => injectRrcDisplay(row));
  684. }
  685.  
  686. function createFiltersContainer() {
  687. const container = document.createElement('div');
  688. container.className = 'steamdb-enhancer';
  689.  
  690. let rrcFilterHTML = '';
  691. if (isRuModeActive) {
  692. rrcFilterHTML = `
  693. <div class="control-group" id="rrc-filter-control-group">
  694. <div class="group-title">Фильтр РРЦ</div>
  695. <div class="btn-group" id="rrc-filter-group">
  696. <button class="btn" data-filter-rrc="lower" title="Дешевле РРЦ">&lt; РРЦ</button>
  697. <button class="btn" data-filter-rrc="equal" title="Соответствует РРЦ">= РРЦ</button>
  698. <button class="btn" data-filter-rrc="higher" title="Дороже РРЦ">&gt; РРЦ</button>
  699. </div>
  700. </div>`;
  701. }
  702.  
  703. let wishlistCalculatorHTML = '';
  704. const isWishlistMode = document.querySelector('input[name="displayOnly"][value="Wishlist"]:checked') !== null;
  705. if (isWishlistMode) {
  706. wishlistCalculatorHTML = `
  707. <div class="control-group" id="wishlist-calculator-group">
  708. <div class="group-title">Калькулятор желаемого</div>
  709. <button class="btn" data-action="calculate-wishlist">${PROCESS_BUTTON_TEXT.calculate_wishlist_idle}</button>
  710. </div>`;
  711. }
  712.  
  713. container.innerHTML = `
  714. <div class="enhancer-header">
  715. <button class="btn" id="process-btn">
  716. <svg class="btn-icon" viewBox="0 0 24 24"><path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.8.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z"/></svg>
  717. <span id="process-btn-text">${PROCESS_BUTTON_TEXT.idle}</span>
  718. </button>
  719. <div class="status-indicator status-inactive">${isRuModeActive ? STATUS_TEXT.ready_to_process : STATUS_TEXT.rrc_disabled}</div>
  720. </div>
  721. <div class="progress-container"><div class="progress-bar"></div></div>
  722. <div class="progress-text">
  723. <span class="progress-count">0/0</span>
  724. <span class="progress-percent">(0%)</span>
  725. </div>
  726. <div class="row-layout">
  727. <div class="control-group">
  728. <div class="group-title">Русский перевод</div>
  729. <div class="btn-group">
  730. <button class="btn" data-filter="russian-any">Только текст</button>
  731. <button class="btn" data-filter="russian-audio">Озвучка</button>
  732. <button class="btn" data-filter="no-russian">Без перевода</button>
  733. </div>
  734. </div>
  735. <div class="control-group">
  736. <div class="group-title">Списки</div>
  737. <div class="btn-group">
  738. <button class="btn" data-action="list1">Список 1</button>
  739. <button class="btn" data-action="list2">Список 2</button>
  740. <button class="btn" data-action="list-filter">Фильтр списков</button>
  741. </div>
  742. </div>
  743. ${wishlistCalculatorHTML}
  744. ${rrcFilterHTML}
  745. <div class="control-group">
  746. <div class="group-title">Дополнительные инструменты</div>
  747. <div class="row-layout compact">
  748. <div class="converter-group">
  749. <input type="number" class="input-field" id="exchange-rate-input" value="${DEFAULT_EXCHANGE_RATE}" step="0.01">
  750. <button class="btn" data-action="convert">Конвертировать</button>
  751. </div>
  752. <div class="btn-group">
  753. <input type="date" class="date-picker">
  754. <button class="btn" data-action="date-filter">Фильтр по дате</button>
  755. </div>
  756. </div>
  757. </div>
  758.  
  759. <div class="control-group">
  760. <div class="group-title">Обзоры</div>
  761. <div class="review-filter-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: center;">
  762. <div>
  763. <label for="review-min-count" style="font-size: 11px; color: #a0b0c0;">Кол-во обзоров</label>
  764. <div style="display: flex; gap: 5px;">
  765. <input type="number" class="input-field" id="review-min-count" placeholder="Мин" style="width: 100%;">
  766. <input type="number" class="input-field" id="review-max-count" placeholder="Макс" style="width: 100%;">
  767. </div>
  768. </div>
  769. <div>
  770. <label for="review-min-rating" style="font-size: 11px; color: #a0b0c0;">Рейтинг (%)</label>
  771. <div style="display: flex; gap: 5px;">
  772. <input type="number" class="input-field" id="review-min-rating" placeholder="Мин" style="width: 100%;" min="0" max="100">
  773. <input type="number" class="input-field" id="review-max-rating" placeholder="Макс" style="width: 100%;" min="0" max="100">
  774. </div>
  775. </div>
  776. <div style="grid-column: 1 / -1; margin-top: 5px;">
  777. <label class="steamy-checkbox-control" style="font-size:13px; color: #c6d4df;">
  778. <input type="checkbox" id="total-sort-checkbox" data-action="toggle-total-sort">
  779. <span>Тотальная сортировка (кол-во * %)</span>
  780. </label>
  781. </div>
  782. </div>
  783. </div>
  784. <div class="control-group">
  785. <div class="group-title">Ранний доступ</div>
  786. <div class="btn-group">
  787. <button class="btn" data-action="ea-show">Только игры с ранним доступом</button>
  788. <button class="btn" data-action="ea-hide">Скрыть игры с ранним доступом</button>
  789. </div>
  790. </div>
  791. </div>`;
  792.  
  793. if (!isRuModeActive) {
  794. const rrcGroup = container.querySelector('#rrc-filter-control-group');
  795. if (rrcGroup) rrcGroup.classList.add('disabled-filter');
  796. }
  797. return container;
  798. }
  799.  
  800. async function ensureAllEntriesAndCountdown(callback, statusElement, actionButton) {
  801. const entriesSelect = document.getElementById('dt-length-0');
  802. const originalStatusText = statusElement.textContent;
  803. let originalButtonTextContent = "";
  804. let processButtonTextSpan = null;
  805.  
  806. if (actionButton) {
  807. actionButton.disabled = true;
  808. if (actionButton.id === 'process-btn') {
  809. processButtonTextSpan = actionButton.querySelector('#process-btn-text');
  810. if(processButtonTextSpan) originalButtonTextContent = processButtonTextSpan.textContent;
  811. } else {
  812. originalButtonTextContent = actionButton.textContent;
  813. }
  814. }
  815.  
  816. if (entriesSelect && entriesSelect.value !== "-1") {
  817. entriesSelect.value = "-1";
  818. entriesSelect.dispatchEvent(new Event('change', { bubbles: true }));
  819.  
  820. let countdown = PAGE_RELOAD_DELAY / 1000;
  821. const updateCountdownText = () => {
  822. const countdownMsg = `${STATUS_TEXT.changing_entries_prefix}${countdown}...`;
  823. statusElement.textContent = countdownMsg;
  824. if (actionButton) {
  825. if (processButtonTextSpan) {
  826. processButtonTextSpan.textContent = countdownMsg;
  827. } else {
  828. actionButton.textContent = countdownMsg;
  829. }
  830. }
  831. };
  832.  
  833. updateCountdownText();
  834.  
  835. const intervalId = setInterval(() => {
  836. countdown--;
  837. updateCountdownText();
  838. if (countdown <= 0) {
  839. clearInterval(intervalId);
  840. statusElement.textContent = originalStatusText;
  841. if (actionButton) {
  842. if (processButtonTextSpan) {
  843. processButtonTextSpan.textContent = PROCESS_BUTTON_TEXT.idle;
  844. } else {
  845. actionButton.textContent = PROCESS_BUTTON_TEXT.calculate_wishlist_idle;
  846. }
  847. actionButton.disabled = false;
  848. }
  849. callback();
  850. }
  851. }, 1000);
  852. } else {
  853. if (actionButton) actionButton.disabled = false;
  854. callback();
  855. }
  856. }
  857.  
  858. function handleDiscountFilterChange(event) {
  859. const checkbox = event.target;
  860. if (!checkbox.matches('input[type="checkbox"][data-discount-type]')) return;
  861.  
  862. const type = checkbox.dataset.discountType;
  863. const mode = checkbox.dataset.filterMode;
  864. const isChecked = checkbox.checked;
  865.  
  866. if (type.startsWith('percent_')) {
  867. if (mode === 'show') {
  868. activeShowAtlPercentFilters[type.replace('percent_', '')] = isChecked;
  869. if (isChecked && activeHideAtlPercentFilters[type.replace('percent_', '')]) {
  870. activeHideAtlPercentFilters[type.replace('percent_', '')] = false;
  871. }
  872. } else if (mode === 'hide') {
  873. activeHideAtlPercentFilters[type.replace('percent_', '')] = isChecked;
  874. if (isChecked && activeShowAtlPercentFilters[type.replace('percent_', '')]) {
  875. activeShowAtlPercentFilters[type.replace('percent_', '')] = false;
  876. }
  877. }
  878. } else {
  879. if (mode === 'show') {
  880. activeShowDiscountFilters[type] = isChecked;
  881. if (isChecked && activeHideDiscountFilters[type]) {
  882. activeHideDiscountFilters[type] = false;
  883. }
  884. } else if (mode === 'hide') {
  885. activeHideDiscountFilters[type] = isChecked;
  886. if (isChecked && activeShowDiscountFilters[type]) {
  887. activeShowDiscountFilters[type] = false;
  888. }
  889. }
  890. }
  891. updateDiscountFilterUI(type);
  892. applyAllFilters();
  893.  
  894. if (typeof $ !== 'undefined' && $.fn.dataTable && $.fn.dataTable.tables(true).length > 0) {
  895. const dtTable = $($.fn.dataTable.tables(true)[0]);
  896. if (dtTable.length > 0 && dtTable.DataTable()?.settings()[0]) {
  897. dtTable.DataTable().draw(false);
  898. }
  899. }
  900. }
  901.  
  902. function updateDiscountFilterUI(specificType = null) {
  903. const typesToUpdateAbsolute = specificType && !specificType.startsWith('percent_') ? [specificType] : ['blue', 'green', 'purple'];
  904. typesToUpdateAbsolute.forEach(type => {
  905. const showCheckbox = document.getElementById(`enhancer-show-${type}-discount`);
  906. const hideCheckbox = document.getElementById(`enhancer-hide-${type}-discount`);
  907.  
  908. if (showCheckbox) {
  909. showCheckbox.checked = activeShowDiscountFilters[type];
  910. showCheckbox.parentElement.classList.toggle('active', activeShowDiscountFilters[type]);
  911. }
  912. if (hideCheckbox) {
  913. hideCheckbox.checked = activeHideDiscountFilters[type];
  914. hideCheckbox.parentElement.classList.toggle('active', activeHideDiscountFilters[type]);
  915. }
  916. });
  917.  
  918. const typesToUpdatePercent = specificType && specificType.startsWith('percent_') ? [specificType.replace('percent_', '')] : ['cheaper', 'equal', 'more_expensive'];
  919. typesToUpdatePercent.forEach(type => {
  920. const showCheckbox = document.getElementById(`enhancer-show-percent_${type}-discount`);
  921. const hideCheckbox = document.getElementById(`enhancer-hide-percent_${type}-discount`);
  922. if (showCheckbox) {
  923. showCheckbox.checked = activeShowAtlPercentFilters[type];
  924. showCheckbox.parentElement.classList.toggle('active', activeShowAtlPercentFilters[type]);
  925. }
  926. if (hideCheckbox) {
  927. hideCheckbox.checked = activeHideAtlPercentFilters[type];
  928. hideCheckbox.parentElement.classList.toggle('active', activeHideAtlPercentFilters[type]);
  929. }
  930. });
  931. }
  932.  
  933. function handleMainPanelClick(event) {
  934. const langBtn = event.target.closest('[data-filter]');
  935. if (langBtn) {
  936. const filterType = langBtn.dataset.filter;
  937. if (filterType.startsWith('russian-') || filterType === 'no-russian') {
  938. const wasActive = langBtn.classList.contains('active');
  939. document.querySelectorAll('.steamdb-enhancer [data-filter^="russian-"], .steamdb-enhancer [data-filter="no-russian"]').forEach(b => b.classList.remove('active'));
  940. if (!wasActive) {
  941. langBtn.classList.add('active');
  942. activeLanguageFilter = filterType;
  943. } else {
  944. activeLanguageFilter = null;
  945. }
  946. applyAllFilters();
  947. }
  948. }
  949.  
  950. if (isRuModeActive) {
  951. const rrcBtn = event.target.closest('[data-filter-rrc]');
  952. if (rrcBtn) {
  953. const filterType = rrcBtn.dataset.filterRrc;
  954. activeRrcFilters[filterType] = !activeRrcFilters[filterType];
  955. rrcBtn.classList.toggle('active', activeRrcFilters[filterType]);
  956. applyAllFilters();
  957. }
  958. }
  959. handleControlClick(event);
  960. }
  961.  
  962. function handleControlClick(event) {
  963. const btn = event.target.closest('[data-action]');
  964. if (!btn) return;
  965. const action = btn.dataset.action;
  966. const statusIndicator = document.querySelector('.steamdb-enhancer .status-indicator');
  967. switch (action) {
  968. case 'list1': saveList('list1'); break;
  969. case 'list2': saveList('list2'); break;
  970. case 'list-filter':
  971. activeListFilter = !activeListFilter;
  972. btn.classList.toggle('active', activeListFilter);
  973. applyAllFilters();
  974. break;
  975. case 'convert':
  976. currentExchangeRate = parseFloat(document.getElementById('exchange-rate-input').value) || DEFAULT_EXCHANGE_RATE;
  977. convertPrices();
  978. break;
  979. case 'date-filter': {
  980. const dateInput = btn.previousElementSibling;
  981. if (btn.classList.contains('active')) {
  982. btn.classList.remove('active'); dateInput.value = ''; activeDateFilterTimestamp = null;
  983. } else {
  984. const selectedDate = dateInput.value;
  985. if (selectedDate) {
  986. const dateObj = new Date(selectedDate + 'T00:00:00Z');
  987. activeDateFilterTimestamp = dateObj.getTime() / 1000;
  988. btn.classList.toggle('active', !isNaN(activeDateFilterTimestamp));
  989. if (isNaN(activeDateFilterTimestamp)) activeDateFilterTimestamp = null;
  990. } else {
  991. activeDateFilterTimestamp = null; btn.classList.remove('active');
  992. }
  993. }
  994. applyAllFilters(); break;
  995. }
  996. case 'calculate-wishlist':
  997. if (statusIndicator) statusIndicator.textContent = STATUS_TEXT.calculating_wishlist;
  998. ensureAllEntriesAndCountdown(extractAndDisplayGameDataSteamStyle, statusIndicator, btn);
  999. break;
  1000. case 'ea-show': {
  1001. const eaHideBtn = document.querySelector('[data-action="ea-hide"]');
  1002. if (btn.classList.contains('active')) {
  1003. btn.classList.remove('active');
  1004. earlyAccessFilter = 'none';
  1005. } else {
  1006. btn.classList.add('active');
  1007. if (eaHideBtn) eaHideBtn.classList.remove('active');
  1008. earlyAccessFilter = 'show';
  1009. }
  1010. applyAllFilters();
  1011. break;
  1012. }
  1013. case 'ea-hide': {
  1014. const eaShowBtn = document.querySelector('[data-action="ea-show"]');
  1015. if (btn.classList.contains('active')) {
  1016. btn.classList.remove('active');
  1017. earlyAccessFilter = 'none';
  1018. } else {
  1019. btn.classList.add('active');
  1020. if (eaShowBtn) eaShowBtn.classList.remove('active');
  1021. earlyAccessFilter = 'hide';
  1022. }
  1023. applyAllFilters();
  1024. break;
  1025. }
  1026. }
  1027. }
  1028.  
  1029. function saveList(listName) {
  1030. const appIds = Array.from(collectedAppIds);
  1031. localStorage.setItem(listName, JSON.stringify(appIds));
  1032. alert(`Список ${listName} сохранён (${appIds.length} игр)`);
  1033. }
  1034.  
  1035. function convertPrices() {
  1036. document.querySelectorAll('tr.app').forEach(row => {
  1037. const priceElement = row.cells[4];
  1038. if (!priceElement) return;
  1039.  
  1040. if (!priceElement.dataset.originalPrice) {
  1041. priceElement.dataset.originalPrice = priceElement.textContent.trim();
  1042. }
  1043. const originalPriceText = priceElement.dataset.originalPrice;
  1044. let priceValue = NaN;
  1045.  
  1046. if (originalPriceText.includes('S/.')) {
  1047. const priceMatch = originalPriceText.match(/S\/\.\s*([0-9,.]+)/);
  1048. priceValue = priceMatch ? parseFloat(priceMatch[1].replace(/\s/g, '').replace(',', '.')) : NaN;
  1049. } else if (originalPriceText.includes('₽')) {
  1050. const priceMatch = originalPriceText.match(/([0-9,\s]+)\s*₽/);
  1051. priceValue = priceMatch ? parseFloat(priceMatch[1].replace(/\s/g, '').replace(',', '.')) : NaN;
  1052. } else if (originalPriceText.toLowerCase().includes('free') || originalPriceText.toLowerCase().includes('бесплатно')) {
  1053. priceValue = 0;
  1054. } else {
  1055. const priceMatch = originalPriceText.replace(',', '.').match(/([0-9.]+)/);
  1056. priceValue = priceMatch ? parseFloat(priceMatch[1]) : NaN;
  1057. }
  1058.  
  1059. if (!isNaN(priceValue)) {
  1060. if (priceValue === 0) {
  1061. priceElement.textContent = priceElement.dataset.originalPrice;
  1062. } else {
  1063. const converted = (priceValue * currentExchangeRate).toFixed(2);
  1064. priceElement.textContent = converted;
  1065. }
  1066. } else {
  1067. priceElement.textContent = originalPriceText;
  1068. }
  1069. });
  1070. }
  1071.  
  1072. function updateReviewFilterPlaceholders() {
  1073. const visibleRows = Array.from(document.querySelectorAll('tr.app[data-appid]')).filter(row => row.style.display !== 'none');
  1074.  
  1075. if (visibleRows.length === 0) return;
  1076.  
  1077. let minCount, maxCount, minRating, maxRating;
  1078.  
  1079. visibleRows.forEach(row => {
  1080. const appId = row.dataset.appid;
  1081. const data = gameData[appId];
  1082. if (data && data.review_count !== undefined && data.percent_positive !== undefined) {
  1083. const count = data.review_count;
  1084. const rating = data.percent_positive;
  1085.  
  1086. if (minCount === undefined || count < minCount) minCount = count;
  1087. if (maxCount === undefined || count > maxCount) maxCount = count;
  1088. if (minRating === undefined || rating < minRating) minRating = rating;
  1089. if (maxRating === undefined || rating > maxRating) maxRating = rating;
  1090. }
  1091. });
  1092.  
  1093. const minCountInput = document.getElementById('review-min-count');
  1094. const maxCountInput = document.getElementById('review-max-count');
  1095. const minRatingInput = document.getElementById('review-min-rating');
  1096. const maxRatingInput = document.getElementById('review-max-rating');
  1097.  
  1098. if (minCountInput) minCountInput.placeholder = minCount !== undefined ? minCount : 'Мин';
  1099. if (maxCountInput) maxCountInput.placeholder = maxCount !== undefined ? maxCount : 'Макс';
  1100. if (minRatingInput) minRatingInput.placeholder = minRating !== undefined ? minRating : 'Мин';
  1101. if (maxRatingInput) maxRatingInput.placeholder = maxRating !== undefined ? maxRating : 'Макс';
  1102. }
  1103.  
  1104. function handleReviewFilterChange() {
  1105. clearTimeout(debounceTimer);
  1106. debounceTimer = setTimeout(() => {
  1107. const minCount = document.getElementById('review-min-count').value;
  1108. const maxCount = document.getElementById('review-max-count').value;
  1109. const minRating = document.getElementById('review-min-rating').value;
  1110. const maxRating = document.getElementById('review-max-rating').value;
  1111.  
  1112. reviewFilters.minCount = minCount ? parseInt(minCount, 10) : null;
  1113. reviewFilters.maxCount = maxCount ? parseInt(maxCount, 10) : null;
  1114. reviewFilters.minRating = minRating ? parseInt(minRating, 10) : null;
  1115. reviewFilters.maxRating = maxRating ? parseInt(maxRating, 10) : null;
  1116.  
  1117. applyAllFilters();
  1118. updateReviewFilterPlaceholders();
  1119. }, 500);
  1120. }
  1121.  
  1122. function applyTotalSort() {
  1123. const dtTableAPI = $($.fn.dataTable.tables(true)[0]).DataTable();
  1124. const ratingColumnIndex = 5;
  1125.  
  1126. dtTableAPI.rows().every(function() {
  1127. const rowNode = this.node();
  1128. const appId = rowNode.dataset.appid;
  1129. const data = gameData[appId];
  1130. const ratingCell = rowNode.cells[ratingColumnIndex];
  1131.  
  1132. if (ratingCell && data && data.review_count !== undefined && data.percent_positive !== undefined) {
  1133.  
  1134. if (!ratingCell.dataset.originalSort) {
  1135. ratingCell.dataset.originalSort = ratingCell.getAttribute('data-sort') || '0';
  1136. }
  1137. if (!ratingCell.dataset.originalText) {
  1138. ratingCell.dataset.originalText = ratingCell.textContent;
  1139. }
  1140.  
  1141. if (isTotalSortEnabled) {
  1142. const totalScore = data.review_count * data.percent_positive;
  1143. ratingCell.setAttribute('data-sort', totalScore);
  1144. ratingCell.textContent = totalScore.toLocaleString('ru-RU');
  1145. } else {
  1146. ratingCell.setAttribute('data-sort', ratingCell.dataset.originalSort);
  1147. ratingCell.textContent = ratingCell.dataset.originalText;
  1148. }
  1149. dtTableAPI.cell(ratingCell).invalidate();
  1150. }
  1151. });
  1152. dtTableAPI.draw('page');
  1153. }
  1154.  
  1155. function applyAllFilters() {
  1156. const rows = document.querySelectorAll('tr.app');
  1157. const list1 = JSON.parse(localStorage.getItem('list1') || '[]');
  1158. const list2 = JSON.parse(localStorage.getItem('list2') || '[]');
  1159. const commonIds = new Set(list1.filter(id => list2.includes(id)));
  1160.  
  1161. rows.forEach(row => {
  1162. const appId = row.dataset.appid;
  1163. const data = gameData[appId];
  1164. let visible = true;
  1165.  
  1166. if (activeListFilter) {
  1167. visible = !commonIds.has(appId);
  1168. }
  1169.  
  1170. if (visible && activeDateFilterTimestamp !== null) {
  1171. const cells = row.querySelectorAll('.timeago');
  1172. const timeToCheck = parseInt(cells[1]?.dataset.sort || cells[0]?.dataset.sort || '0');
  1173. if (!timeToCheck || isNaN(timeToCheck)) {
  1174. visible = false;
  1175. } else {
  1176. visible = timeToCheck >= activeDateFilterTimestamp;
  1177. }
  1178. }
  1179.  
  1180. if (visible && activeLanguageFilter && data) {
  1181. const lang = data.language_support_russian || {};
  1182. switch (activeLanguageFilter) {
  1183. case 'russian-any': visible = (lang.supported || lang.subtitles) && !lang.full_audio; break;
  1184. case 'russian-audio': visible = lang.full_audio; break;
  1185. case 'no-russian': visible = !lang.supported && !lang.full_audio && !lang.subtitles; break;
  1186. }
  1187. } else if (visible && activeLanguageFilter && !data && isProcessingStarted) {
  1188. visible = false;
  1189. }
  1190.  
  1191. if (isRuModeActive && visible) {
  1192. const rrcFilterIsActive = activeRrcFilters.lower || activeRrcFilters.equal || activeRrcFilters.higher;
  1193. const allRrcFiltersSelected = activeRrcFilters.lower && activeRrcFilters.equal && activeRrcFilters.higher;
  1194. const noRrcFiltersSelected = !activeRrcFilters.lower && !activeRrcFilters.equal && !activeRrcFilters.higher;
  1195.  
  1196. if (rrcFilterIsActive && !allRrcFiltersSelected && !noRrcFiltersSelected) {
  1197. const rrcStatus = data?.rrc_status;
  1198. if (!rrcStatus || rrcStatus === 'no_data' || rrcStatus === 'no_price_data' || rrcStatus === 'no_rec_price' || rrcStatus === 'not_applicable') {
  1199. visible = false;
  1200. } else {
  1201. let match = false;
  1202. if (activeRrcFilters.lower && rrcStatus === 'lower') match = true;
  1203. if (activeRrcFilters.equal && rrcStatus === 'equal') match = true;
  1204. if (activeRrcFilters.higher && rrcStatus === 'higher') match = true;
  1205. if (!match) visible = false;
  1206. }
  1207. }
  1208. }
  1209.  
  1210. if (visible) {
  1211. const isBlue = row.querySelector('td.price-discount-major') !== null;
  1212. const isGreen = row.querySelector('td.price-discount:not(.price-discount-major):not(.price-discount-minor)') !== null;
  1213. const isPurple = row.querySelector('td.price-discount-minor') !== null;
  1214.  
  1215. const anyShowDiscountFilterActive = activeShowDiscountFilters.blue || activeShowDiscountFilters.green || activeShowDiscountFilters.purple;
  1216. if (anyShowDiscountFilterActive) {
  1217. let matchesActiveShowFilter = false;
  1218. if (activeShowDiscountFilters.blue && isBlue) matchesActiveShowFilter = true;
  1219. if (activeShowDiscountFilters.green && isGreen) matchesActiveShowFilter = true;
  1220. if (activeShowDiscountFilters.purple && isPurple) matchesActiveShowFilter = true;
  1221.  
  1222. if (!matchesActiveShowFilter) {
  1223. visible = false;
  1224. }
  1225. }
  1226.  
  1227. if (visible) {
  1228. if (activeHideDiscountFilters.blue && isBlue) visible = false;
  1229. if (activeHideDiscountFilters.green && isGreen) visible = false;
  1230. if (activeHideDiscountFilters.purple && isPurple) visible = false;
  1231. }
  1232. }
  1233.  
  1234. if(visible) {
  1235. const atlPercentStatus = row.dataset.atlPercentStatus;
  1236. const anyShowAtlPercentFilterActive = activeShowAtlPercentFilters.cheaper || activeShowAtlPercentFilters.equal || activeShowAtlPercentFilters.more_expensive;
  1237. if (anyShowAtlPercentFilterActive) {
  1238. let matchesActiveShowFilter = false;
  1239. if (activeShowAtlPercentFilters.cheaper && atlPercentStatus === 'cheaper') matchesActiveShowFilter = true;
  1240. if (activeShowAtlPercentFilters.equal && atlPercentStatus === 'equal') matchesActiveShowFilter = true;
  1241. if (activeShowAtlPercentFilters.more_expensive && atlPercentStatus === 'more_expensive') matchesActiveShowFilter = true;
  1242. if (!matchesActiveShowFilter) {
  1243. visible = false;
  1244. }
  1245. }
  1246. if (visible) {
  1247. if (activeHideAtlPercentFilters.cheaper && atlPercentStatus === 'cheaper') visible = false;
  1248. if (activeHideAtlPercentFilters.equal && atlPercentStatus === 'equal') visible = false;
  1249. if (activeHideAtlPercentFilters.more_expensive && atlPercentStatus === 'more_expensive') visible = false;
  1250. }
  1251. }
  1252.  
  1253. if (visible && data) {
  1254. if (earlyAccessFilter === 'show' && !data.is_early_access) {
  1255. visible = false;
  1256. }
  1257. if (earlyAccessFilter === 'hide' && data.is_early_access) {
  1258. visible = false;
  1259. }
  1260.  
  1261. if (reviewFilters.minCount !== null && (data.review_count || 0) < reviewFilters.minCount) {
  1262. visible = false;
  1263. }
  1264. if (reviewFilters.maxCount !== null && (data.review_count || 0) > reviewFilters.maxCount) {
  1265. visible = false;
  1266. }
  1267. if (reviewFilters.minRating !== null && (data.percent_positive || 0) < reviewFilters.minRating) {
  1268. visible = false;
  1269. }
  1270. if (reviewFilters.maxRating !== null && (data.percent_positive || 0) > reviewFilters.maxRating) {
  1271. visible = false;
  1272. }
  1273. } else if (visible && (reviewFilters.minCount || reviewFilters.maxCount || reviewFilters.minRating || reviewFilters.maxRating || earlyAccessFilter !== 'none') && !data && isProcessingStarted) {
  1274. visible = false;
  1275. }
  1276.  
  1277. row.style.display = visible ? '' : 'none';
  1278. });
  1279. }
  1280.  
  1281. function processGameData(items, stage) {
  1282. items.forEach(item => {
  1283. if (!item?.id) return;
  1284. if (!gameData[item.id]) gameData[item.id] = {};
  1285. const purchaseOption = item.best_purchase_option || item.purchase_options?.[0];
  1286.  
  1287. if (stage === 'RU' || (stage === 'SINGLE_FETCH' && isRuModeActive)) {
  1288. if (!gameData[item.id].franchises) {
  1289. gameData[item.id].franchises = item.basic_info?.franchises?.map(f => f.name).join(', ');
  1290. gameData[item.id].percent_positive = item.reviews?.summary_filtered?.percent_positive;
  1291. gameData[item.id].review_count = item.reviews?.summary_filtered?.review_count;
  1292. gameData[item.id].is_early_access = item.is_early_access;
  1293. gameData[item.id].short_description = item.basic_info?.short_description;
  1294. gameData[item.id].language_support_russian = item.supported_languages?.find(l => l.elanguage === 8);
  1295. gameData[item.id].language_support_english = item.supported_languages?.find(l => l.elanguage === 0);
  1296. }
  1297. gameData[item.id].price_ru_initial_cents = getPriceInCents(purchaseOption);
  1298. gameData[item.id].price_ru_formatted_final = purchaseOption?.formatted_final_price;
  1299. if (stage === 'RU') processedRuGames++;
  1300. else processedSingleStageGames++;
  1301. } else if (stage === 'US') {
  1302. gameData[item.id].price_us_initial_cents = getPriceInCents(purchaseOption);
  1303. gameData[item.id].price_us_formatted_final = purchaseOption?.formatted_final_price;
  1304. processedUsGames++;
  1305. } else if (stage === 'SINGLE_FETCH' && !isRuModeActive) {
  1306. if (!gameData[item.id].franchises) {
  1307. gameData[item.id].franchises = item.basic_info?.franchises?.map(f => f.name).join(', ');
  1308. gameData[item.id].percent_positive = item.reviews?.summary_filtered?.percent_positive;
  1309. gameData[item.id].review_count = item.reviews?.summary_filtered?.review_count;
  1310. gameData[item.id].is_early_access = item.is_early_access;
  1311. gameData[item.id].short_description = item.basic_info?.short_description;
  1312. gameData[item.id].language_support_russian = item.supported_languages?.find(l => l.elanguage === 8);
  1313. gameData[item.id].language_support_english = item.supported_languages?.find(l => l.elanguage === 0);
  1314. }
  1315. processedSingleStageGames++;
  1316. }
  1317. });
  1318. updateProgress();
  1319. }
  1320.  
  1321. async function processRequestQueue() {
  1322. if (isProcessingQueue || !requestQueue.length) {
  1323. if (!isProcessingQueue && isRuModeActive) {
  1324. if (currentProcessingStage === 'RU' && processedRuGames >= totalGamesOnPage) {
  1325. currentProcessingStage = 'US';
  1326. updateButtonAndStatus(PROCESS_BUTTON_TEXT.processing_us, STATUS_TEXT.processing_us);
  1327. const usBatches = Array.from(collectedAppIds).reduce((acc, id, i) => {
  1328. if (i % BATCH_SIZE === 0) acc.push([]);
  1329. acc[acc.length - 1].push(id);
  1330. return acc;
  1331. }, []);
  1332. requestQueue.push(...usBatches.map(batch => ({ batch, stage: 'US', lang: 'english', cc: 'US' })));
  1333. updateProgress();
  1334. await processRequestQueue();
  1335. } else if (currentProcessingStage === 'US' && processedUsGames >= totalGamesOnPage) {
  1336. updateButtonAndStatus(PROCESS_BUTTON_TEXT.done, STATUS_TEXT.processing_rrc, true, false);
  1337. injectAllRrcDisplays();
  1338. applyAllFilters();
  1339. isProcessingStarted = false;
  1340. updateButtonAndStatus(PROCESS_BUTTON_TEXT.done, STATUS_TEXT.done, true, true);
  1341. updateReviewFilterPlaceholders();
  1342. }
  1343. } else if (!isProcessingQueue && !isRuModeActive && currentProcessingStage === 'SINGLE_FETCH' && processedSingleStageGames >= totalGamesOnPage) {
  1344. applyAllFilters();
  1345. isProcessingStarted = false;
  1346. updateButtonAndStatus(PROCESS_BUTTON_TEXT.done, STATUS_TEXT.done_no_rrc, true, true);
  1347. updateReviewFilterPlaceholders();
  1348. }
  1349. return;
  1350. }
  1351.  
  1352. isProcessingQueue = true;
  1353. const { batch: currentBatch, stage: batchStage, lang: batchLang, cc: batchCC } = requestQueue.shift();
  1354.  
  1355. try {
  1356. await fetchGameData(currentBatch, batchCC, batchLang, batchStage);
  1357. await new Promise(r => setTimeout(r, REQUEST_DELAY));
  1358. } catch (error) {
  1359. if (batchStage === 'RU') processedRuGames += currentBatch.length;
  1360. else if (batchStage === 'US') processedUsGames += currentBatch.length;
  1361. else if (batchStage === 'SINGLE_FETCH') processedSingleStageGames += currentBatch.length;
  1362. updateProgress();
  1363. } finally {
  1364. isProcessingQueue = false;
  1365. await processRequestQueue();
  1366. }
  1367. }
  1368.  
  1369. function fetchGameData(appIds, countryCode, language, stage) {
  1370. return new Promise((resolve) => {
  1371. if (!appIds || appIds.length === 0) { resolve(); return; }
  1372. const input = {
  1373. ids: appIds.map(appid => ({ appid: parseInt(appid, 10) })),
  1374. context: { language: language, country_code: countryCode, steam_realm: 1 },
  1375. data_request: {
  1376. include_assets: false, include_release: true, include_platforms: false,
  1377. include_all_purchase_options: true, include_screenshots: false, include_trailers: false,
  1378. include_ratings: true, include_tag_count: false, include_reviews: true,
  1379. include_basic_info: true, include_supported_languages: true,
  1380. include_full_description: false, include_included_items: false
  1381. }
  1382. };
  1383. const url = `${API_URL}?input_json=${encodeURIComponent(JSON.stringify(input))}`;
  1384. GM_xmlhttpRequest({
  1385. method: "GET", url: url, timeout: 15000,
  1386. onload: function(response) {
  1387. if (response.status === 200) {
  1388. try {
  1389. const data = JSON.parse(response.responseText);
  1390. if (data?.response?.store_items) {
  1391. processGameData(data.response.store_items, stage);
  1392. } else {
  1393. if (stage === 'RU') processedRuGames += appIds.length;
  1394. else if (stage === 'US') processedUsGames += appIds.length;
  1395. else if (stage === 'SINGLE_FETCH') processedSingleStageGames += appIds.length;
  1396. }
  1397. } catch (e) {
  1398. if (stage === 'RU') processedRuGames += appIds.length;
  1399. else if (stage === 'US') processedUsGames += appIds.length;
  1400. else if (stage === 'SINGLE_FETCH') processedSingleStageGames += appIds.length;
  1401. }
  1402. } else {
  1403. if (stage === 'RU') processedRuGames += appIds.length;
  1404. else if (stage === 'US') processedUsGames += appIds.length;
  1405. else if (stage === 'SINGLE_FETCH') processedSingleStageGames += appIds.length;
  1406. }
  1407. updateProgress(); resolve();
  1408. },
  1409. onerror: function() {
  1410. if (stage === 'RU') processedRuGames += appIds.length;
  1411. else if (stage === 'US') processedUsGames += appIds.length;
  1412. else if (stage === 'SINGLE_FETCH') processedSingleStageGames += appIds.length;
  1413. updateProgress(); resolve();
  1414. },
  1415. ontimeout: function() {
  1416. if (stage === 'RU') processedRuGames += appIds.length;
  1417. else if (stage === 'US') processedUsGames += appIds.length;
  1418. else if (stage === 'SINGLE_FETCH') processedSingleStageGames += appIds.length;
  1419. updateProgress(); resolve();
  1420. }
  1421. });
  1422. });
  1423. }
  1424.  
  1425. async function startDataCollection() {
  1426. if (isProcessingStarted) return;
  1427. isProcessingStarted = true;
  1428. processedRuGames = 0; processedUsGames = 0; processedSingleStageGames = 0;
  1429. requestQueue = []; gameData = {};
  1430. const rows = document.querySelectorAll('tr.app[data-appid]');
  1431. collectedAppIds = new Set(Array.from(rows).map(r => r.dataset.appid));
  1432. totalGamesOnPage = collectedAppIds.size;
  1433.  
  1434. if (totalGamesOnPage === 0) {
  1435. isProcessingStarted = false;
  1436. updateButtonAndStatus(PROCESS_BUTTON_TEXT.idle, isRuModeActive ? STATUS_TEXT.ready_to_process : STATUS_TEXT.rrc_disabled, false, true);
  1437. return;
  1438. }
  1439. const batches = Array.from(collectedAppIds).reduce((acc, id, i) => {
  1440. if (i % BATCH_SIZE === 0) acc.push([]);
  1441. acc[acc.length - 1].push(id);
  1442. return acc;
  1443. }, []);
  1444.  
  1445. if (isRuModeActive) {
  1446. currentProcessingStage = 'RU';
  1447. updateButtonAndStatus(PROCESS_BUTTON_TEXT.processing_ru, STATUS_TEXT.processing_ru);
  1448. requestQueue = batches.map(batch => ({ batch, stage: 'RU', lang: 'russian', cc: 'RU' }));
  1449. } else {
  1450. currentProcessingStage = 'SINGLE_FETCH';
  1451. updateButtonAndStatus(PROCESS_BUTTON_TEXT.processing_single, STATUS_TEXT.processing_single);
  1452. const currentCC = document.querySelector('details#js-select-cc input[name="cc"]:checked')?.value || document.querySelector('details#js-select-cc')?.dataset.default || 'us';
  1453. requestQueue = batches.map(batch => ({ batch, stage: 'SINGLE_FETCH', lang: 'english', cc: currentCC }));
  1454. }
  1455. updateProgress();
  1456. await processRequestQueue();
  1457. }
  1458.  
  1459. function updateButtonAndStatus(btnText, statusMsg, isDone = false, enableButton = false) {
  1460. const processBtnTextEl = document.getElementById('process-btn-text');
  1461. if (processBtnTextEl) processBtnTextEl.textContent = btnText;
  1462.  
  1463. const statusIndicator = document.querySelector('.steamdb-enhancer .status-indicator');
  1464. if (statusIndicator) {
  1465. statusIndicator.textContent = statusMsg;
  1466. statusIndicator.classList.toggle('status-active', isDone);
  1467. statusIndicator.classList.toggle('status-inactive', !isDone && !isProcessingStarted);
  1468. }
  1469. if (processButton) processButton.disabled = !enableButton && isProcessingStarted;
  1470. }
  1471.  
  1472. function updateProgress() {
  1473. const progressBar = document.querySelector('.steamdb-enhancer .progress-bar');
  1474. const progressCountEl = document.querySelector('.steamdb-enhancer .progress-count');
  1475. const progressPercentEl = document.querySelector('.steamdb-enhancer .progress-percent');
  1476. if (!progressBar || !progressCountEl || !progressPercentEl) return;
  1477. let overallPercent = 0; let countText = "0/0";
  1478.  
  1479. if (totalGamesOnPage > 0) {
  1480. if (isRuModeActive) {
  1481. if (currentProcessingStage === 'RU') {
  1482. overallPercent = (processedRuGames / totalGamesOnPage) * 50;
  1483. countText = `Этап RU: ${processedRuGames}/${totalGamesOnPage}`;
  1484. } else if (currentProcessingStage === 'US') {
  1485. overallPercent = 50 + (processedUsGames / totalGamesOnPage) * 50;
  1486. countText = `Этап US: ${processedUsGames}/${totalGamesOnPage}`;
  1487. }
  1488. } else {
  1489. overallPercent = (processedSingleStageGames / totalGamesOnPage) * 100;
  1490. countText = `Обработано: ${processedSingleStageGames}/${totalGamesOnPage}`;
  1491. }
  1492. }
  1493. overallPercent = Math.min(overallPercent, 100);
  1494. progressBar.style.width = `${overallPercent}%`;
  1495. progressCountEl.textContent = countText;
  1496. progressPercentEl.textContent = `(${Math.round(overallPercent)}%)`;
  1497.  
  1498. if (!isProcessingStarted && processButton) {
  1499. const processBtnTextEl = document.getElementById('process-btn-text');
  1500. if (processBtnTextEl) processBtnTextEl.textContent = PROCESS_BUTTON_TEXT.idle;
  1501. processButton.disabled = false;
  1502. }
  1503. }
  1504.  
  1505. function handleHover(event) {
  1506. const row = event.target.closest('tr.app');
  1507. if (!row || tooltip?.style?.opacity === '1') return;
  1508. clearTimeout(hoverTimer);
  1509. hoverTimer = setTimeout(() => {
  1510. const appId = row.dataset.appid;
  1511. if (gameData[appId] && (gameData[appId].franchises || gameData[appId].language_support_russian)) {
  1512. showTooltip(row, gameData[appId]);
  1513. }
  1514. }, HOVER_DELAY);
  1515. row.addEventListener('mouseleave', hideTooltip, { once: true });
  1516. }
  1517.  
  1518. function hideTooltip() {
  1519. clearTimeout(hoverTimer);
  1520. if (tooltip) {
  1521. tooltip.style.opacity = '0';
  1522. setTimeout(() => { if (tooltip && tooltip.style.opacity === '0') tooltip.style.display = 'none'; }, 250);
  1523. }
  1524. }
  1525.  
  1526. function showTooltip(element, data) {
  1527. if (!tooltip) {
  1528. tooltip = document.createElement('div');
  1529. tooltip.className = 'steamdb-tooltip';
  1530. tooltip.addEventListener('mouseenter', () => clearTimeout(hoverTimer));
  1531. tooltip.addEventListener('mouseleave', hideTooltip);
  1532. document.body.appendChild(tooltip);
  1533. }
  1534. tooltip.innerHTML = `<div class="tooltip-arrow"></div><div class="tooltip-content">${buildTooltipContent(data)}</div>`;
  1535. const rect = element.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect();
  1536. let left = rect.right + window.scrollX + 10; let top = rect.top + window.scrollY + (rect.height / 2) - (tooltipRect.height / 2);
  1537. top = Math.max(window.scrollY + 5, top); top = Math.min(window.scrollY + window.innerHeight - tooltipRect.height - 5, top);
  1538. const arrow = tooltip.querySelector('.tooltip-arrow');
  1539. if (left + tooltipRect.width > window.scrollX + window.innerWidth - 10) {
  1540. left = rect.left + window.scrollX - tooltipRect.width - 10;
  1541. arrow.style.left = 'auto'; arrow.style.right = '-10px'; arrow.style.borderRight = 'none'; arrow.style.borderLeft = '10px solid #1b2838';
  1542. } else {
  1543. arrow.style.left = '-10px'; arrow.style.right = 'auto'; arrow.style.borderLeft = 'none'; arrow.style.borderRight = '10px solid #1b2838';
  1544. }
  1545. arrow.style.top = `${Math.max(5, Math.min(tooltipRect.height - 15, (element.offsetHeight / 2) - 5))}px`;
  1546. tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; tooltip.style.display = 'block';
  1547. requestAnimationFrame(() => { tooltip.style.opacity = '1'; });
  1548. }
  1549.  
  1550. function buildTooltipContent(data) {
  1551. const reviewClass = getReviewClass(data.percent_positive, data.review_count);
  1552. const earlyAccessClass = data.is_early_access ? 'early-access-yes' : 'early-access-no';
  1553. let languageSupportRussianText = "Отсутствует"; let languageSupportRussianClass = 'language-no';
  1554. if (data.language_support_russian) {
  1555. let content = [];
  1556. if (data.language_support_russian.supported) content.push("Интерфейс");
  1557. if (data.language_support_russian.subtitles) content.push("Субтитры");
  1558. if (data.language_support_russian.full_audio) content.push("<u>Озвучка</u>");
  1559. languageSupportRussianText = content.join(', ') || "Нет данных";
  1560. languageSupportRussianClass = content.length > 0 ? 'language-yes' : 'language-no';
  1561. }
  1562. let languageSupportEnglishText = "Отсутствует"; let languageSupportEnglishClass = 'language-no';
  1563. if (data.language_support_english) {
  1564. let content = [];
  1565. if (data.language_support_english.supported) content.push("Интерфейс");
  1566. if (data.language_support_english.subtitles) content.push("Субтитры");
  1567. if (data.language_support_english.full_audio) content.push("<u>Озвучка</u>");
  1568. languageSupportEnglishText = content.join(', ') || "Нет данных";
  1569. languageSupportEnglishClass = content.length > 0 ? 'language-yes' : 'language-no';
  1570. }
  1571. return `
  1572. <div class="group-top"><div class="tooltip-row compact"><strong>Серия игр:</strong> <span class="${!data.franchises ? 'no-data' : ''}">${data.franchises || "Нет данных"}</span></div></div>
  1573. <div class="group-middle">
  1574. <div class="tooltip-row spaced"><strong>Отзывы:</strong> <span class="${reviewClass}">${data.percent_positive !== undefined ? data.percent_positive + '%' : "Нет данных"}</span> (${data.review_count || "0"})</div>
  1575. <div class="tooltip-row spaced"><strong>Ранний доступ:</strong> <span class="${earlyAccessClass}">${data.is_early_access ? "Да" : "Нет"}</span></div>
  1576. </div>
  1577. <div class="group-bottom">
  1578. <div class="tooltip-row language"><strong>Русский язык:</strong> <span class="${languageSupportRussianClass}">${languageSupportRussianText}</span></div>
  1579. ${scriptsConfig.toggleEnglishLangInfo ? `<div class="tooltip-row language"><strong>Английский язык:</strong> <span class="${languageSupportEnglishClass}">${languageSupportEnglishText}</span></div>` : ''}
  1580. </div>
  1581. <div class="tooltip-row description"><strong>Описание:</strong> <span class="${!data.short_description ? 'no-data' : ''}">${data.short_description || "Нет данных"}</span></div>`;
  1582. }
  1583.  
  1584. function getReviewClass(percent, totalReviews) {
  1585. if (totalReviews === undefined || totalReviews === null || totalReviews === 0) return 'no-reviews';
  1586. if (percent === undefined || percent === null) return 'no-reviews';
  1587. if (percent >= 70) return 'positive';
  1588. if (percent >= 40) return 'mixed';
  1589. return 'negative';
  1590. }
  1591.  
  1592. function reinitializePanel() {
  1593. const oldFiltersContainer = document.querySelector('.steamdb-enhancer');
  1594. if (oldFiltersContainer) {
  1595. const parent = oldFiltersContainer.parentNode;
  1596. const nextSibling = oldFiltersContainer.nextElementSibling;
  1597. oldFiltersContainer.remove();
  1598.  
  1599. const newFiltersContainer = createFiltersContainer();
  1600. parent.insertBefore(newFiltersContainer, nextSibling);
  1601.  
  1602. processButton = document.getElementById('process-btn');
  1603. if(processButton) {
  1604. processButton.addEventListener('click', () => {
  1605. const statusIndicator = document.querySelector('.steamdb-enhancer .status-indicator');
  1606. ensureAllEntriesAndCountdown(startDataCollection, statusIndicator, processButton);
  1607. });
  1608. }
  1609. newFiltersContainer.addEventListener('click', handleMainPanelClick);
  1610. }
  1611. updateUiForCurrencyMode();
  1612. document.querySelectorAll('tr.app[data-appid]').forEach(row => processAndStyleAtlDiscount(row));
  1613. }
  1614.  
  1615. function updateUiForCurrencyMode() {
  1616. const rrcFilterGroup = document.querySelector('#rrc-filter-control-group');
  1617. const statusIndicator = document.querySelector('.steamdb-enhancer .status-indicator');
  1618. if (rrcFilterGroup) {
  1619. rrcFilterGroup.style.display = isRuModeActive ? '' : 'none';
  1620. if (!isRuModeActive) {
  1621. activeRrcFilters = { lower: false, equal: false, higher: false };
  1622. rrcFilterGroup.querySelectorAll('.btn.active').forEach(b => b.classList.remove('active'));
  1623. }
  1624. }
  1625. if (statusIndicator && !isProcessingStarted) {
  1626. statusIndicator.textContent = isRuModeActive ? STATUS_TEXT.ready_to_process : STATUS_TEXT.rrc_disabled;
  1627. }
  1628. if (document.querySelector('tr.app[data-appid]')) {
  1629. applyAllFilters();
  1630. if (!isRuModeActive) {
  1631. document.querySelectorAll('.rrc-display-container').forEach(el => el.remove());
  1632. }
  1633. }
  1634. }
  1635.  
  1636. function processAndStyleAtlDiscount(row) {
  1637. const highestDiscountSpan = row.querySelector('td:nth-child(3) .subinfo .highest-discount');
  1638. if (!highestDiscountSpan || !highestDiscountSpan.innerText.toLowerCase().includes('all-time low')) {
  1639. row.dataset.atlPercentStatus = 'no_data';
  1640. return;
  1641. }
  1642.  
  1643. const atlText = highestDiscountSpan.innerText;
  1644. const atlMatch = atlText.match(/at\s*-?(\d+)%/i);
  1645. if (!atlMatch || !atlMatch[1]) {
  1646. row.dataset.atlPercentStatus = 'no_data';
  1647. return;
  1648. }
  1649. const historicalPercent = parseInt(atlMatch[1], 10);
  1650.  
  1651. const currentDiscountCell = row.cells[3];
  1652. const currentDiscountText = currentDiscountCell ? currentDiscountCell.innerText.trim() : '0%';
  1653. const currentDiscountMatch = currentDiscountText.match(/-?(\d+)%/);
  1654. const currentPercent = currentDiscountMatch ? parseInt(currentDiscountMatch[1], 10) : 0;
  1655.  
  1656. let status = 'no_data';
  1657. if (currentPercent > historicalPercent) {
  1658. status = 'cheaper';
  1659. } else if (currentPercent === historicalPercent) {
  1660. status = 'equal';
  1661. } else {
  1662. status = 'more_expensive';
  1663. }
  1664. row.dataset.atlPercentStatus = status;
  1665.  
  1666. const atlPercentTextNode = Array.from(highestDiscountSpan.childNodes).find(node => node.nodeType === Node.TEXT_NODE && /\s*at\s*-?\d+%/i.test(node.textContent));
  1667.  
  1668. if (atlPercentTextNode) {
  1669. const textContent = atlPercentTextNode.textContent;
  1670. const match = textContent.match(/(\s*)(at\s*-?\d+%)(.*)/i);
  1671. if (match) {
  1672. const beforeText = match[1];
  1673. const percentText = match[2];
  1674. const afterText = match[3];
  1675.  
  1676. const newSpan = document.createElement('span');
  1677. newSpan.className = `atl-percent-text ${status}`;
  1678. newSpan.textContent = percentText;
  1679.  
  1680. const fragment = document.createDocumentFragment();
  1681. if (beforeText) fragment.appendChild(document.createTextNode(beforeText));
  1682. fragment.appendChild(newSpan);
  1683. if (afterText) fragment.appendChild(document.createTextNode(afterText));
  1684.  
  1685. highestDiscountSpan.replaceChild(fragment, atlPercentTextNode);
  1686. }
  1687. }
  1688. }
  1689.  
  1690. function setupCustomDiscountFilters(originalBlock) {
  1691. originalBlock.innerHTML = '';
  1692. originalBlock.classList.add('steamdb-custom-discount-filters');
  1693.  
  1694. const mainTitle = document.createElement('div');
  1695. mainTitle.className = 'filter-block-title';
  1696. mainTitle.textContent = 'Фильтры по скидкам';
  1697. originalBlock.appendChild(mainTitle);
  1698.  
  1699. const absolutePriceSubtitle = document.createElement('div');
  1700. absolutePriceSubtitle.className = 'filter-block-subtitle';
  1701. absolutePriceSubtitle.textContent = 'Фильтры по абсолютной цене';
  1702. originalBlock.appendChild(absolutePriceSubtitle);
  1703.  
  1704. const absoluteDiscountTypes = [
  1705. { type: 'blue', label: 'Ист. минимум', tooltipShow: 'Показать игры с исторически минимальной ценой', tooltipHide: 'Скрыть игры с исторически минимальной ценой' },
  1706. { type: 'green', label: 'Повтор мин. цены', tooltipShow: 'Показать игры, соответствующие своей минимальной цене', tooltipHide: 'Скрыть игры, соответствующие своей минимальной цене' },
  1707. { type: 'purple', label: 'Мин. за 2 года', tooltipShow: 'Показать игры с минимальной ценой за последние два года', tooltipHide: 'Скрыть игры с минимальной ценой за последние два года' }
  1708. ];
  1709.  
  1710. absoluteDiscountTypes.forEach(dt => {
  1711. const rowDiv = document.createElement('div');
  1712. rowDiv.className = 'discount-filter-row-steamdb';
  1713. const showLabel = document.createElement('label');
  1714. showLabel.className = `steamy-checkbox-control tooltipped tooltipped-${dt.type}`;
  1715. showLabel.title = dt.tooltipShow;
  1716. const showInput = document.createElement('input');
  1717. showInput.type = 'checkbox';
  1718. showInput.dataset.discountType = dt.type;
  1719. showInput.dataset.filterMode = 'show';
  1720. showInput.id = `enhancer-show-${dt.type}-discount`;
  1721. const showSpan = document.createElement('span');
  1722. showSpan.textContent = '';
  1723. showLabel.appendChild(showInput);
  1724. showLabel.appendChild(showSpan);
  1725. const labelTextSpan = document.createElement('span');
  1726. labelTextSpan.className = 'discount-filter-label-text-steamdb';
  1727. labelTextSpan.textContent = dt.label;
  1728. const hideLabel = document.createElement('label');
  1729. hideLabel.className = `steamy-checkbox-control tooltipped tooltipped-${dt.type}`;
  1730. hideLabel.title = dt.tooltipHide;
  1731. const hideInput = document.createElement('input');
  1732. hideInput.type = 'checkbox';
  1733. hideInput.dataset.discountType = dt.type;
  1734. hideInput.dataset.filterMode = 'hide';
  1735. hideInput.id = `enhancer-hide-${dt.type}-discount`;
  1736. const hideSpan = document.createElement('span');
  1737. hideSpan.textContent = 'Скрыть';
  1738. hideLabel.appendChild(hideInput);
  1739. hideLabel.appendChild(hideSpan);
  1740. rowDiv.appendChild(showLabel);
  1741. rowDiv.appendChild(labelTextSpan);
  1742. rowDiv.appendChild(hideLabel);
  1743. originalBlock.appendChild(rowDiv);
  1744. });
  1745. updateDiscountFilterUI();
  1746.  
  1747. const percentSubtitle = document.createElement('div');
  1748. percentSubtitle.className = 'filter-block-subtitle';
  1749. percentSubtitle.textContent = 'Фильтры по процентам в ист. мин.';
  1750. originalBlock.appendChild(percentSubtitle);
  1751.  
  1752. const percentDiscountTypes = [
  1753. { type: 'percent_cheaper', label: '% < Минимума', colorClass: 'blue', tooltipShow: 'Показать игры, где текущий % скидки ВЫШЕ исторического % ATL', tooltipHide: 'Скрыть игры, где текущий % скидки ВЫШЕ исторического % ATL' },
  1754. { type: 'percent_equal', label: '% = Минимуму', colorClass: 'green', tooltipShow: 'Показать игры, где текущий % скидки РАВЕН историческому % ATL', tooltipHide: 'Скрыть игры, где текущий % скидки РАВЕН историческому % ATL' },
  1755. { type: 'percent_more_expensive', label: '% > Минимума', colorClass: 'purple', tooltipShow: 'Показать игры, где текущий % скидки НИЖЕ исторического % ATL', tooltipHide: 'Скрыть игры, где текущий % скидки НИЖЕ исторического % ATL' }
  1756. ];
  1757.  
  1758. percentDiscountTypes.forEach(dt => {
  1759. const rowDiv = document.createElement('div');
  1760. rowDiv.className = 'discount-filter-row-steamdb';
  1761. const showLabel = document.createElement('label');
  1762. showLabel.className = `steamy-checkbox-control tooltipped tooltipped-${dt.colorClass}`;
  1763. showLabel.title = dt.tooltipShow;
  1764. const showInput = document.createElement('input');
  1765. showInput.type = 'checkbox';
  1766. showInput.dataset.discountType = dt.type;
  1767. showInput.dataset.filterMode = 'show';
  1768. showInput.id = `enhancer-show-${dt.type}-discount`;
  1769. const showSpan = document.createElement('span');
  1770. showSpan.textContent = '';
  1771. showLabel.appendChild(showInput);
  1772. showLabel.appendChild(showSpan);
  1773. const labelTextSpan = document.createElement('span');
  1774. labelTextSpan.className = 'discount-filter-label-text-steamdb';
  1775. labelTextSpan.textContent = dt.label;
  1776. const hideLabel = document.createElement('label');
  1777. hideLabel.className = `steamy-checkbox-control tooltipped tooltipped-${dt.colorClass}`;
  1778. hideLabel.title = dt.tooltipHide;
  1779. const hideInput = document.createElement('input');
  1780. hideInput.type = 'checkbox';
  1781. hideInput.dataset.discountType = dt.type;
  1782. hideInput.dataset.filterMode = 'hide';
  1783. hideInput.id = `enhancer-hide-${dt.type}-discount`;
  1784. const hideSpan = document.createElement('span');
  1785. hideSpan.textContent = 'Скрыть';
  1786. hideLabel.appendChild(hideInput);
  1787. hideLabel.appendChild(hideSpan);
  1788. rowDiv.appendChild(showLabel);
  1789. rowDiv.appendChild(labelTextSpan);
  1790. rowDiv.appendChild(hideLabel);
  1791. originalBlock.appendChild(rowDiv);
  1792. });
  1793. updateDiscountFilterUI();
  1794. }
  1795.  
  1796. async function init() {
  1797. isRuModeActive = isRuCurrencySelected();
  1798. const style = document.createElement('style');
  1799. style.textContent = styles;
  1800. document.head.append(style);
  1801.  
  1802. const header = document.querySelector('.header-title');
  1803. if (header) {
  1804. const filtersContainer = createFiltersContainer();
  1805. header.parentNode.insertBefore(filtersContainer, header.nextElementSibling);
  1806. processButton = document.getElementById('process-btn');
  1807. if(processButton) {
  1808. processButton.addEventListener('click', () => {
  1809. const statusIndicator = document.querySelector('.steamdb-enhancer .status-indicator');
  1810. ensureAllEntriesAndCountdown(startDataCollection, statusIndicator, processButton);
  1811. });
  1812. }
  1813. filtersContainer.addEventListener('click', handleMainPanelClick);
  1814.  
  1815. document.getElementById('review-min-count').addEventListener('input', handleReviewFilterChange);
  1816. document.getElementById('review-max-count').addEventListener('input', handleReviewFilterChange);
  1817. document.getElementById('review-min-rating').addEventListener('input', handleReviewFilterChange);
  1818. document.getElementById('review-max-rating').addEventListener('input', handleReviewFilterChange);
  1819.  
  1820. document.getElementById('total-sort-checkbox').addEventListener('change', (event) => {
  1821. isTotalSortEnabled = event.target.checked;
  1822. applyTotalSort();
  1823. });
  1824.  
  1825. } else {
  1826. return;
  1827. }
  1828.  
  1829. const steamDbFilterForm = document.getElementById('js-filters');
  1830. let originalDiscountBlock = null;
  1831. if (steamDbFilterForm) {
  1832. const allFilterBlocks = steamDbFilterForm.querySelectorAll('div.filter-block');
  1833. for (let block of allFilterBlocks) {
  1834. if (block.querySelector('input[id^="js-discounts-"]')) {
  1835. originalDiscountBlock = block;
  1836. break;
  1837. }
  1838. }
  1839. }
  1840.  
  1841. if (originalDiscountBlock) {
  1842. setupCustomDiscountFilters(originalDiscountBlock);
  1843. originalDiscountBlock.addEventListener('change', handleDiscountFilterChange);
  1844. }
  1845.  
  1846. document.querySelectorAll('tr.app[data-appid]').forEach(row => processAndStyleAtlDiscount(row));
  1847. const observerOptions = { childList: true, subtree: true };
  1848. const tableBody = document.querySelector('#DataTables_Table_0 tbody');
  1849. if (tableBody) {
  1850. const tableObserver = new MutationObserver((mutationsList) => {
  1851. for (const mutation of mutationsList) {
  1852. if (mutation.type === 'childList') {
  1853. mutation.addedNodes.forEach(node => {
  1854. if (node.nodeType === Node.ELEMENT_NODE && node.matches('tr.app[data-appid]')) {
  1855. processAndStyleAtlDiscount(node);
  1856. }
  1857. });
  1858. }
  1859. }
  1860. if (typeof $ !== 'undefined' && $.fn.dataTable && $.fn.dataTable.isDataTable('#DataTables_Table_0')) {
  1861. $('#DataTables_Table_0').DataTable().rows().nodes().to$().each(function() {
  1862. processAndStyleAtlDiscount(this);
  1863. });
  1864. }
  1865. });
  1866. tableObserver.observe(tableBody, observerOptions);
  1867. if (typeof $ !== 'undefined' && $.fn.dataTable) {
  1868. $(document).on('draw.dt', function (e, settings) {
  1869. if (settings.nTable.id === 'DataTables_Table_0') {
  1870. $('#DataTables_Table_0').DataTable().rows().nodes().to$().each(function() {
  1871. processAndStyleAtlDiscount(this);
  1872. });
  1873. }
  1874. });
  1875. }
  1876. }
  1877.  
  1878. document.querySelector('#DataTables_Table_0 tbody')?.addEventListener('mouseover', handleHover);
  1879.  
  1880. const currencyDropdown = document.querySelector('details#js-select-cc');
  1881. if (currencyDropdown) {
  1882. const observer = new MutationObserver((mutationsList) => {
  1883. for (let mutation of mutationsList) {
  1884. if (mutation.type === 'attributes' && mutation.attributeName === 'data-default' ||
  1885. mutation.target.nodeName === 'INPUT' && mutation.target.type === 'radio' && mutation.target.name === 'cc') {
  1886. const newRuMode = isRuCurrencySelected();
  1887. if (newRuMode !== isRuModeActive) {
  1888. isRuModeActive = newRuMode;
  1889. reinitializePanel();
  1890. }
  1891. break;
  1892. }
  1893. }
  1894. });
  1895. observer.observe(currencyDropdown, { attributes: true, childList: true, subtree: true });
  1896. }
  1897.  
  1898. const typeDropdown = document.getElementById('js-select-type');
  1899. if (typeDropdown) {
  1900. const typeObserver = new MutationObserver(() => {
  1901. reinitializePanel();
  1902. });
  1903. typeDropdown.querySelectorAll('input[name="displayOnly"]').forEach(radio => {
  1904. typeObserver.observe(radio, { attributes: true, attributeFilter: ['checked'] });
  1905. });
  1906. typeObserver.observe(typeDropdown, { childList: true, subtree: true });
  1907. }
  1908.  
  1909.  
  1910. updateButtonAndStatus(PROCESS_BUTTON_TEXT.idle, isRuModeActive ? STATUS_TEXT.ready_to_process : STATUS_TEXT.rrc_disabled, false, true);
  1911. }
  1912.  
  1913. if (document.readyState === 'loading') {
  1914. document.addEventListener('DOMContentLoaded', init);
  1915. } else {
  1916. init();
  1917. }
  1918. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址