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

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

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