Torn Script Loader

Loads gated scripts for authorized faction members

2026/02/27のページです。最新版はこちら

スクリプトをインストールするには、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 Script Loader
// @namespace    https://github.com/torn-script-loader
// @version      3.0.0
// @description  Loads gated scripts for authorized faction members
// @match        https://www.torn.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @grant        GM_addElement
// @connect      torn-script-loader-v2.theaaronlawrence.workers.dev
// @connect      *
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── Configuration ──────────────────────────────────────────────────
  const WORKER_BASE = 'https://torn-script-loader-v2.theaaronlawrence.workers.dev';
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

  // ── API Key Management ─────────────────────────────────────────────
  function getApiKey() {
    return GM_getValue('torn_api_key', null);
  }

  function setApiKey(key) {
    GM_setValue('torn_api_key', key);
  }

  function clearApiKey() {
    GM_setValue('torn_api_key', null);
    GM_setValue('cache_manifest', null);
  }

  function promptForApiKey() {
    const key = prompt(
      '[Script Loader] Enter your Torn API key.\n' +
      'This is used to verify your faction membership.\n' +
      'You can generate one at Settings > API Key.'
    );
    if (key && key.trim().length === 16) {
      setApiKey(key.trim());
      return key.trim();
    }
    if (key !== null) {
      alert('[Script Loader] Invalid API key. Must be 16 characters. Reload to try again.');
    }
    return null;
  }

  // ── Enable/Disable State ───────────────────────────────────────────
  function getEnabledScripts() {
    return GM_getValue('enabled_scripts', {});
  }

  function setScriptEnabled(name, enabled) {
    const state = getEnabledScripts();
    state[name] = enabled;
    GM_setValue('enabled_scripts', state);
  }

  function isScriptEnabled(name) {
    const state = getEnabledScripts();
    return state[name] !== false;
  }

  // ── Cache ──────────────────────────────────────────────────────────
  function getCached(key) {
    const raw = GM_getValue(`cache_${key}`, null);
    if (!raw) return null;
    try {
      const cached = JSON.parse(raw);
      if (Date.now() - cached.ts < CACHE_TTL) {
        return cached.data;
      }
    } catch { /* cache miss */ }
    return null;
  }

  function setCache(key, data) {
    GM_setValue(`cache_${key}`, JSON.stringify({ ts: Date.now(), data }));
  }

  // ── Manifest Fetch ─────────────────────────────────────────────────
  function fetchManifest(apiKey) {
    return new Promise((resolve, reject) => {
      const cached = getCached('manifest');
      if (cached) {
        resolve(cached);
        return;
      }

      GM_xmlhttpRequest({
        method: 'POST',
        url: `${WORKER_BASE}/manifest`,
        headers: { 'Content-Type': 'application/json' },
        data: JSON.stringify({ apiKey }),
        onload(response) {
          try {
            const data = JSON.parse(response.responseText);
            if (data.ok && data.manifest) {
              setCache('manifest', data.manifest);
              resolve(data.manifest);
            } else {
              showError(data.error || 'Failed to load manifest');
              reject(new Error(data.error));
            }
          } catch (e) {
            showError('Failed to parse manifest response');
            reject(e);
          }
        },
        onerror(err) {
          showError('Network error fetching manifest');
          reject(err);
        },
      });
    });
  }

  // ── Script Loading ─────────────────────────────────────────────────
  function loadScript(scriptName, apiKey) {
    return new Promise((resolve, reject) => {
      const cached = getCached(scriptName);
      if (cached) {
        injectScript(cached, scriptName);
        resolve(true);
        return;
      }

      GM_xmlhttpRequest({
        method: 'POST',
        url: `${WORKER_BASE}/load`,
        headers: { 'Content-Type': 'application/json' },
        data: JSON.stringify({ apiKey, script: scriptName }),
        onload(response) {
          try {
            const data = JSON.parse(response.responseText);
            if (data.ok && data.code) {
              setCache(scriptName, data.code);
              injectScript(data.code, scriptName);
              resolve(true);
            } else {
              showError(data.error || 'Unknown error loading script');
              reject(new Error(data.error));
            }
          } catch (e) {
            showError('Failed to parse worker response');
            reject(e);
          }
        },
        onerror(err) {
          showError('Network error contacting script loader');
          reject(err);
        },
      });
    });
  }

  // Polyfills injected into the page context (via GM_addElement).
  // GM_xmlhttpRequest uses postMessage to bridge back to the loader's
  // Tampermonkey context, which has the real GM_xmlhttpRequest that
  // bypasses CSP restrictions.
  const GM_POLYFILLS = `
    window.GM_addStyle = function(css) {
      var s = document.createElement('style');
      s.textContent = css;
      document.head.appendChild(s);
      return s;
    };
    window.GM_getValue = function(key, def) {
      var v = localStorage.getItem('_gm_' + key);
      return v === null ? def : JSON.parse(v);
    };
    window.GM_setValue = function(key, val) {
      localStorage.setItem('_gm_' + key, JSON.stringify(val));
    };
    window.GM_xmlhttpRequest = function(opts) {
      var id = 'sl_xhr_' + Math.random().toString(36).slice(2);
      function handler(event) {
        if (!event.data || event.data.type !== 'SL_XHR_RESPONSE' || event.data.id !== id) return;
        window.removeEventListener('message', handler);
        if (event.data.error) {
          if (opts.onerror) opts.onerror(event.data.error);
        } else {
          if (opts.onload) opts.onload(event.data.response);
        }
      }
      window.addEventListener('message', handler);
      window.postMessage({
        type: 'SL_XHR_REQUEST',
        id: id,
        opts: {
          method: opts.method,
          url: opts.url,
          headers: opts.headers,
          data: opts.data,
          responseType: opts.responseType
        }
      }, '*');
    };
  `;

  // ── XHR Bridge ─────────────────────────────────────────────────────
  // Listens for postMessage requests from injected scripts and proxies
  // them through the real GM_xmlhttpRequest (which bypasses page CSP).
  function setupXhrBridge() {
    window.addEventListener('message', (event) => {
      if (!event.data || event.data.type !== 'SL_XHR_REQUEST') return;

      const { id, opts } = event.data;
      GM_xmlhttpRequest({
        method: opts.method || 'GET',
        url: opts.url,
        headers: opts.headers || {},
        data: opts.data || undefined,
        responseType: opts.responseType || '',
        onload(response) {
          window.postMessage({
            type: 'SL_XHR_RESPONSE',
            id,
            response: {
              status: response.status,
              statusText: response.statusText,
              responseText: response.responseText,
              response: response.response,
              finalUrl: response.finalUrl,
            },
          }, '*');
        },
        onerror(err) {
          window.postMessage({
            type: 'SL_XHR_RESPONSE',
            id,
            error: true,
          }, '*');
        },
      });
    });
  }

  function injectScript(code, name) {
    try {
      GM_addElement('script', { textContent: GM_POLYFILLS + '\n' + code });
      console.log(`[Script Loader] Loaded: ${name}`);
    } catch (e) {
      console.error(`[Script Loader] Error executing ${name}:`, e);
      showError(`Error running ${name}`);
    }
  }

  // ── UI: Error Toast ────────────────────────────────────────────────
  function showError(message) {
    console.warn(`[Script Loader] ${message}`);
    const el = document.createElement('div');
    el.textContent = `Script Loader: ${message}`;
    Object.assign(el.style, {
      position: 'fixed',
      bottom: '10px',
      right: '10px',
      background: '#c0392b',
      color: '#fff',
      padding: '8px 14px',
      borderRadius: '6px',
      fontSize: '13px',
      zIndex: '99999',
      opacity: '0.95',
      fontFamily: 'Arial, sans-serif',
    });
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 6000);
  }

  // ── UI: Settings Panel (DOM-based, no innerHTML) ───────────────────
  let panelOpen = false;
  let currentManifest = [];

  function createSettingsPanel() {
    // Gear button
    const gear = document.createElement('div');
    gear.id = 'sl-gear';
    gear.textContent = '\u2699';
    Object.assign(gear.style, {
      position: 'fixed',
      top: '14px',
      right: '14px',
      width: '34px',
      height: '34px',
      background: '#333',
      color: '#aaa',
      borderRadius: '50%',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      fontSize: '18px',
      cursor: 'pointer',
      zIndex: '100000',
      border: '1px solid #444',
      userSelect: 'none',
      transition: 'background 0.15s, color 0.15s',
    });
    gear.addEventListener('mouseenter', () => { gear.style.background = '#444'; gear.style.color = '#ddd'; });
    gear.addEventListener('mouseleave', () => { gear.style.background = '#333'; gear.style.color = '#aaa'; });
    gear.addEventListener('click', togglePanel);
    document.body.appendChild(gear);

    // Panel container
    const panel = document.createElement('div');
    panel.id = 'sl-panel';
    Object.assign(panel.style, {
      position: 'fixed',
      top: '56px',
      right: '14px',
      width: '280px',
      maxHeight: '400px',
      overflowY: 'auto',
      background: '#1a1a2e',
      border: '1px solid #333',
      borderRadius: '8px',
      zIndex: '100000',
      fontFamily: 'Arial, sans-serif',
      fontSize: '13px',
      color: '#ccc',
      display: 'none',
      boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
    });
    document.body.appendChild(panel);
  }

  function togglePanel() {
    panelOpen = !panelOpen;
    const panel = document.getElementById('sl-panel');
    panel.style.display = panelOpen ? 'block' : 'none';
    if (panelOpen) renderPanel();
  }

  function makeToggle(name) {
    const enabled = isScriptEnabled(name);

    const label = document.createElement('label');
    Object.assign(label.style, {
      position: 'relative',
      display: 'inline-block',
      width: '40px',
      height: '22px',
      marginLeft: '10px',
      flexShrink: '0',
      cursor: 'pointer',
    });

    const input = document.createElement('input');
    input.type = 'checkbox';
    input.checked = enabled;
    Object.assign(input.style, { opacity: '0', width: '0', height: '0', position: 'absolute' });

    const track = document.createElement('span');
    Object.assign(track.style, {
      position: 'absolute', top: '0', left: '0', right: '0', bottom: '0',
      background: enabled ? '#4CAF50' : '#555',
      borderRadius: '11px', transition: 'background 0.2s',
    });

    const thumb = document.createElement('span');
    Object.assign(thumb.style, {
      position: 'absolute', top: '2px', left: enabled ? '20px' : '2px',
      width: '18px', height: '18px', background: '#fff', borderRadius: '50%',
      transition: 'left 0.2s',
    });

    input.addEventListener('change', () => {
      const on = input.checked;
      setScriptEnabled(name, on);
      track.style.background = on ? '#4CAF50' : '#555';
      thumb.style.left = on ? '20px' : '2px';
    });

    label.append(input, track, thumb);
    return label;
  }

  function renderPanel() {
    const panel = document.getElementById('sl-panel');
    // Clear previous content
    while (panel.firstChild) panel.removeChild(panel.firstChild);

    // ── Header ──
    const header = document.createElement('div');
    Object.assign(header.style, {
      padding: '12px 14px 8px',
      borderBottom: '1px solid #333',
      display: 'flex',
      justifyContent: 'space-between',
      alignItems: 'center',
    });
    const title = document.createElement('span');
    title.textContent = 'Script Loader';
    Object.assign(title.style, { fontWeight: 'bold', fontSize: '14px', color: '#eee' });
    const version = document.createElement('span');
    version.textContent = 'v2.0';
    Object.assign(version.style, { fontSize: '11px', color: '#666' });
    header.append(title, version);
    panel.appendChild(header);

    // ── Script rows ──
    if (currentManifest.length === 0) {
      const empty = document.createElement('div');
      empty.textContent = 'No scripts in manifest';
      Object.assign(empty.style, { padding: '14px', color: '#666', textAlign: 'center' });
      panel.appendChild(empty);
    } else {
      for (const entry of currentManifest) {
        const row = document.createElement('div');
        Object.assign(row.style, {
          padding: '10px 14px',
          borderBottom: '1px solid #262640',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'space-between',
        });

        const info = document.createElement('div');
        Object.assign(info.style, { flex: '1', minWidth: '0' });

        const nameEl = document.createElement('div');
        nameEl.textContent = entry.name;
        Object.assign(nameEl.style, { fontWeight: 'bold', color: '#ddd', marginBottom: '2px' });
        info.appendChild(nameEl);

        if (entry.description) {
          const desc = document.createElement('div');
          desc.textContent = entry.description;
          Object.assign(desc.style, {
            fontSize: '11px', color: '#777',
            whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
          });
          info.appendChild(desc);
        }

        row.append(info, makeToggle(entry.name));
        panel.appendChild(row);
      }
    }

    // ── Footer: Reset API key ──
    const footer = document.createElement('div');
    Object.assign(footer.style, { padding: '10px 14px', borderTop: '1px solid #333' });

    const resetBtn = document.createElement('button');
    resetBtn.textContent = 'Reset API Key';
    Object.assign(resetBtn.style, {
      background: 'none', border: '1px solid #555', color: '#999',
      padding: '5px 10px', borderRadius: '4px', fontSize: '11px',
      cursor: 'pointer', width: '100%', transition: 'background 0.15s, color 0.15s',
    });
    resetBtn.addEventListener('mouseenter', () => {
      resetBtn.style.background = '#c0392b'; resetBtn.style.color = '#fff'; resetBtn.style.borderColor = '#c0392b';
    });
    resetBtn.addEventListener('mouseleave', () => {
      resetBtn.style.background = 'none'; resetBtn.style.color = '#999'; resetBtn.style.borderColor = '#555';
    });
    resetBtn.addEventListener('click', () => {
      if (confirm('Reset your API key? You will need to re-enter it on next page load.')) {
        clearApiKey();
        panelOpen = false;
        document.getElementById('sl-panel').style.display = 'none';
        showError('API key cleared. Reload the page to re-enter.');
      }
    });

    footer.appendChild(resetBtn);
    panel.appendChild(footer);
  }

  // ── Main ───────────────────────────────────────────────────────────
  async function main() {
    let apiKey = getApiKey();
    if (!apiKey) {
      apiKey = promptForApiKey();
      if (!apiKey) return;
    }

    setupXhrBridge();
    createSettingsPanel();

    let manifest;
    try {
      manifest = await fetchManifest(apiKey);
    } catch {
      return;
    }

    currentManifest = manifest;
    const currentUrl = window.location.href;

    for (const entry of manifest) {
      if (!entry.name || !entry.match) continue;

      const pattern = new RegExp(entry.match);
      if (!pattern.test(currentUrl)) continue;
      if (!isScriptEnabled(entry.name)) continue;

      loadScript(entry.name, apiKey);
    }
  }

  main();
})();