Torn Script Finder

Searchable directory overlay for the Tools & Userscripts forum, built on top of Torn's own API.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Torn Script Finder
// @namespace    https://torn.com/
// @version      1.3
// @description  Searchable directory overlay for the Tools & Userscripts forum, built on top of Torn's own API.
// @author       HopesG
// @match        *://www.torn.com/forums.php*
// @match        *://torn.com/forums.php*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @connect      api.torn.com
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // ---------- CONFIG ----------
    const API_BASE = 'https://api.torn.com/v2';
    const TOOLS_CATEGORY_ID = 67; // Tools & Userscripts forum category
    const MIN_REQUEST_INTERVAL_MS = 700; // global throttle, keeps us well under Torn's 100 req/min
    const MAX_RANGE_REQUESTS = 400; // hard safety cap on total requests during a single crawl
    const FORUM_EPOCH_SECONDS = 1104537600; // 2005-01-01 UTC, a safe floor before Torn existed
    const RECENT_OVERLAP_SECONDS = 2 * 24 * 60 * 60; // 2-day overlap when checking for new/updated threads
    const STORAGE_API_KEY = 'sf_api_key';
    const STORAGE_CACHE = 'sf_thread_cache';
    const STORAGE_BTN_POS = 'sf_btn_pos';
    const STORAGE_FAVORITES = 'sf_favorites';
    const STORAGE_LAST_VISIT = 'sf_last_visit';

    const CATEGORY_RULES = [
        { category: 'Poker', keywords: ['poker', 'hand history', 'holdem', 'range blocker', 'gto', 'poker solver'] },
        { category: 'Casino', keywords: ['casino', 'slots', 'roulette', 'blackjack', 'spin the wheel'] },
        { category: 'Faction', keywords: ['faction', 'chain', 'war report', 'oc ', 'organized crime', 'territory'] },
        { category: 'Crimes', keywords: ['crime', 'nerve', 'burglary', 'cracking', 'crimes 2.0'] },
        { category: 'Travel', keywords: ['travel', 'plushie', 'flower', 'abroad', 'foreign stock'] },
        { category: 'Trading', keywords: ['trade', 'market', 'bazaar', 'item price', 'flip'] },
        { category: 'Combat', keywords: ['attack', 'fair fight', 'target finder', 'spy', 'war stats'] },
        { category: 'UI / Quality of Life', keywords: ['theme', 'dark mode', 'ui', 'layout', 'notification', 'overlay', 'hotkey'] },
    ];

    const TYPE_RULES = [
        { type: 'Extension', keywords: ['chrome extension', 'firefox extension', 'browser extension'] },
        { type: 'Website', keywords: ['website', '.com', '.io', '.app', 'webapp', 'web app'] },
        { type: 'Guide / Tool', keywords: ['guide', 'tutorial', 'how to', 'walkthrough'] },
    ];

    // ---------- STYLES ----------
    GM_addStyle(`
        #sf-launch-btn, #sf-overlay, #sf-overlay * {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            box-sizing: border-box;
        }
        #sf-launch-btn {
            position: fixed; top: 100px; right: 16px; z-index: 99999;
            background: linear-gradient(135deg, #7c5cff, #5b8dff); color: #fff; border: none; border-radius: 24px;
            padding: 10px 18px; font-size: 13px; font-weight: 600; letter-spacing: 0.2px; cursor: grab;
            box-shadow: 0 4px 16px rgba(92, 80, 255, 0.35); user-select: none; touch-action: none;
            transition: transform 0.15s ease, box-shadow 0.15s ease;
        }
        #sf-launch-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(92, 80, 255, 0.45); }
        #sf-launch-btn:active { cursor: grabbing; transform: translateY(0); }
        #sf-overlay {
            position: fixed; inset: 0; z-index: 100000; background: rgba(8, 9, 14, 0.65);
            backdrop-filter: blur(2px);
            display: none; align-items: center; justify-content: center;
        }
        #sf-overlay.open { display: flex; }
        #sf-panel {
            background: #14161e; color: #e6e8ef; width: 92%; max-width: 820px; height: 84vh;
            border-radius: 16px; display: flex; flex-direction: column; overflow: hidden;
            border: 1px solid #23273366; box-shadow: 0 24px 70px rgba(0,0,0,0.55);
        }
        #sf-panel header {
            display: flex; align-items: center; gap: 10px; padding: 16px 20px;
            border-bottom: 1px solid #23262f; flex-wrap: wrap;
        }
        #sf-panel header h2 { margin: 0; font-size: 17px; font-weight: 700; flex: 1 0 auto; letter-spacing: 0.2px; }
        #sf-search {
            flex: 1 1 220px; padding: 9px 14px; border-radius: 10px; border: 1px solid #2b2f3a;
            background: #1c1f28; color: #f2f3f7; font-size: 13px; transition: border-color 0.15s ease;
        }
        #sf-search::placeholder { color: #6b7180; }
        #sf-search:focus, #sf-panel select:focus, #sf-panel input:focus {
            outline: none; border-color: #7c5cff; box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.18);
        }
        #sf-panel select, #sf-panel input {
            padding: 8px 10px; border-radius: 10px; border: 1px solid #2b2f3a;
            background: #1c1f28; color: #f2f3f7; font-size: 12.5px; transition: border-color 0.15s ease;
        }
        #sf-close {
            background: none; border: none; color: #8b93a7; font-size: 22px; line-height: 1; cursor: pointer;
            padding: 4px 8px; border-radius: 8px; transition: background 0.15s ease, color 0.15s ease;
        }
        #sf-close:hover { background: #1c1f28; color: #f2f3f7; }
        #sf-keybar { display: none; align-items: center; gap: 10px; padding: 12px 20px; border-bottom: 1px solid #23262f; background: #191c25; flex-wrap: wrap; }
        #sf-keybar input { flex: 1 1 220px; }
        #sf-key-hint { font-size: 11px; color: #6b7180; }
        #sf-chips { display: flex; gap: 6px; flex-wrap: wrap; padding: 12px 20px; border-bottom: 1px solid #23262f; }
        .sf-chip {
            padding: 5px 12px; border-radius: 16px; background: #1c1f28; color: #a8afc0;
            font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid #262a35;
            transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
        }
        .sf-chip:hover { border-color: #3a3f4f; color: #e6e8ef; }
        .sf-chip.active { background: linear-gradient(135deg, #7c5cff, #5b8dff); color: #fff; border-color: transparent; }
        #sf-toolbar { display: flex; gap: 8px; padding: 10px 20px; align-items: center; border-bottom: 1px solid #23262f; font-size: 12px; flex-wrap: wrap; }
        #sf-indexing-status { padding: 6px 20px; font-size: 11px; color: #6b7180; min-height: 14px; }
        #sf-results { flex: 1; overflow-y: auto; padding: 12px 20px 20px; }
        #sf-results::-webkit-scrollbar { width: 9px; }
        #sf-results::-webkit-scrollbar-thumb { background: #262a35; border-radius: 6px; }
        #sf-results::-webkit-scrollbar-track { background: transparent; }
        .sf-card {
            background: #191c25; border: 1px solid #23262f; border-radius: 12px; padding: 14px 16px;
            margin-bottom: 10px; transition: border-color 0.15s ease, transform 0.15s ease;
        }
        .sf-card:hover { border-color: #3a3f5a; transform: translateY(-1px); }
        .sf-card-title { font-weight: 650; font-size: 14.5px; color: #c9d3ff; text-decoration: none; cursor: pointer; }
        .sf-card-title:hover { color: #e6e8ef; text-decoration: underline; }
        .sf-star { cursor: pointer; margin-right: 8px; color: #454a5c; font-size: 15px; vertical-align: -1px; transition: color 0.15s ease; }
        .sf-star:hover { color: #f6c945; }
        .sf-star.active { color: #f6c945; }
        .sf-card-meta { font-size: 11.5px; color: #8b93a7; margin-top: 4px; }
        .sf-card-desc { font-size: 12.5px; color: #b7bdcc; margin-top: 8px; line-height: 1.5; }
        .sf-badge {
            display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: 0.2px;
            padding: 2px 8px; border-radius: 6px; background: #23262f; color: #a8afc0; margin: 6px 4px 0 0;
        }
        .sf-badge.cat { background: rgba(124, 92, 255, 0.15); color: #b3a3ff; }
        .sf-badge.compat { background: rgba(91, 141, 255, 0.15); color: #9dbcff; }
        .sf-badge.new { background: rgba(52, 211, 153, 0.18); color: #6ee7b7; }
        #sf-status { padding: 32px 20px; text-align: center; color: #6b7180; font-size: 13px; line-height: 1.6; }
    `);

    // ---------- STORAGE ----------
    function getApiKey() { return GM_getValue(STORAGE_API_KEY, ''); }
    function setApiKey(key) { GM_setValue(STORAGE_API_KEY, key); }
    function loadCache() { return GM_getValue(STORAGE_CACHE, null); }
    function saveCache(threads) { GM_setValue(STORAGE_CACHE, { threads, fetchedAt: Date.now() }); }
    function loadFavorites() { return new Set(GM_getValue(STORAGE_FAVORITES, [])); }
    function saveFavorites(set) { GM_setValue(STORAGE_FAVORITES, Array.from(set)); }

    // Kept as a fallback path for anyone who prefers it; the in-panel field is the main flow.
    // Torn PDA's GM polyfill doesn't implement this function at all, so guard the call --
    // calling it unconditionally would throw and kill the whole script before the button renders.
    if (typeof GM_registerMenuCommand === 'function') {
        GM_registerMenuCommand('Script Finder: Set Torn API key', () => {
            const current = getApiKey();
            const next = prompt('Enter your Torn API key (Public access is enough to read the forum):', current);
            if (next !== null) setApiKey(next.trim());
        });
    }

    // ---------- API ----------
    function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
    let lastRequestAt = 0;

    // Every call funnels through here, so the global throttle covers thread-list crawling
    // and first-post indexing together -- keeps us safely under Torn's 100 req/min.
    async function apiGet(path) {
        const key = getApiKey();
        if (!key) throw new Error('No Torn API key set yet.');
        const wait = MIN_REQUEST_INTERVAL_MS - (Date.now() - lastRequestAt);
        if (wait > 0) await sleep(wait);
        lastRequestAt = Date.now();
        const sep = path.includes('?') ? '&' : '?';
        const url = `${API_BASE}${path}${sep}key=${encodeURIComponent(key)}`;
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url,
                onload: (res) => {
                    let data;
                    try { data = JSON.parse(res.responseText); }
                    catch (e) { reject(new Error('Bad JSON from Torn API')); return; }
                    if (data && data.error) { reject(new Error(data.error.error || 'Torn API error')); return; }
                    resolve(data);
                },
                onerror: () => reject(new Error('Network error contacting Torn API')),
            });
        });
    }

    // Recursively narrows a [from, to] window until every window returns fewer than the
    // 100-thread page cap. A window under the cap is fully covered and needs no split; a full
    // window might be hiding more threads, so it gets bisected and both halves are re-checked.
    // This doesn't trust any cursor/link Torn provides -- those proved unreliable in testing
    // (the `prev` cursor and a self-computed `to` cursor both stalled after ~6 days of history).
    async function crawlRange(from, to, seen) {
        if (state.progress.requestsMade >= MAX_RANGE_REQUESTS || from >= to) return;
        const path = `/forum/${TOOLS_CATEGORY_ID}/threads?limit=100&sort=desc&from=${from}&to=${to}`;
        let data, requestError = null;
        try {
            data = await apiGet(path);
        } catch (e) {
            requestError = e.message;
            data = {};
        }
        const list = data.threads || data.data || (Array.isArray(data) ? data : []);
        list.forEach((t) => seen.set(t.id ?? t.thread_id, t));

        state.progress.requestsMade++;
        state.progress.totalThreads = seen.size;
        render();

        if (requestError) return; // don't recurse into a failing window
        if (list.length < 100) return; // window fully covered, nothing more to split here
        if (to - from <= 1) return; // can't split a 1-second window any further (safety floor)

        const mid = Math.floor((from + to) / 2);
        await crawlRange(from, mid, seen);
        await crawlRange(mid, to, seen);
    }

    // First post text, used to build the description + feed classification. Fetched lazily
    // in the background (see ensureDescriptions) rather than for the whole list up front.
    async function fetchFirstPostText(threadId) {
        const data = await apiGet(`/forum/${threadId}/posts`);
        const posts = data.posts || data.data || (Array.isArray(data) ? data : []);
        if (!posts.length) return '';
        const first = posts[0];
        return first.content || first.text || first.message || '';
    }

    function normalizeThread(raw) {
        const id = raw.id ?? raw.thread_id ?? raw.threadId;
        const title = raw.title ?? raw.name ?? '(untitled)';
        const author = (raw.author && (raw.author.username || raw.author.name)) || raw.username || raw.author_name || 'unknown';
        const lastPostAt = raw.last_post_time ?? raw.last_post ?? raw.last_poster_timestamp ?? raw.updated_at ?? 0;
        const replies = raw.posts ?? raw.replies ?? raw.num_posts ?? raw.post_count ?? 0;
        const views = raw.views ?? 0;
        const rating = raw.rating ?? 0;
        const lastPostMs = typeof lastPostAt === 'number' ? lastPostAt * (lastPostAt < 2e10 ? 1000 : 1) : Date.parse(lastPostAt) || 0;
        const t = {
            id, title, author, replies, views, rating,
            lastPostMs,
            url: `https://www.torn.com/forums.php?p=threads&f=${TOOLS_CATEGORY_ID}&t=${id}`,
            description: null,
            pdaCompatible: null,
            desktopCompatible: null,
        };
        applyClassification(t);
        return t;
    }

    // ---------- CLASSIFICATION ----------
    // Runs against title + first-post text combined once the description has loaded, so a
    // thread never needs the word "poker" in its title to be found searching/browsing Poker.
    function classifyCategories(text) {
        const t = text.toLowerCase();
        const matches = CATEGORY_RULES.filter((rule) => rule.keywords.some((kw) => t.includes(kw))).map((r) => r.category);
        return matches.length ? matches : ['Other'];
    }

    function classifyType(text) {
        const t = text.toLowerCase();
        for (const rule of TYPE_RULES) {
            if (rule.keywords.some((kw) => t.includes(kw))) return rule.type;
        }
        return 'Userscript';
    }

    function detectCompatibility(text) {
        const t = text.toLowerCase();
        const pda = /pda[\s-]*compat|works on pda|pda friendly/.test(t) ? true
            : /not pda|no pda|desktop only/.test(t) ? false : null;
        const desktop = /desktop only|not.*mobile/.test(t) ? true
            : /desktop.*not supported|no desktop/.test(t) ? false : null;
        return { pda, desktop };
    }

    // Recomputes categories/type/compatibility from whatever text is currently known
    // (title alone at first, title + first post once the background indexer reaches it).
    function applyClassification(t) {
        const combined = `${t.title} ${t.description || ''}`;
        t.categories = classifyCategories(combined);
        t.contentType = classifyType(combined);
        const compat = detectCompatibility(combined);
        t.pdaCompatible = compat.pda;
        t.desktopCompatible = compat.desktop;
    }

    // ---------- STATE ----------
    const state = {
        threads: [],
        query: '',
        category: 'All',
        contentType: 'All',
        sort: 'relevance',
        loading: false,
        checkingForUpdates: false,
        error: null,
        showKeyForm: false,
        favorites: loadFavorites(),
        favoritesOnly: false,
        lastVisitAt: Date.now(),
        progress: { requestsMade: 0, totalThreads: 0, descriptionDone: 0, descriptionTotal: 0 },
    };

    // Full from-scratch re-scan of the entire forum history. Used on the very first run, and
    // available manually via "Rebuild full index" for when the index looks incomplete --
    // refreshRecentThreads() below can only look forward from what's already cached, so it
    // can never fix gaps left by a bad or partial earlier crawl. This can.
    async function runFullCrawl() {
        state.loading = true;
        state.error = null;
        render();
        try {
            const seen = new Map();
            state.progress.requestsMade = 0;
            state.progress.totalThreads = 0;
            const nowPlusBuffer = Math.floor(Date.now() / 1000) + 3600;
            await crawlRange(FORUM_EPOCH_SECONDS, nowPlusBuffer, seen);
            const fresh = Array.from(seen.values()).map(normalizeThread);

            // Keep any first-post text already fetched for threads that still exist, so a
            // rebuild doesn't throw away indexing work that's already done.
            const prevById = new Map(state.threads.map((t) => [t.id, t]));
            fresh.forEach((t) => {
                const prev = prevById.get(t.id);
                if (prev && prev.description) {
                    t.description = prev.description;
                    applyClassification(t);
                }
            });
            state.threads = fresh;
            saveCache(state.threads);
        } catch (e) {
            state.error = e.message;
        } finally {
            state.loading = false;
            render();
            ensureDescriptions();
        }
    }

    async function ensureThreads() {
        const cached = loadCache();
        if (cached && cached.threads.length) {
            state.threads = cached.threads;
            render();
            ensureDescriptions();
            refreshRecentThreads();
            return;
        }
        await runFullCrawl();
    }

    // Cheap "what's new" check: only crawls from the newest thread we already know about
    // (minus a small overlap for safety) up to now, instead of redoing the full history.
    // Runs automatically on every open, plus whenever "Refresh list" is clicked.
    let recentCrawlRunning = false;
    async function refreshRecentThreads() {
        if (recentCrawlRunning || !state.threads.length) return;
        recentCrawlRunning = true;
        state.checkingForUpdates = true;
        render();
        try {
            const newestKnownSec = state.threads.reduce((max, t) => Math.max(max, Math.floor(t.lastPostMs / 1000)), 0);
            const from = Math.max(FORUM_EPOCH_SECONDS, newestKnownSec - RECENT_OVERLAP_SECONDS);
            const now = Math.floor(Date.now() / 1000) + 3600;
            const seen = new Map();
            state.progress.requestsMade = 0; // this is a small crawl, doesn't need the big-crawl counter
            await crawlRange(from, now, seen);

            if (seen.size) {
                const byId = new Map(state.threads.map((t) => [t.id, t]));
                seen.forEach((raw) => {
                    const id = raw.id ?? raw.thread_id;
                    const norm = normalizeThread(raw);
                    const existing = byId.get(id);
                    if (existing && existing.description) {
                        norm.description = existing.description;
                        applyClassification(norm);
                    }
                    byId.set(id, norm);
                });
                state.threads = Array.from(byId.values());
                saveCache(state.threads);
            }
        } catch (e) {
            // silent -- this is a background convenience check, not worth interrupting the user for
        } finally {
            recentCrawlRunning = false;
            state.checkingForUpdates = false;
            render();
            ensureDescriptions();
        }
    }

    // Gradually fetches the first post of every thread still missing one, in the background,
    // saving progress every few threads so a closed tab doesn't lose the work. Safe to call
    // repeatedly -- it no-ops if a crawl is already running.
    let descriptionCrawlRunning = false;
    async function ensureDescriptions() {
        if (descriptionCrawlRunning) return;
        descriptionCrawlRunning = true;
        try {
            const pending = state.threads.filter((t) => t.description === null);
            state.progress.descriptionDone = 0;
            state.progress.descriptionTotal = pending.length;
            for (const t of pending) {
                try {
                    const text = await fetchFirstPostText(t.id);
                    t.description = text.replace(/\s+/g, ' ').trim().slice(0, 600);
                } catch (e) {
                    t.description = ''; // mark attempted so we don't retry forever
                }
                applyClassification(t);
                state.progress.descriptionDone++;
                if (state.progress.descriptionDone % 5 === 0 || state.progress.descriptionDone === pending.length) {
                    saveCache(state.threads);
                    render();
                }
            }
        } finally {
            descriptionCrawlRunning = false;
        }
    }

    function toggleFavorite(id) {
        if (state.favorites.has(id)) state.favorites.delete(id);
        else state.favorites.add(id);
        saveFavorites(state.favorites);
        render();
    }

    function getFiltered() {
        const q = state.query.trim().toLowerCase();
        let list = state.threads.filter((t) => {
            if (state.favoritesOnly && !state.favorites.has(t.id)) return false;
            if (state.category !== 'All' && !t.categories.includes(state.category)) return false;
            if (state.contentType !== 'All' && t.contentType !== state.contentType) return false;
            if (q && !t.title.toLowerCase().includes(q) && !(t.description || '').toLowerCase().includes(q)) return false;
            return true;
        });
        switch (state.sort) {
            case 'newest':
            case 'updated':
                list = list.sort((a, b) => b.lastPostMs - a.lastPostMs); break;
            case 'popular':
                list = list.sort((a, b) => b.replies - a.replies); break;
            default:
                if (q) list = list.sort((a, b) => a.title.toLowerCase().indexOf(q) - b.title.toLowerCase().indexOf(q));
        }
        return list;
    }

    function relativeTime(ms) {
        if (!ms) return 'unknown';
        const diffSec = Math.floor((Date.now() - ms) / 1000);
        if (diffSec < 60) return 'just now';
        const mins = Math.floor(diffSec / 60);
        if (mins < 60) return `${mins}m ago`;
        const hours = Math.floor(mins / 60);
        if (hours < 24) return `${hours}h ago`;
        const days = Math.floor(hours / 24);
        if (days < 30) return `${days}d ago`;
        const months = Math.floor(days / 30);
        if (months < 12) return `${months}mo ago`;
        return `${Math.floor(months / 12)}y ago`;
    }

    // ---------- DRAGGABLE LAUNCH BUTTON ----------
    function applyBtnPosition(btn) {
        const pos = GM_getValue(STORAGE_BTN_POS, null);
        if (pos) {
            btn.style.left = pos.left;
            btn.style.top = pos.top;
            btn.style.right = 'auto';
        }
    }

    function makeDraggable(btn) {
        let dragging = false, moved = false, startX, startY, startLeft, startTop;
        btn.addEventListener('pointerdown', (e) => {
            dragging = true; moved = false;
            startX = e.clientX; startY = e.clientY;
            const rect = btn.getBoundingClientRect();
            startLeft = rect.left; startTop = rect.top;
            btn.setPointerCapture(e.pointerId);
        });
        btn.addEventListener('pointermove', (e) => {
            if (!dragging) return;
            const dx = e.clientX - startX, dy = e.clientY - startY;
            if (Math.abs(dx) > 4 || Math.abs(dy) > 4) moved = true;
            if (!moved) return;
            const left = Math.min(window.innerWidth - btn.offsetWidth - 4, Math.max(4, startLeft + dx));
            const top = Math.min(window.innerHeight - btn.offsetHeight - 4, Math.max(4, startTop + dy));
            btn.style.left = `${left}px`;
            btn.style.top = `${top}px`;
            btn.style.right = 'auto';
        });
        btn.addEventListener('pointerup', () => {
            dragging = false;
            if (moved) GM_setValue(STORAGE_BTN_POS, { left: btn.style.left, top: btn.style.top });
        });
        btn.addEventListener('click', (e) => {
            if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; return; }
            open();
        });
    }

    // ---------- UI ----------
    let keybarEl, keyFieldEl;

    function buildShell() {
        const btn = document.createElement('button');
        btn.id = 'sf-launch-btn';
        btn.textContent = '🔎 Script Finder';
        document.body.appendChild(btn);
        applyBtnPosition(btn);
        makeDraggable(btn);

        const overlay = document.createElement('div');
        overlay.id = 'sf-overlay';
        overlay.innerHTML = `
            <div id="sf-panel">
                <header>
                    <h2>Script Finder</h2>
                    <input id="sf-search" type="text" placeholder="Search tools, scripts, sites..." />
                    <select id="sf-sort">
                        <option value="relevance">Relevance</option>
                        <option value="newest">Newest</option>
                        <option value="updated">Recently updated</option>
                        <option value="popular">Popular</option>
                    </select>
                    <button id="sf-key-btn" class="sf-chip">🔑 API Key</button>
                    <button id="sf-close">&times;</button>
                </header>
                <div id="sf-keybar">
                    <input id="sf-key-field" type="text" placeholder="Paste your Torn API key here..." />
                    <button id="sf-key-save" class="sf-chip">Save</button>
                    <span id="sf-key-hint">Public access is enough. Torn &rarr; Settings &rarr; API Keys.</span>
                </div>
                <div id="sf-chips"></div>
                <div id="sf-toolbar">
                    <select id="sf-type">
                        <option value="All">All types</option>
                        <option>Userscript</option>
                        <option>Website</option>
                        <option>Extension</option>
                        <option>Guide / Tool</option>
                    </select>
                    <button id="sf-fav-toggle" class="sf-chip">⭐ Favorites only</button>
                    <button id="sf-refresh" class="sf-chip">Refresh list</button>
                    <button id="sf-deepscan" class="sf-chip">Load details for visible results</button>
                    <button id="sf-rebuild" class="sf-chip">Rebuild full index</button>
                </div>
                <div id="sf-indexing-status"></div>
                <div id="sf-results"></div>
            </div>
        `;
        document.body.appendChild(overlay);

        keybarEl = overlay.querySelector('#sf-keybar');
        keyFieldEl = overlay.querySelector('#sf-key-field');
        if (getApiKey()) keyFieldEl.placeholder = 'Key saved. Paste a new one to replace it...';

        overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
        overlay.querySelector('#sf-close').addEventListener('click', close);
        overlay.querySelector('#sf-search').addEventListener('input', (e) => { state.query = e.target.value; render(); });
        overlay.querySelector('#sf-sort').addEventListener('change', (e) => { state.sort = e.target.value; render(); });
        overlay.querySelector('#sf-type').addEventListener('change', (e) => { state.contentType = e.target.value; render(); });
        overlay.querySelector('#sf-refresh').addEventListener('click', () => {
            if (state.threads.length) refreshRecentThreads();
            else ensureThreads();
        });
        overlay.querySelector('#sf-deepscan').addEventListener('click', deepScanVisible);
        overlay.querySelector('#sf-rebuild').addEventListener('click', () => {
            if (confirm("Fully re-scan the forum's entire history? This can take a minute or two, but fixes a gappy index.")) {
                runFullCrawl();
            }
        });
        overlay.querySelector('#sf-key-btn').addEventListener('click', () => toggleKeyBar());
        overlay.querySelector('#sf-key-save').addEventListener('click', saveKeyFromField);
        keyFieldEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveKeyFromField(); });

        const favToggle = overlay.querySelector('#sf-fav-toggle');
        favToggle.addEventListener('click', () => {
            state.favoritesOnly = !state.favoritesOnly;
            favToggle.classList.toggle('active', state.favoritesOnly);
            render();
        });

        overlay.querySelector('#sf-results').addEventListener('click', (e) => {
            const star = e.target.closest('.sf-star');
            if (star) { e.preventDefault(); toggleFavorite(Number(star.dataset.id)); }
        });

        const chipsEl = overlay.querySelector('#sf-chips');
        ['All', 'Poker', 'Casino', 'Faction', 'Crimes', 'Travel', 'Trading', 'Combat', 'UI / Quality of Life', 'Other'].forEach((cat) => {
            const chip = document.createElement('span');
            chip.className = 'sf-chip' + (cat === 'All' ? ' active' : '');
            chip.textContent = cat;
            chip.addEventListener('click', () => {
                state.category = cat;
                chipsEl.querySelectorAll('.sf-chip').forEach((c) => c.classList.remove('active'));
                chip.classList.add('active');
                render();
            });
            chipsEl.appendChild(chip);
        });
    }

    function toggleKeyBar(forceOpen) {
        state.showKeyForm = forceOpen === true ? true : !state.showKeyForm;
        keybarEl.style.display = state.showKeyForm ? 'flex' : 'none';
        if (state.showKeyForm) keyFieldEl.focus();
    }

    function saveKeyFromField() {
        const val = keyFieldEl.value.trim();
        if (!val) return;
        setApiKey(val);
        keyFieldEl.value = '';
        keyFieldEl.placeholder = 'Key saved. Paste a new one to replace it...';
        toggleKeyBar(false);
        state.error = null;
        if (!state.threads.length) ensureThreads();
        else render();
    }

    // Manual priority-boost for whatever's currently on screen; the background crawler
    // (ensureDescriptions) will get to everything eventually, this just jumps the queue.
    async function deepScanVisible() {
        const visible = getFiltered().slice(0, 30).filter((t) => t.description === null);
        for (const t of visible) {
            try {
                const text = await fetchFirstPostText(t.id);
                t.description = text.replace(/\s+/g, ' ').trim().slice(0, 600);
            } catch (e) {
                t.description = '';
            }
            applyClassification(t);
        }
        saveCache(state.threads);
        render();
    }

    function open() {
        document.getElementById('sf-overlay').classList.add('open');
        if (!getApiKey()) {
            toggleKeyBar(true);
            render();
            return;
        }
        state.lastVisitAt = GM_getValue(STORAGE_LAST_VISIT, Date.now());
        GM_setValue(STORAGE_LAST_VISIT, Date.now());
        if (!state.threads.length) ensureThreads();
        else render();
    }
    function close() { document.getElementById('sf-overlay').classList.remove('open'); }

    function renderIndexingStatus() {
        const el = document.getElementById('sf-indexing-status');
        if (!el) return;
        const parts = [];
        if (state.threads.length) parts.push(`${state.threads.length} threads indexed`);
        if (state.checkingForUpdates) parts.push('checking for new threads...');
        const p = state.progress;
        if (p.descriptionTotal && p.descriptionDone < p.descriptionTotal) parts.push(`indexing first posts: ${p.descriptionDone}/${p.descriptionTotal}`);
        el.textContent = parts.join(' · ');
    }

    function render() {
        renderIndexingStatus();
        const results = document.getElementById('sf-results');
        if (!results) return;
        if (!getApiKey()) {
            results.innerHTML = `<div id="sf-status">No Torn API key set yet.<br>Click "🔑 API Key" above and paste one in (Public access is enough).</div>`;
            return;
        }
        if (state.loading) {
            const p = state.progress;
            const progress = p.totalThreads ? ` (${p.totalThreads} threads found across ${p.requestsMade} requests so far)` : '';
            results.innerHTML = `<div id="sf-status">Crawling full forum history for the first time...${progress}<br>This only happens once -- after this, checking for new threads is quick.</div>`;
            return;
        }
        if (state.error) { results.innerHTML = `<div id="sf-status">Error: ${escapeHtml(state.error)}</div>`; return; }

        const list = getFiltered();
        if (!list.length) { results.innerHTML = `<div id="sf-status">No results.</div>`; return; }

        results.innerHTML = list.map((t) => {
            const isFav = state.favorites.has(t.id);
            const isNew = t.lastPostMs > state.lastVisitAt;
            const badges = [
                isNew ? `<span class="sf-badge new">New</span>` : '',
                ...t.categories.map((c) => `<span class="sf-badge cat">${escapeHtml(c)}</span>`),
                `<span class="sf-badge">${escapeHtml(t.contentType)}</span>`,
                t.pdaCompatible === true ? `<span class="sf-badge compat">PDA OK</span>` : '',
                t.desktopCompatible === false ? `<span class="sf-badge compat">Not desktop</span>` : '',
            ].join('');
            const updatedAbs = t.lastPostMs ? new Date(t.lastPostMs).toLocaleString() : '';
            return `
                <div class="sf-card">
                    <span class="sf-star ${isFav ? 'active' : ''}" data-id="${t.id}">${isFav ? '★' : '☆'}</span>
                    <a class="sf-card-title" href="${t.url}" target="_blank" rel="noopener">${escapeHtml(t.title)}</a>
                    <div class="sf-card-meta">by ${escapeHtml(t.author)} &middot; updated <span title="${escapeHtml(updatedAbs)}">${relativeTime(t.lastPostMs)}</span> &middot; ${t.replies} replies</div>
                    <div>${badges}</div>
                    ${t.description ? `<div class="sf-card-desc">${escapeHtml(t.description.slice(0, 220))}</div>` : ''}
                </div>
            `;
        }).join('');
    }

    function escapeHtml(s) {
        return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
    }

    buildShell();
})();