Ultimate Steam Enhancer

Добавляет множество функций для улучшения взаимодействия с магазином и сообществом (Полный список на странице скрипта)

当前为 2025-02-10 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Ultimate Steam Enhancer
  3. // @namespace https://store.steampowered.com/
  4. // @version 1.8
  5. // @description Добавляет множество функций для улучшения взаимодействия с магазином и сообществом (Полный список на странице скрипта)
  6. // @author 0wn3df1x
  7. // @license MIT
  8. // @require https://code.jquery.com/jquery-3.6.0.min.js
  9. // @match https://store.steampowered.com/*
  10. // @match *://*steamcommunity.com/*
  11. // @grant GM_xmlhttpRequest
  12. // @grant GM_getResourceText
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant GM_addStyle
  16. // @connect zoneofgames.ru
  17. // @connect raw.githubusercontent.com
  18. // @connect gist.githubusercontent.com
  19. // @connect store.steampowered.com
  20. // @connect api.steampowered.com
  21. // @connect steamcommunity.com
  22. // @connect shared.cloudflare.steamstatic.com
  23. // @connect umadb.ro
  24. // @connect api.github.com
  25. // @connect howlongtobeat.com
  26. // ==/UserScript==
  27.  
  28. (function() {
  29. 'use strict';
  30.  
  31. const scriptsConfig = {
  32. // Основные скрипты
  33. gamePage: true, // Скрипт для страницы игры (индикаторы о наличии русского перевода; получение дополнительных обзоров) | https://store.steampowered.com/app/*
  34. hltbData: true, // Скрипт для страницы игры (HLTB; получение сведений о времени прохождения) | https://store.steampowered.com/app/*
  35. friendsPlaytime: true, // Скрипт для страницы игры (Время друзей & Достижения) | https://store.steampowered.com/app/*
  36. zogInfo: true, // Скрипт для страницы игры (ZOG; получение сведение о наличии русификаторов) | https://store.steampowered.com/app/*
  37. catalogInfo: true, // Скрипт для получения дополнительной информации об игре при наведении на неё на странице поиска по каталогу | https://store.steampowered.com/search/
  38. catalogHider: false, // Скрипт скрытия игр на странице поиска по каталогу | https://store.steampowered.com/search/
  39. newsFilter: true, // Скрипт для скрытия новостей в новостном центре: | https://store.steampowered.com/news/
  40. Kaznachei: true, // Скрипт для показа годовых и исторических продаж предмета на торговой площадке Steam | https://steamcommunity.com/market/listings/*
  41. homeInfo: true, // Скрипт для получения дополнительной информации об игре при наведении на неё на странице вашей активности Steam | https://steamcommunity.com/my/
  42. wishlistTracker: true, // Скрипт для получения уведомлений об изменении дат выхода игр из вашего списка желаемого Steam и показа календаря с датами | https://steamcommunity.com/my/wishlist/
  43. // Дополнительные настройки
  44. autoExpandHltb: false, // Автоматически раскрывать спойлер HLTB
  45. autoLoadReviews: false, // Автоматически загружать дополнительные обзоры
  46. toggleEnglishLangInfo: false // Отображает данные об английском языке в дополнительной информации при поиске по каталогу и в активности (функция для переводчиков)
  47. };
  48.  
  49.  
  50. // Скрипт для страницы игры (индикаторы о наличии русского перевода; получение дополнительных обзоров) | https://store.steampowered.com/app/*
  51. if (scriptsConfig.gamePage && window.location.pathname.includes('/app/')) {
  52. (function() {
  53. 'use strict';
  54.  
  55. function createFruitIndicator(apple, hasSupport, orange) {
  56. const banana = document.createElement('div');
  57. banana.style.position = 'relative';
  58. banana.style.cursor = 'pointer';
  59.  
  60. const grape = document.createElement('div');
  61. grape.style.width = '60px';
  62. grape.style.height = '60px';
  63. grape.style.borderRadius = '4px';
  64. grape.style.display = 'flex';
  65. grape.style.alignItems = 'center';
  66. grape.style.justifyContent = 'center';
  67. grape.style.background = hasSupport ? 'rgba(66, 135, 245, 0.2)' : 'rgba(0, 0, 0, 0.1)';
  68. grape.style.border = `1px solid ${hasSupport ? '#2A5891' : '#3c3c3c'}`;
  69. grape.style.opacity = '0.95';
  70. grape.style.transition = 'transform 0.3s ease, box-shadow 0.3s ease';
  71. grape.style.overflow = 'hidden';
  72. grape.style.position = 'relative';
  73. grape.style.transform = 'translateZ(0)';
  74.  
  75. const kiwi = document.createElement('div');
  76. kiwi.innerHTML = apple;
  77. kiwi.style.width = '30px';
  78. kiwi.style.height = '30px';
  79. kiwi.style.display = 'block';
  80. kiwi.style.margin = '0 auto';
  81. kiwi.style.transition = 'fill 0.3s ease';
  82.  
  83. grape.appendChild(kiwi);
  84.  
  85. const svgElement = kiwi.querySelector('svg');
  86.  
  87. function setColor(hasSupport) {
  88. const borderColor = hasSupport ? '#2A5891' : '#3c3c3c';
  89. const svgFill = hasSupport ? '#FFFFFF' : '#0E1C25';
  90.  
  91. grape.style.border = `1px solid ${borderColor}`;
  92. svgElement.style.fill = svgFill;
  93. }
  94.  
  95. setColor(hasSupport);
  96.  
  97. const pineapple = document.createElement('div');
  98. const hasLabel = hasSupport ? orange : getGenitiveCase(orange);
  99. pineapple.textContent = hasSupport ? `Есть ${orange}` : `Нет ${hasLabel}`;
  100. pineapple.style.position = 'absolute';
  101. pineapple.style.top = '50%';
  102. pineapple.style.left = '100%';
  103. pineapple.style.transform = 'translateY(-50%) translateX(10px)';
  104. pineapple.style.background = 'rgba(0, 0, 0, 0.8)';
  105. pineapple.style.color = '#fff';
  106. pineapple.style.padding = '8px 12px';
  107. pineapple.style.borderRadius = '8px';
  108. pineapple.style.fontSize = '14px';
  109. pineapple.style.whiteSpace = 'nowrap';
  110. pineapple.style.opacity = '0';
  111. pineapple.style.transition = 'opacity 0.3s ease';
  112. pineapple.style.zIndex = '10000';
  113. pineapple.style.pointerEvents = 'none';
  114. banana.appendChild(pineapple);
  115.  
  116. banana.addEventListener('mouseenter', () => {
  117. grape.style.transform = 'scale(1.1) translateZ(0)';
  118. pineapple.style.opacity = '1';
  119. });
  120.  
  121. banana.addEventListener('mouseleave', () => {
  122. grape.style.transform = 'scale(1) translateZ(0)';
  123. pineapple.style.opacity = '0';
  124. });
  125.  
  126. banana.appendChild(grape);
  127. return banana;
  128. }
  129.  
  130. function getGenitiveCase(orange) {
  131. switch (orange) {
  132. case 'интерфейс':
  133. return 'интерфейса';
  134. case 'озвучка':
  135. return 'озвучки';
  136. case 'субтитры':
  137. return 'субтитров';
  138. default:
  139. return orange;
  140. }
  141. }
  142.  
  143. function checkRussianSupport() {
  144. const mango = document.querySelector('#languageTable table.game_language_options');
  145. if (!mango) return {
  146. interface: false,
  147. voice: false,
  148. subtitles: false
  149. };
  150.  
  151. const strawberry = mango.querySelectorAll('tr');
  152. for (let blueberry of strawberry) {
  153. const watermelon = blueberry.querySelector('td.ellipsis');
  154. if (watermelon && /русский|Russian/i.test(watermelon.textContent.trim())) {
  155. const cherry = blueberry.querySelector('td.checkcol:nth-child(2) span');
  156. const raspberry = blueberry.querySelector('td.checkcol:nth-child(3) span');
  157. const blackberry = blueberry.querySelector('td.checkcol:nth-child(4) span');
  158.  
  159. return {
  160. interface: cherry !== null,
  161. voice: raspberry !== null,
  162. subtitles: blackberry !== null
  163. };
  164. }
  165. }
  166. return {
  167. interface: false,
  168. voice: false,
  169. subtitles: false
  170. };
  171. }
  172.  
  173. function addRussianIndicators() {
  174. const russianSupport = checkRussianSupport();
  175. if (!russianSupport) return;
  176.  
  177. let lemon = document.querySelector('#gameHeaderImageCtn');
  178. if (!lemon) return;
  179.  
  180. const lime = document.createElement('div');
  181. lime.style.position = 'absolute';
  182. lime.style.top = '-10px';
  183. lime.style.left = 'calc(100% + 10px)';
  184. lime.style.display = 'flex';
  185. lime.style.flexDirection = 'column';
  186. lime.style.gap = '15px';
  187. lime.style.alignItems = 'flex-start';
  188. lime.style.zIndex = '2';
  189. lime.style.marginTop = '10px';
  190.  
  191. const peach = createFruitIndicator(`<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12,0C5.38,0,0,5.38,0,12s5.38,12,12,12s12-5.38,12-12S18.62,0,12,0z M12,22C6.49,22,2,17.51,2,12S6.49,2,12,2 s10,4.49,10,10S17.51,22,12,22z M10.5,10h3v8h-3V10z M10.5,5h3v3h-3V5z" /></svg>`, russianSupport.interface, 'интерфейс');
  192. const plum = createFruitIndicator(`<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M15,21v-2c3.86,0,7-3.14,7-7s-3.14-7-7-7V3c4.96,0,9,4.04,9,9S19.96,21,15,21z M15,17v-2c1.65,0,3-1.35,3-3s-1.35-3-3-3V7 c2.76,0,5,2.24,5,5S17.76,17,15,17z M1,12v4h5l6,5V3L6,8H1V12" /></svg>`, russianSupport.voice, 'озвучка');
  193. const apricot = createFruitIndicator(`<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g><path d="M11,24l-4.4-5H0V0h23v19h-7.6L11,24z M2,17h5.4l3.6,4l3.6-4H21V2H2V17z" /></g><g><rect x="5" y="8" width="3" height="3" /></g><g><rect x="10" y="8" width="3" height="3" /></g><g><rect x="15" y="8" width="3" height="3" /></g></svg>`, russianSupport.subtitles, 'субтитры');
  194.  
  195. lime.appendChild(peach);
  196. lime.appendChild(plum);
  197. lime.appendChild(apricot);
  198.  
  199. lemon.style.position = 'relative';
  200. lemon.appendChild(lime);
  201.  
  202. const appName = document.querySelector('#appHubAppName.apphub_AppName');
  203. if (appName) {
  204. appName.style.maxWidth = '530px';
  205. appName.style.overflow = 'hidden';
  206. appName.style.textOverflow = 'ellipsis';
  207. appName.style.whiteSpace = 'nowrap';
  208. appName.title = appName.textContent;
  209. }
  210. }
  211.  
  212. const settings = {
  213. showTotalReviews: true,
  214. showNonChineseReviews: true,
  215. showRussianReviews: true
  216. };
  217.  
  218. function fetchReviews(appid, language, callback) {
  219. let url = `https://store.steampowered.com/appreviews/${appid}?json=1&language=${language}&purchase_type=all`;
  220. GM_xmlhttpRequest({
  221. method: "GET",
  222. url: url,
  223. onload: function(response) {
  224. let data = JSON.parse(response.responseText);
  225. callback(data);
  226. }
  227. });
  228. }
  229.  
  230. function fetchRussianReviewsHTML(appid, filter, callback) {
  231. let url = `https://store.steampowered.com/appreviews/${appid}?language=russian&purchase_type=all&filter=${filter}&day_range=365`;
  232. GM_xmlhttpRequest({
  233. method: "GET",
  234. url: url,
  235. onload: function(response) {
  236. let data = JSON.parse(response.responseText);
  237. callback(data.html);
  238. }
  239. });
  240. }
  241.  
  242. function addStyles() {
  243. GM_addStyle(`
  244. .additional-reviews {
  245. margin-top: 10px;
  246. }
  247. .additional-reviews .user_reviews_summary_row {
  248. display: flex;
  249. line-height: 16px;
  250. cursor: pointer;
  251. margin-bottom: 5px;
  252. }
  253. .additional-reviews .subtitle {
  254. flex: 1;
  255. color: #556772;
  256. font-size: 12px;
  257. }
  258. .additional-reviews .summary {
  259. flex: 3;
  260. color: #c6d4df;
  261. font-size: 12px;
  262. overflow: hidden;
  263. white-space: nowrap;
  264. text-overflow: ellipsis;
  265. }
  266. .additional-reviews .game_review_summary {
  267. font-weight: normal;
  268. }
  269. .additional-reviews .positive {
  270. color: #66c0f4;
  271. }
  272. .additional-reviews .mixed {
  273. color: #B9A074;
  274. }
  275. .additional-reviews .negative {
  276. color: #a34c25;
  277. }
  278. .additional-reviews .no_reviews {
  279. color: #929396;
  280. }
  281. .additional-reviews .responsive_hidden {
  282. color: #556772;
  283. margin-left: 5px;
  284. }
  285. .ofxmodal {
  286. display: none;
  287. position: fixed;
  288. z-index: 1000;
  289. left: 0;
  290. top: 0;
  291. width: 100%;
  292. height: 100%;
  293. overflow: auto;
  294. background-color: rgba(0,0,0,0.8);
  295. }
  296. .ofxmodal-content {
  297. background-color: #1b2838;
  298. margin: 10% auto;
  299. padding: 20px;
  300. border: 1px solid #888;
  301. width: 80%;
  302. max-width: 800px;
  303. color: #c6d4df;
  304. position: relative;
  305. max-height: 80vh;
  306. overflow-y: auto;
  307. }
  308. .ofxclose {
  309. color: #aaa;
  310. position: sticky;
  311. top: 0;
  312. float: right;
  313. font-size: 28px;
  314. font-weight: bold;
  315. cursor: pointer;
  316. background: rgba(0,0,0,0.8);
  317. padding: 5px 10px;
  318. border-radius: 5px;
  319. transition: color 0.2s ease, background 0.2s ease, transform 0.2s ease;
  320. box-shadow: 0 2px 4px rgba(0,0,0,0.2);
  321. }
  322. .ofxclose:hover {
  323. color: #fff;
  324. background: #e64a4a;
  325. transform: scale(1.1);
  326. }
  327. .ofxclose:active {
  328. background: #c43c3c;
  329. transform: scale(0.95);
  330. }
  331. .refresh-button {
  332. position: left;
  333. top: 10px;
  334. right: 50px;
  335. background: #66c0f4;
  336. color: #1b2838;
  337. padding: 10px 20px;
  338. border: none;
  339. cursor: pointer;
  340. z-index: 1001;
  341. border-radius: 2px;
  342. transition: background 0.2s ease, color 0.2s ease;
  343. }
  344. .refresh-button:hover {
  345. background: #45b0e6;
  346. color: #fff;
  347. }
  348. .refresh-button:active {
  349. background: #329cd4;
  350. transform: translateY(1px);
  351. }
  352. `);
  353. }
  354.  
  355. function formatNumber(number) {
  356. return number.toLocaleString('en-US');
  357. }
  358.  
  359. function getReviewClass(percent, totalReviews) {
  360. if (totalReviews === 0) return 'no_reviews';
  361. if (percent >= 70) return 'positive';
  362. if (percent >= 40) return 'mixed';
  363. if (percent >= 1) return 'negative';
  364. return 'negative';
  365. }
  366.  
  367. function addLoadButton() {
  368. let reviewsContainer = document.querySelector('.user_reviews');
  369. if (reviewsContainer) {
  370. let additionalReviews = document.createElement('div');
  371. additionalReviews.className = 'additional-reviews';
  372.  
  373. additionalReviews.innerHTML = `
  374. <div class="user_reviews_summary_row" id="load-reviews-button">
  375. <div class="subtitle column all">Доп. обзоры:</div>
  376. <div class="summary column">
  377. <span class="game_review_summary no_reviews">Загрузить</span>
  378. </div>
  379. </div>
  380. `;
  381.  
  382. reviewsContainer.appendChild(additionalReviews);
  383.  
  384. document.getElementById('load-reviews-button').addEventListener('click', function() {
  385. loadAdditionalReviews();
  386. });
  387.  
  388. if (scriptsConfig.autoLoadReviews) {
  389. loadAdditionalReviews();
  390. }
  391. }
  392. }
  393.  
  394. function loadAdditionalReviews() {
  395. let appid = window.location.pathname.match(/\/app\/(\d+)/)[1];
  396. let languages = [];
  397. let data = {};
  398.  
  399. let loadButton = document.getElementById('load-reviews-button');
  400. if (loadButton) {
  401. loadButton.querySelector('.game_review_summary').textContent = 'Загрузка...';
  402. }
  403.  
  404. if (settings.showTotalReviews || settings.showNonChineseReviews) {
  405. languages.push('all');
  406. }
  407. if (settings.showNonChineseReviews) {
  408. languages.push('schinese');
  409. }
  410. if (settings.showRussianReviews) {
  411. languages.push('russian');
  412. }
  413.  
  414. languages.forEach(language => {
  415. fetchReviews(appid, language, (response) => {
  416. data[language] = response;
  417. if (Object.keys(data).length === languages.length) {
  418. displayAdditionalReviews(data['all'], data['schinese'], data['russian']);
  419.  
  420. if (loadButton) {
  421. loadButton.querySelector('.game_review_summary').textContent = 'Загрузить';
  422. }
  423. }
  424. });
  425. });
  426. }
  427.  
  428. function displayAdditionalReviews(allData, schineseData, russianData) {
  429. let allReviews = allData ? allData.query_summary : null;
  430. let schineseReviews = schineseData ? schineseData.query_summary : null;
  431. let russianReviews = russianData ? russianData.query_summary : null;
  432.  
  433. let additionalReviews = document.querySelector('.additional-reviews');
  434. if (additionalReviews) {
  435. additionalReviews.innerHTML = '';
  436.  
  437. if (settings.showTotalReviews && allReviews) {
  438. let allPercent = allReviews.total_reviews > 0 ? Math.round((allReviews.total_positive / allReviews.total_reviews) * 100) : 0;
  439. let allClass = getReviewClass(allPercent, allReviews.total_reviews);
  440. additionalReviews.innerHTML += `
  441. <div class="user_reviews_summary_row">
  442. <div class="subtitle column all">Тотальные:</div>
  443. <div class="summary column">
  444. <span class="game_review_summary ${allClass}">${allPercent}% из ${formatNumber(allReviews.total_reviews)} положительные</span>
  445. </div>
  446. </div>
  447. `;
  448. }
  449.  
  450. if (settings.showNonChineseReviews && allReviews && schineseReviews) {
  451. let schintotalrev = allReviews.total_reviews - schineseReviews.total_reviews;
  452. let schintotapos = allReviews.total_positive - schineseReviews.total_positive;
  453. let schinpercent = schintotalrev > 0 ? Math.round((schintotapos / schintotalrev) * 100) : 0;
  454. let schinClass = getReviewClass(schinpercent, schintotalrev);
  455. additionalReviews.innerHTML += `
  456. <div class="user_reviews_summary_row">
  457. <div class="subtitle column all">Безкитайские:</div>
  458. <div class="summary column">
  459. <span class="game_review_summary ${schinClass}">${schinpercent}% из ${formatNumber(schintotalrev)} положительные</span>
  460. </div>
  461. </div>
  462. `;
  463. }
  464.  
  465. if (settings.showRussianReviews && russianReviews) {
  466. let rustotalrev = russianReviews.total_reviews;
  467. let ruspositive = russianReviews.total_positive;
  468. let ruspercent = rustotalrev > 0 ? Math.round((ruspositive / rustotalrev) * 100) : 0;
  469. let rusClass = getReviewClass(ruspercent, rustotalrev);
  470. additionalReviews.innerHTML += `
  471. <div class="user_reviews_summary_row" id="russian-reviews-row">
  472. <div class="subtitle column all">Русские:</div>
  473. <div class="summary column">
  474. <span class="game_review_summary ${rusClass}">${ruspercent}% из ${formatNumber(rustotalrev)} положительные</span>
  475. </div>
  476. </div>
  477. `;
  478.  
  479. document.getElementById('russian-reviews-row').addEventListener('click', function() {
  480. openModal();
  481. });
  482. }
  483. }
  484. }
  485.  
  486. function openModal() {
  487. let ofxmodal = document.createElement('div');
  488. ofxmodal.className = 'ofxmodal';
  489. ofxmodal.innerHTML = `
  490. <div class="ofxmodal-content">
  491. <span class="ofxclose">×</span>
  492. <button class="refresh-button" id="refresh-reviews">Загрузить актуальные</button>
  493. <div id="reviews-container"></div>
  494. </div>
  495. `;
  496. document.body.appendChild(ofxmodal);
  497.  
  498. ofxmodal.querySelector('.ofxclose').addEventListener('click', function() {
  499. ofxmodal.style.display = 'none';
  500. });
  501.  
  502. ofxmodal.querySelector('#refresh-reviews').addEventListener('click', function() {
  503. refreshReviews(ofxmodal);
  504. });
  505.  
  506. ofxmodal.style.display = 'block';
  507.  
  508. loadReviews(ofxmodal, 'all');
  509. }
  510.  
  511. function refreshReviews(ofxmodal) {
  512. ofxmodal.querySelector('#reviews-container').innerHTML = '';
  513. loadReviews(ofxmodal, 'recent');
  514. }
  515.  
  516. function loadReviews(ofxmodal, filter) {
  517. fetchRussianReviewsHTML(window.location.pathname.match(/\/app\/(\d+)/)[1], filter, function(html) {
  518. ofxmodal.querySelector('#reviews-container').innerHTML = html;
  519. ofxmodal.querySelector('#LoadMoreReviewsall')?.remove();
  520. ofxmodal.querySelector('#LoadMoreReviewsrecent')?.remove();
  521. });
  522. }
  523.  
  524. function main() {
  525. addStyles();
  526. addRussianIndicators();
  527. addLoadButton();
  528. }
  529.  
  530. main();
  531. })();
  532. }
  533.  
  534. // Скрипт для страницы игры (HLTB; получение сведений о времени прохождения) | https://store.steampowered.com/app/*
  535. if (window.location.pathname.includes('/app/') && scriptsConfig.hltbData) {
  536. (async function() {
  537. let hltbBlock = document.createElement('div');
  538. hltbBlock.style.position = 'absolute';
  539. hltbBlock.style.top = '232px';
  540. hltbBlock.style.left = '334px';
  541. hltbBlock.style.width = '30px';
  542. hltbBlock.style.height = '30px';
  543. hltbBlock.style.background = 'rgba(27, 40, 56, 0.95)';
  544. hltbBlock.style.padding = '15px';
  545. hltbBlock.style.borderRadius = '4px';
  546. hltbBlock.style.border = '1px solid #3c3c3c';
  547. hltbBlock.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  548. hltbBlock.style.zIndex = '2';
  549. hltbBlock.style.fontFamily = 'Arial, sans-serif';
  550. hltbBlock.style.overflow = 'hidden';
  551. hltbBlock.style.transition = 'all 0.3s ease';
  552.  
  553. let triangle = document.createElement('div');
  554. triangle.className = 'triangle-down';
  555. triangle.style.position = 'absolute';
  556. triangle.style.bottom = '5px';
  557. triangle.style.left = '50%';
  558. triangle.style.transform = 'translateX(-50%)';
  559. triangle.style.width = '0';
  560. triangle.style.height = '0';
  561. triangle.style.borderLeft = '5px solid transparent';
  562. triangle.style.borderRight = '5px solid transparent';
  563. triangle.style.borderTop = '5px solid #67c1f5';
  564. triangle.style.cursor = 'pointer';
  565. hltbBlock.appendChild(triangle);
  566.  
  567. let title = document.createElement('div');
  568. title.style.fontSize = '12px';
  569. title.style.fontWeight = 'bold';
  570. title.style.color = '#67c1f5';
  571. title.style.marginBottom = '10px';
  572. title.textContent = 'HLTB';
  573. title.style.cursor = 'pointer';
  574. hltbBlock.appendChild(title);
  575.  
  576. let content = document.createElement('div');
  577. content.style.fontSize = '14px';
  578. content.style.color = '#c6d4df';
  579. content.style.display = 'none';
  580. content.style.whiteSpace = 'auto';
  581. content.style.padding = '0 0';
  582. hltbBlock.appendChild(content);
  583.  
  584. const updateHltbPosition = () => {
  585. const russianIndicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
  586.  
  587. if (scriptsConfig.gamePage && russianIndicators) {
  588. hltbBlock.style.top = `${russianIndicators.offsetTop + russianIndicators.offsetHeight + 16}px`;
  589. } else {
  590. hltbBlock.style.top = '0px';
  591. }
  592.  
  593. hltbBlock.style.left = '334px';
  594. };
  595.  
  596. const initHltbObservers = () => {
  597. if (scriptsConfig.gamePage) {
  598. const indicatorsObserver = new MutationObserver(() => {
  599. updateHltbPosition();
  600. });
  601.  
  602. const indicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
  603. if (indicators) {
  604. indicatorsObserver.observe(indicators, {
  605. attributes: true,
  606. childList: true,
  607. subtree: true
  608. });
  609. }
  610. }
  611.  
  612. const generalObserver = new MutationObserver((mutations) => {
  613. mutations.forEach(mutation => {
  614. if (mutation.type === 'childList') {
  615. updateHltbPosition();
  616. }
  617. });
  618. });
  619.  
  620. generalObserver.observe(document.querySelector('#gameHeaderImageCtn'), {
  621. childList: true,
  622. subtree: true
  623. });
  624. };
  625.  
  626. document.querySelector('#gameHeaderImageCtn').appendChild(hltbBlock);
  627. initHltbObservers();
  628. updateHltbPosition();
  629.  
  630.  
  631. const handleClick = async function() {
  632. if (content.style.display === 'none') {
  633. hltbBlock.style.transition = 'width 0.3s ease, height 0.3s ease';
  634.  
  635. updateHltbPosition();
  636. await new Promise(resolve => setTimeout(resolve, 50));
  637.  
  638. hltbBlock.style.width = '200px';
  639. hltbBlock.style.height = '40px';
  640. await new Promise(resolve => setTimeout(resolve, 300));
  641.  
  642. content.textContent = 'Ищем в базе...';
  643. content.style.display = 'block';
  644.  
  645. triangle.classList.remove('triangle-down');
  646. triangle.classList.add('triangle-up');
  647. triangle.style.borderTop = 'none';
  648. triangle.style.borderBottom = '5px solid #67c1f5';
  649.  
  650. let gameName = getGameName();
  651. let gameNameNormalized = normalizeGameName(gameName);
  652.  
  653. let orangutanFetchUrl = 'https://umadb.ro/hltb/fetch.php';
  654. let orangutanHltbUrl = "https://howlongtobeat.com";
  655.  
  656. try {
  657. const response = await new Promise((resolve, reject) => {
  658. GM_xmlhttpRequest({
  659. method: "GET",
  660. url: orangutanFetchUrl,
  661. onload: resolve,
  662. onerror: reject
  663. });
  664. });
  665.  
  666. if (response.status === 200) {
  667. const key = response.responseText.trim();
  668. orangutanHltbUrl = "https://howlongtobeat.com" + key;
  669. } else {
  670. throw new Error('Failed to fetch key. Status: ' + response.status);
  671. }
  672. } catch (error) {
  673. content.textContent = 'Ошибка при получении ключа.';
  674. return;
  675. }
  676.  
  677. let chimpQuery = '{"searchType":"games","searchTerms":[' + gameNameNormalized + '],"searchPage":1,"size":20,"searchOptions":{"games":{"userId":0,"platform":"","sortCategory":"popular","rangeCategory":"main","rangeTime":{"min":null,"max":null},"gameplay":{"perspective":"","flow":"","genre":"","difficulty":""},"rangeYear":{"min":"","max":""},"modifier":""},"users":{"sortCategory":"postcount"},"lists":{"sortCategory":"follows"},"filter":"","sort":0,"randomizer":0},"useCache":true}';
  678.  
  679. GM_xmlhttpRequest({
  680. method: "POST",
  681. url: orangutanHltbUrl,
  682. data: chimpQuery,
  683. headers: {
  684. "Content-Type": "application/json",
  685. "origin": "https://howlongtobeat.com",
  686. "referer": "https://howlongtobeat.com/"
  687. },
  688. onload: async function(response) {
  689. let baboonData = {
  690. count: 0,
  691. data: []
  692. };
  693. if (!response.responseText.includes("<title>HowLongToBeat - 404</title>")) {
  694. try {
  695. baboonData = JSON.parse(response.responseText);
  696. } catch (e) {
  697. content.textContent = 'Ошибка при обработке данных.';
  698. return;
  699. }
  700. }
  701.  
  702. if (baboonData.count === 0 && /[а-яё]/i.test(gameName)) {
  703. const appId = window.location.pathname.split('/')[2];
  704. const steamApiUrl = `https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json={"ids": [{"appid": ${appId}}], "context": {"language": "english", "country_code": "US", "steam_realm": 1}, "data_request": {"include_assets": true}}`;
  705.  
  706. try {
  707. const steamResponse = await new Promise((resolve, reject) => {
  708. GM_xmlhttpRequest({
  709. method: "GET",
  710. url: steamApiUrl,
  711. onload: resolve,
  712. onerror: reject
  713. });
  714. });
  715.  
  716. if (steamResponse.status === 200) {
  717. const steamData = JSON.parse(steamResponse.responseText);
  718. const englishName = steamData.response.store_items[0].name;
  719.  
  720. if (englishName) {
  721. gameName = englishName;
  722. gameNameNormalized = normalizeGameName(gameName);
  723. chimpQuery = '{"searchType":"games","searchTerms":[' + gameNameNormalized + '],"searchPage":1,"size":20,"searchOptions":{"games":{"userId":0,"platform":"","sortCategory":"popular","rangeCategory":"main","rangeTime":{"min":null,"max":null},"gameplay":{"perspective":"","flow":"","genre":"","difficulty":""},"rangeYear":{"min":"","max":""},"modifier":""},"users":{"sortCategory":"postcount"},"lists":{"sortCategory":"follows"},"filter":"","sort":0,"randomizer":0},"useCache":true}';
  724.  
  725. const secondResponse = await new Promise((resolve, reject) => {
  726. GM_xmlhttpRequest({
  727. method: "POST",
  728. url: orangutanHltbUrl,
  729. data: chimpQuery,
  730. headers: {
  731. "Content-Type": "application/json",
  732. "origin": "https://howlongtobeat.com",
  733. "referer": "https://howlongtobeat.com/"
  734. },
  735. onload: resolve,
  736. onerror: reject
  737. });
  738. });
  739.  
  740. if (secondResponse.status === 200) {
  741. baboonData = JSON.parse(secondResponse.responseText);
  742. }
  743. }
  744. }
  745. } catch (error) {
  746. console.error('Ошибка при запросе к Steam API:', error);
  747. }
  748. }
  749.  
  750. if (baboonData.count > 0) {
  751. const matches = findPossibleMatches(gameName, baboonData.data);
  752. if (matches.length > 0) {
  753. renderPossibleMatches(matches);
  754. hltbBlock.style.height = `${content.scrollHeight + 30}px`;
  755. return;
  756. }
  757. }
  758.  
  759. renderContent(baboonData.data[0]);
  760. hltbBlock.style.height = `${content.scrollHeight + 30}px`;
  761. },
  762. onerror: function(error) {
  763. content.textContent = 'Ошибка при запросе к HLTB.';
  764. },
  765. ontimeout: function() {
  766. content.textContent = 'Тайм-аут при запросе к HLTB.';
  767. },
  768. timeout: 10000
  769. });
  770. } else {
  771. content.style.display = 'none';
  772. hltbBlock.style.height = '30px';
  773. hltbBlock.style.width = '30px';
  774.  
  775. triangle.classList.remove('triangle-up');
  776. triangle.classList.add('triangle-down');
  777. triangle.style.borderBottom = 'none';
  778. triangle.style.borderTop = '5px solid #67c1f5';
  779. }
  780. };
  781.  
  782. title.onclick = handleClick;
  783. triangle.onclick = handleClick;
  784.  
  785. window.addEventListener('resize', updateHltbPosition);
  786.  
  787. function normalizeGameName(name) {
  788. return name
  789. .normalize("NFD")
  790. .replace(/[\u0300-\u036f]/g, "")
  791. .replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
  792. .toLowerCase()
  793. .split(/\s+/)
  794. .map(word => `"${word}"`)
  795. .join(",");
  796. }
  797.  
  798. function findPossibleMatches(gameName, data) {
  799. const cleanGameName = gameName
  800. .normalize("NFD")
  801. .replace(/[\u0300-\u036f]/g, "")
  802. .replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
  803. .toLowerCase();
  804.  
  805. return data
  806. .map(item => {
  807. const cleanItemName = item.game_name
  808. .normalize("NFD")
  809. .replace(/[\u0300-\u036f]/g, "")
  810. .replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
  811. .toLowerCase();
  812.  
  813. const similarity = calculateSimilarity(cleanGameName, cleanItemName);
  814. const startsWith = cleanItemName.startsWith(cleanGameName);
  815.  
  816. return {
  817. ...item,
  818. percentage: similarity,
  819. startsWith: startsWith
  820. };
  821. })
  822. .filter(item => item.percentage > 50 || item.startsWith)
  823. .sort((a, b) => {
  824. if (a.startsWith && !b.startsWith) return -1;
  825. if (!a.startsWith && b.startsWith) return 1;
  826. return b.percentage - a.percentage;
  827. })
  828. .slice(0, 5);
  829. }
  830.  
  831. function calculateSimilarity(str1, str2) {
  832. const len = Math.max(str1.length, str2.length);
  833. if (len === 0) return 100;
  834. const distance = levenshteinDistance(str1, str2);
  835. return Math.round(((len - distance) / len) * 100);
  836. }
  837.  
  838. function levenshteinDistance(str1, str2) {
  839. const m = str1.length;
  840. const n = str2.length;
  841. const dp = Array.from({
  842. length: m + 1
  843. }, () => Array(n + 1).fill(0));
  844.  
  845. for (let i = 0; i <= m; i++) {
  846. for (let j = 0; j <= n; j++) {
  847. if (i === 0) {
  848. dp[i][j] = j;
  849. } else if (j === 0) {
  850. dp[i][j] = i;
  851. } else {
  852. dp[i][j] = Math.min(
  853. dp[i - 1][j - 1] + (str1[i - 1] === str2[j - 1] ? 0 : 1),
  854. dp[i - 1][j] + 1,
  855. dp[i][j - 1] + 1
  856. );
  857. }
  858. }
  859. }
  860.  
  861. return dp[m][n];
  862. }
  863.  
  864. function getTextWidth(text, font) {
  865. const canvas = document.createElement('canvas');
  866. const context = canvas.getContext('2d');
  867. context.font = font;
  868. const metrics = context.measureText(text);
  869. return metrics.width;
  870. }
  871.  
  872. function renderPossibleMatches(matches) {
  873. content.innerHTML = '';
  874.  
  875. const title = document.createElement('div');
  876. title.textContent = 'Возможные совпадения:';
  877. title.style.color = '#67c1f5';
  878. title.style.marginBottom = '10px';
  879. content.appendChild(title);
  880.  
  881. const list = document.createElement('ul');
  882. list.style.paddingLeft = '15px';
  883. list.style.marginTop = '5px';
  884. list.style.marginBottom = '0';
  885.  
  886. matches.forEach(match => {
  887. const li = document.createElement('li');
  888. li.style.marginBottom = '8px';
  889.  
  890. const link = document.createElement('a');
  891. link.href = '#';
  892. link.textContent = `${match.game_name} (${match.percentage}%)`;
  893. link.style.color = '#c6d4df';
  894. link.style.wordBreak = 'break-word';
  895. link.style.textDecoration = 'none';
  896. link.onclick = () => {
  897. renderContent(match);
  898. hltbBlock.style.height = `${content.scrollHeight + 30}px`;
  899. return false;
  900. };
  901.  
  902. li.appendChild(link);
  903. list.appendChild(li);
  904. });
  905.  
  906. const noMatch = document.createElement('li');
  907. noMatch.style.marginBottom = '8px';
  908.  
  909. const noMatchLink = document.createElement('a');
  910. noMatchLink.href = '#';
  911. noMatchLink.textContent = 'Ничего не подходит';
  912. noMatchLink.style.color = '#c6d4df';
  913. noMatchLink.style.wordBreak = 'break-word';
  914. noMatchLink.style.textDecoration = 'none';
  915. noMatchLink.onclick = () => {
  916. renderContent(null);
  917. hltbBlock.style.height = `${content.scrollHeight + 30}px`;
  918. return false;
  919. };
  920.  
  921. noMatch.appendChild(noMatchLink);
  922. list.appendChild(noMatch);
  923.  
  924. content.appendChild(list);
  925.  
  926. let maxWidth = 0;
  927. content.querySelectorAll('a').forEach(link => {
  928. const text = link.textContent;
  929. const font = window.getComputedStyle(link).font;
  930. const width = getTextWidth(text, font);
  931. if (width > maxWidth) maxWidth = width;
  932. });
  933.  
  934. hltbBlock.style.width = `${Math.max(maxWidth + 40, 250)}px`;
  935. }
  936.  
  937. function renderContent(entry) {
  938. content.innerHTML = '';
  939.  
  940. if (!entry) {
  941. content.textContent = 'Игра не найдена в базе HLTB';
  942. return;
  943. }
  944.  
  945. const titleLink = document.createElement('a');
  946. titleLink.href = `https://howlongtobeat.com/game/${entry.game_id}`;
  947. titleLink.target = '_blank';
  948. titleLink.textContent = entry.game_name || 'Без названия';
  949. titleLink.style.color = '#67c1f5';
  950. titleLink.style.wordBreak = 'break-word';
  951. content.appendChild(titleLink);
  952.  
  953. const list = document.createElement('ul');
  954. list.style.paddingLeft = '15px';
  955. list.style.marginTop = '5px';
  956. list.style.marginBottom = '0';
  957.  
  958. const times = [{
  959. label: 'Только сюжет',
  960. time: entry.comp_main,
  961. count: entry.comp_main_count
  962. },
  963. {
  964. label: 'Сюжет + доп.',
  965. time: entry.comp_plus,
  966. count: entry.comp_plus_count
  967. },
  968. {
  969. label: 'Комплеционист',
  970. time: entry.comp_100,
  971. count: entry.comp_100_count
  972. },
  973. {
  974. label: 'Все стили',
  975. time: entry.comp_all,
  976. count: entry.comp_all_count
  977. }
  978. ];
  979.  
  980. times.forEach(time => {
  981. const li = document.createElement('li');
  982. li.style.marginBottom = '8px';
  983.  
  984. const timeText = time.time ? formatTime(time.time) : "X";
  985. li.innerHTML = `${time.label}: <span style="color: #fff;">${timeText}</span> (${time.count} чел.)`;
  986.  
  987. list.appendChild(li);
  988. });
  989.  
  990. content.appendChild(list);
  991.  
  992. let maxWidth = 0;
  993. content.querySelectorAll('li').forEach(child => {
  994. const text = child.textContent;
  995. const font = window.getComputedStyle(child).font;
  996. const width = getTextWidth(text, font);
  997. if (width > maxWidth) maxWidth = width;
  998. });
  999.  
  1000. hltbBlock.style.width = `${Math.max(maxWidth + 30, 200)}px`;
  1001. hltbBlock.style.whiteSpace = 'nowrap';
  1002. }
  1003.  
  1004. function formatTime(seconds) {
  1005. const hours = Math.floor(seconds / 3600);
  1006. const minutes = Math.round((seconds % 3600) / 60);
  1007.  
  1008. if (hours === 0) {
  1009. return `${minutes} м.`;
  1010. } else if (hours + (minutes / 60) >= hours + 0.5) {
  1011. return `${hours + 1} ч.`;
  1012. } else {
  1013. return `${hours} ч.`;
  1014. }
  1015. }
  1016.  
  1017. function getGameName() {
  1018. return document.querySelector('.apphub_AppName').textContent
  1019. .normalize("NFD")
  1020. .replace(/[\u0300-\u036f]/g, "")
  1021. .replace(/[’]/g, "'")
  1022. .replace(/[^a-zA-Zа-яёА-ЯЁ0-9 _'\-!]/g, '')
  1023. .trim()
  1024. .toLowerCase();
  1025. }
  1026.  
  1027. if (scriptsConfig.autoExpandHltb) {
  1028. handleClick();
  1029. }
  1030. })();
  1031. }
  1032.  
  1033. // Скрипт для страницы игры (Время друзей & Достижения) | https://store.steampowered.com/app/*
  1034. if (window.location.pathname.includes('/app/') && scriptsConfig.friendsPlaytime) {
  1035. (async function() {
  1036. const statsBlock = document.createElement('div');
  1037. statsBlock.style.position = 'absolute';
  1038. statsBlock.style.top = '0px';
  1039. statsBlock.style.left = '406px';
  1040. statsBlock.style.width = '30px';
  1041. statsBlock.style.height = '30px';
  1042. statsBlock.style.background = 'rgba(27, 40, 56, 0.95)';
  1043. statsBlock.style.padding = '15px';
  1044. statsBlock.style.borderRadius = '4px';
  1045. statsBlock.style.border = '1px solid #3c3c3c';
  1046. statsBlock.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  1047. statsBlock.style.zIndex = '1';
  1048. statsBlock.style.fontFamily = 'Arial, sans-serif';
  1049. statsBlock.style.overflow = 'hidden';
  1050. statsBlock.style.transition = 'all 0.3s ease';
  1051.  
  1052. const triangle = document.createElement('div');
  1053. triangle.style.position = 'absolute';
  1054. triangle.style.bottom = '5px';
  1055. triangle.style.left = '50%';
  1056. triangle.style.transform = 'translateX(-50%)';
  1057. triangle.style.width = '0';
  1058. triangle.style.height = '0';
  1059. triangle.style.borderLeft = '5px solid transparent';
  1060. triangle.style.borderRight = '5px solid transparent';
  1061. triangle.style.borderTop = '5px solid #67c1f5';
  1062. triangle.style.cursor = 'pointer';
  1063. statsBlock.appendChild(triangle);
  1064.  
  1065. const title = document.createElement('div');
  1066. title.style.display = 'flex';
  1067. title.style.alignItems = 'center';
  1068. title.style.marginBottom = '7px';
  1069. title.style.cursor = 'pointer';
  1070.  
  1071. const combinedImg = document.createElement('div');
  1072. combinedImg.style.width = '29px';
  1073. combinedImg.style.height = '29px';
  1074. combinedImg.style.backgroundImage = 'url(https://gist.githubusercontent.com/0wn3dg0d/9c259eebc40a1e97397ccf3da7ee7bd6/raw/SUEftach.png)';
  1075. combinedImg.style.backgroundSize = 'contain';
  1076. combinedImg.style.backgroundPosition = 'center';
  1077.  
  1078.  
  1079. title.appendChild(combinedImg);
  1080. statsBlock.appendChild(title);
  1081.  
  1082.  
  1083. const content = document.createElement('div');
  1084. content.style.fontSize = '14px';
  1085. content.style.color = '#c6d4df';
  1086. content.style.display = 'none';
  1087. content.style.padding = '0';
  1088. statsBlock.appendChild(content);
  1089.  
  1090. const toggleBlock = async () => {
  1091. if (content.style.display === 'none') {
  1092. statsBlock.style.width = '250px';
  1093. statsBlock.style.height = '60px';
  1094. content.style.display = 'block';
  1095. content.textContent = 'Загрузка...';
  1096. triangle.style.borderTop = 'none';
  1097. triangle.style.borderBottom = '5px solid #67c1f5';
  1098.  
  1099. try {
  1100. const friendsData = await loadFriendsData();
  1101. const achievementsData = await loadAchievementsData();
  1102. content.innerHTML = '';
  1103.  
  1104. const friendsTitle = document.createElement('div');
  1105. friendsTitle.style.fontSize = '12px';
  1106. friendsTitle.style.fontWeight = 'bold';
  1107. friendsTitle.style.color = '#67c1f5';
  1108. friendsTitle.style.marginBottom = '5px';
  1109. friendsTitle.textContent = 'ВРЕМЯ ДРУЗЕЙ';
  1110. content.appendChild(friendsTitle);
  1111.  
  1112. if (friendsData.length > 0) {
  1113. const maxHours = Math.max(...friendsData.map(f => f.hours));
  1114. const minHours = Math.min(...friendsData.map(f => f.hours));
  1115. const avgHours = friendsData.reduce((sum, f) => sum + f.hours, 0) / friendsData.length;
  1116. const maxPlayers = friendsData.filter(f => f.hours === maxHours);
  1117.  
  1118. const maxEl = document.createElement('div');
  1119. maxEl.style.marginBottom = '4px';
  1120. maxEl.innerHTML = `<span style="color: #67c1f5;">Макс:</span> ${maxHours.toFixed(1)} ч.`;
  1121.  
  1122. if (maxPlayers.length > 0) {
  1123. maxEl.innerHTML += ` (${maxPlayers.map(p =>
  1124. `<a href="${p.profile}" target="_blank" style="color: #c6d4df; text-decoration: none;">${p.name}</a>`
  1125. ).join(', ')})`;
  1126. }
  1127.  
  1128. const avgEl = document.createElement('div');
  1129. avgEl.style.marginBottom = '4px';
  1130. avgEl.innerHTML = `<span style="color: #67c1f5;">Среднее:</span> ${avgHours.toFixed(1)} ч. (${friendsData.length} чел.)`;
  1131.  
  1132. const minEl = document.createElement('div');
  1133. minEl.innerHTML = `<span style="color: #67c1f5;">Минимальное:</span> ${minHours.toFixed(1)} ч.`;
  1134.  
  1135. content.append(maxEl, avgEl, minEl);
  1136. } else {
  1137. const noData = document.createElement('div');
  1138. noData.textContent = 'Друзья не играли';
  1139. noData.style.marginBottom = '12px';
  1140. content.appendChild(noData);
  1141. }
  1142.  
  1143. const achTitle = document.createElement('div');
  1144. achTitle.style.fontSize = '12px';
  1145. achTitle.style.fontWeight = 'bold';
  1146. achTitle.style.color = '#67c1f5';
  1147. achTitle.style.margin = '16px 0 5px 0';
  1148. achTitle.textContent = 'ГЛОБАЛЬНЫЕ ДОСТИЖЕНИЯ';
  1149. content.appendChild(achTitle);
  1150.  
  1151. if (achievementsData.hasAchievements) {
  1152. const platinumEl = document.createElement('div');
  1153. platinumEl.style.marginBottom = '4px';
  1154. platinumEl.innerHTML = `<span style="color: #67c1f5;">Платина:</span> ${achievementsData.platinumPercent}%`;
  1155.  
  1156. const averageEl = document.createElement('div');
  1157. averageEl.innerHTML = `<span style="color: #67c1f5;">Средний прогресс:</span> ${achievementsData.averageAdjustedPercent}%`;
  1158.  
  1159. content.append(platinumEl, averageEl);
  1160. } else {
  1161. const noAch = document.createElement('div');
  1162. noAch.textContent = achievementsData.error === 'Нет достижений' ?
  1163. 'Достижений нет' :
  1164. achievementsData.error;
  1165. noAch.style.marginBottom = '12px';
  1166. content.appendChild(noAch);
  1167. }
  1168.  
  1169. statsBlock.style.height = `${content.scrollHeight + 38}px`;
  1170.  
  1171. } catch (error) {
  1172. content.textContent = 'Ошибка загрузки';
  1173. statsBlock.style.height = '60px';
  1174. }
  1175. } else {
  1176. content.style.display = 'none';
  1177. statsBlock.style.height = '30px';
  1178. statsBlock.style.width = '30px';
  1179. triangle.style.borderBottom = 'none';
  1180. triangle.style.borderTop = '5px solid #67c1f5';
  1181. }
  1182. };
  1183.  
  1184. async function loadFriendsData() {
  1185. try {
  1186. const friendsLink = document.querySelector('.recommendation_reasons a[href*="friendsthatplay"]');
  1187. if (!friendsLink) return [];
  1188.  
  1189. const response = await new Promise((resolve, reject) => {
  1190. GM_xmlhttpRequest({
  1191. method: "GET",
  1192. url: friendsLink.href,
  1193. onload: resolve,
  1194. onerror: reject,
  1195. timeout: 5000
  1196. });
  1197. });
  1198.  
  1199. const parser = new DOMParser();
  1200. const doc = parser.parseFromString(response.responseText, 'text/html');
  1201.  
  1202. return Array.from(doc.querySelectorAll('.friendBlockContent'))
  1203. .map(block => {
  1204. const timeText = block.querySelector('.friendSmallText')?.textContent;
  1205. const hoursMatch = timeText?.match(/(\d+[,.]?\d*)\s*ч/);
  1206. return {
  1207. name: block.firstChild.textContent.trim(),
  1208. hours: hoursMatch ? parseFloat(hoursMatch[1].replace(',', '.')) : 0,
  1209. profile: block.closest('.friendBlock').querySelector('a').href
  1210. };
  1211. })
  1212. .filter(f => f.hours > 0);
  1213.  
  1214. } catch (error) {
  1215. return [];
  1216. }
  1217. }
  1218.  
  1219. async function loadAchievementsData() {
  1220. try {
  1221. const appIdMatch = window.location.pathname.match(/\/app\/(\d+)/);
  1222. if (!appIdMatch) return {
  1223. hasAchievements: false,
  1224. error: 'Не найден App ID'
  1225. };
  1226.  
  1227. const appId = appIdMatch[1];
  1228. const achievementsUrl = `https://steamcommunity.com/stats/${appId}/achievements/`;
  1229.  
  1230. const response = await new Promise((resolve, reject) => {
  1231. GM_xmlhttpRequest({
  1232. method: "GET",
  1233. url: achievementsUrl,
  1234. onload: resolve,
  1235. onerror: reject,
  1236. timeout: 8000
  1237. });
  1238. });
  1239.  
  1240. if (response.status !== 200) return {
  1241. hasAchievements: false,
  1242. error: 'Ошибка загрузки страницы'
  1243. };
  1244.  
  1245. const parser = new DOMParser();
  1246. const doc = parser.parseFromString(response.responseText, 'text/html');
  1247.  
  1248. if (doc.querySelector('.no_achievements_message')) {
  1249. return {
  1250. hasAchievements: false,
  1251. error: 'Достижения отсутствуют'
  1252. };
  1253. }
  1254.  
  1255. const percentElements = doc.querySelectorAll('.achievePercent');
  1256. if (percentElements.length === 0) return {
  1257. hasAchievements: false,
  1258. error: 'Достижения отсутствуют'
  1259. };
  1260.  
  1261. const percents = Array.from(percentElements)
  1262. .map(el => {
  1263. const text = el.textContent.trim();
  1264. return parseFloat(text.replace('%', '')) || 0;
  1265. })
  1266. .filter(p => p > 0);
  1267.  
  1268. if (percents.length === 0) return {
  1269. hasAchievements: false,
  1270. error: 'Нет данных'
  1271. };
  1272.  
  1273. const maxPercent = Math.max(...percents);
  1274. const minPercent = Math.min(...percents);
  1275. const adjustment = 100 - maxPercent;
  1276. const adjustedPercents = percents.map(p => p + adjustment);
  1277. const averageAdjusted = adjustedPercents.reduce((sum, p) => sum + p, 0) / adjustedPercents.length;
  1278.  
  1279. return {
  1280. hasAchievements: true,
  1281. platinumPercent: (minPercent + adjustment).toFixed(1),
  1282. averageAdjustedPercent: averageAdjusted.toFixed(1),
  1283. };
  1284.  
  1285. } catch (error) {
  1286. return {
  1287. hasAchievements: false,
  1288. error: 'Ошибка соединения'
  1289. };
  1290. }
  1291. }
  1292.  
  1293. title.addEventListener('click', toggleBlock);
  1294. triangle.addEventListener('click', toggleBlock);
  1295.  
  1296. document.querySelector('#gameHeaderImageCtn').appendChild(statsBlock);
  1297.  
  1298. if (scriptsConfig.autoExpandFriends) {
  1299. toggleBlock();
  1300. }
  1301. })();
  1302. }
  1303.  
  1304. // Скрипт для получения дополнительной информации об игре при наведении на неё на странице поиска по каталогу | https://store.steampowered.com/search/
  1305. if (scriptsConfig.catalogInfo && window.location.pathname.includes('/search')) {
  1306. (function() {
  1307. 'use strict';
  1308.  
  1309. const ALEXANDER_API_URL = "https://api.steampowered.com/IStoreBrowseService/GetItems/v1";
  1310. const HANNIBAL_WAIT_TIME = 2000;
  1311. const CAESAR_VISIBLE_ELEMENTS_SELECTOR = "a.search_result_row[data-ds-appid]";
  1312. const NAPOLEON_HOVER_ELEMENT_SELECTOR = "a.search_result_row";
  1313.  
  1314. let GENghis_collectedAppIds = new Set();
  1315. let ATTILA_tooltip = null;
  1316. let SALADIN_hoverTimer = null;
  1317. let TAMERLAN_hideTimer = null;
  1318.  
  1319. let RUSSIAN_TRANSLATION_CHECKBOX = null;
  1320. let RUSSIAN_VOICE_CHECKBOX = null;
  1321. let NO_RUSSIAN_CHECKBOX = null;
  1322. const STEAM_TAGS_CACHE_KEY = 'SteamEnhancer_TagsCache_v2';
  1323. const STEAM_TAGS_URL = "https://gist.githubusercontent.com/0wn3dg0d/22a351ff4c65e50a9a8af6da360defad/raw/steamrutagsownd.json";
  1324.  
  1325. async function loadSteamTags() {
  1326. const cached = GM_getValue(STEAM_TAGS_CACHE_KEY, {
  1327. data: null,
  1328. timestamp: 0
  1329. });
  1330. const now = Date.now();
  1331. const CACHE_DURATION = 744 * 60 * 60 * 1000;
  1332.  
  1333. if (cached.data && (now - cached.timestamp) < CACHE_DURATION) {
  1334. return cached.data;
  1335. }
  1336.  
  1337. try {
  1338. const response = await new Promise((resolve, reject) => {
  1339. GM_xmlhttpRequest({
  1340. method: "GET",
  1341. url: STEAM_TAGS_URL,
  1342. onload: resolve,
  1343. onerror: reject
  1344. });
  1345. });
  1346.  
  1347. if (response.status === 200) {
  1348. const data = JSON.parse(response.responseText);
  1349. GM_setValue(STEAM_TAGS_CACHE_KEY, {
  1350. data: data,
  1351. timestamp: now
  1352. });
  1353. return data;
  1354. }
  1355. } catch (e) {
  1356. console.error('Ошибка загрузки тегов:', e);
  1357. return cached.data || {};
  1358. }
  1359.  
  1360. return {};
  1361. }
  1362.  
  1363. function fetchGameData(appIds) {
  1364. const inputJson = {
  1365. ids: Array.from(appIds).map(appid => ({
  1366. appid
  1367. })),
  1368. context: {
  1369. language: "russian",
  1370. country_code: "US",
  1371. steam_realm: 1
  1372. },
  1373. data_request: {
  1374. include_assets: true,
  1375. include_release: true,
  1376. include_platforms: true,
  1377. include_all_purchase_options: true,
  1378. include_screenshots: true,
  1379. include_trailers: true,
  1380. include_ratings: true,
  1381. include_tag_count: true,
  1382. include_reviews: true,
  1383. include_basic_info: true,
  1384. include_supported_languages: true,
  1385. include_full_description: true,
  1386. include_included_items: true,
  1387. included_item_data_request: {
  1388. include_assets: true,
  1389. include_release: true,
  1390. include_platforms: true,
  1391. include_all_purchase_options: true,
  1392. include_screenshots: true,
  1393. include_trailers: true,
  1394. include_ratings: true,
  1395. include_tag_count: true,
  1396. include_reviews: true,
  1397. include_basic_info: true,
  1398. include_supported_languages: true,
  1399. include_full_description: true,
  1400. include_included_items: true,
  1401. include_assets_without_overrides: true,
  1402. apply_user_filters: false,
  1403. include_links: true
  1404. },
  1405. include_assets_without_overrides: true,
  1406. apply_user_filters: false,
  1407. include_links: true
  1408. }
  1409. };
  1410.  
  1411. GM_xmlhttpRequest({
  1412. method: "GET",
  1413. url: `${ALEXANDER_API_URL}?input_json=${encodeURIComponent(JSON.stringify(inputJson))}`,
  1414. onload: function(response) {
  1415. const data = JSON.parse(response.responseText);
  1416. processGameData(data);
  1417. }
  1418. });
  1419. }
  1420.  
  1421. function processGameData(data) {
  1422. const items = data.response.store_items;
  1423. items.forEach(item => {
  1424. const appId = item.id;
  1425. const gameElement = document.querySelector(`a.search_result_row[data-ds-appid="${appId}"]`);
  1426. if (gameElement) {
  1427. const gameData = {
  1428. is_early_access: item.is_early_access,
  1429. review_count: item.reviews?.summary_filtered?.review_count,
  1430. percent_positive: item.reviews?.summary_filtered?.percent_positive,
  1431. short_description: item.basic_info?.short_description,
  1432. publishers: item.basic_info?.publishers?.map(p => p.name).join(", "),
  1433. developers: item.basic_info?.developers?.map(d => d.name).join(", "),
  1434. franchises: item.basic_info?.franchises?.map(f => f.name).join(", "),
  1435. tagids: item.tagids || [],
  1436. language_support_russian: item.supported_languages?.find(lang => lang.elanguage === 8),
  1437. language_support_english: item.supported_languages?.find(lang => lang.elanguage === 0)
  1438. };
  1439.  
  1440. gameElement.dataset.gameInfo = JSON.stringify(gameData);
  1441. applyRussianLanguageFilter(gameElement);
  1442. }
  1443. });
  1444. }
  1445.  
  1446. function collectAndFetchAppIds() {
  1447. const visibleElements = document.querySelectorAll(CAESAR_VISIBLE_ELEMENTS_SELECTOR);
  1448. const newAppIds = new Set();
  1449.  
  1450. visibleElements.forEach(element => {
  1451. const appId = element.dataset.dsAppid;
  1452. if (!GENghis_collectedAppIds.has(appId)) {
  1453. newAppIds.add(parseInt(appId, 10));
  1454. GENghis_collectedAppIds.add(appId);
  1455. }
  1456. });
  1457.  
  1458. if (newAppIds.size > 0) {
  1459. fetchGameData(newAppIds);
  1460. }
  1461. }
  1462.  
  1463. function handleHover(event) {
  1464. const gameElement = event.target.closest(NAPOLEON_HOVER_ELEMENT_SELECTOR);
  1465.  
  1466. if (gameElement && gameElement.dataset.gameInfo) {
  1467. clearTimeout(SALADIN_hoverTimer);
  1468. clearTimeout(TAMERLAN_hideTimer);
  1469.  
  1470. SALADIN_hoverTimer = setTimeout(() => {
  1471. const gameData = JSON.parse(gameElement.dataset.gameInfo);
  1472. displayGameInfo(gameElement, gameData);
  1473. }, 300);
  1474. } else {
  1475. clearTimeout(SALADIN_hoverTimer);
  1476. clearTimeout(TAMERLAN_hideTimer);
  1477. if (ATTILA_tooltip) {
  1478. ATTILA_tooltip.style.opacity = 0;
  1479. setTimeout(() => {
  1480. ATTILA_tooltip.style.display = 'none';
  1481. }, 300);
  1482. }
  1483. }
  1484. }
  1485.  
  1486. function getReviewClassCatalog(percent, totalReviews) {
  1487. if (totalReviews === 0) return 'catalog-no-reviews';
  1488. if (percent >= 70) return 'catalog-positive';
  1489. if (percent >= 40) return 'catalog-mixed';
  1490. if (percent >= 1) return 'catalog-negative';
  1491. return 'catalog-negative';
  1492. }
  1493.  
  1494. async function getTagNames(tagIds) {
  1495. const tagsData = await loadSteamTags();
  1496. return tagIds.slice(0, 5).map(tagId =>
  1497. tagsData[tagId] || `Тег #${tagId}`
  1498. );
  1499. }
  1500.  
  1501. async function displayGameInfo(element, data) {
  1502. if (!ATTILA_tooltip) {
  1503. ATTILA_tooltip = document.createElement('div');
  1504. ATTILA_tooltip.className = 'custom-tooltip';
  1505. ATTILA_tooltip.innerHTML = '<div class="tooltip-arrow"></div><div class="tooltip-content"></div>';
  1506. document.body.appendChild(ATTILA_tooltip);
  1507. }
  1508.  
  1509. const tooltipContent = ATTILA_tooltip.querySelector('.tooltip-content');
  1510.  
  1511. let languageSupportRussianText = "Отсутствует";
  1512. let languageSupportRussianClass = 'catalog-language-no';
  1513. if (data.language_support_russian) {
  1514. languageSupportRussianText = "";
  1515. if (data.language_support_russian.supported) languageSupportRussianText += "<br>Интерфейс: ✔ ";
  1516. if (data.language_support_russian.full_audio) languageSupportRussianText += "<br>Озвучка: ✔ ";
  1517. if (data.language_support_russian.subtitles) languageSupportRussianText += "<br>Субтитры: ✔";
  1518. if (languageSupportRussianText === "") languageSupportRussianText = "Отсутствует";
  1519. else languageSupportRussianClass = 'catalog-language-yes';
  1520. }
  1521.  
  1522. let languageSupportEnglishText = "Отсутствует";
  1523. let languageSupportEnglishClass = 'catalog-language-no';
  1524. if (scriptsConfig.toggleEnglishLangInfo && data.language_support_english) {
  1525. languageSupportEnglishText = "";
  1526. if (data.language_support_english.supported) languageSupportEnglishText += "<br>Интерфейс: ✔ ";
  1527. if (data.language_support_english.full_audio) languageSupportEnglishText += "<br>Озвучка: ✔ ";
  1528. if (data.language_support_english.subtitles) languageSupportEnglishText += "<br>Субтитры: ✔";
  1529. if (languageSupportEnglishText === "") languageSupportEnglishText = "Отсутствует";
  1530. else languageSupportEnglishClass = 'catalog-language-yes';
  1531. }
  1532.  
  1533. const reviewClass = getReviewClassCatalog(data.percent_positive, data.review_count);
  1534. const earlyAccessClass = data.is_early_access ? 'catalog-early-access-yes' : 'catalog-early-access-no';
  1535. const tags = await getTagNames(data.tagids || []);
  1536. const tagsHtml = tags.map(tag =>
  1537. `<div class="custom-tag">${tag}</div>`
  1538. ).join('');
  1539.  
  1540. tooltipContent.innerHTML = `
  1541. <div style="margin-bottom: 0px;"><strong>Издатели:</strong> <span class="${!data.publishers ? 'catalog-no-reviews' : ''}">${data.publishers || "Нет данных"}</span></div>
  1542. <div style="margin-bottom: 0px;"><strong>Разработчики:</strong> <span class="${!data.developers ? 'catalog-no-reviews' : ''}">${data.developers || "Нет данных"}</span></div>
  1543. <div style="margin-bottom: 10px;"><strong>Серия игр:</strong> <span class="${!data.franchises ? 'catalog-no-reviews' : ''}">${data.franchises || "Нет данных"}</span></div>
  1544. <div style="margin-bottom: 10px;"><strong>Отзывы: </strong><span id="reviewCount">${data.review_count || "0"} </span><span class="${reviewClass}">(${data.percent_positive || "0"}% положительных)</span></div>
  1545. <div style="margin-bottom: 10px;"><strong>Ранний доступ:</strong> <span class="${earlyAccessClass}">${data.is_early_access ? "Да" : "Нет"}</span></div>
  1546. <div style="margin-bottom: 10px;"><strong>Русский язык:</strong> <span class="${languageSupportRussianClass}">${languageSupportRussianText}</span></div>
  1547. ${scriptsConfig.toggleEnglishLangInfo ? `<div style="margin-bottom: 10px;"><strong>Английский язык:</strong> <span class="${languageSupportEnglishClass}">${languageSupportEnglishText}</span></div>` : ''}
  1548. <div style="margin-bottom: 10px;"><strong>Метки:</strong><br>
  1549. <div class="custom-tags-container">${tagsHtml}</div></div>
  1550. <div style="margin-bottom: 10px;"><strong>Описание:</strong> <span class="${!data.short_description ? 'catalog-no-reviews' : ''}">${data.short_description || "Нет данных"}</span></div>
  1551. `;
  1552.  
  1553. ATTILA_tooltip.style.display = 'block';
  1554.  
  1555. const rect = element.getBoundingClientRect();
  1556. const tooltipRect = ATTILA_tooltip.getBoundingClientRect();
  1557. ATTILA_tooltip.style.left = `${rect.left + window.scrollX - tooltipRect.width - 4}px`;
  1558. ATTILA_tooltip.style.top = `${rect.top + window.scrollY - 20}px`;
  1559.  
  1560. ATTILA_tooltip.style.opacity = 0;
  1561. ATTILA_tooltip.style.display = 'block';
  1562. setTimeout(() => {
  1563. ATTILA_tooltip.style.opacity = 1;
  1564. }, 10);
  1565.  
  1566. element.addEventListener('mouseleave', () => {
  1567. clearTimeout(TAMERLAN_hideTimer);
  1568. TAMERLAN_hideTimer = setTimeout(() => {
  1569. ATTILA_tooltip.style.opacity = 0;
  1570. setTimeout(() => {
  1571. ATTILA_tooltip.style.display = 'none';
  1572. }, 300);
  1573. }, 200);
  1574. }, {
  1575. once: true
  1576. });
  1577.  
  1578. element.addEventListener('mouseover', () => {
  1579. clearTimeout(TAMERLAN_hideTimer);
  1580. });
  1581. }
  1582.  
  1583. function createRussianLanguageFilterBlock() {
  1584. const filterBlock = document.createElement('div');
  1585. filterBlock.className = 'block search_collapse_block';
  1586. filterBlock.innerHTML = `
  1587. <div data-panel="{&quot;focusable&quot;:true,&quot;clickOnActivate&quot;:true}" class="block_header labs_block_header">
  1588. <div>Русский перевод</div>
  1589. </div>
  1590. <div class="block_content block_content_inner">
  1591. <div class="tab_filter_control_row" data-param="russian_translation" data-value="__toggle" data-loc="Только текст" data-clientside="0">
  1592. <span data-panel="{&quot;focusable&quot;:true,&quot;clickOnActivate&quot;:true}" class="tab_filter_control tab_filter_control_include" data-param="russian_translation" data-value="__toggle" data-loc="Только текст" data-clientside="0" data-gpfocus="item">
  1593. <span>
  1594. <span class="tab_filter_control_checkbox"></span>
  1595. <span class="tab_filter_control_label">Только текст</span>
  1596. <span class="tab_filter_control_count" style="display: none;"></span>
  1597. </span>
  1598. </span>
  1599. </div>
  1600. <div class="tab_filter_control_row" data-param="russian_voice" data-value="__toggle" data-loc="Озвучка" data-clientside="0">
  1601. <span data-panel="{&quot;focusable&quot;:true,&quot;clickOnActivate&quot;:true}" class="tab_filter_control tab_filter_control_include" data-param="russian_voice" data-value="__toggle" data-loc="Озвучка" data-clientside="0" data-gpfocus="item">
  1602. <span>
  1603. <span class="tab_filter_control_checkbox"></span>
  1604. <span class="tab_filter_control_label">Озвучка</span>
  1605. <span class="tab_filter_control_count" style="display: none;"></span>
  1606. </span>
  1607. </span>
  1608. </div>
  1609. <div class="tab_filter_control_row" data-param="no_russian" data-value="__toggle" data-loc="Без перевода" data-clientside="0">
  1610. <span data-panel="{&quot;focusable&quot;:true,&quot;clickOnActivate&quot;:true}" class="tab_filter_control tab_filter_control_include" data-param="no_russian" data-value="__toggle" data-loc="Без перевода" data-clientside="0" data-gpfocus="item">
  1611. <span>
  1612. <span class="tab_filter_control_checkbox"></span>
  1613. <span class="tab_filter_control_label">Без перевода</span>
  1614. <span class="tab_filter_control_count" style="display: none;"></span>
  1615. </span>
  1616. </span>
  1617. </div>
  1618. </div>
  1619. `;
  1620.  
  1621. const priceBlock = document.querySelector('.block.search_collapse_block[data-collapse-name="price"]');
  1622. priceBlock.parentNode.insertBefore(filterBlock, priceBlock.nextSibling);
  1623.  
  1624. const translationRow = filterBlock.querySelector('[data-param="russian_translation"]');
  1625. const voiceRow = filterBlock.querySelector('[data-param="russian_voice"]');
  1626. const noRussianRow = filterBlock.querySelector('[data-param="no_russian"]');
  1627.  
  1628. [translationRow, voiceRow, noRussianRow].forEach(row => {
  1629. row.addEventListener('click', () => {
  1630. const control = row.querySelector('.tab_filter_control');
  1631. const wasChecked = control.classList.contains('checked');
  1632.  
  1633. [translationRow, voiceRow, noRussianRow].forEach(r => {
  1634. r.querySelector('.tab_filter_control').classList.remove('checked');
  1635. r.classList.remove('checked');
  1636. });
  1637.  
  1638. if (!wasChecked) {
  1639. control.classList.add('checked');
  1640. row.classList.add('checked');
  1641. }
  1642.  
  1643. document.querySelectorAll(CAESAR_VISIBLE_ELEMENTS_SELECTOR).forEach(gameElement => {
  1644. applyRussianLanguageFilter(gameElement);
  1645. });
  1646. });
  1647. });
  1648. }
  1649.  
  1650. function applyRussianLanguageFilter(gameElement) {
  1651. if (!gameElement.dataset.gameInfo) return;
  1652.  
  1653. const gameData = JSON.parse(gameElement.dataset.gameInfo);
  1654. const hasRussianText = gameData.language_support_russian?.supported || gameData.language_support_russian?.subtitles;
  1655. const hasRussianVoice = gameData.language_support_russian?.full_audio;
  1656. const hasAnyRussian = hasRussianText || hasRussianVoice;
  1657.  
  1658. const translationChecked = document.querySelector('[data-param="russian_translation"] .tab_filter_control').classList.contains('checked');
  1659. const voiceChecked = document.querySelector('[data-param="russian_voice"] .tab_filter_control').classList.contains('checked');
  1660. const noRussianChecked = document.querySelector('[data-param="no_russian"] .tab_filter_control').classList.contains('checked');
  1661.  
  1662. if (translationChecked) {
  1663. if (!hasRussianText || hasRussianVoice) animateDisappearance(gameElement);
  1664. else animateAppearance(gameElement);
  1665. } else if (voiceChecked) {
  1666. if (!hasRussianVoice) animateDisappearance(gameElement);
  1667. else animateAppearance(gameElement);
  1668. } else if (noRussianChecked) {
  1669. if (hasAnyRussian) animateDisappearance(gameElement);
  1670. else animateAppearance(gameElement);
  1671. } else {
  1672. animateAppearance(gameElement);
  1673. }
  1674. }
  1675.  
  1676. function animateDisappearance(element) {
  1677. element.style.transition = 'opacity 0.5s ease-out, transform 0.5s ease-out';
  1678. element.style.opacity = '0';
  1679. element.style.transform = 'translateX(-100%)';
  1680.  
  1681. setTimeout(() => {
  1682. element.style.display = 'none';
  1683. }, 500);
  1684. }
  1685.  
  1686. function animateAppearance(element) {
  1687. element.style.display = 'block';
  1688. element.style.opacity = '0';
  1689. element.style.transform = 'translateX(-100%)';
  1690. element.style.transition = 'opacity 0.5s ease-in-out, transform 0.5s ease-in-out';
  1691.  
  1692. setTimeout(() => {
  1693. element.style.opacity = '1';
  1694. element.style.transform = 'translateX(0)';
  1695. }, 0);
  1696.  
  1697. setTimeout(() => {
  1698. element.style.transition = '';
  1699. }, 500);
  1700. }
  1701.  
  1702. function observeNewElements() {
  1703. const observer = new MutationObserver((mutations) => {
  1704. mutations.forEach(mutation => {
  1705. if (mutation.type === 'childList') {
  1706. collectAndFetchAppIds();
  1707. }
  1708. });
  1709. });
  1710.  
  1711. observer.observe(document.body, {
  1712. childList: true,
  1713. subtree: true
  1714. });
  1715. }
  1716.  
  1717. function initialize() {
  1718. setTimeout(() => {
  1719. collectAndFetchAppIds();
  1720. observeNewElements();
  1721. document.addEventListener('mouseover', handleHover);
  1722. createRussianLanguageFilterBlock();
  1723. }, HANNIBAL_WAIT_TIME);
  1724. }
  1725.  
  1726. initialize();
  1727.  
  1728. const style = document.createElement('style');
  1729. style.innerHTML = `
  1730. .custom-tooltip {
  1731. position: absolute;
  1732. background: linear-gradient(to bottom, #e3eaef, #c7d5e0);
  1733. color: #30455a;
  1734. padding: 12px;
  1735. border-radius: 0px;
  1736. box-shadow: 0 0 12px #000;
  1737. font-size: 12px;
  1738. max-width: 300px;
  1739. display: none;
  1740. z-index: 1000;
  1741. opacity: 0;
  1742. transition: opacity 0.4s ease-in-out;
  1743. }
  1744. .tooltip-arrow {
  1745. position: absolute;
  1746. right: -9px;
  1747. top: 32px;
  1748. width: 0;
  1749. height: 0;
  1750. border-top: 10px solid transparent;
  1751. border-bottom: 10px solid transparent;
  1752. border-left: 10px solid #E1E8ED;
  1753. }
  1754. .catalog-positive {
  1755. color: #2B80E9;
  1756. }
  1757. .catalog-mixed {
  1758. color: #997a00;
  1759. }
  1760. .catalog-negative {
  1761. color: #E53E3E;
  1762. }
  1763. .catalog-no-reviews {
  1764. color: #929396;
  1765. }
  1766. .catalog-language-yes {
  1767. color: #2B80E9;
  1768. }
  1769. .catalog-language-no {
  1770. color: #E53E3E;
  1771. }
  1772. .catalog-early-access-yes {
  1773. color: #2B80E9;
  1774. }
  1775. .catalog-early-access-no {
  1776. color: #929396;
  1777. }
  1778. .search_result_row {
  1779. transition: opacity 0.5s ease-in-out, transform 0.5s ease-in-out;
  1780. }
  1781. .custom-tags-container {
  1782. display: flex;
  1783. flex-wrap: wrap;
  1784. gap: 3px;
  1785. margin-top: 6px;
  1786. }
  1787. .custom-tag {
  1788. background-color: #96a3ae;
  1789. color: #e3eaef;
  1790. padding: 0 4px;
  1791. border-radius: 2px;
  1792. font-size: 11px;
  1793. line-height: 19px;
  1794. white-space: nowrap;
  1795. overflow: hidden;
  1796. text-overflow: ellipsis;
  1797. max-width: 200px;
  1798. box-shadow: none;
  1799. margin-bottom: 3px;
  1800. }
  1801. `;
  1802. document.head.appendChild(style);
  1803. })();
  1804. }
  1805.  
  1806. // Скрипт скрытия игр на странице поиска по каталогу | https://store.steampowered.com/search/
  1807. if (scriptsConfig.catalogHider && window.location.pathname.includes('/search')) {
  1808. (function() {
  1809. "use strict";
  1810.  
  1811. function addBeetles() {
  1812. const scarabLinks = document.querySelectorAll("a.search_result_row:not(.ds_ignored):not(.ds_excluded_by_preferences):not(.ds_wishlist):not(.ds_owned)");
  1813. scarabLinks.forEach(link => {
  1814. if (link.querySelector(".my-checkbox")) return;
  1815. const ladybug = document.createElement("input");
  1816. ladybug.type = "checkbox";
  1817. ladybug.className = "my-checkbox";
  1818. ladybug.dataset.aphid = link.dataset.dsAppid;
  1819. link.insertBefore(ladybug, link.firstChild);
  1820.  
  1821. ladybug.addEventListener("change", function() {
  1822. link.style.background = this.checked ? "linear-gradient(to bottom, #381616, #5d1414)" : "";
  1823. });
  1824. });
  1825. }
  1826.  
  1827. function hideSelectedCrickets() {
  1828. const checkedLadybugs = document.querySelectorAll(".my-checkbox:checked");
  1829. checkedLadybugs.forEach(ladybug => {
  1830. const link = document.querySelector(`a[data-ds-appid="${ladybug.dataset.aphid}"]`);
  1831. if (link) {
  1832. link.classList.add("ds_ignored", "ds_flagged");
  1833. ladybug.remove();
  1834. jQuery.ajax({
  1835. url: "https://store.steampowered.com/recommended/ignorerecommendation/",
  1836. type: "POST",
  1837. data: {
  1838. sessionid: g_sessionID,
  1839. appid: ladybug.dataset.aphid,
  1840. remove: 0,
  1841. snr: "1_account_notinterested_",
  1842. },
  1843. success: () => {
  1844. console.log(`Game with appid ${ladybug.dataset.aphid} added to the ignore list`);
  1845. GDynamicStore.InvalidateCache();
  1846. },
  1847. });
  1848. }
  1849. });
  1850. updateAntCounter();
  1851. }
  1852.  
  1853. function removeIgnoredDragonflies() {
  1854. const ignoredGames = document.querySelectorAll("a.search_result_row.ds_ignored, a.search_result_row.ds_excluded_by_preferences,a.search_result_row.ds_wishlist");
  1855. ignoredGames.forEach(game => game.remove());
  1856. updateAntCounter();
  1857. }
  1858.  
  1859. function updateAntCounter() {
  1860. const scarabLinks = document.querySelectorAll("a.search_result_row:not(.ds_ignored):not(.ds_excluded_by_preferences):not(.ds_wishlist):not(.ds_owned)");
  1861. const termiteElement = document.querySelector(".game-counter");
  1862. if (termiteElement) {
  1863. termiteElement.textContent = `Игр осталось: ${scarabLinks.length}`;
  1864. }
  1865. }
  1866.  
  1867. const grasshopperButton = document.createElement("button");
  1868. grasshopperButton.textContent = "Скрыть выбранное";
  1869. grasshopperButton.addEventListener("click", hideSelectedCrickets);
  1870. grasshopperButton.classList.add("my-button", "floating-button");
  1871. document.body.appendChild(grasshopperButton);
  1872.  
  1873. const cockroach = document.createElement("div");
  1874. cockroach.textContent = "Игр осталось: 0";
  1875. cockroach.classList.add("game-counter", "floating-button");
  1876. document.body.appendChild(cockroach);
  1877.  
  1878.  
  1879.  
  1880. GM_addStyle(`
  1881. input[type=checkbox] {
  1882. -webkit-appearance: none;
  1883. -moz-appearance: none;
  1884. appearance: none;
  1885. border: 6px inset rgba(255, 0, 0, 0.8);
  1886. border-radius: 50%;
  1887. width: 42px;
  1888. height: 42px;
  1889. outline: none;
  1890. transition: .15s ease-in-out;
  1891. vertical-align: middle;
  1892. position: absolute;
  1893. left: 0px;
  1894. top: 50%;
  1895. transform: translateY(-50%);
  1896. background-color: rgba(0, 0, 0, 0.0);
  1897. box-shadow: inset 0 0 0 0 rgba(255, 255, 255, 0.5);
  1898. cursor: pointer;
  1899. z-index: 9999;
  1900. }
  1901. input[type=checkbox]:checked {
  1902. background-color: rgba(0, 0, 0, 0.5);
  1903. border-color: #b71c1c;
  1904. box-shadow: inset 0 0 0 12px rgba(255, 0, 0, 0.5);
  1905. }
  1906. input[type=checkbox]:after {
  1907. content: "";
  1908. display: block;
  1909. position: absolute;
  1910. left: 50%;
  1911. top: 50%;
  1912. transform: translate(-50%, -50%) scale(0);
  1913. width: 25px;
  1914. height: 25px;
  1915. border-radius: 50%;
  1916. background-color: rgba(0, 0, 0, 0.9);
  1917. opacity: 0.9;
  1918. box-shadow: 0 0 0 0 #b71c1c;
  1919. transition: transform .15s ease-in-out, box-shadow .15s ease-in-out;
  1920. }
  1921. input[type=checkbox]:checked:after {
  1922. transform: translate(-50%, -50%) scale(1);
  1923. box-shadow: 0 0 0 4px #b71c1c;
  1924. }
  1925.  
  1926. .my-button {
  1927. margin-right: 10px;
  1928. padding: 10px 20px;
  1929. border: none;
  1930. border-radius: 50px;
  1931. font-size: 16px;
  1932. font-weight: 700;
  1933. color: #fff;
  1934. background: linear-gradient(to right, #16202D, #1B2838);
  1935. box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
  1936. cursor: pointer;
  1937. font-family: "Roboto", sans-serif;
  1938. margin-top: 245px;
  1939. }
  1940. .my-button:hover {
  1941. background: linear-gradient(to right, #0072ff, #00c6ff);
  1942. box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
  1943. }
  1944. .floating-button {
  1945. position: fixed;
  1946. top: -189px;
  1947. left: 240px;
  1948. z-index: 1000;
  1949. }
  1950. .game-counter {
  1951. margin-right: 10px;
  1952. padding: 10px 20px;
  1953. border: none;
  1954. border-radius: 50px;
  1955. font-size: 16px;
  1956. font-weight: 700;
  1957. color: #fff;
  1958. background: linear-gradient(to right, #16202D, #1B2838);
  1959. box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
  1960. font-family: "Roboto", sans-serif;
  1961. margin-top: 195px;
  1962. }
  1963. `);
  1964.  
  1965. const butterflyObserver = new MutationObserver(mutations => {
  1966. mutations.forEach(mutation => {
  1967. if (mutation.type === "childList" && mutation.addedNodes.length) {
  1968. addBeetles();
  1969. removeIgnoredDragonflies();
  1970. updateAntCounter();
  1971. }
  1972. });
  1973. });
  1974.  
  1975. butterflyObserver.observe(document.body, {
  1976. childList: true,
  1977. subtree: true
  1978. });
  1979.  
  1980. addBeetles();
  1981. removeIgnoredDragonflies();
  1982. updateAntCounter();
  1983. })();
  1984. }
  1985.  
  1986. // Скрипт для скрытия новостей в новостном центре: | https://store.steampowered.com/news/
  1987. if (scriptsConfig.newsFilter && window.location.pathname.includes('/news')) {
  1988. (function() {
  1989. 'use strict';
  1990.  
  1991. const stromboliStyle = `
  1992. .etna-checkbox {
  1993. position: absolute;
  1994. top: 50%;
  1995. right: 10px;
  1996. width: 60px;
  1997. height: 60px;
  1998. border-radius: 50%;
  1999. border: 2px solid #66c0f4;
  2000. background-color: rgba(27, 40, 56, 0.7);
  2001. cursor: pointer;
  2002. z-index: 1000;
  2003. transform: translateY(-50%);
  2004. opacity: 0.5;
  2005. }
  2006. .etna-checkbox:checked {
  2007. background-color: rgba(102, 192, 244, 0.8);
  2008. }
  2009. .vesuvius-hide-button {
  2010. position: fixed;
  2011. top: 20px;
  2012. right: 20px;
  2013. padding: 15px 30px;
  2014. background-color: #66c0f4;
  2015. color: #fff;
  2016. border: none;
  2017. border-radius: 5px;
  2018. cursor: pointer;
  2019. z-index: 1000;
  2020. font-size: 18px;
  2021. transition: background-color 0.3s, box-shadow 0.3s;
  2022. }
  2023.  
  2024. .vesuvius-hide-button:hover {
  2025. background-color: #4a90e2;
  2026. box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
  2027. }
  2028. `;
  2029.  
  2030. const krakatoaStyleElement = document.createElement('style');
  2031. krakatoaStyleElement.innerHTML = stromboliStyle;
  2032. document.head.appendChild(krakatoaStyleElement);
  2033.  
  2034. function addEtnaCheckboxes(newsItems) {
  2035. newsItems.forEach(item => {
  2036. const fujiNewsLink = item.querySelector('a.Focusable[href^="/news/app/"]');
  2037. if (fujiNewsLink && !item.querySelector('.etna-checkbox')) {
  2038. const rainierCheckbox = document.createElement('input');
  2039. rainierCheckbox.type = 'checkbox';
  2040. rainierCheckbox.className = 'etna-checkbox';
  2041. rainierCheckbox.addEventListener('click', (event) => {
  2042. event.stopPropagation();
  2043. });
  2044. const kilimanjaroOverlayDiv = item.querySelector('._3HF9tOy_soo1B_odf1XArk');
  2045. if (kilimanjaroOverlayDiv) {
  2046. kilimanjaroOverlayDiv.style.position = 'relative';
  2047. kilimanjaroOverlayDiv.appendChild(rainierCheckbox);
  2048. }
  2049. }
  2050. });
  2051. }
  2052.  
  2053. function addVesuviusHideButton() {
  2054. const vesuviusHideButton = document.createElement('button');
  2055. vesuviusHideButton.className = 'vesuvius-hide-button';
  2056. vesuviusHideButton.textContent = 'Скрыть';
  2057. vesuviusHideButton.onclick = hideSelectedNews;
  2058. document.body.appendChild(vesuviusHideButton);
  2059. }
  2060.  
  2061. function hideSelectedNews() {
  2062. const maunaLoaCheckboxes = document.querySelectorAll('.etna-checkbox:checked');
  2063. maunaLoaCheckboxes.forEach(maunaLoaCheckbox => {
  2064. const newsItem = maunaLoaCheckbox.closest('._398u23KF15gxmeH741ZSyL');
  2065. const fujiNewsLink = newsItem.querySelector('a.Focusable[href^="/news/app/"]').getAttribute('href');
  2066. const shishaldinNewsTitle = newsItem.querySelector('._1M8-Pa3b3WboayCgd5VBJT').textContent;
  2067. const bakerNewsDate = new Date().toISOString();
  2068.  
  2069. const hiddenNews = JSON.parse(localStorage.getItem('hiddenNews') || '[]');
  2070. hiddenNews.push({
  2071. link: fujiNewsLink,
  2072. title: shishaldinNewsTitle,
  2073. date: bakerNewsDate
  2074. });
  2075. localStorage.setItem('hiddenNews', JSON.stringify(hiddenNews));
  2076.  
  2077. newsItem.remove();
  2078. });
  2079. }
  2080.  
  2081. function removeHiddenNews() {
  2082. const hiddenNews = JSON.parse(localStorage.getItem('hiddenNews') || '[]');
  2083. hiddenNews.forEach(news => {
  2084. const newsItem = document.querySelector(`a[href="${news.link}"]`)?.closest('._398u23KF15gxmeH741ZSyL');
  2085. if (newsItem) {
  2086. newsItem.remove();
  2087. }
  2088. });
  2089. }
  2090.  
  2091. function init() {
  2092. removeHiddenNews();
  2093. addEtnaCheckboxes(document.querySelectorAll('._398u23KF15gxmeH741ZSyL'));
  2094. addVesuviusHideButton();
  2095. }
  2096.  
  2097. setTimeout(init, 1000);
  2098.  
  2099. const erebusObserver = new MutationObserver((mutations) => {
  2100. mutations.forEach(mutation => {
  2101. if (mutation.type === 'childList') {
  2102. const newNewsItems = document.querySelectorAll('._398u23KF15gxmeH741ZSyL');
  2103. addEtnaCheckboxes(newNewsItems);
  2104. removeHiddenNews();
  2105. }
  2106. });
  2107. });
  2108.  
  2109. erebusObserver.observe(document.body, {
  2110. childList: true,
  2111. subtree: true
  2112. });
  2113.  
  2114. })();
  2115. }
  2116.  
  2117. // Скрипт для показа годовых и исторических продаж предмета на торговой площадке Steam | https://steamcommunity.com/market/listings/*
  2118. if (scriptsConfig.Kaznachei && window.location.pathname.includes('/market/listings/')) {
  2119. async function fetchSalesInfo() {
  2120. const urlParts = window.location.pathname.split('/');
  2121. const appId = urlParts[3];
  2122. const marketHashName = decodeURIComponent(urlParts[4]);
  2123. const apiUrl = `https://steamcommunity.com/market/pricehistory/?appid=${appId}&market_hash_name=${marketHashName}`;
  2124.  
  2125. try {
  2126. const response = await fetch(apiUrl);
  2127. const data = await response.json();
  2128.  
  2129. if (data.success) {
  2130. const salesData = data.prices;
  2131. const yearlySales = {};
  2132. let totalSales = 0;
  2133.  
  2134. salesData.forEach(sale => {
  2135. const date = sale[0];
  2136. const price = parseFloat(sale[1]);
  2137. const quantity = parseInt(sale[2]);
  2138. const year = date.split(' ')[2];
  2139.  
  2140. const totalForDay = price * quantity;
  2141.  
  2142. if (!yearlySales[year]) {
  2143. yearlySales[year] = {
  2144. total: 0,
  2145. commission: 0,
  2146. developerShare: 0,
  2147. valveShare: 0
  2148. };
  2149. }
  2150.  
  2151. yearlySales[year].total += totalForDay;
  2152. totalSales += totalForDay;
  2153. });
  2154.  
  2155. for (const year in yearlySales) {
  2156. const commission = yearlySales[year].total * 0.13;
  2157. const developerShare = commission * 0.6667;
  2158. const valveShare = commission * 0.3333;
  2159.  
  2160. yearlySales[year].commission = commission;
  2161. yearlySales[year].developerShare = developerShare;
  2162. yearlySales[year].valveShare = valveShare;
  2163. }
  2164.  
  2165. displaySalesInfo(yearlySales, totalSales);
  2166. } else {
  2167. console.error('Не удалось получить информацию о продажах.');
  2168. }
  2169. } catch (error) {
  2170. console.error('Ошибка при получении данных:', error);
  2171. }
  2172. }
  2173.  
  2174. function displaySalesInfo(yearlySales, totalSales) {
  2175. const salesInfoContainer = document.createElement('div');
  2176. salesInfoContainer.style.marginTop = '20px';
  2177. salesInfoContainer.style.padding = '10px';
  2178. salesInfoContainer.style.border = '1px solid #4a4a4a';
  2179. salesInfoContainer.style.backgroundColor = '#1b2838';
  2180. salesInfoContainer.style.borderRadius = '4px';
  2181. salesInfoContainer.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.5)';
  2182. salesInfoContainer.style.color = '#c7d5e0';
  2183.  
  2184. const spoilerHeader = document.createElement('div');
  2185. spoilerHeader.style.cursor = 'pointer';
  2186. spoilerHeader.style.padding = '10px';
  2187. spoilerHeader.style.backgroundColor = '#171a21';
  2188. spoilerHeader.style.borderRadius = '4px 4px 0 0';
  2189. spoilerHeader.style.color = '#c7d5e0';
  2190. spoilerHeader.style.fontWeight = 'bold';
  2191. spoilerHeader.style.fontFamily = '"Motiva Sans", sans-serif';
  2192. spoilerHeader.style.fontSize = '16px';
  2193. spoilerHeader.style.display = 'flex';
  2194. spoilerHeader.style.alignItems = 'center';
  2195. spoilerHeader.style.justifyContent = 'space-between';
  2196. spoilerHeader.innerHTML = 'Информация о продажах <span style="font-size: 12px; transform: rotate(0deg); transition: transform 0.3s ease;">&#9660;</span>';
  2197.  
  2198. spoilerHeader.addEventListener('click', () => {
  2199. const content = spoilerHeader.nextElementSibling;
  2200. content.style.display = content.style.display === 'none' ? 'block' : 'none';
  2201. const arrow = spoilerHeader.querySelector('span');
  2202. arrow.style.transform = content.style.display === 'none' ? 'rotate(0deg)' : 'rotate(180deg)';
  2203. });
  2204.  
  2205. const spoilerContent = document.createElement('div');
  2206. spoilerContent.style.display = 'none';
  2207. spoilerContent.style.padding = '10px';
  2208. spoilerContent.style.borderTop = '1px solid #4a4a4a';
  2209.  
  2210. const yearlySalesTable = document.createElement('table');
  2211. yearlySalesTable.style.width = '100%';
  2212. yearlySalesTable.style.borderCollapse = 'collapse';
  2213. yearlySalesTable.style.marginBottom = '20px';
  2214. yearlySalesTable.style.fontFamily = '"Motiva Sans", sans-serif';
  2215. yearlySalesTable.style.fontSize = '14px';
  2216.  
  2217. const yearlySalesHeader = document.createElement('tr');
  2218. yearlySalesHeader.innerHTML = '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #4a4a4a; background-color: #171a21; color: #c7d5e0;">Год</th><th style="padding: 8px; text-align: left; border-bottom: 2px solid #4a4a4a; background-color: #171a21; color: #c7d5e0;">Сумма продаж за год</th><th style="padding: 8px; text-align: left; border-bottom: 2px solid #4a4a4a; background-color: #171a21; color: #c7d5e0;">Ушло разработчику</th><th style="padding: 8px; text-align: left; border-bottom: 2px solid #4a4a4a; background-color: #171a21; color: #c7d5e0;">Ушло Valve</th>';
  2219. yearlySalesTable.appendChild(yearlySalesHeader);
  2220.  
  2221. for (const year in yearlySales) {
  2222. const row = document.createElement('tr');
  2223. row.innerHTML = `<td style="padding: 8px; border-bottom: 1px solid #4a4a4a; background-color: #1b2838; color: #c7d5e0;">${year}</td><td style="padding: 8px; border-bottom: 1px solid #4a4a4a; background-color: #1b2838; color: #c7d5e0;">${yearlySales[year].total.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.</td><td style="padding: 8px; border-bottom: 1px solid #4a4a4a; background-color: #1b2838; color: #c7d5e0;">${yearlySales[year].developerShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.</td><td style="padding: 8px; border-bottom: 1px solid #4a4a4a; background-color: #1b2838; color: #c7d5e0;">${yearlySales[year].valveShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.</td>`;
  2224. yearlySalesTable.appendChild(row);
  2225. }
  2226.  
  2227. const totalSalesParagraph = document.createElement('p');
  2228. totalSalesParagraph.textContent = `Сумма продаж за всё время: ${totalSales.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.`;
  2229. totalSalesParagraph.style.fontWeight = 'bold';
  2230. totalSalesParagraph.style.fontSize = '16px';
  2231. totalSalesParagraph.style.color = '#c7d5e0';
  2232. totalSalesParagraph.style.fontFamily = '"Motiva Sans", sans-serif';
  2233.  
  2234. const commission = totalSales * 0.13;
  2235. const developerShare = commission * 0.6667;
  2236. const valveShare = commission * 0.3333;
  2237.  
  2238. const developerShareParagraph = document.createElement('p');
  2239. developerShareParagraph.textContent = `Ушло разработчику: ${developerShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.`;
  2240. developerShareParagraph.style.fontSize = '14px';
  2241. developerShareParagraph.style.color = '#c7d5e0';
  2242. developerShareParagraph.style.fontFamily = '"Motiva Sans", sans-serif';
  2243.  
  2244. const valveShareParagraph = document.createElement('p');
  2245. valveShareParagraph.textContent = `Ушло Valve: ${valveShare.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} руб.`;
  2246. valveShareParagraph.style.fontSize = '14px';
  2247. valveShareParagraph.style.color = '#c7d5e0';
  2248. valveShareParagraph.style.fontFamily = '"Motiva Sans", sans-serif';
  2249.  
  2250. spoilerContent.appendChild(yearlySalesTable);
  2251. spoilerContent.appendChild(totalSalesParagraph);
  2252. spoilerContent.appendChild(developerShareParagraph);
  2253. spoilerContent.appendChild(valveShareParagraph);
  2254.  
  2255. salesInfoContainer.appendChild(spoilerHeader);
  2256. salesInfoContainer.appendChild(spoilerContent);
  2257.  
  2258. const marketHeaderBg = document.querySelector('.market_header_bg');
  2259. if (marketHeaderBg) {
  2260. marketHeaderBg.parentNode.insertBefore(salesInfoContainer, marketHeaderBg.nextSibling);
  2261. }
  2262. }
  2263.  
  2264. setTimeout(fetchSalesInfo, 100);
  2265. }
  2266.  
  2267. // Скрипт для получения дополнительной информации об игре при наведении на неё на странице вашей активности Steam
  2268. if (scriptsConfig.homeInfo && window.location.href.includes('steamcommunity.com') && window.location.pathname.includes('/home')) {
  2269. (function() {
  2270. 'use strict';
  2271.  
  2272. const MOREL_API_URL = "https://api.steampowered.com/IStoreBrowseService/GetItems/v1";
  2273. const CHANTERELLE_WAIT_TIME = 2000;
  2274. const PORCINI_VISIBLE_ELEMENTS_SELECTOR = "a[href*='/app/'], a[data-appid]";
  2275. const TRUFFLE_HOVER_ELEMENT_SELECTOR = "a[href*='/app/'], a[data-appid]";
  2276.  
  2277. let SHIITAKE_collectedAppIds = new Set();
  2278. let ENOKI_tooltip = null;
  2279. let MAITAKE_hoverTimer = null;
  2280. let HEN_OF_THE_WOODS_hideTimer = null;
  2281.  
  2282. const MUSHROOM_GAME_DATA = {};
  2283.  
  2284. const STEAM_TAGS_CACHE_KEY = 'SteamEnhancer_TagsCache_v2';
  2285. const STEAM_TAGS_URL = "https://gist.githubusercontent.com/0wn3dg0d/22a351ff4c65e50a9a8af6da360defad/raw/steamrutagsownd.json";
  2286.  
  2287. function fetchGameData(appIds) {
  2288. const inputJson = {
  2289. ids: Array.from(appIds).map(appid => ({
  2290. appid
  2291. })),
  2292. context: {
  2293. language: "russian",
  2294. country_code: "US",
  2295. steam_realm: 1
  2296. },
  2297. data_request: {
  2298. include_assets: true,
  2299. include_release: true,
  2300. include_platforms: true,
  2301. include_all_purchase_options: true,
  2302. include_screenshots: true,
  2303. include_trailers: true,
  2304. include_ratings: true,
  2305. include_tag_count: true,
  2306. include_reviews: true,
  2307. include_basic_info: true,
  2308. include_supported_languages: true,
  2309. include_full_description: true,
  2310. include_included_items: true,
  2311. included_item_data_request: {
  2312. include_assets: true,
  2313. include_release: true,
  2314. include_platforms: true,
  2315. include_all_purchase_options: true,
  2316. include_screenshots: true,
  2317. include_trailers: true,
  2318. include_ratings: true,
  2319. include_tag_count: true,
  2320. include_reviews: true,
  2321. include_basic_info: true,
  2322. include_supported_languages: true,
  2323. include_full_description: true,
  2324. include_included_items: true,
  2325. include_assets_without_overrides: true,
  2326. apply_user_filters: false,
  2327. include_links: true
  2328. },
  2329. include_assets_without_overrides: true,
  2330. apply_user_filters: false,
  2331. include_links: true
  2332. }
  2333. };
  2334.  
  2335. GM_xmlhttpRequest({
  2336. method: "GET",
  2337. url: `${MOREL_API_URL}?input_json=${encodeURIComponent(JSON.stringify(inputJson))}`,
  2338. onload: function(response) {
  2339. const data = JSON.parse(response.responseText);
  2340. processGameData(data);
  2341. }
  2342. });
  2343. }
  2344.  
  2345. function processGameData(data) {
  2346. const items = data.response.store_items;
  2347. items.forEach(item => {
  2348. const appId = item.id;
  2349. MUSHROOM_GAME_DATA[appId] = {
  2350. name: item.name,
  2351. is_early_access: item.is_early_access,
  2352. review_count: item.reviews?.summary_filtered?.review_count,
  2353. percent_positive: item.reviews?.summary_filtered?.percent_positive,
  2354. short_description: item.basic_info?.short_description,
  2355. publishers: item.basic_info?.publishers?.map(p => p.name).join(", "),
  2356. developers: item.basic_info?.developers?.map(d => d.name).join(", "),
  2357. franchises: item.basic_info?.franchises?.map(f => f.name).join(", "),
  2358. tagids: item.tagids || [],
  2359. language_support_russian: item.supported_languages?.find(lang => lang.elanguage === 8),
  2360. language_support_english: item.supported_languages?.find(lang => lang.elanguage === 0),
  2361. release_date: item.release?.steam_release_date ? new Date(item.release.steam_release_date * 1000).toLocaleDateString() : "Нет данных"
  2362. };
  2363. });
  2364. }
  2365.  
  2366. function collectAndFetchAppIds() {
  2367. const visibleElements = document.querySelectorAll(PORCINI_VISIBLE_ELEMENTS_SELECTOR);
  2368. const newAppIds = new Set();
  2369.  
  2370. visibleElements.forEach(element => {
  2371. const appId = element.dataset.appid || element.href.match(/app\/(\d+)/)?.[1];
  2372. if (appId && !SHIITAKE_collectedAppIds.has(appId)) {
  2373. newAppIds.add(parseInt(appId, 10));
  2374. SHIITAKE_collectedAppIds.add(appId);
  2375. }
  2376. });
  2377.  
  2378. if (newAppIds.size > 0) {
  2379. fetchGameData(newAppIds);
  2380. }
  2381. }
  2382.  
  2383. function handleHover(event) {
  2384. const gameElement = event.target.closest(TRUFFLE_HOVER_ELEMENT_SELECTOR);
  2385.  
  2386. if (gameElement) {
  2387. const appId = gameElement.dataset.appid || gameElement.href.match(/app\/(\d+)/)?.[1];
  2388. if (appId && MUSHROOM_GAME_DATA[appId]) {
  2389. clearTimeout(MAITAKE_hoverTimer);
  2390. clearTimeout(HEN_OF_THE_WOODS_hideTimer);
  2391.  
  2392. MAITAKE_hoverTimer = setTimeout(() => {
  2393. displayGameInfo(gameElement, MUSHROOM_GAME_DATA[appId], appId);
  2394. }, 300);
  2395. } else {
  2396. clearTimeout(MAITAKE_hoverTimer);
  2397. clearTimeout(HEN_OF_THE_WOODS_hideTimer);
  2398. if (ENOKI_tooltip) {
  2399. ENOKI_tooltip.style.opacity = 0;
  2400. setTimeout(() => {
  2401. ENOKI_tooltip.style.display = 'none';
  2402. }, 300);
  2403. }
  2404. }
  2405. }
  2406. }
  2407.  
  2408. function getReviewClassCatalog(percent, totalReviews) {
  2409. if (totalReviews === 0) return 'mushroom-no-reviews';
  2410. if (percent >= 70) return 'mushroom-positive';
  2411. if (percent >= 40) return 'mushroom-mixed';
  2412. if (percent >= 1) return 'mushroom-negative';
  2413. return 'mushroom-negative';
  2414. }
  2415.  
  2416.  
  2417. async function loadSteamTags() {
  2418. const cached = GM_getValue(STEAM_TAGS_CACHE_KEY, {
  2419. data: null,
  2420. timestamp: 0
  2421. });
  2422. const now = Date.now();
  2423. const CACHE_DURATION = 744 * 60 * 60 * 1000;
  2424.  
  2425. if (cached.data && (now - cached.timestamp) < CACHE_DURATION) {
  2426. return cached.data;
  2427. }
  2428.  
  2429. try {
  2430. const response = await new Promise((resolve, reject) => {
  2431. GM_xmlhttpRequest({
  2432. method: "GET",
  2433. url: STEAM_TAGS_URL,
  2434. onload: resolve,
  2435. onerror: reject
  2436. });
  2437. });
  2438.  
  2439. if (response.status === 200) {
  2440. const data = JSON.parse(response.responseText);
  2441. GM_setValue(STEAM_TAGS_CACHE_KEY, {
  2442. data: data,
  2443. timestamp: now
  2444. });
  2445. return data;
  2446. }
  2447. } catch (e) {
  2448. console.error('Ошибка загрузки тегов:', e);
  2449. return cached.data || {};
  2450. }
  2451.  
  2452. return {};
  2453. }
  2454.  
  2455. async function displayGameInfo(element, data, appId) {
  2456. if (!ENOKI_tooltip) {
  2457. ENOKI_tooltip = document.createElement('div');
  2458. ENOKI_tooltip.className = 'mushroom-tooltip';
  2459. ENOKI_tooltip.innerHTML = '<div class="tooltip-arrow"></div><div class="tooltip-content"></div>';
  2460. document.body.appendChild(ENOKI_tooltip);
  2461. }
  2462.  
  2463. const tooltipContent = ENOKI_tooltip.querySelector('.tooltip-content');
  2464.  
  2465. let languageSupportRussianText = "Отсутствует";
  2466. let languageSupportRussianClass = 'mushroom-language-no';
  2467. if (data.language_support_russian) {
  2468. languageSupportRussianText = "";
  2469. if (data.language_support_russian.supported) languageSupportRussianText += "<br>Интерфейс: ✔ ";
  2470. if (data.language_support_russian.full_audio) languageSupportRussianText += "<br>Озвучка: ✔ ";
  2471. if (data.language_support_russian.subtitles) languageSupportRussianText += "<br>Субтитры: ✔";
  2472. if (languageSupportRussianText === "") languageSupportRussianText = "Отсутствует";
  2473. else languageSupportRussianClass = 'mushroom-language-yes';
  2474. }
  2475.  
  2476. let languageSupportEnglishText = "Отсутствует";
  2477. let languageSupportEnglishClass = 'mushroom-language-no';
  2478. if (scriptsConfig.toggleEnglishLangInfo && data.language_support_english) {
  2479. languageSupportEnglishText = "";
  2480. if (data.language_support_english.supported) languageSupportEnglishText += "<br>Интерфейс: ✔ ";
  2481. if (data.language_support_english.full_audio) languageSupportEnglishText += "<br>Озвучка: ✔ ";
  2482. if (data.language_support_english.subtitles) languageSupportEnglishText += "<br>Субтитры: ✔";
  2483. if (languageSupportEnglishText === "") languageSupportEnglishText = "Отсутствует";
  2484. else languageSupportEnglishClass = 'mushroom-language-yes';
  2485. }
  2486.  
  2487. const reviewClass = getReviewClassCatalog(data.percent_positive, data.review_count);
  2488. const earlyAccessClass = data.is_early_access ? 'mushroom-early-access-yes' : 'mushroom-early-access-no';
  2489.  
  2490. async function getTagNames(tagIds) {
  2491. const tagsData = await loadSteamTags();
  2492. return tagIds.slice(0, 5).map(tagId =>
  2493. tagsData[tagId] || `Тег #${tagId}`
  2494. );
  2495. }
  2496.  
  2497. const tags = await getTagNames(data.tagids || []);
  2498. const tagsHtml = tags.map(tag =>
  2499. `<div class="mushroom-tag">${tag}</div>`
  2500. ).join('');
  2501.  
  2502. tooltipContent.innerHTML = `
  2503. <div style="margin-bottom: 10px;"><strong>Название:</strong> ${data.name || "Нет данных"}</div>
  2504. <div style="margin-bottom: 10px;"><img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appId}/header.jpg" alt="${data.name}" style="width: 50%; height: auto;"></div>
  2505. <div style="margin-bottom: 10px;"><strong>Дата выхода:</strong> ${data.release_date}</div>
  2506. <div style="margin-bottom: 0px;"><strong>Издатели:</strong> <span class="${!data.publishers ? 'mushroom-no-reviews' : ''}">${data.publishers || "Нет данных"}</span></div>
  2507. <div style="margin-bottom: 0px;"><strong>Разработчики:</strong> <span class="${!data.developers ? 'mushroom-no-reviews' : ''}">${data.developers || "Нет данных"}</span></div>
  2508. <div style="margin-bottom: 10px;"><strong>Серия игр:</strong> <span class="${!data.franchises ? 'mushroom-no-reviews' : ''}">${data.franchises || "Нет данных"}</span></div>
  2509. <div style="margin-bottom: 10px;"><strong>Отзывы: </strong><span id="reviewCount">${data.review_count || "0"} </span><span class="${reviewClass}">(${data.percent_positive || "0"}% положительных)</span></div>
  2510. <div style="margin-bottom: 10px;"><strong>Ранний доступ:</strong> <span class="${earlyAccessClass}">${data.is_early_access ? "Да" : "Нет"}</span></div>
  2511. <div style="margin-bottom: 10px;"><strong>Русский язык:</strong> <span class="${languageSupportRussianClass}">${languageSupportRussianText}</span></div>
  2512. ${scriptsConfig.toggleEnglishLangInfo ? `<div style="margin-bottom: 10px;"><strong>Английский язык:</strong> <span class="${languageSupportEnglishClass}">${languageSupportEnglishText}</span></div>` : ''}
  2513. <div style="margin-bottom: 10px;"><strong>Метки:</strong><br>
  2514. <div class="mushroom-tags-container">${tagsHtml}</div></div>
  2515. <div style="margin-bottom: 10px;"><strong>Описание:</strong> <span class="${!data.short_description ? 'mushroom-no-reviews' : ''}">${data.short_description || "Нет данных"}</span></div>
  2516. `;
  2517.  
  2518. ENOKI_tooltip.style.display = 'block';
  2519.  
  2520. const blotterDayElement = document.querySelector('.blotter_day');
  2521. if (blotterDayElement) {
  2522. const blotterRect = blotterDayElement.getBoundingClientRect();
  2523. const tooltipRect = ENOKI_tooltip.getBoundingClientRect();
  2524.  
  2525. ENOKI_tooltip.style.left = `${blotterRect.left - tooltipRect.width - 5}px`;
  2526. ENOKI_tooltip.style.top = `${element.getBoundingClientRect().top + window.scrollY - 35}px`;
  2527. }
  2528.  
  2529. ENOKI_tooltip.style.opacity = 0;
  2530. ENOKI_tooltip.style.display = 'block';
  2531. setTimeout(() => {
  2532. ENOKI_tooltip.style.opacity = 1;
  2533. }, 10);
  2534.  
  2535. element.addEventListener('mouseleave', () => {
  2536. clearTimeout(HEN_OF_THE_WOODS_hideTimer);
  2537. HEN_OF_THE_WOODS_hideTimer = setTimeout(() => {
  2538. ENOKI_tooltip.style.opacity = 0;
  2539. setTimeout(() => {
  2540. ENOKI_tooltip.style.display = 'none';
  2541. }, 300);
  2542. }, 200);
  2543. }, {
  2544. once: true
  2545. });
  2546.  
  2547. element.addEventListener('mouseover', () => {
  2548. clearTimeout(HEN_OF_THE_WOODS_hideTimer);
  2549. });
  2550. }
  2551.  
  2552. function observeNewElements() {
  2553. const observer = new MutationObserver((mutations) => {
  2554. mutations.forEach(mutation => {
  2555. if (mutation.type === 'childList') {
  2556. collectAndFetchAppIds();
  2557. }
  2558. });
  2559. });
  2560.  
  2561. observer.observe(document.body, {
  2562. childList: true,
  2563. subtree: true
  2564. });
  2565. }
  2566.  
  2567. function initialize() {
  2568. setTimeout(() => {
  2569. collectAndFetchAppIds();
  2570. observeNewElements();
  2571. document.addEventListener('mouseover', handleHover);
  2572. }, CHANTERELLE_WAIT_TIME);
  2573. }
  2574.  
  2575. initialize();
  2576.  
  2577. const style = document.createElement('style');
  2578. style.innerHTML = `
  2579. .mushroom-tooltip {
  2580. position: absolute;
  2581. background: linear-gradient(to bottom, #e3eaef, #c7d5e0);
  2582. color: #30455a;
  2583. padding: 12px;
  2584. border-radius: 0px;
  2585. box-shadow: 0 0 12px #000;
  2586. font-size: 12px;
  2587. max-width: 300px;
  2588. display: none;
  2589. z-index: 1000;
  2590. opacity: 0;
  2591. transition: opacity 0.4s ease-in-out;
  2592. }
  2593. .tooltip-arrow {
  2594. position: absolute;
  2595. right: -9px;
  2596. top: 32px;
  2597. width: 0;
  2598. height: 0;
  2599. border-top: 10px solid transparent;
  2600. border-bottom: 10px solid transparent;
  2601. border-left: 10px solid #E1E8ED;
  2602. }
  2603. .mushroom-positive {
  2604. color: #2B80E9;
  2605. }
  2606. .mushroom-mixed {
  2607. color: #997a00;
  2608. }
  2609. .mushroom-negative {
  2610. color: #E53E3E;
  2611. }
  2612. .mushroom-no-reviews {
  2613. color: #929396;
  2614. }
  2615. .mushroom-language-yes {
  2616. color: #2B80E9;
  2617. }
  2618. .mushroom-language-no {
  2619. color: #E53E3E;
  2620. }
  2621. .mushroom-early-access-yes {
  2622. color: #2B80E9;
  2623. }
  2624. .mushroom-early-access-no {
  2625. color: #929396;
  2626. }
  2627. .mushroom-tags-container {
  2628. display: flex;
  2629. flex-wrap: wrap;
  2630. gap: 3px;
  2631. margin-top: 6px;
  2632. }
  2633. .mushroom-tag {
  2634. background-color: #96a3ae;
  2635. color: #e3eaef;
  2636. padding: 0 4px;
  2637. border-radius: 2px;
  2638. font-size: 11px;
  2639. line-height: 19px;
  2640. white-space: nowrap;
  2641. overflow: hidden;
  2642. text-overflow: ellipsis;
  2643. max-width: 200px;
  2644. box-shadow: none;
  2645. margin-bottom: 3px;
  2646. }
  2647. `;
  2648. document.head.appendChild(style);
  2649. })();
  2650. }
  2651.  
  2652. // Скрипт для страницы игры (ZOG; получение сведений о наличии русификаторов) | https://store.steampowered.com/app/*
  2653. if (window.location.pathname.includes('/app/') && scriptsConfig.zogInfo) {
  2654. (async function() {
  2655. const ZOG_CACHE_KEY = 'ZoGRusekiEdrit';
  2656. const ZOG_DATA_URL = 'https://gist.githubusercontent.com/0wn3dg0d/7baa8d9f42b0304fe303e903d44d2ada/raw/zogrusbase.json';
  2657.  
  2658. const zogBlock = document.createElement('div');
  2659. Object.assign(zogBlock.style, {
  2660. position: 'absolute',
  2661. left: '334px',
  2662. width: '30px',
  2663. height: '30px',
  2664. background: 'rgba(27, 40, 56, 0.95)',
  2665. padding: '15px',
  2666. borderRadius: '4px',
  2667. border: '1px solid #3c3c3c',
  2668. boxShadow: '0 0 10px rgba(0, 0, 0, 0.5)',
  2669. zIndex: '2',
  2670. fontFamily: 'Arial, sans-serif',
  2671. overflow: 'hidden',
  2672. transition: 'all 0.3s ease'
  2673. });
  2674.  
  2675. let hltbBlock = null;
  2676. let hltbObserver = null;
  2677. let zogMap = null;
  2678. let zogNameMap = null;
  2679.  
  2680. const updatePosition = () => {
  2681. hltbBlock = document.querySelector('#gameHeaderImageCtn > div[style*="background: rgba(27, 40, 56, 0.95)"]');
  2682.  
  2683. const russianIndicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
  2684.  
  2685. if (hltbBlock && scriptsConfig.hltbData) {
  2686. zogBlock.style.top = `${hltbBlock.offsetTop + hltbBlock.offsetHeight + 16}px`;
  2687. } else if (russianIndicators && scriptsConfig.gamePage) {
  2688. zogBlock.style.top = `${russianIndicators.offsetTop + russianIndicators.offsetHeight + 16}px`;
  2689. } else {
  2690. const headerImage = document.querySelector('#gameHeaderImageCtn');
  2691. if (headerImage) {
  2692. zogBlock.style.top = `${0}px`;
  2693. }
  2694. }
  2695.  
  2696. zogBlock.style.left = '334px';
  2697. zogBlock.style.zIndex = '2';
  2698. };
  2699.  
  2700. const initObservers = () => {
  2701. if (scriptsConfig.hltbData) {
  2702. hltbBlock = document.querySelector('#gameHeaderImageCtn > div[style*="background: rgba(27, 40, 56, 0.95)"]');
  2703. if (hltbBlock && !hltbObserver) {
  2704. hltbObserver = new ResizeObserver(updatePosition);
  2705. hltbObserver.observe(hltbBlock);
  2706. hltbBlock.addEventListener('transitionend', updatePosition);
  2707. }
  2708. }
  2709.  
  2710. if (scriptsConfig.gamePage) {
  2711. const russianObserver = new MutationObserver((mutations) => {
  2712. mutations.forEach(mutation => {
  2713. if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
  2714. updatePosition();
  2715. }
  2716. });
  2717. });
  2718.  
  2719. const indicators = document.querySelector('#gameHeaderImageCtn > div[style*="position: absolute; top: -10px; left: calc(100% + 10px);"]');
  2720. if (indicators) {
  2721. russianObserver.observe(indicators, {
  2722. attributes: true,
  2723. attributeFilter: ['style']
  2724. });
  2725. }
  2726. }
  2727.  
  2728. const generalObserver = new MutationObserver((mutations) => {
  2729. mutations.forEach(mutation => {
  2730. if (mutation.type === 'childList') {
  2731. updatePosition();
  2732. initObservers();
  2733. }
  2734. });
  2735. });
  2736.  
  2737. generalObserver.observe(document.querySelector('#gameHeaderImageCtn'), {
  2738. childList: true,
  2739. subtree: true
  2740. });
  2741. };
  2742.  
  2743. async function loadZogData() {
  2744. const cached = GM_getValue(ZOG_CACHE_KEY);
  2745. const lastUpdated = cached?.lastUpdated || '';
  2746.  
  2747. try {
  2748. const metaResponse = await new Promise((resolve, reject) => {
  2749. GM_xmlhttpRequest({
  2750. method: 'GET',
  2751. url: 'https://api.github.com/gists/7baa8d9f42b0304fe303e903d44d2ada',
  2752. onload: resolve,
  2753. onerror: reject
  2754. });
  2755. });
  2756.  
  2757. const metaData = JSON.parse(metaResponse.responseText);
  2758.  
  2759. const newLastUpdated = metaData.updated_at;
  2760.  
  2761. if (newLastUpdated === lastUpdated) {
  2762. return cached.data;
  2763. }
  2764.  
  2765. const dataResponse = await new Promise((resolve, reject) => {
  2766. GM_xmlhttpRequest({
  2767. method: 'GET',
  2768. url: ZOG_DATA_URL,
  2769. onload: resolve,
  2770. onerror: reject
  2771. });
  2772. });
  2773.  
  2774. const newData = JSON.parse(dataResponse.responseText);
  2775.  
  2776. GM_setValue(ZOG_CACHE_KEY, {
  2777. lastUpdated: newLastUpdated,
  2778. data: newData,
  2779. timestamp: Date.now()
  2780. });
  2781.  
  2782. return newData;
  2783. } catch (error) {
  2784. console.error('Ошибка загрузки данных ZOG:', error);
  2785. return cached?.data || [];
  2786. }
  2787. }
  2788.  
  2789. async function initZogData() {
  2790. try {
  2791. const data = await loadZogData();
  2792. zogMap = new Map(data.map(item => [item.app_id, item]));
  2793. zogNameMap = new Map(data.map(item => [
  2794. item.title
  2795. ?.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
  2796. .replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
  2797. .toLowerCase(),
  2798. item
  2799. ]));
  2800. } catch (e) {
  2801. console.error('Ошибка инициализации данных ZOG:', e);
  2802. content.textContent = 'Ошибка загрузки базы';
  2803. }
  2804. }
  2805.  
  2806. const title = document.createElement('div');
  2807. Object.assign(title.style, {
  2808. fontSize: '12px',
  2809. fontWeight: 'bold',
  2810. color: '#67c1f5',
  2811. marginBottom: '10px',
  2812. cursor: 'pointer'
  2813. });
  2814. title.textContent = 'ZOG';
  2815.  
  2816. const content = document.createElement('div');
  2817. Object.assign(content.style, {
  2818. display: 'none',
  2819. color: '#c6d4df',
  2820. fontSize: '14px',
  2821. maxWidth: '300px',
  2822. overflowY: 'auto',
  2823. whiteSpace: 'normal',
  2824. lineHeight: '1.4',
  2825. padding: '0 5px'
  2826. });
  2827.  
  2828. const arrow = createArrow();
  2829.  
  2830. zogBlock.append(arrow, title, content);
  2831. document.querySelector('#gameHeaderImageCtn').appendChild(zogBlock);
  2832.  
  2833. initObservers();
  2834. updatePosition();
  2835. await initZogData();
  2836.  
  2837. title.onclick = () => toggleBlock(arrow);
  2838. arrow.onclick = () => toggleBlock(arrow);
  2839.  
  2840. async function toggleBlock(arrowElement) {
  2841. if (content.style.display === 'none') {
  2842. await expandBlock(arrowElement);
  2843. } else {
  2844. collapseBlock(arrowElement);
  2845. }
  2846. }
  2847.  
  2848. async function expandBlock(arrowElement) {
  2849. if (!zogMap || !zogNameMap) {
  2850. console.error('Данные ZOG не инициализированы');
  2851. return;
  2852. }
  2853.  
  2854. zogBlock.style.transition = 'width 0.3s ease, height 0.3s ease';
  2855. zogBlock.style.width = '300px';
  2856. zogBlock.style.height = '40px';
  2857.  
  2858. arrowElement.style.transform = 'translateX(-50%) rotate(180deg)';
  2859. await new Promise(resolve => setTimeout(resolve, 300));
  2860.  
  2861. content.style.display = 'block';
  2862. content.textContent = 'Ищем в базе...';
  2863.  
  2864. await new Promise(resolve => requestAnimationFrame(resolve));
  2865.  
  2866. const appId = getAppId();
  2867. let entry = zogMap.get(appId);
  2868.  
  2869. if (!entry) {
  2870. const gameName = getGameName()
  2871. .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
  2872. .replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
  2873. .toLowerCase();
  2874.  
  2875. content.textContent = 'Ищем углубленно...';
  2876. await new Promise(resolve => requestAnimationFrame(resolve));
  2877.  
  2878. entry = zogNameMap.get(gameName);
  2879.  
  2880. if (!entry && /[а-яё]/i.test(gameName)) {
  2881. content.textContent = 'Запрашиваем англ. название...';
  2882. await new Promise(resolve => requestAnimationFrame(resolve));
  2883.  
  2884. const steamApiUrl = `https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json={"ids": [{"appid": ${appId}}], "context": {"language": "english", "country_code": "US", "steam_realm": 1}, "data_request": {"include_assets": true}}`;
  2885.  
  2886. try {
  2887. const steamResponse = await new Promise((resolve, reject) => {
  2888. GM_xmlhttpRequest({
  2889. method: "GET",
  2890. url: steamApiUrl,
  2891. onload: resolve,
  2892. onerror: reject
  2893. });
  2894. });
  2895.  
  2896. if (steamResponse.status === 200) {
  2897. const steamData = JSON.parse(steamResponse.responseText);
  2898. const englishName = steamData.response.store_items[0]?.name;
  2899.  
  2900. if (englishName) {
  2901. const cleanEnglishName = englishName
  2902. .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
  2903. .replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
  2904. .toLowerCase();
  2905.  
  2906. content.textContent = 'Проверяем англ. название...';
  2907. await new Promise(resolve => requestAnimationFrame(resolve));
  2908.  
  2909. entry = zogNameMap.get(cleanEnglishName);
  2910.  
  2911. if (!entry) {
  2912. content.textContent = 'Проверяем возможные совпадения...';
  2913. await new Promise(resolve => requestAnimationFrame(resolve));
  2914.  
  2915. const possibleMatches = findPossibleMatches(cleanEnglishName, Array.from(zogNameMap.values()));
  2916. if (possibleMatches.length > 0) {
  2917. renderPossibleMatches(possibleMatches);
  2918. zogBlock.style.height = `${content.scrollHeight + 30}px`;
  2919. updatePosition();
  2920. return;
  2921. }
  2922. }
  2923. }
  2924. }
  2925. } catch (error) {
  2926. console.error('Ошибка при запросе к Steam API:', error);
  2927. }
  2928. }
  2929. }
  2930.  
  2931. if (!entry) {
  2932. content.textContent = 'Проверяем возможные совпадения...';
  2933. await new Promise(resolve => requestAnimationFrame(resolve));
  2934. const possibleMatches = findPossibleMatches(getGameName(), Array.from(zogNameMap.values()));
  2935. if (possibleMatches.length > 0) {
  2936. renderPossibleMatches(possibleMatches);
  2937. zogBlock.style.height = `${content.scrollHeight + 30}px`;
  2938. updatePosition();
  2939. return;
  2940. }
  2941. }
  2942.  
  2943. renderContent(entry);
  2944. zogBlock.style.height = `${content.scrollHeight + 30}px`;
  2945. updatePosition();
  2946. }
  2947.  
  2948. function nextFrame() {
  2949. return new Promise(resolve => requestAnimationFrame(resolve));
  2950. }
  2951.  
  2952. function collapseBlock(arrowElement) {
  2953. zogBlock.style.transition = 'width 0.3s ease, height 0.3s ease';
  2954. zogBlock.style.width = '30px';
  2955. zogBlock.style.height = '30px';
  2956.  
  2957. arrowElement.style.transform = 'translateX(-50%) rotate(0deg)';
  2958.  
  2959. content.style.display = 'none';
  2960. updatePosition();
  2961. }
  2962.  
  2963. function renderContent(entry) {
  2964. content.innerHTML = '';
  2965.  
  2966. if (!entry) {
  2967. content.textContent = 'Игра не найдена в базе ZOG';
  2968. return;
  2969. }
  2970.  
  2971. const titleLink = document.createElement('a');
  2972. titleLink.href = `https://www.zoneofgames.ru/games/${entry.id}.html`;
  2973. titleLink.target = '_blank';
  2974. titleLink.textContent = entry.title || 'Без названия';
  2975. titleLink.style.color = '#67c1f5';
  2976. titleLink.style.wordBreak = 'break-word';
  2977. content.appendChild(titleLink);
  2978.  
  2979. const list = document.createElement('ul');
  2980. list.style.paddingLeft = '15px';
  2981. list.style.marginTop = '5px';
  2982. list.style.marginBottom = '0';
  2983.  
  2984. if (entry.localizations?.length > 0) {
  2985. entry.localizations.forEach(loc => {
  2986. const li = document.createElement('li');
  2987. li.style.marginBottom = '8px';
  2988.  
  2989. const link = document.createElement('a');
  2990. link.href = loc.link;
  2991. link.target = '_blank';
  2992. link.textContent = `${loc.name} ${loc.size || ''}`;
  2993. link.style.color = '#c6d4df';
  2994. link.style.wordBreak = 'break-word';
  2995. link.style.textDecoration = 'none';
  2996.  
  2997. li.appendChild(link);
  2998. list.appendChild(li);
  2999. });
  3000. } else {
  3001. list.textContent = 'Русификаторы отсутствуют';
  3002. list.style.color = '#999';
  3003. }
  3004.  
  3005. content.appendChild(list);
  3006. }
  3007.  
  3008. function renderPossibleMatches(matches) {
  3009. content.innerHTML = '';
  3010.  
  3011. const title = document.createElement('div');
  3012. title.textContent = 'Возможные совпадения:';
  3013. title.style.color = '#67c1f5';
  3014. title.style.marginBottom = '10px';
  3015. content.appendChild(title);
  3016.  
  3017. const list = document.createElement('ul');
  3018. list.style.paddingLeft = '15px';
  3019. list.style.marginTop = '5px';
  3020. list.style.marginBottom = '0';
  3021.  
  3022. matches.forEach(match => {
  3023. const li = document.createElement('li');
  3024. li.style.marginBottom = '8px';
  3025.  
  3026. const link = document.createElement('a');
  3027. link.href = `https://www.zoneofgames.ru/games/${match.id}.html`;
  3028. link.target = '_blank';
  3029. link.textContent = `${match.title} (${match.percentage}%)`;
  3030. link.style.color = '#c6d4df';
  3031. link.style.wordBreak = 'break-word';
  3032. link.style.textDecoration = 'none';
  3033. link.onclick = () => {
  3034. renderContent(match);
  3035. zogBlock.style.height = `${content.scrollHeight + 30}px`;
  3036. updatePosition();
  3037. return false;
  3038. };
  3039.  
  3040. li.appendChild(link);
  3041. list.appendChild(li);
  3042. });
  3043.  
  3044. const noMatch = document.createElement('li');
  3045. noMatch.style.marginBottom = '8px';
  3046.  
  3047. const noMatchLink = document.createElement('a');
  3048. noMatchLink.href = '#';
  3049. noMatchLink.textContent = 'Ничего не подходит';
  3050. noMatchLink.style.color = '#c6d4df';
  3051. noMatchLink.style.wordBreak = 'break-word';
  3052. noMatchLink.style.textDecoration = 'none';
  3053. noMatchLink.onclick = () => {
  3054. renderContent(null);
  3055. zogBlock.style.height = `${content.scrollHeight + 30}px`;
  3056. updatePosition();
  3057. return false;
  3058. };
  3059.  
  3060. noMatch.appendChild(noMatchLink);
  3061. list.appendChild(noMatch);
  3062.  
  3063. content.appendChild(list);
  3064. }
  3065.  
  3066. function findPossibleMatches(gameName, data) {
  3067. const cleanGameName = gameName
  3068. .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
  3069. .replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
  3070. .toLowerCase();
  3071.  
  3072. return data
  3073. .map(item => {
  3074. const cleanItemName = item.title
  3075. .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
  3076. .replace(/[^a-zа-яё0-9 _'\-!]/gi, '')
  3077. .toLowerCase();
  3078.  
  3079. const similarity = calculateSimilarity(cleanGameName, cleanItemName);
  3080. const startsWith = cleanItemName.startsWith(cleanGameName);
  3081.  
  3082. return {
  3083. ...item,
  3084. percentage: similarity,
  3085. startsWith: startsWith
  3086. };
  3087. })
  3088. .filter(item => item.percentage > 50 || item.startsWith)
  3089. .sort((a, b) => {
  3090. if (a.startsWith && !b.startsWith) return -1;
  3091. if (!a.startsWith && b.startsWith) return 1;
  3092. return b.percentage - a.percentage;
  3093. })
  3094. .slice(0, 5);
  3095. }
  3096.  
  3097. function calculateSimilarity(str1, str2) {
  3098. const len = Math.max(str1.length, str2.length);
  3099. if (len === 0) return 100;
  3100. const distance = levenshteinDistance(str1, str2);
  3101. return Math.round(((len - distance) / len) * 100);
  3102. }
  3103.  
  3104. function levenshteinDistance(str1, str2) {
  3105. const m = str1.length;
  3106. const n = str2.length;
  3107. const dp = Array.from({
  3108. length: m + 1
  3109. }, () => Array(n + 1).fill(0));
  3110.  
  3111. for (let i = 0; i <= m; i++) {
  3112. for (let j = 0; j <= n; j++) {
  3113. if (i === 0) {
  3114. dp[i][j] = j;
  3115. } else if (j === 0) {
  3116. dp[i][j] = i;
  3117. } else {
  3118. dp[i][j] = Math.min(
  3119. dp[i - 1][j - 1] + (str1[i - 1] === str2[j - 1] ? 0 : 1),
  3120. dp[i - 1][j] + 1,
  3121. dp[i][j - 1] + 1
  3122. );
  3123. }
  3124. }
  3125. }
  3126.  
  3127. return dp[m][n];
  3128. }
  3129.  
  3130. function createArrow() {
  3131. const arrow = document.createElement('div');
  3132. Object.assign(arrow.style, {
  3133. position: 'absolute',
  3134. bottom: '5px',
  3135. left: '50%',
  3136. width: '0',
  3137. height: '0',
  3138. borderLeft: '5px solid transparent',
  3139. borderRight: '5px solid transparent',
  3140. borderTop: '5px solid #67c1f5',
  3141. cursor: 'pointer',
  3142. transition: 'transform 0.3s ease',
  3143. transform: 'translateX(-50%)'
  3144. });
  3145. return arrow;
  3146. }
  3147.  
  3148. function getAppId() {
  3149. return window.location.pathname.split('/')[2];
  3150. }
  3151.  
  3152. function getGameName() {
  3153. return document.querySelector('.apphub_AppName').textContent
  3154. .normalize("NFD")
  3155. .replace(/[\u0300-\u036f]/g, "")
  3156. .replace(/[’]/g, "'")
  3157. .replace(/[^a-zA-Zа-яёА-ЯЁ0-9 _'\-!]/g, '')
  3158. .trim()
  3159. .toLowerCase();
  3160. }
  3161. })();
  3162. }
  3163.  
  3164. // Скрипт для получения уведомлений об изменении дат выхода игр из вашего списка желаемого Steam и показа календаря с датами | https://steamcommunity.com/my/wishlist/
  3165. if (scriptsConfig.wishlistTracker) {
  3166. (function() {
  3167. 'use strict';
  3168.  
  3169. const STORAGE_PREFIX = 'USE_Wishlist_';
  3170. const STORAGE_KEYS = {
  3171. NOTIFICATIONS: STORAGE_PREFIX + 'notifications',
  3172. GAME_DATA: STORAGE_PREFIX + 'gameData',
  3173. LAST_UPDATE: STORAGE_PREFIX + 'lastUpdate'
  3174. };
  3175.  
  3176. const calendarIcon = `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
  3177. <path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM5 20V10h14v10H5zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2zm-8 4H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2z"/>
  3178. </svg>`;
  3179.  
  3180. const BATCH_SIZE = 200;
  3181. const MILLISECONDS_IN_HOUR = 60 * 60 * 1000;
  3182. let notifications = GM_getValue(STORAGE_KEYS.NOTIFICATIONS, []);
  3183. let isPanelOpen = false;
  3184.  
  3185. GM_addStyle(`
  3186. .wishlist-tracker-container {
  3187. position: absolute;
  3188. right: 180px;
  3189. top: 6px;
  3190. z-index: 999;
  3191. }
  3192.  
  3193. .wishlist-tracker-button {
  3194. color: #c6d4df;
  3195. background: rgba(103, 193, 245, 0.1);
  3196. padding: 7px 12px;
  3197. border-radius: 2px;
  3198. cursor: pointer;
  3199. font-size: 13px;
  3200. display: flex;
  3201. align-items: center;
  3202. gap: 4px;
  3203. align-items: center;
  3204. transition: all 0.2s ease;
  3205. }
  3206.  
  3207. .wishlist-tracker-button:hover {
  3208. background: rgba(103, 193, 245, 0.2);
  3209. }
  3210.  
  3211. .notification-badge {
  3212. background: #67c1f5;
  3213. color: #1b2838;
  3214. border-radius: 3px;
  3215. padding: 3px 6px;
  3216. font-size: 14px;
  3217. font-weight: bold;
  3218. margin-left: 8px;
  3219. min-width: 20px;
  3220. text-align: center;
  3221. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  3222. }
  3223.  
  3224. .status-indicator {
  3225. background: #4a5562;
  3226. color: #c6d4df;
  3227. border-radius: 3px;
  3228. padding: 3px 6px;
  3229. font-size: 12px;
  3230. font-weight: bold;
  3231. margin-left: 5px;
  3232. min-width: 30px;
  3233. text-align: center;
  3234. transition: all 0.3s ease;
  3235. cursor: help;
  3236. }
  3237.  
  3238. .status-ok { background: #4a5562; }
  3239. .status-warning { background: #4a5562; }
  3240. .status-alert1 { background: #665c3a; color: #ffd700; }
  3241. .status-alert2 { background: #804d4d; color: #ffb3b3; }
  3242. .status-critical { background: #e60000; color: #fff; }
  3243. .status-unknown { background: #1b2838; color: #8f98a0; }
  3244.  
  3245. .wishlist-tracker-panel {
  3246. position: fixed;
  3247. right: 132px;
  3248. top: 50px;
  3249. background: #1b2838;
  3250. border: 1px solid #67c1f5;
  3251. width: 500px;
  3252. max-height: 500px;
  3253. min-width: 460px;
  3254. overflow-y: auto;
  3255. z-index: 9999;
  3256. box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
  3257. display: none;
  3258. }
  3259.  
  3260. .wt-panel-header {
  3261. padding: 15px;
  3262. background: #171a21;
  3263. display: flex;
  3264. justify-content: space-between;
  3265. align-items: center;
  3266. }
  3267.  
  3268. .panel-title {
  3269. font-size: 17px;
  3270. font-weight: 500;
  3271. color: #67c1f5;
  3272. }
  3273.  
  3274.  
  3275. .panel-controls {
  3276. display: flex;
  3277.  
  3278. }
  3279.  
  3280. .panel-controls button {
  3281. background: rgba(30, 45, 60, 0.7);
  3282. border: none;
  3283. color: #c6d4df;
  3284. padding: 8px 14px;
  3285. cursor: pointer;
  3286. margin-left: 5px;
  3287. border-radius: 2px;
  3288. font-weight: 400;
  3289. text-transform: uppercase;
  3290. letter-spacing: 0.5px;
  3291. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
  3292. transition: background 0.2s ease, box-shadow 0.2s ease;
  3293. }
  3294.  
  3295. .panel-controls button:hover {
  3296. background: rgba(40, 60, 80, 0.9);
  3297. box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
  3298. }
  3299.  
  3300. .panel-controls button:active {
  3301. background: rgba(30, 45, 60, 0.6);
  3302. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
  3303. }
  3304.  
  3305. .calendar-btn {
  3306. padding: 8px 10px !important;
  3307. display: flex;
  3308. align-items: center;
  3309. }
  3310.  
  3311. .wt-notification-item {
  3312. padding: 15px;
  3313. border-bottom: 1px solid #2a475e;
  3314. position: relative;
  3315. transition: opacity 0.3s;
  3316. }
  3317.  
  3318. .notification-content {
  3319. display: flex;
  3320. gap: 15px;
  3321. }
  3322.  
  3323. .notification-image {
  3324. width: 80px;
  3325. height: 45px;
  3326. object-fit: cover;
  3327. }
  3328.  
  3329. .notification-text {
  3330. flex-grow: 1;
  3331. padding-right: 25px;
  3332. }
  3333.  
  3334. .notification-game-title {
  3335. color: #66c0f4;
  3336. font-weight: bold;
  3337. text-decoration: none;
  3338. display: block;
  3339. margin-bottom: 5px;
  3340. }
  3341.  
  3342. .notification-date {
  3343. font-size: 12px;
  3344. color: #8f98a0;
  3345. }
  3346.  
  3347. .notification-dates {
  3348. color: #c6d4df;
  3349. font-size: 13px;
  3350. }
  3351.  
  3352. .wtunread {
  3353. background: rgba(102, 192, 244, 0.15);
  3354. }
  3355.  
  3356. .notification-controls {
  3357. position: absolute;
  3358. right: 10px;
  3359. top: 10px;
  3360. display: flex;
  3361. gap: 8px;
  3362. }
  3363.  
  3364. .notification-control {
  3365. cursor: pointer;
  3366. width: 18px;
  3367. height: 18px;
  3368. opacity: 0.7;
  3369. transition: opacity 0.2s;
  3370. }
  3371.  
  3372. .notification-control:hover {
  3373. opacity: 1;
  3374. }
  3375.  
  3376. .delete-btn {
  3377. width: 20px;
  3378. height: 20px;
  3379. display: flex;
  3380. align-items: center;
  3381. justify-content: center;
  3382. color: #6C7781;
  3383. font-size: 16px;
  3384. font-weight: bold;
  3385. line-height: 1;
  3386. border: none;
  3387. cursor: pointer;
  3388. transition: color 0.2s ease, transform 0.1s ease;
  3389. }
  3390.  
  3391. .delete-btn:hover {
  3392. color: #8F98A0;
  3393. }
  3394.  
  3395. .delete-btn:active {
  3396. color: #800000;
  3397. transform: scale(0.9);
  3398. }
  3399.  
  3400. .loading-indicator {
  3401. color: #67c1f5;
  3402. text-align: center;
  3403. padding: 10px;
  3404. }
  3405.  
  3406. .calendar-wtmodal.active {
  3407. display: flex;
  3408. flex-direction: column;
  3409. }
  3410.  
  3411. .calendar-wtmodal {
  3412. position: fixed;
  3413. top: 50%;
  3414. left: 50%;
  3415. transform: translate(-50%, -50%);
  3416. width: 80%;
  3417. height: 80vh;
  3418. background: #1b2838;
  3419. border: 1px solid #67c1f5;
  3420. box-shadow: 0 0 30px rgba(0,0,0,0.7);
  3421. z-index: 100000;
  3422. display: none;
  3423. padding: 20px;
  3424. overflow: hidden;
  3425. }
  3426.  
  3427. .calendar-header {
  3428. display: flex;
  3429. justify-content: space-between;
  3430. align-items: center;
  3431. padding-bottom: 15px;
  3432. border-bottom: 1px solid #2a475e;
  3433. margin-bottom: 15px;
  3434. }
  3435.  
  3436. .calendar-title {
  3437. color: #67c1f5;
  3438. font-size: 25px;
  3439. }
  3440.  
  3441. .calendar-close {
  3442. cursor: pointer;
  3443. color: #8f98a0;
  3444. font-size: 54px;
  3445. padding: 5px;
  3446. }
  3447.  
  3448. .calendar-close:hover {
  3449. color: #67c1f5;
  3450. }
  3451.  
  3452. .calendar-content {
  3453. flex-grow: 1;
  3454. overflow-y: auto;
  3455. padding-right: 10px;
  3456. }
  3457.  
  3458. .calendar-month {
  3459. margin-bottom: 30px;
  3460. }
  3461.  
  3462. .month-header {
  3463. color: #67c1f5;
  3464. font-size: 24px;
  3465. margin-bottom: 15px;
  3466. }
  3467.  
  3468. .calendar-grid {
  3469. display: grid;
  3470. grid-template-columns: repeat(7, 1fr);
  3471. gap: 2px;
  3472. font-size: 14px;
  3473. font-weight: 500;
  3474. }
  3475.  
  3476. .calendar-grid > div:not(.calendar-day) {
  3477. padding: 10px 0;
  3478. background: #1b2838;
  3479. color: #67c1f5;
  3480. border-bottom: 2px solid #67c1f5;
  3481. text-transform: uppercase;
  3482. text-align: center;
  3483. }
  3484.  
  3485. .calendar-day {
  3486. background: #2a475e;
  3487. min-height: 69px;
  3488. padding: 20px 0 16px 0;
  3489. position: relative;
  3490. display: flex;
  3491. flex-direction: column;
  3492. gap: 3px;
  3493. }
  3494.  
  3495. .day-number {
  3496. position: absolute;
  3497. top: 3px;
  3498. right: 5px;
  3499. color: #8f98a0;
  3500. font-size: 14px;
  3501. z-index: 100003
  3502. }
  3503.  
  3504. .calendar-game {
  3505. display: flex;
  3506. position: relative;
  3507. padding-bottom: 8px;
  3508. align-items: center;
  3509. margin: 5px 0;
  3510. padding: 5px;
  3511. background: rgba(42,71,94,0.5);
  3512. border-radius: 3px;
  3513. transition: background 0.2s;
  3514. text-decoration: none !important;
  3515. color: inherit;
  3516. }
  3517.  
  3518. .calendar-game:not(:last-child)::after {
  3519. content: "";
  3520. position: absolute;
  3521. bottom: -7px;
  3522. left: 0;
  3523. right: 0;
  3524. height: 1px;
  3525. background: linear-gradient(90deg,
  3526. transparent 0%,
  3527. rgba(103, 193, 245, 0.3) 20%,
  3528. rgba(103, 193, 245, 0.4) 50%,
  3529. rgba(103, 193, 245, 0.3) 80%,
  3530. transparent 100%
  3531. );
  3532. margin-top: 8px;
  3533. }
  3534.  
  3535. .calendar-game-approximate .calendar-game-title {
  3536. color: #FFD580 !important;
  3537. opacity: 0.9;
  3538. }
  3539.  
  3540. .calendar-game:hover {
  3541. background: rgba(67, 103, 133, 0.5);
  3542. }
  3543.  
  3544. .calendar-game-image {
  3545. width: 100px;
  3546. height: 45px;
  3547. object-fit: cover;
  3548. margin-right: 10px;
  3549. }
  3550.  
  3551. .calendar-game-title {
  3552. color: #c6d4df;
  3553. font-size: 13px;
  3554. }
  3555.  
  3556.  
  3557. .load-more-months {
  3558. text-align: center;
  3559. padding: 15px;
  3560. }
  3561.  
  3562. .load-more-btn {
  3563. background: rgba(103, 193, 245, 0.1);
  3564. color: #67c1f5;
  3565. border: none;
  3566. padding: 10px 20px;
  3567. cursor: pointer;
  3568. border-radius: 3px;
  3569. }
  3570.  
  3571. .load-more-btn:hover {
  3572. background: rgba(103, 193, 245, 0.2);
  3573. }
  3574.  
  3575. .wt-tooltip {
  3576. display: flex !important;
  3577. position: relative;
  3578. }
  3579.  
  3580. .wt-tooltip .wt-tooltiptext {
  3581. visibility: hidden;
  3582. width: 220px;
  3583. background-color: #171a21;
  3584. color: #c6d4df;
  3585. text-align: center;
  3586. border-radius: 3px;
  3587. padding: 12px;
  3588. position: absolute;
  3589. z-index: 1;
  3590. left: 100%;
  3591. margin-left: 2px;
  3592. opacity: 0;
  3593. transition: opacity 0.3s;
  3594. border: 1px solid #67c1f5;
  3595. }
  3596.  
  3597. .wt-tooltip:hover .wt-tooltiptext {
  3598. visibility: visible;
  3599. opacity: 1;
  3600. }
  3601.  
  3602. `);
  3603.  
  3604. const envelopeIcons = {
  3605. wtunread: `<svg width="20" height="16" viewBox="0 0 32 32" fill="#67c1f5" xmlns="http://www.w3.org/2000/svg">
  3606. <path d="M16.015 18.861l-4.072-3.343-8.862 10.463h25.876l-8.863-10.567-4.079 3.447zM29.926 6.019h-27.815l13.908 11.698 13.907-11.698zM20.705 14.887l9.291 11.084v-18.952l-9.291 7.868zM2.004 7.019v18.952l9.291-11.084-9.291-7.868z"/>
  3607. </svg>`,
  3608. wtread: `<svg width="20" height="16" viewBox="0 0 32 32" fill="#8f98a0" xmlns="http://www.w3.org/2000/svg">
  3609. <path d="M20.139 18.934l9.787-7.999-13.926-9.833-13.89 9.833 9.824 8.032 8.205-0.033zM12.36 19.936l-9.279 10.962h25.876l-9.363-10.9-7.234-0.062zM20.705 19.803l9.291 11.084v-18.952l-9.291 7.868zM2.004 11.935v18.952l9.291-11.084-9.291-7.868z"/>
  3610. </svg>`
  3611. };
  3612.  
  3613. function createNotificationUI() {
  3614. const container = $(`
  3615. <div class="wishlist-tracker-container">
  3616. <div class="wishlist-tracker-button">
  3617. <span>Отслеживание вишлиста</span>
  3618. <div class="status-indicator status-unknown">??</div>
  3619. <div class="notification-badge">${getUnreadCount()}</div>
  3620. </div>
  3621. <div class="wishlist-tracker-panel">
  3622. <div class="wt-panel-header">
  3623. <div class="panel-title">Уведомлений: (${notifications.length})</div>
  3624. <div class="panel-controls">
  3625. <button class="refresh-btn">⟳ Обновить</button>
  3626. <button class="clear-btn" Очистить</button>
  3627. <button class="calendar-btn">${calendarIcon}</button>
  3628. </div>
  3629. </div>
  3630. </div>
  3631. </div>
  3632. `);
  3633.  
  3634. const panel = container.find('.wishlist-tracker-panel');
  3635. const button = container.find('.wishlist-tracker-button');
  3636.  
  3637. button.click(function(e) {
  3638. e.stopPropagation();
  3639. togglePanel();
  3640. });
  3641.  
  3642. container.find('.refresh-btn').click((e) => {
  3643. e.stopPropagation();
  3644. updateData();
  3645. });
  3646.  
  3647. container.find('.clear-btn').click(() => {
  3648. notifications = [];
  3649. GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
  3650. updateNotificationPanel();
  3651. updateBadge();
  3652. });
  3653. container.find('.calendar-btn').click((e) => {
  3654. e.stopPropagation();
  3655. showCalendarModal();
  3656. });
  3657.  
  3658. if (window.self === window.top) {
  3659. document.body.appendChild(container[0]);
  3660. }
  3661. updateNotificationPanel();
  3662.  
  3663. $(document).click(() => {
  3664. if (isPanelOpen) {
  3665. panel.hide();
  3666. isPanelOpen = false;
  3667. }
  3668. });
  3669. }
  3670.  
  3671. function showLoadingIndicator() {
  3672. const panel = $('.wishlist-tracker-panel');
  3673. panel.find('.loading-indicator').remove();
  3674. const loading = $(`<div class="loading-indicator">Обновление данных...</div>`);
  3675. panel.append(loading);
  3676. }
  3677.  
  3678. function togglePanel() {
  3679. updateStatusIndicator();
  3680. const panel = $('.wishlist-tracker-panel');
  3681. panel.toggle();
  3682. isPanelOpen = !isPanelOpen;
  3683. if (isPanelOpen) {
  3684. panel.css('display', 'block');
  3685. }
  3686. }
  3687.  
  3688. function updateNotificationPanel() {
  3689. const panel = $('.wishlist-tracker-panel');
  3690. panel.find('.wt-notification-item, .loading-indicator').remove();
  3691. panel.find('.panel-title').text(`Уведомлений: (${notifications.length})`);
  3692.  
  3693. notifications.slice(0, 5000).forEach((notification, index) => {
  3694. const item = $(`
  3695. <div class="wt-notification-item ${notification.wtread ? '' : 'wtunread'}">
  3696. <div class="notification-controls">
  3697. <div class="toggle-wtread-btn notification-control">
  3698. ${notification.wtread ? envelopeIcons.wtread : envelopeIcons.wtunread}
  3699. </div>
  3700. <div class="delete-btn notification-control">X</div>
  3701. </div>
  3702. <div class="notification-content">
  3703. <a href="https://store.steampowered.com/app/${notification.appid}" target="_blank">
  3704. <img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${notification.appid}/header.jpg"
  3705. class="notification-image">
  3706. </a>
  3707. <div class="notification-text">
  3708. <a href="https://store.steampowered.com/app/${notification.appid}"
  3709. class="notification-game-title" target="_blank">
  3710. ${notification.name}
  3711. </a>
  3712. <div class="notification-dates">
  3713. Дата выхода изменилась:<br>
  3714. <span class="old-date">${formatDate(notification.oldDate)}</span>
  3715. <span class="new-date">${formatDate(notification.newDate)}</span>
  3716. </div>
  3717. <div class="notification-date">
  3718. Обнаружено: ${new Date(notification.timestamp).toLocaleString()}
  3719. </div>
  3720. </div>
  3721. </div>
  3722. </div>
  3723. `);
  3724.  
  3725. item.find('.delete-btn').click((e) => {
  3726. e.stopPropagation();
  3727. notifications = notifications.filter((_, i) => i !== index);
  3728. GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
  3729. item.fadeOut(300, () => {
  3730. updateNotificationPanel();
  3731. updateBadge();
  3732. });
  3733. });
  3734.  
  3735. item.find('.toggle-wtread-btn').click((e) => {
  3736. e.stopPropagation();
  3737. notifications[index].wtread = !notifications[index].wtread;
  3738. GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
  3739. item.toggleClass('wtunread', !notifications[index].wtread);
  3740. item.find('.toggle-wtread-btn').html(notifications[index].wtread ? envelopeIcons.wtread : envelopeIcons.wtunread);
  3741. updateBadge();
  3742. });
  3743.  
  3744. panel.append(item);
  3745. });
  3746. }
  3747.  
  3748. function formatDate(dateInfo) {
  3749. if (!dateInfo || dateInfo.value === 'Не указана') return 'Не указано';
  3750.  
  3751. const value = dateInfo.value;
  3752. const displayType = dateInfo.displayType;
  3753.  
  3754. if (typeof value === 'string' && isNaN(value)) {
  3755. return value;
  3756. }
  3757.  
  3758. const ts = formatTimestamp(value);
  3759. const date = new Date(ts * 1000);
  3760.  
  3761. const monthNames = ["январь", "февраль", "март", "апрель", "май", "июнь",
  3762. "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"
  3763. ];
  3764. const quarter = Math.floor(date.getMonth() / 3) + 1;
  3765.  
  3766. if (displayType) {
  3767. switch (displayType) {
  3768. case 'date_month':
  3769. return `${monthNames[date.getMonth()]} ${date.getFullYear()}`;
  3770. case 'date_quarter':
  3771. return `Q${quarter} ${date.getFullYear()}`;
  3772. case 'date_year':
  3773. return `${date.getFullYear()}`;
  3774. case 'date_full':
  3775. default:
  3776. return date.toLocaleDateString('ru-RU', {
  3777. day: 'numeric',
  3778. month: 'long',
  3779. year: 'numeric'
  3780. });
  3781. }
  3782. }
  3783.  
  3784. return date.toLocaleDateString('ru-RU', {
  3785. day: 'numeric',
  3786. month: 'long',
  3787. year: 'numeric'
  3788. });
  3789. }
  3790.  
  3791. function updateStatusIndicator() {
  3792. const lastUpdate = GM_getValue(STORAGE_KEYS.LAST_UPDATE, 0);
  3793. const hoursPassed = (Date.now() - lastUpdate) / MILLISECONDS_IN_HOUR;
  3794. const indicator = $('.status-indicator');
  3795. const days = Math.floor(hoursPassed / 24);
  3796. const hours = Math.floor(hoursPassed % 24);
  3797.  
  3798. indicator.attr('title', `Данные не обновлялись: ${days} д. и ${hours} ч.`);
  3799.  
  3800. if (!lastUpdate) {
  3801. indicator.text('-').removeClass().addClass('status-indicator status-unknown');
  3802. return;
  3803. }
  3804.  
  3805. if (hoursPassed < 12) {
  3806. indicator.text('OK').removeClass().addClass('status-indicator status-ok');
  3807. } else if (hoursPassed < 24) {
  3808. indicator.text('OK?').removeClass().addClass('status-indicator status-warning');
  3809. } else if (hoursPassed < 48) {
  3810. indicator.text('!').removeClass().addClass('status-indicator status-alert1');
  3811. } else if (hoursPassed < 72) {
  3812. indicator.text('!!').removeClass().addClass('status-indicator status-alert2');
  3813. } else if (hoursPassed < 96) {
  3814. indicator.text('!!!').removeClass().addClass('status-indicator status-critical');
  3815. } else {
  3816. indicator.text('???').removeClass().addClass('status-indicator status-critical');
  3817. }
  3818. }
  3819.  
  3820. function updateBadge() {
  3821. $('.notification-badge').text(getUnreadCount());
  3822. }
  3823.  
  3824. function getUnreadCount() {
  3825. return notifications.filter(n => !n.wtread).length;
  3826. }
  3827.  
  3828.  
  3829. async function fetchWishlistAppIds() {
  3830. return new Promise(resolve => {
  3831. GM_xmlhttpRequest({
  3832. method: 'GET',
  3833. url: 'https://store.steampowered.com/dynamicstore/userdata/',
  3834. onload: function(response) {
  3835. const data = JSON.parse(response.responseText);
  3836. resolve(data.rgWishlist || []);
  3837. }
  3838. });
  3839. });
  3840. }
  3841.  
  3842. async function fetchGameDetails(appIds) {
  3843. const batches = [];
  3844. for (let i = 0; i < appIds.length; i += BATCH_SIZE) {
  3845. batches.push(appIds.slice(i, i + BATCH_SIZE));
  3846. }
  3847.  
  3848. const allDetails = [];
  3849. for (const batch of batches) {
  3850. const details = await fetchBatchDetails(batch);
  3851. allDetails.push(...details);
  3852. await new Promise(resolve => setTimeout(resolve, 1000));
  3853. }
  3854.  
  3855. return allDetails;
  3856. }
  3857.  
  3858. async function fetchBatchDetails(appIds) {
  3859. const requestData = {
  3860. ids: appIds.map(appid => ({
  3861. appid
  3862. })),
  3863. context: {
  3864. language: 'russian',
  3865. country_code: 'RU',
  3866. steam_realm: 1
  3867. },
  3868. data_request: {
  3869. include_release: true,
  3870. include_basic_info: true
  3871. }
  3872. };
  3873.  
  3874. return new Promise(resolve => {
  3875. GM_xmlhttpRequest({
  3876. method: 'GET',
  3877. url: `https://api.steampowered.com/IStoreBrowseService/GetItems/v1?input_json=${encodeURIComponent(JSON.stringify(requestData))}`,
  3878. onload: function(response) {
  3879. try {
  3880. const data = JSON.parse(response.responseText);
  3881. resolve(data.response?.store_items || []);
  3882. } catch (e) {
  3883. console.error('Error parsing response:', e);
  3884. resolve([]);
  3885. }
  3886. }
  3887. });
  3888. });
  3889. }
  3890.  
  3891. function checkForChanges(currentData) {
  3892. const previousData = GM_getValue(STORAGE_KEYS.GAME_DATA, {});
  3893. const changes = [];
  3894.  
  3895. currentData.forEach(game => {
  3896. const prevGame = previousData[game.appid];
  3897. const currentRelease = getReleaseInfo(game.release);
  3898. const prevRelease = prevGame ? getReleaseInfo(prevGame.rawRelease) : null;
  3899.  
  3900. if (prevGame && (
  3901. currentRelease.date !== prevRelease?.date ||
  3902. currentRelease.type !== prevRelease?.type ||
  3903. currentRelease.displayType !== prevRelease?.displayType
  3904. )) {
  3905. changes.push({
  3906. appid: game.appid,
  3907. name: game.name,
  3908. oldDate: {
  3909. value: prevRelease?.date || 'Не указана',
  3910. displayType: prevRelease?.displayType
  3911. },
  3912. newDate: {
  3913. value: currentRelease.date,
  3914. displayType: currentRelease.displayType
  3915. },
  3916. timestamp: Date.now(),
  3917. wtread: false
  3918. });
  3919. }
  3920. });
  3921.  
  3922. const newGameData = currentData.reduce((acc, game) => {
  3923. acc[game.appid] = {
  3924. name: game.name,
  3925. rawRelease: game.release,
  3926. releaseInfo: getReleaseInfo(game.release)
  3927. };
  3928. return acc;
  3929. }, {});
  3930.  
  3931. GM_setValue(STORAGE_KEYS.GAME_DATA, {
  3932. ...previousData,
  3933. ...newGameData
  3934. });
  3935.  
  3936. if (changes.length > 0) {
  3937. notifications = [...changes, ...notifications];
  3938. GM_setValue(STORAGE_KEYS.NOTIFICATIONS, notifications);
  3939. updateNotificationPanel();
  3940. updateBadge();
  3941. }
  3942.  
  3943. $('.wishlist-tracker-panel .loading-indicator').remove();
  3944. }
  3945.  
  3946. function getReleaseInfo(releaseData) {
  3947. if (!releaseData) return {
  3948. date: 'Не указана',
  3949. type: 'unknown',
  3950. displayType: null
  3951. };
  3952.  
  3953. const displayType = releaseData.coming_soon_display || null;
  3954.  
  3955. if (releaseData.steam_release_date) {
  3956. return {
  3957. date: releaseData.steam_release_date,
  3958. type: 'date',
  3959. displayType: displayType
  3960. };
  3961. }
  3962.  
  3963. if (releaseData.custom_release_date_message) {
  3964. return {
  3965. date: releaseData.custom_release_date_message,
  3966. type: 'custom',
  3967. displayType: null
  3968. };
  3969. }
  3970.  
  3971. return {
  3972. date: 'Не указана',
  3973. type: 'unknown',
  3974. displayType: null
  3975. };
  3976. }
  3977.  
  3978. function formatTimestamp(ts) {
  3979. if (!ts) return ts;
  3980. if (typeof ts === 'string') {
  3981. if (/^\d{4}-\d{2}-\d{2}$/.test(ts)) {
  3982. return Math.floor(new Date(ts).getTime() / 1000);
  3983. }
  3984. return ts;
  3985. }
  3986. return typeof ts === 'number' ? ts : parseInt(ts);
  3987. }
  3988.  
  3989. async function updateData() {
  3990. try {
  3991. showLoadingIndicator();
  3992. const indicator = $('.status-indicator');
  3993. indicator.text('...').removeClass().addClass('status-indicator status-unknown');
  3994. const appIds = await fetchWishlistAppIds();
  3995. const gameDetails = await fetchGameDetails(appIds);
  3996. checkForChanges(gameDetails);
  3997. GM_setValue(STORAGE_KEYS.LAST_UPDATE, Date.now());
  3998. updateStatusIndicator();
  3999. } catch (e) {
  4000. console.error('Update error:', e);
  4001. showErrorIndicator();
  4002. updateStatusIndicator();
  4003. } finally {
  4004. $('.wishlist-tracker-panel .loading-indicator').remove();
  4005. }
  4006. }
  4007.  
  4008. function showErrorIndicator() {
  4009. const panel = $('.wishlist-tracker-panel');
  4010. const error = $(`
  4011. <div class="wt-notification-item" style="color: #ff4747;">
  4012. Ошибка при обновлении данных
  4013. </div>
  4014. `);
  4015. panel.prepend(error);
  4016. setTimeout(() => error.remove(), 5000);
  4017. }
  4018.  
  4019. function showCalendarModal() {
  4020. const gameData = GM_getValue(STORAGE_KEYS.GAME_DATA, {});
  4021. const monthsData = getGamesByMonths(gameData);
  4022.  
  4023. const wtmodal = $(`
  4024. <div class="calendar-wtmodal">
  4025. <div class="calendar-header">
  4026. <div class="calendar-title">Календарь релизов (${monthsData.length} месяцев)</div>
  4027. <div class="calendar-close">×</div>
  4028. </div>
  4029. <div class="calendar-content"></div>
  4030. </div>
  4031. `);
  4032.  
  4033. const clickHandler = (e) => {
  4034. if (!$(e.target).closest('.calendar-wtmodal').length) {
  4035. e.preventDefault();
  4036. e.stopPropagation();
  4037. e.stopImmediatePropagation();
  4038.  
  4039. wtmodal.remove();
  4040. $(document).off('click', clickHandler);
  4041. }
  4042. };
  4043.  
  4044. wtmodal.find('.calendar-close').click((e) => {
  4045. e.preventDefault();
  4046. e.stopPropagation();
  4047. wtmodal.remove();
  4048. $(document).off('click', clickHandler);
  4049. });
  4050.  
  4051. wtmodal.click(e => e.stopPropagation());
  4052.  
  4053. $(document).on('click', clickHandler);
  4054.  
  4055. $('body').append(wtmodal);
  4056. wtmodal.addClass('active');
  4057.  
  4058. let visibleMonths = 3;
  4059. const renderCalendar = () => {
  4060. const visibleData = monthsData.slice(0, visibleMonths);
  4061. const content = wtmodal.find('.calendar-content').empty();
  4062.  
  4063. visibleData.forEach(({
  4064. month,
  4065. year,
  4066. games
  4067. }) => {
  4068. const monthDate = new Date(year, month);
  4069. const monthName = monthDate.toLocaleString('ru-RU', {
  4070. month: 'long'
  4071. });
  4072. const daysInMonth = new Date(year, month + 1, 0).getDate();
  4073. const firstDay = new Date(year, month, 1).getDay();
  4074. const adjustedFirstDay = firstDay === 0 ? 6 : firstDay - 1;
  4075.  
  4076. const monthBlock = $(`
  4077. <div class="calendar-month">
  4078. <div class="month-header">${monthName} ${year}</div>
  4079. <div class="calendar-grid"></div>
  4080. </div>
  4081. `);
  4082.  
  4083. const grid = monthBlock.find('.calendar-grid');
  4084. grid.append('<div>Пн</div><div>Вт</div><div>Ср</div><div>Чт</div><div>Пт</div><div>Сб</div><div>Вс</div>');
  4085.  
  4086. for (let i = 0; i < adjustedFirstDay; i++) {
  4087. grid.append('<div class="calendar-day"></div>');
  4088. }
  4089.  
  4090. for (let day = 1; day <= daysInMonth; day++) {
  4091. const dayGames = games.filter(g => {
  4092. const releaseDate = new Date(g.releaseInfo.date * 1000);
  4093. return releaseDate.getDate() === day &&
  4094. releaseDate.getMonth() === month &&
  4095. releaseDate.getFullYear() === year;
  4096. });
  4097.  
  4098. const dayElement = $(`
  4099. <div class="calendar-day">
  4100. <div class="day-number">${day}</div>
  4101. </div>
  4102. `);
  4103.  
  4104. dayGames.sort((a, b) => a.name.localeCompare(b.name)).forEach(game => {
  4105. const isApproximate = ['date_month', 'date_quarter', 'date_year']
  4106. .includes(game.releaseInfo.displayType);
  4107.  
  4108. const gameElement = $(`
  4109. <a href="https://store.steampowered.com/app/${game.appid}"
  4110. target="_blank"
  4111. class="calendar-game ${isApproximate ? 'calendar-game-approximate wt-tooltip' : ''}">
  4112. <img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.appid}/header.jpg"
  4113. class="calendar-game-image">
  4114. <div class="calendar-game-title">${game.name}</div>
  4115. ${isApproximate ?
  4116. `<div class="wt-tooltiptext">Приблизительная дата: ${getApproximateDateText(game.releaseInfo)}</div>`
  4117. : ''}
  4118. </a>
  4119. `);
  4120.  
  4121.  
  4122.  
  4123. dayElement.append(gameElement);
  4124. });
  4125.  
  4126. grid.append(dayElement);
  4127. }
  4128.  
  4129. content.append(monthBlock);
  4130. });
  4131.  
  4132. if (visibleMonths < monthsData.length) {
  4133. content.append(`
  4134. <div class="load-more-months">
  4135. <button class="load-more-btn">Показать ещё 3 месяца</button>
  4136. </div>
  4137. `);
  4138.  
  4139. content.find('.load-more-btn').click(() => {
  4140. visibleMonths += 3;
  4141. renderCalendar();
  4142. });
  4143. }
  4144. };
  4145.  
  4146. wtmodal.addClass('active');
  4147. renderCalendar();
  4148. }
  4149.  
  4150. function getGamesByMonths(gameData) {
  4151. const now = new Date();
  4152. const currentYear = now.getFullYear();
  4153. const currentMonth = now.getMonth();
  4154.  
  4155. const games = Object.entries(gameData)
  4156. .map(([appid, game]) => ({
  4157. appid: parseInt(appid),
  4158. ...game,
  4159. releaseDate: game.releaseInfo.date && typeof game.releaseInfo.date === 'number' ?
  4160. new Date(game.releaseInfo.date * 1000) : null
  4161. }))
  4162. .filter(g => g.releaseDate)
  4163. .filter(g => {
  4164. const releaseYear = g.releaseDate.getFullYear();
  4165. const releaseMonth = g.releaseDate.getMonth();
  4166. return (releaseYear > currentYear) ||
  4167. (releaseYear === currentYear && releaseMonth >= currentMonth);
  4168. });
  4169.  
  4170. const monthMap = games.reduce((acc, game) => {
  4171. const year = game.releaseDate.getFullYear();
  4172. const month = game.releaseDate.getMonth();
  4173. const key = `${year}-${month}`;
  4174.  
  4175. if (!acc[key]) {
  4176. acc[key] = {
  4177. year,
  4178. month,
  4179. games: []
  4180. };
  4181. }
  4182. acc[key].games.push(game);
  4183. return acc;
  4184. }, {});
  4185.  
  4186. return Object.values(monthMap)
  4187. .sort((a, b) => a.year === b.year ? a.month - b.month : a.year - b.year);
  4188. }
  4189.  
  4190. function getApproximateDateText(releaseInfo) {
  4191. const date = new Date(releaseInfo.date * 1000);
  4192. const quarter = Math.floor(date.getMonth() / 3) + 1;
  4193. switch (releaseInfo.displayType) {
  4194. case 'date_month':
  4195. return date.toLocaleString('ru-RU', {
  4196. month: 'long',
  4197. year: 'numeric'
  4198. });
  4199. case 'date_quarter':
  4200. return `Q${quarter} ${date.getFullYear()}`;
  4201. case 'date_year':
  4202. return date.getFullYear().toString();
  4203. default:
  4204. return date.toLocaleDateString('ru-RU');
  4205. }
  4206. }
  4207.  
  4208. function initialize() {
  4209. createNotificationUI();
  4210. updateStatusIndicator();
  4211. }
  4212.  
  4213. $(document).ready(initialize);
  4214. })();
  4215. }
  4216.  
  4217. })();