// ==UserScript==
// @name 1337x - Steam Hover Preview
// @namespace https://greasyfork.org/en/users/1340389-deonholo
// @version 2.9
// @description On-hover Steam thumbnail, description, Steam Ratings, Steam‐provided tags, and a direct “Open on Steam” link for 1337x torrent titles
// @icon https://greasyfork.s3.us-east-2.amazonaws.com/x432yc9hx5t6o2gbe9ccr7k5l6u8
// @author DeonHolo
// @license MIT
// @match *://*.1337x.to/*
// @match *://*.1337x.ws/*
// @match *://*.1337x.is/*
// @match *://*.1337x.gd/*
// @match *://*.x1337x.cc/*
// @match *://*.1337x.st/*
// @match *://*.x1337x.ws/*
// @match *://*.1337x.eu/*
// @match *://*.1337x.se/*
// @match *://*.x1337x.eu/*
// @match *://*.x1337x.se/*
// @match http://l337xdarkkaqfwzntnfk5bmoaroivtl6xsbatabvlb52umg6v3ch44yd.onion/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect store.steampowered.com
// @connect steamcdn-a.akamaihd.net
// @run-at document-idle
// ==/UserScript==
(() => {
'use strict';
const tip = document.createElement('div');
tip.className = 'steamHoverTip';
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/"]';
const MIN_INTERVAL = 50;
const MAX_CACHE = 100;
const CACHE_TTL = 15 * 60 * 1000;
const HIDE_DELAY = 100;
const FADE_DURATION = 200;
const API_TIMEOUT = 10000;
const TAG_TIMEOUT = 15000;
const SHOW_DELAY = 150;
async function preloadAll() {
const links = Array.from(document.querySelectorAll(SEL));
const toFetch = new Set();
for (const link of links) {
const name = cleanName(link.textContent);
if (name && !apiCache.has(name)) {
toFetch.add(name);
}
}
for (const name of toFetch) {
fetchSteam(name).catch(()=>{});
await new Promise(r => setTimeout(r, MIN_INTERVAL));
}
}
window.addEventListener('load', () => {
setTimeout(preloadAll, 50);
});
GM_addStyle(`
.steamHoverTip {
position: absolute;
padding: 8px;
background: rgba(240, 240, 240, 0.97);
border: 1px solid #555;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
z-index: 2147483647;
max-width: 310px;
font-size: 12px;
line-height: 1.45;
display: none;
white-space: normal !important;
overflow-wrap: break-word;
color: #111;
opacity: 0;
transition: opacity ${FADE_DURATION}ms ease-in-out;
pointer-events: none;
}
.steamHoverTip p {
margin: 0 0 5px 0;
padding: 0;
}
.steamHoverTip p:last-child {
margin-bottom: 0;
}
.steamHoverTip img {
display: block;
width: 100%;
margin-bottom: 8px;
border-radius: 2px;
}
.steamHoverTip strong {
color: #000;
}
.steamHoverTip .steamRating,
.steamHoverTip .steamTags {
margin-top: 8px;
font-size: 12px;
color: #333;
}
.steamHoverTip .steamTags strong,
.steamHoverTip .steamRating strong {
color: #111;
margin-right: 4px;
}
.steamHoverTip .ratingStars {
color: #f5c518;
margin-right: 6px;
letter-spacing: 1px;
font-size: 14px;
display: inline-block;
vertical-align: middle;
}
.steamHoverTip .ratingText {
vertical-align: middle;
}
.steamHoverTip a {
color: #0645ad;
text-decoration: underline;
cursor: pointer;
}
`);
const apiCache = new Map();
let lastRequest = 0;
let hoverId = 0;
let showTimeout = null;
let hideTimeout = null;
let displayTimeout = null;
let currentFetch = null;
let trackingMove = false;
let lastMoveEvent = null;
let currentHoveredLink = null;
document.body.appendChild(tip);
function pruneCache(map) {
if (map.size > MAX_CACHE) {
map.delete(map.keys().next().value);
}
}
function getRatingStars(percent, desc) {
const filled = '★';
const empty = '☆';
const p = parseInt(percent, 10);
let stars = '';
if (!isNaN(p)) {
if (p >= 95) stars = filled.repeat(5);
else if (p >= 80) stars = filled.repeat(4) + empty;
else if (p >= 70) stars = filled.repeat(3) + empty.repeat(2);
else if (p >= 40) stars = filled.repeat(2) + empty.repeat(3);
else if (p >= 20) stars = filled + empty.repeat(4);
else stars = empty.repeat(5);
} else if (desc) {
const d = desc.toLowerCase();
if (d.includes('overwhelmingly positive')) stars = filled.repeat(5);
else if (d.includes('very positive')) stars = filled.repeat(4) + empty;
else if (d.includes('mostly positive')) stars = filled.repeat(4) + empty;
else if (d.includes('positive')) stars = filled.repeat(4) + empty;
else if (d.includes('mixed')) stars = filled.repeat(3) + empty.repeat(2);
else if (d.includes('mostly negative')) stars = filled.repeat(2) + empty.repeat(3);
else if (d.includes('negative')) stars = filled + empty.repeat(4);
else if (d.includes('very negative')) stars = filled + empty.repeat(4);
else if (d.includes('overwhelmingly negative')) stars = filled + empty.repeat(4);
}
return stars ? `<span class="ratingStars">${stars}</span>` : '';
}
function cleanName(raw) {
if (/soundtrack|ost|demo|dlc pack|artbook|season pass|multiplayer crack/i.test(raw)) {
return null;
}
let name = raw.trim();
name = name.replace(/\(\d{4}\)/, '').replace(/S\d{1,2}(E\d{1,2})?/, '').trim();
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;
name = name.split(delim)[0].trim();
name = name.replace(/[-. ](CODEX|CPY|SKIDROW|PLAZA|HOODLUM|FLT|DOGE|DARKSiDERS|EMPRESS|RUNE|TENOKE|TiNYiSO|ElAmigos|FitGirl|DODI)$/i, '').trim();
name = name.replace(/^(The|Sid Meier'?s|Tom Clancy'?s)\s+/i, '').trim();
return name || null;
}
function gmFetch(url, responseType = 'json', timeout = API_TIMEOUT) {
const wait = Math.max(0, MIN_INTERVAL - (Date.now() - lastRequest));
return new Promise(resolve => setTimeout(resolve, wait))
.then(() => new Promise((resolve, reject) => {
lastRequest = Date.now();
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: responseType,
timeout: timeout,
headers: {
'Accept-Language': 'en-US,en;q=0.9'
},
onload: (res) => {
if (res.status >= 200 && res.status < 300) {
if (responseType === 'json') {
if (typeof res.response === 'object' && res.response !== null) {
resolve(res.response);
} else {
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
console.error(`JSON parse error for ${url}:`, e, res.responseText);
reject(new Error(`JSON parse error for ${url}`));
}
}
} else {
resolve(res.response || res.responseText);
}
} else {
console.warn(`HTTP ${res.status} for ${url}`);
reject(new Error(`HTTP ${res.status} for ${url}`));
}
},
onerror: (err) => {
console.error(`Network error for ${url}:`, err);
reject(new Error(`Network error for ${url}: ${err.statusText || err.error || 'Unknown'}`));
},
ontimeout: () => {
console.warn(`Timeout ${timeout}ms for ${url}`);
reject(new Error(`Timeout ${timeout}ms for ${url}`));
},
onabort: () => {
console.warn(`Aborted request for ${url}`);
reject(new Error(`Aborted request for ${url}`));
}
});
}));
}
async function fetchSteam(name) {
const now = Date.now();
const hit = apiCache.get(name);
if (hit && now - hit.ts < CACHE_TTL) {
return hit.data;
}
let appId = null;
let appData = null;
try {
const searchUrl = `https://store.steampowered.com/api/storesearch/?cc=us&l=en&term=${encodeURIComponent(name)}`;
const searchRes = await gmFetch(searchUrl, 'json');
let result = searchRes?.items?.[0];
if (searchRes?.items?.length > 1) {
const exactMatch = searchRes.items.find(item => item.name.toLowerCase() === name.toLowerCase());
if (exactMatch) {
result = exactMatch;
}
}
appId = result?.id;
if (!appId) {
throw new Error('No suitable AppID found in search results.');
}
const detailsUrl = `https://store.steampowered.com/api/appdetails?appids=${appId}&cc=us&l=en`;
const detailsRes = await gmFetch(detailsUrl, 'json');
if (detailsRes?.[appId]?.success) {
appData = detailsRes[appId].data;
} else {
throw new Error('Failed to fetch app details or API indicated failure.');
}
} catch (err) {
console.warn(`Steam search/details fetch failed for "${name}":`, err.message);
apiCache.set(name, { data: null, ts: now });
pruneCache(apiCache);
return null;
}
let reviewInfo = null;
try {
const reviewUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&language=all&purchase_type=all&filter=summary`;
const reviewRes = await gmFetch(reviewUrl, 'json');
if (reviewRes?.success && reviewRes.query_summary) {
const summary = reviewRes.query_summary;
const percent = summary.total_reviews ? Math.round((summary.total_positive / summary.total_reviews) * 100) : null;
reviewInfo = {
desc: summary.review_score_desc || 'No Reviews',
percent: percent,
total: summary.total_reviews || 0
};
}
} catch (revErr) {
console.warn(`Steam reviews fetch failed for AppID ${appId}:`, revErr.message);
}
let tags = [];
try {
const appPageUrl = `https://store.steampowered.com/app/${appId}/?cc=us&l=en`;
const html = await gmFetch(appPageUrl, 'text', TAG_TIMEOUT);
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
tags = Array.from(doc.querySelectorAll('.glance_tags.popular_tags a.app_tag'))
.map(el => el.textContent.trim())
.slice(0, 5);
} catch (tagErr) {
console.warn(`Steam tag scrape failed for AppID ${appId}:`, tagErr.message);
}
if (tags.length === 0 && appData) {
const genreTags = (appData.genres || []).map(g => g.description);
const categoryTags = (appData.categories || []).map(c => c.description);
tags = [...genreTags, ...categoryTags].filter(Boolean).slice(0, 5);
}
const data = {
...appData,
tags: tags,
reviewInfo: reviewInfo,
storeUrl: `https://store.steampowered.com/app/${appId}/`
};
apiCache.set(name, { data: data, ts: now });
pruneCache(apiCache);
return data;
}
function positionTip(ev) {
if (!tip) return;
let x = ev.pageX + 15;
let y = ev.pageY + 15;
const tipWidth = tip.offsetWidth;
const tipHeight = tip.offsetHeight;
const margin = 10;
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
const viewWidth = window.innerWidth;
const viewHeight = window.innerHeight;
if (x + tipWidth + margin > scrollX + viewWidth) {
x = ev.pageX - tipWidth - 15;
if (x < scrollX + margin) {
x = scrollX + margin;
}
}
if (x < scrollX + margin) {
x = scrollX + margin;
}
if (y + tipHeight + margin > scrollY + viewHeight) {
let yAbove = ev.pageY - tipHeight - 15;
if (yAbove > scrollY + margin) {
y = yAbove;
} else {
y = scrollY + viewHeight - tipHeight - margin;
if (y < scrollY + margin) {
y = scrollY + margin;
}
}
}
if (y < scrollY + margin) {
y = scrollY + margin;
}
tip.style.left = `${x}px`;
tip.style.top = `${y}px`;
}
function startHideAnimation() {
if (tip.style.display !== 'none' && tip.style.opacity !== '0') {
tip.style.opacity = '0';
tip.style.pointerEvents = 'none';
trackingMove = false;
clearTimeout(displayTimeout);
displayTimeout = setTimeout(() => {
tip.style.display = 'none';
}, FADE_DURATION);
} else if (tip.style.display !== 'none') {
clearTimeout(displayTimeout);
displayTimeout = setTimeout(() => { tip.style.display = 'none'; }, FADE_DURATION);
}
}
function actuallyHideTip() {
hoverId++;
currentFetch = null;
currentHoveredLink = null;
clearTimeout(showTimeout);
startHideAnimation();
}
function scheduleHideTip() {
clearTimeout(hideTimeout);
clearTimeout(displayTimeout);
hideTimeout = setTimeout(actuallyHideTip, HIDE_DELAY);
}
function cancelHideTip() {
clearTimeout(hideTimeout);
clearTimeout(displayTimeout);
if (tip.style.display === 'block' && tip.style.opacity === '0') {
tip.style.opacity = '1';
tip.style.pointerEvents = 'auto';
}
}
function triggerShowAndFadeIn(event, gameName) {
cancelHideTip();
clearTimeout(displayTimeout);
tip.innerHTML = `<p>Loading <strong>${gameName}</strong>…</p>`;
positionTip(event);
tip.style.display = 'block';
void tip.offsetHeight;
tip.style.opacity = '1';
tip.style.pointerEvents = 'auto';
}
tip.addEventListener('mouseenter', () => {
cancelHideTip();
if (trackingMove) {
trackingMove = false;
}
});
tip.addEventListener('mouseleave', () => {
scheduleHideTip();
});
document.addEventListener('mouseover', async (e) => {
const targetLink = e.target.closest(SEL);
const isOverTip = tip.contains(e.target);
if (targetLink || isOverTip) {
cancelHideTip();
}
if (!targetLink || (targetLink === currentHoveredLink && !trackingMove)) {
return;
}
if (currentHoveredLink && targetLink !== currentHoveredLink && tip.style.display === 'block') {
tip.style.opacity = '0';
tip.style.pointerEvents = 'none';
tip.style.display = 'none';
hoverId++;
trackingMove = false;
currentFetch = null;
}
currentHoveredLink = targetLink;
const rawName = targetLink.textContent;
const gameName = cleanName(rawName);
if (!gameName) {
currentHoveredLink = null;
return;
}
clearTimeout(showTimeout);
const thisId = ++hoverId;
trackingMove = true;
lastMoveEvent = e;
triggerShowAndFadeIn(e, gameName);
showTimeout = setTimeout(async () => {
if (hoverId !== thisId || !currentHoveredLink || currentHoveredLink !== targetLink) {
if (!currentHoveredLink || currentHoveredLink !== targetLink) {
trackingMove = false;
}
return;
}
currentFetch = fetchSteam(gameName);
const data = await currentFetch;
currentFetch = null;
if (hoverId !== thisId || !currentHoveredLink || currentHoveredLink !== targetLink) {
if (!currentHoveredLink || currentHoveredLink !== targetLink) {
trackingMove = false;
}
return;
}
if (!data) {
tip.innerHTML = `<p>No Steam info found for<br><strong>${gameName}</strong>.</p>`;
} else {
const tagsHtml = data.tags?.length ?
`<p class="steamTags"><strong>Tags:</strong> ${data.tags.join(' • ')}</p>` :
'';
const reviewHtml = (data.reviewInfo && data.reviewInfo.desc !== 'N/A' && data.reviewInfo.desc !== 'No Reviews') ?
`<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>` :
'';
tip.innerHTML = `
${data.header_image ? `<img src="${data.header_image}" alt="${data.name || gameName}" onerror="this.style.display='none'">` : ''}
<p><strong>${data.name || gameName}</strong></p>
<p>${data.short_description || 'No description available.'}</p>
${reviewHtml}
${tagsHtml}
${data.storeUrl ? `<p><a class="steam-link-in-tip" href="${data.storeUrl}" target="_blank" rel="noopener noreferrer">Open on Steam</a></p>`: ''}
`;
}
if (hoverId === thisId && currentHoveredLink === targetLink) {
positionTip(lastMoveEvent);
trackingMove = false;
tip.style.opacity = '1';
tip.style.pointerEvents = 'auto';
} else {
startHideAnimation();
}
}, SHOW_DELAY);
}, true);
document.addEventListener('mouseout', (e) => {
const leavingCurrentLink = currentHoveredLink && currentHoveredLink === e.target.closest(SEL);
const destinationIsTip = tip.contains(e.relatedTarget);
if (leavingCurrentLink && !destinationIsTip) {
scheduleHideTip();
currentHoveredLink = null;
}
}, true);
document.addEventListener('pointermove', (e) => {
if (trackingMove && tip.style.display === 'block') {
lastMoveEvent = e;
positionTip(e);
}
}, { capture: true, passive: true });
console.log("1337x Steam Hover Preview script loaded.");
})();