1337x - Steam Hover Preview

On-hover Steam thumbnail, description, Steam Ratings, Steam‐provided tags, and a direct “Open on Steam” link for 1337x torrent titles

  1. // ==UserScript==
  2. // @name 1337x - Steam Hover Preview
  3. // @namespace https://greasyfork.org/en/users/1340389-deonholo
  4. // @version 3.0
  5. // @description On-hover Steam thumbnail, description, Steam Ratings, Steam‐provided tags, and a direct “Open on Steam” link 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. const tip = document.createElement('div');
  32. tip.className = 'steamHoverTip';
  33. const SEL = 'table.torrent-list td.name a[href^="/torrent/"], table.torrents td.name a[href^="/torrent/"], table.table-list td.name a[href^="/torrent/"]';
  34. const MIN_INTERVAL = 50;
  35. const MAX_CACHE = 100;
  36. const CACHE_TTL = 15 * 60 * 1000;
  37. const HIDE_DELAY = 100;
  38. const FADE_DURATION = 200;
  39. const API_TIMEOUT = 10000;
  40. const TAG_TIMEOUT = 15000;
  41. const SHOW_DELAY = 0;
  42.  
  43. async function preloadAll() {
  44. const links = Array.from(document.querySelectorAll(SEL));
  45. const toFetch = new Set();
  46.  
  47. for (const link of links) {
  48. const name = cleanName(link.textContent);
  49. if (name && !apiCache.has(name)) {
  50. toFetch.add(name);
  51. }
  52. }
  53.  
  54. for (const name of toFetch) {
  55. fetchSteam(name).catch(()=>{});
  56. await new Promise(r => setTimeout(r, MIN_INTERVAL));
  57. }
  58. }
  59.  
  60. window.addEventListener('load', () => {
  61. setTimeout(preloadAll, 50);
  62. });
  63.  
  64. GM_addStyle(`
  65. .steamHoverTip {
  66. position: absolute;
  67. padding: 8px;
  68. background: rgba(240, 240, 240, 0.97);
  69. border: 1px solid #555;
  70. border-radius: 4px;
  71. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
  72. z-index: 2147483647;
  73. max-width: 310px;
  74. font-size: 12px;
  75. line-height: 1.45;
  76. display: none;
  77. white-space: normal !important;
  78. overflow-wrap: break-word;
  79. color: #111;
  80. opacity: 0;
  81. transition: opacity ${FADE_DURATION}ms ease-in-out;
  82. pointer-events: none;
  83. }
  84.  
  85. .steamHoverTip p {
  86. margin: 0 0 5px 0;
  87. padding: 0;
  88. }
  89. .steamHoverTip p:last-child {
  90. margin-bottom: 0;
  91. }
  92. .steamHoverTip img {
  93. display: block;
  94. width: 100%;
  95. margin-bottom: 8px;
  96. border-radius: 2px;
  97. }
  98. .steamHoverTip strong {
  99. color: #000;
  100. }
  101. .steamHoverTip .steamRating,
  102. .steamHoverTip .steamTags {
  103. margin-top: 8px;
  104. font-size: 12px;
  105. color: #333;
  106. }
  107. .steamHoverTip .steamTags strong,
  108. .steamHoverTip .steamRating strong {
  109. color: #111;
  110. margin-right: 4px;
  111. }
  112. .steamHoverTip .ratingStars {
  113. color: #f5c518;
  114. margin-right: 6px;
  115. letter-spacing: 1px;
  116. font-size: 14px;
  117. display: inline-block;
  118. vertical-align: middle;
  119. }
  120. .steamHoverTip .ratingText {
  121. vertical-align: middle;
  122. }
  123. .steamHoverTip a {
  124. color: #0645ad;
  125. text-decoration: underline;
  126. cursor: pointer;
  127. }
  128. `);
  129.  
  130. const apiCache = new Map();
  131. let lastRequest = 0;
  132. let hoverId = 0;
  133. let showTimeout = null;
  134. let hideTimeout = null;
  135. let displayTimeout = null;
  136. let currentFetch = null;
  137. let trackingMove = false;
  138. let lastMoveEvent = null;
  139. let currentHoveredLink = null;
  140.  
  141. document.body.appendChild(tip);
  142.  
  143. function pruneCache(map) {
  144. if (map.size > MAX_CACHE) {
  145. map.delete(map.keys().next().value);
  146. }
  147. }
  148.  
  149. function getRatingStars(percent, desc) {
  150. const filled = '★';
  151. const empty = '☆';
  152. const p = parseInt(percent, 10);
  153. let stars = '';
  154.  
  155. if (!isNaN(p)) {
  156. if (p >= 95) stars = filled.repeat(5);
  157. else if (p >= 80) stars = filled.repeat(4) + empty;
  158. else if (p >= 70) stars = filled.repeat(3) + empty.repeat(2);
  159. else if (p >= 40) stars = filled.repeat(2) + empty.repeat(3);
  160. else if (p >= 20) stars = filled + empty.repeat(4);
  161. else stars = empty.repeat(5);
  162. } else if (desc) {
  163. const d = desc.toLowerCase();
  164. if (d.includes('overwhelmingly positive')) stars = filled.repeat(5);
  165. else if (d.includes('very positive')) stars = filled.repeat(4) + empty;
  166. else if (d.includes('mostly positive')) stars = filled.repeat(4) + empty;
  167. else if (d.includes('positive')) stars = filled.repeat(4) + empty;
  168. else if (d.includes('mixed')) stars = filled.repeat(3) + empty.repeat(2);
  169. else if (d.includes('mostly negative')) stars = filled.repeat(2) + empty.repeat(3);
  170. else if (d.includes('negative')) stars = filled + empty.repeat(4);
  171. else if (d.includes('very negative')) stars = filled + empty.repeat(4);
  172. else if (d.includes('overwhelmingly negative')) stars = filled + empty.repeat(4);
  173. }
  174. return stars ? `<span class="ratingStars">${stars}</span>` : '';
  175. }
  176.  
  177. function cleanName(raw) {
  178. if (/soundtrack|ost|demo|dlc pack|artbook|season pass|multiplayer crack/i.test(raw)) {
  179. return null;
  180. }
  181. let name = raw.trim();
  182. name = name.replace(/\(\d{4}\)/, '').replace(/S\d{1,2}(E\d{1,2})?/, '').trim();
  183. const delim = /(?:[.\-_/(\[]|\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|\bUltimate\b)/i;
  184. name = name.split(delim)[0].trim();
  185. name = name.replace(/[-. ](CODEX|CPY|SKIDROW|PLAZA|HOODLUM|FLT|DOGE|DARKSiDERS|EMPRESS|RUNE|TENOKE|TiNYiSO|ElAmigos|FitGirl|DODI)$/i, '').trim();
  186. name = name.replace(/^(The|Sid Meier'?s|Tom Clancy'?s)\s+/i, '').trim();
  187. return name || null;
  188. }
  189.  
  190. function gmFetch(url, responseType = 'json', timeout = API_TIMEOUT) {
  191. const wait = Math.max(0, MIN_INTERVAL - (Date.now() - lastRequest));
  192. return new Promise(resolve => setTimeout(resolve, wait))
  193. .then(() => new Promise((resolve, reject) => {
  194. lastRequest = Date.now();
  195. GM_xmlhttpRequest({
  196. method: 'GET',
  197. url: url,
  198. responseType: responseType,
  199. timeout: timeout,
  200. headers: {
  201. 'Accept-Language': 'en-US,en;q=0.9'
  202. },
  203. onload: (res) => {
  204. if (res.status >= 200 && res.status < 300) {
  205. if (responseType === 'json') {
  206. if (typeof res.response === 'object' && res.response !== null) {
  207. resolve(res.response);
  208. } else {
  209. try {
  210. resolve(JSON.parse(res.responseText));
  211. } catch (e) {
  212. console.error(`JSON parse error for ${url}:`, e, res.responseText);
  213. reject(new Error(`JSON parse error for ${url}`));
  214. }
  215. }
  216. } else {
  217. resolve(res.response || res.responseText);
  218. }
  219. } else {
  220. console.warn(`HTTP ${res.status} for ${url}`);
  221. reject(new Error(`HTTP ${res.status} for ${url}`));
  222. }
  223. },
  224. onerror: (err) => {
  225. console.error(`Network error for ${url}:`, err);
  226. reject(new Error(`Network error for ${url}: ${err.statusText || err.error || 'Unknown'}`));
  227. },
  228. ontimeout: () => {
  229. console.warn(`Timeout ${timeout}ms for ${url}`);
  230. reject(new Error(`Timeout ${timeout}ms for ${url}`));
  231. },
  232. onabort: () => {
  233. console.warn(`Aborted request for ${url}`);
  234. reject(new Error(`Aborted request for ${url}`));
  235. }
  236. });
  237. }));
  238. }
  239.  
  240. async function fetchSteam(name) {
  241. const now = Date.now();
  242. const hit = apiCache.get(name);
  243. if (hit && now - hit.ts < CACHE_TTL) {
  244. return hit.data;
  245. }
  246. let appId = null;
  247. let appData = null;
  248. try {
  249. const searchUrl = `https://store.steampowered.com/api/storesearch/?cc=us&l=en&term=${encodeURIComponent(name)}`;
  250. const searchRes = await gmFetch(searchUrl, 'json');
  251. let result = searchRes?.items?.[0];
  252. if (searchRes?.items?.length > 1) {
  253. const exactMatch = searchRes.items.find(item => item.name.toLowerCase() === name.toLowerCase());
  254. if (exactMatch) {
  255. result = exactMatch;
  256. }
  257. }
  258. appId = result?.id;
  259. if (!appId) {
  260. throw new Error('No suitable AppID found in search results.');
  261. }
  262. const detailsUrl = `https://store.steampowered.com/api/appdetails?appids=${appId}&cc=us&l=en`;
  263. const detailsRes = await gmFetch(detailsUrl, 'json');
  264. if (detailsRes?.[appId]?.success) {
  265. appData = detailsRes[appId].data;
  266. } else {
  267. throw new Error('Failed to fetch app details or API indicated failure.');
  268. }
  269. } catch (err) {
  270. console.warn(`Steam search/details fetch failed for "${name}":`, err.message);
  271. apiCache.set(name, { data: null, ts: now });
  272. pruneCache(apiCache);
  273. return null;
  274. }
  275. let reviewInfo = null;
  276. try {
  277. const reviewUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&language=all&purchase_type=all&filter=summary`;
  278. const reviewRes = await gmFetch(reviewUrl, 'json');
  279. if (reviewRes?.success && reviewRes.query_summary) {
  280. const summary = reviewRes.query_summary;
  281. const percent = summary.total_reviews ? Math.round((summary.total_positive / summary.total_reviews) * 100) : null;
  282. reviewInfo = {
  283. desc: summary.review_score_desc || 'No Reviews',
  284. percent: percent,
  285. total: summary.total_reviews || 0
  286. };
  287. }
  288. } catch (revErr) {
  289. console.warn(`Steam reviews fetch failed for AppID ${appId}:`, revErr.message);
  290. }
  291. let tags = [];
  292. try {
  293. const appPageUrl = `https://store.steampowered.com/app/${appId}/?cc=us&l=en`;
  294. const html = await gmFetch(appPageUrl, 'text', TAG_TIMEOUT);
  295. const parser = new DOMParser();
  296. const doc = parser.parseFromString(html, 'text/html');
  297. tags = Array.from(doc.querySelectorAll('.glance_tags.popular_tags a.app_tag'))
  298. .map(el => el.textContent.trim())
  299. .slice(0, 5);
  300. } catch (tagErr) {
  301. console.warn(`Steam tag scrape failed for AppID ${appId}:`, tagErr.message);
  302. }
  303. if (tags.length === 0 && appData) {
  304. const genreTags = (appData.genres || []).map(g => g.description);
  305. const categoryTags = (appData.categories || []).map(c => c.description);
  306. tags = [...genreTags, ...categoryTags].filter(Boolean).slice(0, 5);
  307. }
  308. const data = {
  309. ...appData,
  310. tags: tags,
  311. reviewInfo: reviewInfo,
  312. storeUrl: `https://store.steampowered.com/app/${appId}/`
  313. };
  314. apiCache.set(name, { data: data, ts: now });
  315. pruneCache(apiCache);
  316. return data;
  317. }
  318.  
  319. function positionTip(ev) {
  320. if (!tip) return;
  321. let x = ev.pageX + 15;
  322. let y = ev.pageY + 15;
  323. const tipWidth = tip.offsetWidth;
  324. const tipHeight = tip.offsetHeight;
  325. const margin = 10;
  326. const scrollX = window.scrollX || window.pageXOffset;
  327. const scrollY = window.scrollY || window.pageYOffset;
  328. const viewWidth = window.innerWidth;
  329. const viewHeight = window.innerHeight;
  330. if (x + tipWidth + margin > scrollX + viewWidth) {
  331. x = ev.pageX - tipWidth - 15;
  332. if (x < scrollX + margin) {
  333. x = scrollX + margin;
  334. }
  335. }
  336. if (x < scrollX + margin) {
  337. x = scrollX + margin;
  338. }
  339. if (y + tipHeight + margin > scrollY + viewHeight) {
  340. let yAbove = ev.pageY - tipHeight - 15;
  341. if (yAbove > scrollY + margin) {
  342. y = yAbove;
  343. } else {
  344. y = scrollY + viewHeight - tipHeight - margin;
  345. if (y < scrollY + margin) {
  346. y = scrollY + margin;
  347. }
  348. }
  349. }
  350. if (y < scrollY + margin) {
  351. y = scrollY + margin;
  352. }
  353. tip.style.left = `${x}px`;
  354. tip.style.top = `${y}px`;
  355. }
  356.  
  357. function startHideAnimation() {
  358. if (tip.style.display !== 'none' && tip.style.opacity !== '0') {
  359. tip.style.opacity = '0';
  360. tip.style.pointerEvents = 'none';
  361. trackingMove = false;
  362. clearTimeout(displayTimeout);
  363. displayTimeout = setTimeout(() => {
  364. tip.style.display = 'none';
  365. }, FADE_DURATION);
  366. } else if (tip.style.display !== 'none') {
  367. clearTimeout(displayTimeout);
  368. displayTimeout = setTimeout(() => { tip.style.display = 'none'; }, FADE_DURATION);
  369. }
  370. }
  371.  
  372. function actuallyHideTip() {
  373. hoverId++;
  374. currentFetch = null;
  375. currentHoveredLink = null;
  376. clearTimeout(showTimeout);
  377. startHideAnimation();
  378. }
  379.  
  380. function scheduleHideTip() {
  381. clearTimeout(hideTimeout);
  382. clearTimeout(displayTimeout);
  383. hideTimeout = setTimeout(actuallyHideTip, HIDE_DELAY);
  384. }
  385.  
  386. function cancelHideTip() {
  387. clearTimeout(hideTimeout);
  388. clearTimeout(displayTimeout);
  389. if (tip.style.display === 'block' && tip.style.opacity === '0') {
  390. tip.style.opacity = '1';
  391. tip.style.pointerEvents = 'auto';
  392. }
  393. }
  394.  
  395. function triggerShowAndFadeIn(event, gameName) {
  396. cancelHideTip();
  397. clearTimeout(displayTimeout);
  398. tip.innerHTML = `<p>Loading <strong>${gameName}</strong>…</p>`;
  399. positionTip(event);
  400. tip.style.display = 'block';
  401. void tip.offsetHeight;
  402. tip.style.opacity = '1';
  403. tip.style.pointerEvents = 'auto';
  404. }
  405.  
  406. tip.addEventListener('mouseenter', () => {
  407. cancelHideTip();
  408. if (trackingMove) {
  409. trackingMove = false;
  410. }
  411. });
  412.  
  413. tip.addEventListener('mouseleave', () => {
  414. scheduleHideTip();
  415. });
  416.  
  417. document.addEventListener('mouseover', async (e) => {
  418. const targetLink = e.target.closest(SEL);
  419. const isOverTip = tip.contains(e.target);
  420.  
  421. if (targetLink || isOverTip) {
  422. cancelHideTip();
  423. }
  424.  
  425. if (!targetLink || (targetLink === currentHoveredLink && !trackingMove)) {
  426. return;
  427. }
  428.  
  429. if (currentHoveredLink && targetLink !== currentHoveredLink && tip.style.display === 'block') {
  430. tip.style.opacity = '0';
  431. tip.style.pointerEvents = 'none';
  432. tip.style.display = 'none';
  433. hoverId++;
  434. trackingMove = false;
  435. currentFetch = null;
  436. }
  437.  
  438. currentHoveredLink = targetLink;
  439. const rawName = targetLink.textContent;
  440. const gameName = cleanName(rawName);
  441.  
  442. if (!gameName) {
  443. currentHoveredLink = null;
  444. return;
  445. }
  446.  
  447. clearTimeout(showTimeout);
  448.  
  449. const thisId = ++hoverId;
  450. trackingMove = true;
  451. lastMoveEvent = e;
  452.  
  453. triggerShowAndFadeIn(e, gameName);
  454.  
  455. showTimeout = setTimeout(async () => {
  456. if (hoverId !== thisId || !currentHoveredLink || currentHoveredLink !== targetLink) {
  457. if (!currentHoveredLink || currentHoveredLink !== targetLink) {
  458. trackingMove = false;
  459. }
  460. return;
  461. }
  462.  
  463. currentFetch = fetchSteam(gameName);
  464. const data = await currentFetch;
  465. currentFetch = null;
  466.  
  467. if (hoverId !== thisId || !currentHoveredLink || currentHoveredLink !== targetLink) {
  468. if (!currentHoveredLink || currentHoveredLink !== targetLink) {
  469. trackingMove = false;
  470. }
  471. return;
  472. }
  473.  
  474. if (!data) {
  475. tip.innerHTML = `<p>No Steam info found for<br><strong>${gameName}</strong>.</p>`;
  476. } else {
  477. const tagsHtml = data.tags?.length ?
  478. `<p class="steamTags"><strong>Tags:</strong> ${data.tags.join(' • ')}</p>` :
  479. '';
  480. const reviewHtml = (data.reviewInfo && data.reviewInfo.desc !== 'N/A' && data.reviewInfo.desc !== 'No Reviews') ?
  481. `<p class="steamRating"><strong>Rating:</strong> ${getRatingStars(data.reviewInfo.percent, data.reviewInfo.desc)}<span class="ratingText">${data.reviewInfo.desc}${data.reviewInfo.total ? `  |  ${data.reviewInfo.total.toLocaleString()} reviews` : ''}</span></p>` :
  482. '';
  483.  
  484. tip.innerHTML = `
  485. ${data.header_image ? `<img src="${data.header_image}" alt="${data.name || gameName}" onerror="this.style.display='none'">` : ''}
  486. <p><strong>${data.name || gameName}</strong></p>
  487. <p>${data.short_description || 'No description available.'}</p>
  488. ${reviewHtml}
  489. ${tagsHtml}
  490. ${data.storeUrl ? `<p><a class="steam-link-in-tip" href="${data.storeUrl}" target="_blank" rel="noopener noreferrer">Open on Steam</a></p>`: ''}
  491. `;
  492. }
  493.  
  494. if (hoverId === thisId && currentHoveredLink === targetLink) {
  495. positionTip(lastMoveEvent);
  496. trackingMove = false;
  497. tip.style.opacity = '1';
  498. tip.style.pointerEvents = 'auto';
  499. } else {
  500. startHideAnimation();
  501. }
  502.  
  503. }, SHOW_DELAY);
  504. }, true);
  505.  
  506.  
  507. document.addEventListener('mouseout', (e) => {
  508. const leavingCurrentLink = currentHoveredLink && currentHoveredLink === e.target.closest(SEL);
  509. const destinationIsTip = tip.contains(e.relatedTarget);
  510. if (leavingCurrentLink && !destinationIsTip) {
  511. scheduleHideTip();
  512. currentHoveredLink = null;
  513. }
  514. }, true);
  515.  
  516. document.addEventListener('pointermove', (e) => {
  517. if (trackingMove && tip.style.display === 'block') {
  518. lastMoveEvent = e;
  519. positionTip(e);
  520. }
  521. }, { capture: true, passive: true });
  522.  
  523. console.log("1337x Steam Hover Preview script loaded.");
  524.  
  525. })();