1337x - Steam Hover Preview

On-hover Steam thumbnail, description, Steam‐provided tags, and User Review for 1337x torrent titles

当前为 2025-04-25 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name 1337x - Steam Hover Preview
  3. // @namespace https://greasyfork.org/en/users/1340389-deonholo
  4. // @version 2.5
  5. // @description On-hover Steam thumbnail, description, Steam‐provided tags, and User Review for 1337x torrent titles
  6. // @icon https://greasyfork.s3.us-east-2.amazonaws.com/x432yc9hx5t6o2gbe9ccr7k5l6u8
  7. // @author DeonHolo
  8. // @license MIT
  9. // @match *://*.1337x.to/*
  10. // @match *://*.1337x.ws/*
  11. // @match *://*.1337x.is/*
  12. // @match *://*.1337x.gd/*
  13. // @match *://*.x1337x.cc/*
  14. // @match *://*.1337x.st/*
  15. // @match *://*.x1337x.ws/*
  16. // @match *://*.1337x.eu/*
  17. // @match *://*.1337x.se/*
  18. // @match *://*.x1337x.eu/*
  19. // @match *://*.x1337x.se/*
  20. // @match http://l337xdarkkaqfwzntnfk5bmoaroivtl6xsbatabvlb52umg6v3ch44yd.onion/*
  21. // @grant GM_xmlhttpRequest
  22. // @grant GM_addStyle
  23. // @connect store.steampowered.com
  24. // @connect steamcdn-a.akamaihd.net
  25. // @run-at document-idle
  26. // ==/UserScript==
  27.  
  28. (() => {
  29. 'use strict';
  30.  
  31. GM_addStyle(`
  32. .steamHoverTip {
  33. position: fixed; padding: 8px; background: rgba(240, 240, 240, 0.97);
  34. border: 1px solid #555; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
  35. z-index: 2147483647; max-width: 310px; font-size: 12px;
  36. line-height: 1.45; display: none; pointer-events: none;
  37. white-space: normal !important; overflow-wrap: break-word; color: #111;
  38. }
  39. .steamHoverTip p { margin: 0 0 5px 0; padding: 0; white-space: normal; }
  40. .steamHoverTip p:last-child { margin-bottom: 0; }
  41. .steamHoverTip img { display: block; width: 100%; margin-bottom: 8px; border-radius: 2px; }
  42. .steamHoverTip strong { color: #000; }
  43. .steamHoverTip .steamRating, .steamHoverTip .steamTags {
  44. margin-top: 8px; font-size: 12px; color: #333;
  45. }
  46. .steamHoverTip .steamTags strong, .steamHoverTip .steamRating strong {
  47. color: #111; margin-right: 4px;
  48. }
  49. .steamHoverTip .ratingStars {
  50. color: #f5c518; margin-right: 6px; letter-spacing: 1px;
  51. font-size: 14px; display: inline-block; vertical-align: middle;
  52. }
  53. .steamHoverTip .ratingText { vertical-align: middle; }
  54. `);
  55.  
  56. const tip = document.createElement('div');
  57. tip.className = 'steamHoverTip';
  58. document.body.appendChild(tip);
  59.  
  60. const MIN_INTERVAL = 50;
  61. let lastRequest = 0;
  62. const apiCache = new Map();
  63. const MAX_CACHE = 100;
  64. const SCRAPE_TTL = 1000 * 60 * 5;
  65.  
  66. function pruneCache(map) {
  67. if (map.size <= MAX_CACHE) return;
  68. map.delete(map.keys().next().value);
  69. }
  70.  
  71. function gmFetch(url, responseType = 'json', timeout = 10000) {
  72. const wait = Math.max(0, MIN_INTERVAL - (Date.now() - lastRequest));
  73. return new Promise(r => setTimeout(r, wait))
  74. .then(() => new Promise((resolve, reject) => {
  75. lastRequest = Date.now();
  76. GM_xmlhttpRequest({
  77. method: 'GET', url, responseType, timeout,
  78. onload: res => {
  79. if (responseType === 'json') {
  80. if (typeof res.response === 'object' && res.response !== null) { resolve(res.response); }
  81. else { console.warn(`Invalid JSON for ${url}`); try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('JSON parse error')); } }
  82. } else { resolve(res.response); }
  83. },
  84. onerror: err => reject(new Error(`Network error: ${err.statusText || err.error || 'Unknown'}`)),
  85. ontimeout: () => reject(new Error(`Timeout (${timeout}ms)`)),
  86. onabort: () => reject(new Error('Aborted'))
  87. });
  88. }));
  89. }
  90.  
  91. function parseReviewFromTooltip(tooltipText) {
  92. if (!tooltipText) return { percent: null, total: null };
  93. const percentMatch = tooltipText.match(/(\d+)%/);
  94. const totalMatch = tooltipText.match(/of the ([\d,]+) user reviews/);
  95. return {
  96. percent: percentMatch ? percentMatch[1] : null,
  97. total: totalMatch ? totalMatch[1].replace(/,/g, '') : null
  98. };
  99. }
  100.  
  101. function getRatingStars(percent, desc) {
  102. const filledStar = '★';
  103. const emptyStar = '☆';
  104. let stars = '';
  105. let usedPercent = false;
  106.  
  107. const numPercent = parseInt(percent, 10);
  108. if (!isNaN(numPercent)) {
  109. usedPercent = true;
  110. if (numPercent >= 95) stars = filledStar.repeat(5);
  111. else if (numPercent >= 80) stars = filledStar.repeat(4) + emptyStar;
  112. else if (numPercent >= 70) stars = filledStar.repeat(3) + emptyStar.repeat(2);
  113. else if (numPercent >= 40) stars = filledStar.repeat(2) + emptyStar.repeat(3);
  114. else if (numPercent >= 20) stars = filledStar.repeat(1) + emptyStar.repeat(4);
  115. else stars = emptyStar.repeat(5);
  116. }
  117.  
  118. if (!usedPercent && desc) {
  119. const lowerDesc = desc.toLowerCase();
  120. if (lowerDesc.includes('overwhelmingly positive')) stars = filledStar.repeat(5);
  121. else if (lowerDesc.includes('very positive')) stars = filledStar.repeat(4) + emptyStar;
  122. else if (lowerDesc.includes('mostly positive')) stars = filledStar.repeat(4) + emptyStar;
  123. else if (lowerDesc.includes('positive')) stars = filledStar.repeat(4) + emptyStar;
  124. else if (lowerDesc.includes('mixed')) stars = filledStar.repeat(3) + emptyStar.repeat(2);
  125. else if (lowerDesc.includes('mostly negative')) stars = filledStar.repeat(2) + emptyStar.repeat(3);
  126. else if (lowerDesc.includes('negative')) stars = filledStar.repeat(1) + emptyStar.repeat(4);
  127. else if (lowerDesc.includes('very negative')) stars = filledStar.repeat(1) + emptyStar.repeat(4);
  128. else if (lowerDesc.includes('overwhelmingly negative')) stars = filledStar.repeat(1) + emptyStar.repeat(4);
  129. }
  130. return stars ? `<span class="ratingStars">${stars}</span>` : '';
  131. }
  132.  
  133. async function fetchSteam(name) {
  134. const now = Date.now();
  135. let cachedData = apiCache.get(name);
  136. if (cachedData && (now - cachedData.ts < SCRAPE_TTL)) return cachedData.data;
  137.  
  138. let appData, reviewInfoFromSearch, appId;
  139. try {
  140. const search = await gmFetch(`https://store.steampowered.com/api/storesearch/?cc=us&l=en&term=${encodeURIComponent(name)}`);
  141. let sr = search?.items?.[0];
  142. if (search?.items?.length > 1) { const lc = name.toLowerCase(); const em = search.items.find(i => i.name.toLowerCase() === lc); if (em) sr = em; }
  143. appId = sr?.id; if (!appId) throw new Error('No Store ID');
  144. if (sr?.review_desc) reviewInfoFromSearch = { desc: sr.review_desc, percent: sr.reviews_percent, total: sr.reviews_total?.replace(/,/g,''), source: 'api_search' };
  145. const detailsResp = await gmFetch(`https://store.steampowered.com/api/appdetails?appids=${appId}&cc=us&l=en`);
  146. appData = detailsResp?.[appId]?.success ? detailsResp[appId].data : null; if (!appData) throw new Error('No appdetails');
  147. } catch (err) { console.error(`API fetch err for "${name}": ${err.message}`); apiCache.set(name, { data: null, ts: now }); pruneCache(apiCache); return null; }
  148.  
  149. let scrapedTags = [], scrapedReviewInfo = null, finalReviewInfo = reviewInfoFromSearch;
  150. try {
  151. const html = await gmFetch(`https://store.steampowered.com/app/${appId}/?cc=us&l=en`, 'text', 15000);
  152. const doc = new DOMParser().parseFromString(html, 'text/html');
  153. scrapedTags = Array.from(doc.querySelectorAll('.glance_tags.popular_tags a.app_tag')).map(el => el.textContent.trim()).slice(0, 5);
  154.  
  155. if (!finalReviewInfo) {
  156. const row = Array.from(doc.querySelectorAll('.user_reviews_summary_row')).find(r => r.querySelector('.subtitle')?.textContent.trim().startsWith('All Review'));
  157. if (row) {
  158. const span = row.querySelector('.summary .game_review_summary');
  159. if (span) {
  160. const desc = span.textContent.trim(); const tooltipData = parseReviewFromTooltip(span.dataset.tooltipText);
  161. if (!tooltipData.total) { const cs = row.querySelector('.summary .responsive_hidden'); if(cs){ const cm=cs.textContent.match(/\(([\d,]+)\)/); if(cm) tooltipData.total=cm[1].replace(/,/g,'');}}
  162. if (desc) { scrapedReviewInfo = { desc, percent: tooltipData.percent, total: tooltipData.total, source: 'scrape' }; finalReviewInfo = scrapedReviewInfo; }
  163. }
  164. }
  165. }
  166. } catch (err) { console.warn(`HTML scrape err for "${name}":`, err); }
  167.  
  168. const finalTags = scrapedTags.length ? scrapedTags : [...new Set([...(appData.genres || []).map(g => g.description), ...(appData.categories || []).map(c => c.description)])].slice(0, 5);
  169. if (finalReviewInfo && !finalReviewInfo.desc) finalReviewInfo = null;
  170. const combinedData = { ...appData, tags: finalTags, reviewInfo: finalReviewInfo };
  171. apiCache.set(name, { data: combinedData, ts: now }); pruneCache(apiCache);
  172. return combinedData;
  173. }
  174.  
  175. function cleanName(raw) {
  176. if (/soundtrack|ost|demo|dlc pack|artbook|season pass|multiplayer crack/i.test(raw)) return null;
  177. let name = raw.trim().replace(/\(\d{4}\)/, '').replace(/S\d{1,2}(E\d{1,2})?/, '').trim();
  178. const delimiters = /(?:[.\-_/(\[]|\bUpdate\b|\bBuild\b|v[\d.]+|\bEdition\b|\bDeluxe\b|\bDirectors? Cut\b|\bComplete\b|\bGold\b|\bGOTY\b|\bRemastered\b|\bAnniversary\b|\bEnhanced\b|\bVR\b)/i;
  179. name = name.split(delimiters)[0].trim();
  180. name = name.replace(/[-. ](CODEX|CPY|SKIDROW|PLAZA|HOODLUM|FLT|DOGE|DARKSiDERS|EMPRESS|RUNE|TENOKE|TiNYiSO|ElAmigos|FitGirl|DODI)$/i, '').trim();
  181. name = name.replace(/^(The|Sid Meier'?s|Tom Clancy'?s)\s+/i, '').trim();
  182. return name || null;
  183. }
  184.  
  185. function positionTip(e) {
  186. let x = e.clientX + 15; let y = e.clientY + 15; const w = tip.offsetWidth; const h = tip.offsetHeight; const margin = 10;
  187. if (x + w + margin > window.innerWidth) { x = e.clientX - w - 15; if (x < margin) x = margin; }
  188. if (y + h + margin > window.innerHeight) { y = window.innerHeight - h - margin; if (y < margin) y = margin; }
  189. tip.style.left = x + 'px'; tip.style.top = y + 'px';
  190. }
  191.  
  192. let hoverId = 0, pointerOn = false, lastEvent = null, showTimeout = null;
  193.  
  194. async function showTip(e) {
  195. clearTimeout(showTimeout);
  196. pointerOn = true;
  197. lastEvent = e;
  198. const raw = e.target.textContent;
  199. const name = cleanName(raw);
  200. if (!name) return;
  201.  
  202. const thisId = ++hoverId;
  203.  
  204. tip.innerHTML = `<p>Loading <strong>${name}</strong>…</p>`;
  205. tip.style.display = 'block';
  206. positionTip(e);
  207.  
  208. showTimeout = setTimeout(async () => {
  209. if (hoverId !== thisId || !pointerOn || !document.querySelector(`${SEL}:hover`)) {
  210. if (!document.querySelector(`${SEL}:hover`)) hideTip();
  211. return;
  212. }
  213.  
  214. const data = await fetchSteam(name);
  215.  
  216. if (hoverId !== thisId || !pointerOn || !document.querySelector(`${SEL}:hover`)) {
  217. if (!document.querySelector(`${SEL}:hover`)) hideTip();
  218. return;
  219. }
  220.  
  221. if (!data) {
  222. tip.innerHTML = `<p>No Steam info found for<br><strong>${name}</strong>.</p>`;
  223. positionTip(e);
  224. return;
  225. }
  226.  
  227. let reviewHtml = '';
  228. if (data.reviewInfo && data.reviewInfo.desc) {
  229. const { desc, percent, total } = data.reviewInfo;
  230. const starsHtml = getRatingStars(percent, desc);
  231. const formattedTotal = total ? parseInt(String(total).replace(/,/g, '')).toLocaleString('en-US') : '';
  232. reviewHtml = `
  233. <p class="steamRating">
  234. <strong>Rating:</strong>
  235. ${starsHtml}
  236. <span class="ratingText">
  237. ${desc}
  238. ${formattedTotal ? `   |   ${formattedTotal} reviews` : ''}
  239. </span>
  240. </p>`;
  241. }
  242.  
  243. const tags = data.tags || [];
  244. const tagHtml = tags.length ? `<p class="steamTags"><strong>Tags:</strong> ${tags.join(' • ')}</p>` : '';
  245.  
  246. tip.innerHTML = `
  247. <img src="${data.header_image}" alt="${data.name || name} header image">
  248. <p>${data.short_description || 'No description available.'}</p>
  249. ${reviewHtml}
  250. ${tagHtml}`;
  251. positionTip(e);
  252.  
  253. }, 10);
  254. }
  255.  
  256. function hideTip() {
  257. pointerOn = false;
  258. clearTimeout(showTimeout);
  259. tip.style.display = 'none';
  260. }
  261.  
  262.  
  263. function onPointerMove(e) { if (!pointerOn) return; lastEvent = e; }
  264. function rafLoop() { if (pointerOn && lastEvent && tip.style.display === 'block') { positionTip(lastEvent); } requestAnimationFrame(rafLoop); }
  265.  
  266. const SEL = 'td.coll-1 a[href^="/torrent/"]';
  267. document.addEventListener('mouseenter', e => { if (e.target.matches(SEL)) { showTip(e); } }, true);
  268. document.addEventListener('mouseleave', e => { if (e.target.matches(SEL)) { hideTip(); } }, true);
  269. document.addEventListener('pointermove', onPointerMove, true);
  270. document.addEventListener('mouseleave', (e) => { if (e.target === document.documentElement) hideTip(); });
  271.  
  272. console.log("1337x Steam Hover Preview script-timingmod loaded.");
  273. rafLoop();
  274. })();