Torn Faction & Player Tracker

Track Factions/Players with Torn Stats Spy Integration

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn Faction & Player Tracker
// @namespace    http://tampermonkey.net/
// @version      3.3
// @description  Track Factions/Players with Torn Stats Spy Integration
// @author       dingus
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      www.tornstats.com
// @connect      api.torn.com
// ==/UserScript==

(function() {
    'use strict';

    let countdownInterval = null;

    const logger = {
        call: (type, url) => console.log(`%c🚀 API CALL [${type}]`, "background: #222; color: #ff922b; font-weight: bold; padding: 2px 5px; border-radius: 3px;", url),
        return: (type, data) => console.log(`%c📥 RETURN [${type}]`, "background: #222; color: #51cf66; font-weight: bold; padding: 2px 5px;", data),
        error: (msg, err) => console.error(`%c❌ ERROR: ${msg}`, "color: white; background: red; padding: 2px 5px;", err)
    };

    const getApiKey = () => GM_getValue('torn_api_key', '');
    const saveApiKey = (key) => GM_setValue('torn_api_key', key);
    const getTsKey = () => GM_getValue('ts_api_key', '');
    const saveTsKey = (key) => GM_setValue('ts_api_key', key);
    const getTrackedFactions = () => GM_getValue('trackedFactions', []);
    const saveTrackedFactions = (list) => GM_setValue('trackedFactions', list);
    const getTrackedPlayers = () => GM_getValue('trackedPlayers', []);
    const saveTrackedPlayers = (list) => GM_setValue('trackedPlayers', list);
    const getNetworthCache = () => GM_getValue('networthCache', {});
    const saveNetworthCache = (cache) => GM_setValue('networthCache', cache);
    const getWarCache = () => GM_getValue('warCache', {});
    const saveWarCache = (cache) => GM_setValue('warCache', cache);
    const getMaxHospTime = () => GM_getValue('maxHospTime', 999);
    const saveMaxHospTime = (val) => GM_setValue('maxHospTime', val);
    const getSpecialFilter = () => GM_getValue('specialFilter', false);
    const saveSpecialFilter = (val) => GM_setValue('specialFilter', val);

    const formatCurrency = (num) => '$' + String(num).replace(/\B(?=(\d{3})+(?!\d))/g, ",");

    const styleSheet = document.createElement("style");
    styleSheet.innerText = `
        #faction-tracker-panel {
            position: fixed; top: 0; right: -100%; width: 100vw; max-width: 380px; height: 100%;
            background: #1a1a1a; border-left: 2px solid #333; z-index: 999999;
            transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); color: #fff; padding: 15px; overflow-y: auto; font-family: 'Segoe UI', Arial;
            box-shadow: -5px 0 15px rgba(0,0,0,0.7); box-sizing: border-box;
        }
        #faction-tracker-panel.open { right: 0; }
        .tracker-btn { margin: 5px 0; padding: 6px 12px; cursor: pointer; background: #333; border: 1px solid #444; color: #ccc; border-radius: 3px; font-size: 11px; font-weight: bold; text-transform: uppercase; border: 1px solid transparent; }
        .tracker-btn:hover { background: #444; color: #fff; border-color: #666; }
        .tracker-btn.active { background: #ff4757; color: #fff; border-color: #ff6b81; }
        .member-row { border-bottom: 1px solid #222; padding: 12px 0; font-size: 12px; }
        .status-red { color: #ff4757; font-weight: bold; font-family: monospace; font-size: 13px; }
        .hosp-reason { color: #aaa; font-size: 11px; font-style: italic; margin-left: 5px; border-left: 1px solid #444; padding-left: 5px; }
        .networth-tag { color: #ffa502; font-weight: bold; margin-left: 5px; font-size: 11px; }
        .spy-tag { display: block; background: #2b2b2b; color: #70a1ff; font-family: monospace; font-size: 10px; padding: 4px; margin-top: 5px; border-radius: 2px; line-height: 1.4; border-left: 2px solid #1e90ff; }
        .faction-header { background: #2f3542; padding: 8px; margin-top: 15px; font-weight: bold; display: flex; justify-content: space-between; border-radius: 3px; border-left: 3px solid #747d8c; }
        .watchlist-header { background: #3c4453; color: #ffa502; border-left: 3px solid #ffa502; }
        .remove-item { cursor: pointer; color: #ff4757; opacity: 0.6; font-size: 14px; }
        .action-btns { margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap; }
        .action-link { text-decoration: none; padding: 6px 12px; border-radius: 2px; font-size: 10px; font-weight: bold; color: #fff; transition: 0.2s; flex-grow: 1; text-align: center; }
        .btn-profile { background: #2f3542; border: 1px solid #57606f; }
        .btn-attack { background: #ff4757; border: 1px solid #ff6b81; }
        .custom-input-field { background: #000; border: 1px solid #444; color: #fff; padding: 4px; border-radius: 3px; font-size: 12px; width: 100%; margin-bottom: 5px; font-family: monospace; }
        #trak_hosp_99 { width: 45px; border-color: #ff4757; color: #ff4757; text-align: center; font-weight: bold; display: inline-block; }
        .menu-section { background: #222; padding: 10px; border-radius: 4px; border: 1px solid #333; margin-bottom: 10px; }
    `;
    document.head.appendChild(styleSheet);

    const panel = document.createElement('div');
    panel.id = 'faction-tracker-panel';
    panel.innerHTML = `
        <h3 style="margin:0; color: #ff4757; letter-spacing: 1px;">HOSPITAL WATCH</h3>

        <div class="menu-section" style="margin-top:15px;">
            <div style="font-size:10px; color:#888; margin-bottom:5px; text-transform:uppercase; font-weight:bold;">API Configuration</div>
            <input type="password" class="custom-input-field" id="trak_key_torn" name="trak_key_torn" placeholder="Torn Public Key" autocomplete="new-password" spellcheck="false">
            <input type="password" class="custom-input-field" id="trak_key_stats" name="trak_key_stats" placeholder="Torn Stats API Key" autocomplete="new-password" spellcheck="false">
            <div style="display:flex; flex-wrap:wrap; gap:4px;">
                <button id="trak_save_btn" class="tracker-btn" style="flex:1 1 100%; margin:0; background:#ff4757; color:#fff;">Save All Keys</button>
                <a href="https://www.torn.com/preferences.php#tab=api" target="_blank" class="tracker-btn" style="text-decoration:none; flex:1; text-align:center;">Get Torn Key</a>
                <a href="https://www.tornstats.com/settings/general" target="_blank" class="tracker-btn" style="text-decoration:none; flex:1; text-align:center;">Get TS Key</a>
            </div>
        </div>

        <div class="menu-section">
            <div style="font-size:10px; color:#888; margin-bottom:5px; text-transform:uppercase; font-weight:bold;">Manual Entry (ID)</div>
            <input type="text" class="custom-input-field" id="trak_manual_id" name="trak_manual_id" placeholder="Faction or Player ID" autocomplete="off" spellcheck="false">
            <div style="display:flex; gap:5px;">
                <button id="trak_add_fac_btn" class="tracker-btn" style="flex-grow:1; margin:0;">+ Faction</button>
                <button id="trak_add_ply_btn" class="tracker-btn" style="flex-grow:1; margin:0;">+ Player</button>
            </div>
        </div>

        <div style="margin: 5px 0; font-size: 11px; background: #222; padding: 10px; border-radius: 4px; border: 1px solid #333;">
            🎯 TARGETS UNDER: <input type="text" id="trak_hosp_99" name="trak_hosp_99" maxlength="3" autocomplete="off"> MINS
            <div style="margin-top: 10px;">
                <button id="trak_spec_filt" class="tracker-btn" style="width: 100%; margin: 0;">🧪 Syrup/Blood Only</button>
            </div>
        </div>

        <button id="trak_close" class="tracker-btn">Close</button>
        <button id="trak_sync" class="tracker-btn">🔄 Sync</button>
        <button id="trak_clear" class="tracker-btn" style="color:#ff4757; float:right;">Clear</button>
        <hr style="border:0; border-top:1px solid #333; margin:10px 0;">
        <div id="tracker-content"></div>
    `;
    document.body.appendChild(panel);

    const toggleBtn = document.createElement('button');
    toggleBtn.innerText = '🏥 Hosp Watch';
    toggleBtn.className = 'tracker-btn';
    toggleBtn.style = "position:fixed; top:60px; right:10px; z-index:99999; box-shadow:0 0 10px rgba(0,0,0,0.5); border: 1px solid #ff4757;";
    document.body.appendChild(toggleBtn);

    const loggedFetch = (type, url) => {
        const key = getApiKey();
        if (!key || key.length < 16) return Promise.resolve(null);
        const finalUrl = url.includes('?') ? `${url}&key=${key}` : `${url}?key=${key}`;
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET", url: finalUrl,
                onload: (res) => {
                    try { const data = JSON.parse(res.responseText); resolve(data); }
                    catch (e) { resolve(null); }
                },
                onerror: () => resolve(null)
            });
        });
    };

    const getSpyData = async (userID) => {
        const tsKey = getTsKey();
        if (!tsKey) return null;
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: `https://www.tornstats.com/api/v1/${tsKey}/spy/${userID}`,
                onload: (res) => {
                    try {
                        const r = JSON.parse(res.responseText);
                        if (r.spy && r.spy.status === true) {
                            resolve({
                                str: r.spy.strength?.toLocaleString() || "???",
                                def: r.spy.defense?.toLocaleString() || "???",
                                spd: r.spy.speed?.toLocaleString() || "???",
                                dex: r.spy.dexterity?.toLocaleString() || "???",
                                total: r.spy.total?.toLocaleString() || "???",
                                age: r.spy.difference || "Unknown age"
                            });
                        } else resolve(null);
                    } catch (e) { resolve(null); }
                },
                onerror: () => resolve(null)
            });
        });
    };

    const isFactionInWarCached = async (factionId) => {
        const cache = getWarCache();
        const now = Date.now();
        if (cache[factionId] && (now - cache[factionId].timestamp < 1800000)) return cache[factionId].inWar;
        const data = await loggedFetch("WAR_CHECK", `https://api.torn.com/v2/faction/${factionId}/rankedwars?offset=0&limit=1`);
        const inWar = (data && data.rankedwars?.[0] && data.rankedwars[0].winner === null);
        cache[factionId] = { inWar: inWar, timestamp: now };
        saveWarCache(cache);
        return inWar;
    };

    const getCachedNetworth = async (userId) => {
        const cache = getNetworthCache();
        const now = Date.now();
        if (cache[userId] && (now - cache[userId].timestamp < 120000)) return cache[userId].value;
        const data = await loggedFetch("NET_WORTH", `https://api.torn.com/v2/user/${userId}/personalstats?cat=networth`);
        if (data && data.personalstats) {
            const value = data.personalstats.networth.total;
            cache[userId] = { value: value, timestamp: now };
            saveNetworthCache(cache);
            return value;
        }
        return null;
    };

    const startCountdown = () => {
        if (countdownInterval) clearInterval(countdownInterval);
        countdownInterval = setInterval(() => {
            const timers = document.querySelectorAll('.live-timer');
            timers.forEach(timer => {
                const until = parseInt(timer.dataset.until);
                const diff = until - Math.floor(Date.now() / 1000);
                if (diff <= 0) {
                    timer.innerHTML = "LANDING!";
                    timer.style.color = "#2ed573";
                } else {
                    const m = Math.floor(diff / 60);
                    const s = diff % 60;
                    timer.innerHTML = `${m}m ${s}s`;
                }
            });
        }, 1000);
    };

    const renderUserRow = async (m, maxMins, useSpecialFilter, specialReasons) => {
        const remainingMins = Math.floor((m.status.until - Math.floor(Date.now() / 1000)) / 60);
        if (remainingMins > maxMins && m.status.state === 'Hospital') return null;
        if (useSpecialFilter && !specialReasons.includes(m.status.details)) return null;

        const [nwValue, spy] = await Promise.all([getCachedNetworth(m.id), getSpyData(m.id)]);
        const row = document.createElement('div');
        row.className = 'member-row';

        let spyHtml = "";
        if (spy) {
            spyHtml = `<div class="spy-tag">
                STR: ${spy.str} | DEF: ${spy.def}<br>
                SPD: ${spy.spd} | DEX: ${spy.dex}<br>
                <strong>Total: ${spy.total}</strong> (${spy.age})
            </div>`;
        }

        row.innerHTML = `
            <div><strong>${m.name} [${m.id}]</strong> <span class="networth-tag">${nwValue ? formatCurrency(nwValue) : ""}</span></div>
            <div class="${m.status.state === 'Hospital' ? 'status-red' : ''}" style="${m.status.state !== 'Hospital' ? 'color:#2ed573;' : ''}">
                ${m.status.state === 'Hospital' ? `<span class="live-timer" data-until="${m.status.until}">--m --s</span>` : 'OK'}
                <span class="hosp-reason">${m.status.details || m.status.state}</span>
            </div>
            <div style="font-size:10px; color:#747d8c; margin-top:2px;">${m.status.description}</div>
            ${spyHtml}
            <div class="action-btns">
                <a href="https://www.torn.com/profiles.php?XID=${m.id}" target="_blank" class="action-link btn-profile">PROFILE</a>
                <a href="https://www.torn.com/loader.php?sid=attack&user2ID=${m.id}" target="_blank" class="action-link btn-attack">ATTACK</a>
            </div>
        `;
        return row;
    };

    const refreshPanel = async () => {
        if (!getApiKey()) {
            document.getElementById('tracker-content').innerHTML = '<p style="font-size:11px; color:#ff4757; text-align:center;">⚠️ TORN API KEY REQUIRED.</p>';
            return;
        }
        const content = document.getElementById('tracker-content');
        const factions = getTrackedFactions();
        const players = getTrackedPlayers();
        const maxMins = parseInt(getMaxHospTime()) || 999;
        const useSpecialFilter = getSpecialFilter();
        const specialReasons = ["Severe emesis following Ipecac Syrup ingestion", "Suffering from an acute hemolytic transfusion reaction"];

        content.innerHTML = '<p style="font-size:12px; color: #888;">Syncing Data...</p>';

        const container = document.createDocumentFragment();

        if (players.length > 0) {
            const watchDiv = document.createElement('div');
            watchDiv.innerHTML = `<div class="faction-header watchlist-header"><span>INDIVIDUAL WATCHLIST</span></div>`;
            for (const p of players) {
                const pData = await loggedFetch("PLAYER_INFO", `https://api.torn.com/v2/user/${p.id}/profile`);
                if (pData) {
                    const row = await renderUserRow({id: p.id, name: pData.name, status: pData.status}, 9999, useSpecialFilter, specialReasons);
                    if (row) {
                        row.firstChild.innerHTML += ` <span class="remove-item" data-remove-player="${p.id}" style="float:right;">❌</span>`;
                        watchDiv.appendChild(row);
                    }
                }
            }
            container.appendChild(watchDiv);
        }

        for (const fac of factions) {
            const factionDiv = document.createElement('div');
            factionDiv.innerHTML = `<div class="faction-header"><span>${fac.name}</span><span class="remove-item" data-remove-fac="${fac.id}">❌</span></div>`;

            if (await isFactionInWarCached(fac.id)) {
                factionDiv.innerHTML += `<div style="padding:10px; color:#ffa502; text-align:center; font-size:11px;">Ranked War: Hidden</div>`;
            } else {
                const memData = await loggedFetch("MEMBERS", `https://api.torn.com/v2/faction/members?striptags=true&id=${fac.id}`);
                if (memData && memData.members) {
                    const hosps = memData.members.filter(m => m.status.state === 'Hospital');
                    for (const m of hosps) {
                        const row = await renderUserRow(m, maxMins, useSpecialFilter, specialReasons);
                        if (row) factionDiv.appendChild(row);
                    }
                }
            }
            container.appendChild(factionDiv);
        }

        content.innerHTML = '';
        content.appendChild(container);
        startCountdown();
    };

    const addFaction = async (id) => {
        const data = await loggedFetch("FAC_INFO", `https://api.torn.com/v2/faction/${id}/basic`);
        if (data && data.basic) {
            let list = getTrackedFactions();
            if (!list.find(f => f.id == id)) {
                list.push({ id: id, name: data.basic.name });
                saveTrackedFactions(list);
                return true;
            }
        }
        return false;
    };

    const addPlayer = async (id) => {
        const data = await loggedFetch("PLAYER_INFO", `https://api.torn.com/v2/user/${id}/profile`);
        if (data) {
            let list = getTrackedPlayers();
            if (!list.find(p => p.id == id)) {
                list.push({ id: id, name: data.name });
                saveTrackedPlayers(list);
                return true;
            }
        }
        return false;
    };

    const init = () => {
        document.getElementById('trak_key_torn').value = getApiKey();
        document.getElementById('trak_key_stats').value = getTsKey();

        document.getElementById('trak_save_btn').onclick = () => {
            const tKey = document.getElementById('trak_key_torn').value.trim();
            const tsKey = document.getElementById('trak_key_stats').value.trim();
            saveApiKey(tKey);
            saveTsKey(tsKey);
            alert("Keys Saved");
            refreshPanel();
        };

        document.getElementById('trak_add_fac_btn').onclick = async () => {
            const id = document.getElementById('trak_manual_id').value.trim();
            if (id && await addFaction(id)) { document.getElementById('trak_manual_id').value = ''; refreshPanel(); }
        };

        document.getElementById('trak_add_ply_btn').onclick = async () => {
            const id = document.getElementById('trak_manual_id').value.trim();
            if (id && await addPlayer(id)) { document.getElementById('trak_manual_id').value = ''; refreshPanel(); }
        };

        const filterInput = document.getElementById('trak_hosp_99');
        filterInput.value = getMaxHospTime();
        filterInput.oninput = (e) => saveMaxHospTime(e.target.value.replace(/\D/g, ''));

        const specialBtn = document.getElementById('trak_spec_filt');
        if (getSpecialFilter()) specialBtn.classList.add('active');
        specialBtn.onclick = () => {
            const newState = !getSpecialFilter();
            saveSpecialFilter(newState);
            specialBtn.classList.toggle('active', newState);
            refreshPanel();
        };

        const observer = new MutationObserver(() => {
            const container = document.querySelector('.content-title');
            if (container && !document.getElementById('add-to-tracker-btn')) {
                const url = window.location.href;
                const isProfile = url.includes('profiles.php');
                const idMatch = url.match(/ID=(\d+)/) || url.match(/XID=(\d+)/);
                if (idMatch) {
                    const id = idMatch[1];
                    const btn = document.createElement('button');
                    btn.id = 'add-to-tracker-btn';
                    btn.innerText = isProfile ? '➕ Track Player' : '➕ Track Faction';
                    btn.className = 'tracker-btn';
                    btn.style.marginLeft = '10px';
                    btn.onclick = async () => {
                        btn.innerText = '⌛...';
                        const success = isProfile ? await addPlayer(id) : await addFaction(id);
                        btn.innerText = success ? '✅ OK' : '⚠️ YES';
                        setTimeout(() => { btn.innerText = isProfile ? '➕ Track Player' : '➕ Track Faction'; refreshPanel(); }, 2000);
                    };
                    container.appendChild(btn);
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });

        toggleBtn.onclick = () => { panel.classList.toggle('open'); if (panel.classList.contains('open')) refreshPanel(); };
        document.getElementById('trak_close').onclick = () => panel.classList.remove('open');
        document.getElementById('trak_sync').onclick = () => refreshPanel();
        document.getElementById('trak_clear').onclick = () => { if(confirm("Clear All?")) { saveTrackedFactions([]); saveTrackedPlayers([]); refreshPanel(); }};

        panel.addEventListener('click', (e) => {
            if (e.target.dataset.removeFac) {
                saveTrackedFactions(getTrackedFactions().filter(f => f.id != e.target.dataset.removeFac));
                refreshPanel();
            }
            if (e.target.dataset.removePlayer) {
                saveTrackedPlayers(getTrackedPlayers().filter(p => p.id != e.target.dataset.removePlayer));
                refreshPanel();
            }
        });
    };

    init();
})();