On-hover Steam thumbnail, description, Steam‐provided tags, and Steam Ratings for 1337x torrent titles
当前为
// ==UserScript==
// @name 1337x - Steam Hover Preview
// @namespace https://greasyfork.org/en/users/1340389-deonholo
// @version 2.7
// @description On-hover Steam thumbnail, description, Steam‐provided tags, and Steam Ratings 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';
GM_addStyle(`
.steamHoverTip {
position: fixed; 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; pointer-events: none;
white-space: normal !important; overflow-wrap: break-word; color: #111;
}
.steamHoverTip p { margin: 0 0 5px 0; padding: 0; white-space: normal; }
.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; }
`);
const tip = document.createElement('div');
tip.className = 'steamHoverTip';
document.body.appendChild(tip);
const MIN_INTERVAL = 50;
let lastRequest = 0;
const apiCache = new Map();
const MAX_CACHE = 100;
const CACHE_TTL = 1000 * 60 * 15;
function pruneCache(map) {
if (map.size <= MAX_CACHE) return;
map.delete(map.keys().next().value);
}
function gmFetch(url, responseType = 'json', timeout = 10000) {
const wait = Math.max(0, MIN_INTERVAL - (Date.now() - lastRequest));
return new Promise(r => setTimeout(r, wait))
.then(() => new Promise((resolve, reject) => {
lastRequest = Date.now();
GM_xmlhttpRequest({
method: 'GET', url, responseType, 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) { reject(new Error(`JSON parse error for ${url}`)); } }
} else { resolve(res.response); }
} else {
reject(new Error(`HTTP ${res.status} for ${url}`));
}
},
onerror: err => reject(new Error(`Network error for ${url}: ${err.statusText || err.error || 'Unknown'}`)),
ontimeout: () => reject(new Error(`Timeout (${timeout}ms) for ${url}`)),
onabort: () => reject(new Error(`Aborted request for ${url}`))
});
}));
}
function getRatingStars(percent, desc) {
const filledStar = '★';
const emptyStar = '☆';
let stars = '';
let usedPercent = false;
const numPercent = parseInt(percent, 10);
if (!isNaN(numPercent)) {
usedPercent = true;
if (numPercent >= 95) stars = filledStar.repeat(5);
else if (numPercent >= 80) stars = filledStar.repeat(4) + emptyStar;
else if (numPercent >= 70) stars = filledStar.repeat(3) + emptyStar.repeat(2);
else if (numPercent >= 40) stars = filledStar.repeat(2) + emptyStar.repeat(3);
else if (numPercent >= 20) stars = filledStar.repeat(1) + emptyStar.repeat(4);
else stars = emptyStar.repeat(5);
}
if (!usedPercent && desc) {
const lowerDesc = desc.toLowerCase();
if (lowerDesc.includes('overwhelmingly positive')) stars = filledStar.repeat(5);
else if (lowerDesc.includes('very positive')) stars = filledStar.repeat(4) + emptyStar;
else if (lowerDesc.includes('mostly positive')) stars = filledStar.repeat(4) + emptyStar;
else if (lowerDesc.includes('positive')) stars = filledStar.repeat(4) + emptyStar;
else if (lowerDesc.includes('mixed')) stars = filledStar.repeat(3) + emptyStar.repeat(2);
else if (lowerDesc.includes('mostly negative')) stars = filledStar.repeat(2) + emptyStar.repeat(3);
else if (lowerDesc.includes('negative')) stars = filledStar.repeat(1) + emptyStar.repeat(4);
else if (lowerDesc.includes('very negative')) stars = filledStar.repeat(1) + emptyStar.repeat(4);
else if (lowerDesc.includes('overwhelmingly negative')) stars = filledStar.repeat(1) + emptyStar.repeat(4);
}
return stars ? `<span class="ratingStars">${stars}</span>` : '';
}
async function fetchSteam(name) {
const now = Date.now();
let cachedData = apiCache.get(name);
if (cachedData && (now - cachedData.ts < CACHE_TTL)) {
return cachedData.data;
}
let appData, appId;
try {
const searchUrl = `https://store.steampowered.com/api/storesearch/?cc=us&l=en&term=${encodeURIComponent(name)}`;
const search = await gmFetch(searchUrl);
let sr = search?.items?.[0];
if (search?.items?.length > 1) {
const lcName = name.toLowerCase();
const exactMatch = search.items.find(i => i.name.toLowerCase() === lcName);
if (exactMatch) sr = exactMatch;
}
appId = sr?.id;
if (!appId) throw new Error('No AppID found via storesearch');
const detailsUrl = `https://store.steampowered.com/api/appdetails?appids=${appId}&cc=us&l=en`;
const detailsResp = await gmFetch(detailsUrl);
appData = detailsResp?.[appId]?.success ? detailsResp[appId].data : null;
if (!appData) throw new Error('No appdetails data');
} catch (err) {
apiCache.set(name, { data: null, ts: now }); pruneCache(apiCache);
return null;
}
let finalReviewInfo = null;
try {
const reviewApiUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&language=all&purchase_type=all&filter=summary`;
const reviewData = await gmFetch(reviewApiUrl);
if (reviewData && reviewData.success && reviewData.query_summary) {
const summary = reviewData.query_summary;
const percent = (summary.total_reviews && summary.total_reviews > 0)
? Math.round((summary.total_positive / summary.total_reviews) * 100)
: null;
const desc = summary.review_score_desc || (summary.total_reviews === 0 ? 'No Reviews' : 'N/A');
if (summary.total_reviews > 0 || desc !== 'N/A') {
finalReviewInfo = {
desc: desc,
percent: percent,
total: summary.total_reviews || 0,
source: 'api_appreviews'
};
}
}
} catch (reviewErr) {}
let finalTags = [];
try {
const htmlUrl = `https://store.steampowered.com/app/${appId}/?cc=us&l=en`;
const html = await gmFetch(htmlUrl, 'text', 15000);
const doc = new DOMParser().parseFromString(html, 'text/html');
const scrapedTags = Array.from(doc.querySelectorAll('.glance_tags.popular_tags a.app_tag'))
.map(el => el.textContent.trim())
.slice(0, 5);
if (scrapedTags.length > 0) {
finalTags = scrapedTags;
} else {
finalTags = [...new Set([
...(appData.genres || []).map(g => g.description),
...(appData.categories || []).map(c => c.description)
])].slice(0, 5);
}
} catch (tagErr) {
finalTags = [...new Set([
...(appData.genres || []).map(g => g.description),
...(appData.categories || []).map(c => c.description)
])].slice(0, 5);
}
const combinedData = { ...appData, tags: finalTags, reviewInfo: finalReviewInfo };
apiCache.set(name, { data: combinedData, ts: now }); pruneCache(apiCache);
return combinedData;
}
function cleanName(raw) {
if (/soundtrack|ost|demo|dlc pack|artbook|season pass|multiplayer crack/i.test(raw)) return null;
let name = raw.trim().replace(/\(\d{4}\)/, '').replace(/S\d{1,2}(E\d{1,2})?/, '').trim();
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|\bUltimate\b)/i;
name = name.split(delimiters)[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 positionTip(e) {
let x = e.clientX + 15; let y = e.clientY + 15; const w = tip.offsetWidth; const h = tip.offsetHeight; const margin = 10;
if (x + w + margin > window.innerWidth) { x = e.clientX - w - 15; if (x < margin) x = margin; }
if (y + h + margin > window.innerHeight) { y = window.innerHeight - h - margin; if (y < margin) y = margin; }
tip.style.left = x + 'px'; tip.style.top = y + 'px';
}
let hoverId = 0, pointerOn = false, lastEvent = null, showTimeout = null, currentFetchPromise = null;
async function showTip(e) {
clearTimeout(showTimeout);
pointerOn = true;
lastEvent = e;
const targetElement = e.target;
const raw = targetElement.textContent;
const name = cleanName(raw);
if (!name) return;
const thisId = ++hoverId;
tip.innerHTML = `<p>Loading <strong>${name}</strong>…</p>`;
tip.style.display = 'block';
positionTip(e);
showTimeout = setTimeout(async () => {
if (hoverId !== thisId || !pointerOn || !targetElement.matches(':hover')) {
if (!document.querySelector(`${SEL}:hover`)) hideTip();
return;
}
try {
currentFetchPromise = fetchSteam(name);
const data = await currentFetchPromise;
currentFetchPromise = null;
if (hoverId !== thisId || !pointerOn || !targetElement.matches(':hover')) {
if (!document.querySelector(`${SEL}:hover`)) hideTip();
return;
}
if (!data) {
tip.innerHTML = `<p>No Steam info found for<br><strong>${name}</strong>.</p>`;
positionTip(lastEvent || e);
return;
}
let reviewHtml = '';
if (data.reviewInfo && data.reviewInfo.desc && data.reviewInfo.desc !== 'N/A') {
const { desc, percent, total } = data.reviewInfo;
const starsHtml = getRatingStars(percent, desc);
const formattedTotal = (typeof total === 'number' && total > 0) ? total.toLocaleString('en-US') : '';
reviewHtml = `
<p class="steamRating">
<strong>Rating:</strong>
${starsHtml}
<span class="ratingText">
${desc}
${formattedTotal ? ` | ${formattedTotal} reviews` : ''}
</span>
</p>`;
}
const tags = data.tags || [];
const tagHtml = tags.length ? `<p class="steamTags"><strong>Tags:</strong> ${tags.join(' • ')}</p>` : '';
tip.innerHTML = `
<img src="${data.header_image}" alt="${data.name || name} header image" onerror="this.style.display='none';">
<p><strong>${data.name || name}</strong></p> <p>${data.short_description || 'No description available.'}</p>
${reviewHtml}
${tagHtml}`;
positionTip(lastEvent || e);
} catch (error) {
if (hoverId === thisId && pointerOn && targetElement.matches(':hover')) {
tip.innerHTML = `<p>Error loading data for<br><strong>${name}</strong>.</p>`;
positionTip(lastEvent || e);
}
currentFetchPromise = null;
}
}, 150);
}
function hideTip() {
pointerOn = false;
clearTimeout(showTimeout);
tip.style.display = 'none';
hoverId++;
}
function onPointerMove(e) { if (!pointerOn) return; lastEvent = e; }
function rafLoop() { if (pointerOn && lastEvent && tip.style.display === 'block') { positionTip(lastEvent); } requestAnimationFrame(rafLoop); }
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/"]';
document.addEventListener('mouseover', e => {
const target = e.target.closest(SEL);
if (target) {
if (tip.style.display === 'block' && tip.innerHTML.includes(`<strong>${cleanName(target.textContent)}</strong>`)) {
pointerOn = true;
lastEvent = e;
return;
}
if(tip.style.display === 'none' || !tip.innerHTML.includes(`<strong>${cleanName(target.textContent)}</strong>`)) {
showTip({ target: target, clientX: e.clientX, clientY: e.clientY });
}
}
}, true);
document.addEventListener('mouseout', e => {
const target = e.target.closest(SEL);
if (target) {
if (!target.contains(e.relatedTarget) && !tip.contains(e.relatedTarget)) {
hideTip();
}
} else if (!tip.contains(e.relatedTarget) && !document.querySelector(`${SEL}:hover`)) {
hideTip();
}
}, true);
document.addEventListener('pointermove', onPointerMove, { capture: true, passive: true });
console.log("1337x Steam Hover Preview script loaded.");
rafLoop();
})();