Torn Search Helper

Show hospital time live with attack button on user search page.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Torn Search Helper
// @namespace    torn-search-helper
// @version      1.8
// @description  Show hospital time live with attack button on user search page.
// @author       Rick-Grimes
// @match        https://www.torn.com/page.php?sid=UserList*
// @match        https://www.torn.com/loader.php?sid=UserList*
// @license      MIT
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
  "use strict";

  console.log("[TornSearchHelper] loaded");

  // --- configuration & key storage ---
  const STORAGE_KEY = "tsh-apikey-v1";
  let apiKey = localStorage.getItem(STORAGE_KEY) ?? "";

  try {
    GM_registerMenuCommand("Torn Search Helper: Set API Key", () => {
      const v = prompt("Enter your PUBLIC Torn API key (16 chars) — leave empty to disable API usage:", apiKey || "");
      if (v === null) return;
      if (v === "" || v.length === 16) {
        apiKey = v;
        localStorage.setItem(STORAGE_KEY, apiKey);
        alert("API key saved.");
      } else alert("API key must be 16 characters or blank.");
    });
  } catch (e) {
    console.warn("[TornSearchHelper] GM_registerMenuCommand unavailable:", e);
  }

  // --- styles ---
  GM_addStyle(`
.tsh-status { display:inline-block; margin-left:6px; font-size:12px; font-weight:600; color:#f39c12; white-space:nowrap; text-shadow:0 1px 0 rgba(0,0,0,0.6);}
.tsh-status.urgent { color:#ff6b6b; }
.tsh-status.small { font-weight:500; color:#bdbdbd; }
.tsh-attack { display:inline-block; margin-left:6px; padding:2px 6px; font-size:11px; font-weight:700; color:#fff; background:#e74c3c; border-radius:3px; cursor:pointer; text-decoration:none; vertical-align:middle; line-height:1; box-shadow:0 1px 0 rgba(0,0,0,0.2);}
.tsh-attack:hover { filter: brightness(0.95); }
.tsh-attack:active { transform: translateY(1px); }
.tsh-wrap { display:inline-flex; align-items:center; gap:6px; }
`);

  // --- helpers ---
  function qAll(sel, root = document) { return Array.from(root.querySelectorAll(sel)); }
  function q(sel, root = document) { return root.querySelector(sel); }
  function pad(n){ return String(n).padStart(2,"0"); }
  function formatRemaining(seconds){
    if(seconds<=0) return "00:00:00";
    const h=Math.floor(seconds/3600), m=Math.floor((seconds%3600)/60), s=seconds%60;
    return `${pad(h)}:${pad(m)}:${pad(s)}`;
  }

  const lastUpdated = new Map();
  const UPDATE_INTERVAL_MS = 10_000;

  function collectRows(){
    let listItems = qAll("ul.user-info-list-wrap li[class^='user'], ul.user-info-list-wrap li[class*='user']");
    if(listItems.length===0){
      return qAll("ul.user-info-list-wrap li").filter(li=>li.querySelector("a[href*='profiles.php?XID=']"));
    }
    return listItems;
  }

  function extractUserFromLi(li){
    const a = li.querySelector("a[href*='profiles.php?XID=']");
    if(!a) return null;
    const id = a.href.match(/XID=(\d+)/)?.[1];
    return id ? {id, nameEl:a, li, expander: li.querySelector("div.expander")||li.querySelector(".expander")} : null;
  }

  function ensureNodes(user){
    const li = user.li;
    let iconsWrap = q(".icons-wrap", li) || q("ul.big.svg", li);
    if(!iconsWrap) iconsWrap = user.expander; if(!iconsWrap) return null;

    let wrapper = iconsWrap.nextElementSibling;
    if(!wrapper || !wrapper.classList?.contains("tsh-wrap")){
      wrapper = document.createElement("span"); wrapper.className="tsh-wrap";
      iconsWrap.parentNode.insertBefore(wrapper, iconsWrap.nextSibling);
    }

    let timerNode = wrapper.querySelector(".tsh-status");
    if(!timerNode){ timerNode=document.createElement("span"); timerNode.className="tsh-status"; wrapper.appendChild(timerNode); }

    let attackNode = wrapper.querySelector(".tsh-attack");
    if(!attackNode){
      attackNode=document.createElement("a");
      attackNode.className="tsh-attack"; attackNode.textContent="Attack"; attackNode.href="#"; attackNode.setAttribute("role","button");
      attackNode.addEventListener("click",(ev)=>{ ev.preventDefault(); if(attackNode.dataset.attackHref) window.open(attackNode.dataset.attackHref,"_blank"); },{passive:false});
      wrapper.appendChild(attackNode);
    }

    return {wrapper, timerNode, attackNode};
  }

  function parseFallback(li){
    const iconLis=qAll(".icons-wrap li, ul.big.svg li", li);
    for(const icon of iconLis){
      const title = icon.getAttribute("title")||"";
      if(/Hospital|Jail|Traveling|Abroad/i.test(title)){
        const timeMatch = title.match(/data-time=['"]?(\d+)/i);
        if(timeMatch) return {state:"Hospital", untilInSeconds:parseInt(timeMatch[1],10)};
        const timerSpan=icon.querySelector(".timer");
        if(timerSpan){
          const dt = timerSpan.getAttribute("data-time");
          if(dt && /^\d+$/.test(dt)) return {state:"Hospital", untilInSeconds:parseInt(dt,10)};
          return {state:"Hospital", text:timerSpan.textContent.trim()||title};
        }
        return {state:title.replace(/<[^>]+>/g,"").replace(/\s+/g," ").trim()};
      }
    }
    return null;
  }

  async function fetchStatus(id){
    if(!apiKey) return null;
    try{
      const resp = await fetch(`https://api.torn.com/user/${id}?selections=basic&key=${apiKey}`);
      const json = await resp.json();
      if(!json || json.error) return null;
      return json.status||null;
    }catch(e){ console.error(e); return null; }
  }

  async function updateOne(user){
    const now = Date.now();
    if(now - (lastUpdated.get(user.id)||0)<UPDATE_INTERVAL_MS) return;
    lastUpdated.set(user.id, now);

    const nodes = ensureNodes(user); if(!nodes) return;
    const {timerNode, attackNode} = nodes;
    timerNode.classList.remove("urgent","small"); timerNode.textContent="";

    attackNode.dataset.attackHref=`https://www.torn.com/loader.php?sid=attack&user2ID=${user.id}`;

    let status = apiKey ? await fetchStatus(user.id) : null;
    let st = null;

    if(!status){
      const fallback=parseFallback(user.li);
      if(fallback){
        if(fallback.untilInSeconds!==undefined){
          const untilUnix=Math.floor(Date.now()/1000)+fallback.untilInSeconds;
          timerNode.dataset.until=untilUnix;
          timerNode.textContent=formatRemaining(fallback.untilInSeconds);
          if(fallback.untilInSeconds<300) timerNode.classList.add("urgent");
          st=fallback.state;
        } else if(fallback.text){ timerNode.textContent=fallback.text; timerNode.classList.add("small"); st=fallback.state; }
        else{ timerNode.textContent=String(fallback.state).slice(0,30); timerNode.classList.add("small"); st=fallback.state; }
      } else timerNode.textContent="";
    } else{
      st=status.state||"Unknown";
      if(st==="Hospital"||st==="Jail"){
        const nowSec=Math.floor(Date.now()/1000);
        const until=parseInt(status.until||0,10);
        const remaining=Math.max(0,until-nowSec);
        timerNode.dataset.until=until;
        timerNode.textContent=formatRemaining(remaining);
        if(remaining<300) timerNode.classList.add("urgent");
      } else if(st==="Traveling"||st==="Abroad"){
        let desc=status.description||st;
        let out=desc;
        const m=desc.match(/Traveling to (.+)/i); if(m) out=`► ${m[1].slice(0,30)}`;
        const m2=desc.match(/In (.+)/i); if(m2) out=`${m2[1].slice(0,30)}`;
        timerNode.textContent=out; timerNode.classList.add("small");
      } else{ timerNode.textContent=st; timerNode.classList.add("small"); }
    }

    // Show attack button only if not in Jail/Federal Jail/Traveling/Abroad
    attackNode.style.display = /Jail|Traveling|Abroad/i.test(st) ? "none" : "inline-block";
  }

  async function updateVisibleSet(){
    const rows=collectRows();
    if(!rows || !rows.length) return;
    const users=[];
    rows.forEach(li=>{ const u=extractUserFromLi(li); if(u) users.push(u); });
    users.forEach(u=>ensureNodes(u));
    for(const u of users){ updateOne(u); await new Promise(r=>setTimeout(r,120)); }
  }

  function tickTimers(){
    const nodes=qAll(".tsh-status"); const nowSec=Math.floor(Date.now()/1000);
    nodes.forEach(node=>{
      if(!node.dataset.until) return;
      const until=parseInt(node.dataset.until,10);
      const remaining=Math.max(0,until-nowSec);
      node.textContent=formatRemaining(remaining);
      node.classList.toggle("urgent",remaining<300);
    });
  }

  setTimeout(()=>updateVisibleSet(),1200);

  const listWrap=document.querySelector("ul.user-info-list-wrap");
  const observerTarget=listWrap||document.body;
  const mo=new MutationObserver(muts=>{ if(muts.some(m=>m.addedNodes.length>0)){ if(window.__tsh_update_timer) clearTimeout(window.__tsh_update_timer); window.__tsh_update_timer=setTimeout(()=>updateVisibleSet(),300); } });
  mo.observe(observerTarget,{childList:true,subtree:true});

  setInterval(()=>{ tickTimers(); updateVisibleSet(); },1000);

  console.log("[TornSearchHelper] initialized (accurate server-synced timers + Attack button logic)");
})();