On-hover Steam thumbnail, description, and Steam‐provided tags for 1337x torrent titles
目前為
// ==UserScript==
// @name 1337x - Steam Hover Preview
// @namespace https://greasyfork.org/en/users/1340389-deonholo
// @version 2.4
// @description On-hover Steam thumbnail, description, and Steam‐provided tags 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(255,255,255,0.95);
border: 1px solid #444;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
z-index: 2147483647;
max-width: 300px;
font-size: 12px;
line-height: 1.3;
display: none;
pointer-events: none;
white-space: normal !important;
overflow-wrap: break-word;
}
.steamHoverTip p {
margin: 0;
white-space: normal;
}
`);
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 tagCache = new Map();
const TAG_TTL = 1000 * 60 * 5; // 5-minute freshness window
function pruneCache(map) {
if (map.size <= MAX_CACHE) return;
const key = map.keys().next().value;
map.delete(key);
}
function gmFetch(url, responseType = 'json') {
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,
onload: res => resolve(res.response),
onerror: err => reject(err)
});
}));
}
async function fetchSteam(name) {
// —— API data cache ——
let dataPromise = apiCache.get(name);
if (!dataPromise) {
dataPromise = gmFetch(
`https://store.steampowered.com/api/storesearch/?cc=us&l=en&term=${encodeURIComponent(name)}`
)
.then(search => {
const id = search?.items?.[0]?.id;
if (!id) return null;
return gmFetch(
`https://store.steampowered.com/api/appdetails?appids=${id}&cc=us&l=en`,
'json'
)
.then(resp => resp[id]?.data || null);
})
.catch(() => null);
apiCache.set(name, dataPromise);
pruneCache(apiCache);
}
const data = await dataPromise;
if (!data) return null;
// —— Tag caching with TTL ——
const now = Date.now();
let entry = tagCache.get(name);
if (!entry || now - entry.ts > TAG_TTL) {
let scraped = [];
try {
const html = await gmFetch(
`https://store.steampowered.com/app/${data.steam_appid}/?cc=us&l=en`,
'text'
);
const doc = new DOMParser().parseFromString(html, 'text/html');
scraped = Array.from(
doc.querySelectorAll('.glance_tags.popular_tags a.app_tag')
).map(el => el.textContent.trim()).slice(0, 5);
} catch {
scraped = [];
}
entry = { tags: scraped, ts: now };
tagCache.set(name, entry);
pruneCache(tagCache);
}
// —— Choose your tags ——
const tags = entry.tags.length
? entry.tags
: [...new Set([
...(data.genres || []).map(g => g.description),
...(data.categories|| []).map(c => c.description)
])].slice(0, 5);
return { ...data, tags };
}
function cleanName(raw) {
if (/soundtrack|ost|demo/i.test(raw)) return null;
let name = raw.trim();
name = name.split(/(?:[-\/(\[]|Update|Edition|Deluxe)/i)[0].trim();
return name.replace(/ v[\d.].*$/i, '').trim() || null;
}
function positionTip(e) {
let x = e.clientX + 12;
let y = e.clientY + 12;
const w = tip.offsetWidth;
const h = tip.offsetHeight;
if (x + w > window.innerWidth) x = window.innerWidth - w - 8;
if (y + h > window.innerHeight) y = window.innerHeight - h - 8;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
}
let hoverId = 0;
let pointerOn = false;
let lastEvent = null;
async function showTip(e) {
pointerOn = true;
lastEvent = e;
const thisId = ++hoverId;
const raw = e.target.textContent;
const name = cleanName(raw);
if (!name) return hideTip();
tip.innerHTML = `<p>Loading <strong>${name}</strong>…</p>`;
tip.style.display = 'block';
positionTip(e);
await new Promise(r => setTimeout(r, 30));
if (thisId !== hoverId) return;
const data = await fetchSteam(name);
if (thisId !== hoverId) return;
if (!data) {
tip.innerHTML = `<p>No info for<br><strong>${name}</strong>.</p>`;
return;
}
// use the unified `tags` array from fetchSteam
const tags = data.tags || [];
const tagHtml = tags.length
? `<p style="margin-top:6px"><strong>Tags:</strong> ${tags.join(' • ')}</p>`
: '';
tip.innerHTML = `
<img src="${data.header_image}" style="width:100%;margin-bottom:6px">
<p>${data.short_description}</p>
${tagHtml}
`;
positionTip(e);
}
function hideTip() {
pointerOn = false;
tip.style.display = 'none';
hoverId++;
}
function onPointerMove(e) {
if (!pointerOn) return;
lastEvent = e;
}
function rafLoop() {
if (pointerOn && lastEvent) {
positionTip(lastEvent);
}
requestAnimationFrame(rafLoop);
}
const SEL = 'td.coll-1 a[href^="/torrent/"]';
document.addEventListener('pointerenter', e => {
if (e.target.matches(SEL)) showTip(e);
}, true);
document.addEventListener('pointerleave', e => {
if (e.target.matches(SEL)) hideTip();
}, true);
document.addEventListener('pointermove', onPointerMove, true);
rafLoop();
})();