Torn Search Helper

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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)");
})();