1337x - Steam Hover Preview

On-hover Steam thumbnail, description, and Steam‐provided tags for 1337x torrent titles

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

// ==UserScript==
// @name         1337x - Steam Hover Preview
// @namespace    https://gf.qytechs.cn/en/users/1340389-deonholo
// @version      2.3
// @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;

  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)
        });
      }));
  }

  function fetchSteam(name) {
  if (apiCache.has(name)) return apiCache.get(name);
  const p = (async () => {
    // 1a. find the app id
    const search = await gmFetch(
      `https://store.steampowered.com/api/storesearch/?cc=us&l=en&term=${encodeURIComponent(name)}`
    ).catch(() => null);
    const id = search?.items?.[0]?.id;
    if (!id) return null;

    // 1b. HTML-scrape the Steam store page for popular user tags
    const html = await gmFetch(
      `https://store.steampowered.com/app/${id}/?cc=us&l=en`,
      'text'
    ).catch(() => '');
    let scrapedTags = [];
    if (html) {
      try {
        const doc = new DOMParser().parseFromString(html, 'text/html');
        scrapedTags = Array.from(
          doc.querySelectorAll('.glance_tags.popular_tags a.app_tag')
        ).map(el => el.textContent.trim());
      } catch (e) {
        scrapedTags = [];
      }
    }

    // 1c. always fetch the API too (for description, header_image, fallback)
    const resp = await gmFetch(
      `https://store.steampowered.com/api/appdetails?appids=${id}&cc=us&l=en&cc=us`,
      'json'
    ).catch(() => null);
    const data = resp?.[id]?.data || null;
    if (!data) return null;

    // return both scrapedTags and API data
    return { ...data, scrapedTags };
  })();

  apiCache.set(name, p);
  pruneCache(apiCache);
  return p;
}

  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;
    }

    // build tags from Steam-provided genres & categories
    const genres     = (data.genres    || []).map(g => g.description);
    const categories = (data.categories|| []).map(c => c.description);
    const tags = (data.scrapedTags && data.scrapedTags.length)
      // use scraped user tags first
      ? data.scrapedTags.slice(0, 5)
      // otherwise fall back to genres + categories
      : [...new Set([
          ...(data.genres    || []).map(g => g.description),
          ...(data.categories|| []).map(c => c.description)
        ])].slice(0, 5);

    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();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址