GF Code Scanner [Local + AI]

Проверка кода скриптов GreasyFork на безопасность (Локально + ИИ)

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         GF Code Scanner [Local + AI]
// @name:en      GF Code Scanner [Local + AI]
// @namespace    http://tampermonkey.net/
// @version      1.2.0
// @description  Проверка кода скриптов GreasyFork на безопасность (Локально + ИИ)
// @description:en Security check for GreasyFork scripts (Local scan + AI analysis)
// @author       Kimi, Claude, DeepSeek on base script GreasyFork Code Safety Scanner
// @match        https://greatest.deepsurf.us/*/scripts/*/code*
// @match        https://greatest.deepsurf.us/*/scripts/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM.xmlHttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        unsafeWindow
// @connect      api.groq.com
// @connect      api.deepseek.com
// @connect      api.openai.com
// @connect      api.x.ai
// @connect      generativelanguage.googleapis.com
// @connect      openrouter.ai
// @connect      js.puter.com
// @require      https://update.greatest.deepsurf.us/scripts/34138/223779/markedjs.js
// @run-at       document-end
// @compatible   firefox     78+  TamperMonkey 4.18+
// @compatible   firefox     78+  GreasyMonkey 4.11+
// @compatible   chrome      88+  TamperMonkey 4.18+
// @compatible   chrome      88+  ScriptCat 0.9+
// @compatible   edge        88+  TamperMonkey 4.18+
// @compatible   opera       74+  TamperMonkey 4.18+
// @compatible   firefox     78+  ViolentMonkey 2.12+  (не полностью протестирован)
// @compatible   chrome      88+  ViolentMonkey 2.12+  (не полностью протестирован)
// @license      MIT
// ==/UserScript==

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  СОВМЕСТИМОСТЬ БРАУЗЕРОВ И МЕНЕДЖЕРОВ СКРИПТОВ                           ║
// ╠═══════════════════════════════════════════════════════════════════════════╣
// ║  ✅ Chrome 88+   + TamperMonkey 4.18+   — полная поддержка               ║
// ║  ✅ Chrome 88+   + ScriptCat 0.9+       — полная поддержка               ║
// ║  ✅ Firefox 78+  + TamperMonkey 4.18+   — полная поддержка               ║
// ║  ✅ Firefox 78+  + GreasyMonkey 4.11+   — полная поддержка               ║
// ║  ✅ Edge 88+     + TamperMonkey 4.18+   — полная поддержка               ║
// ║  ✅ Opera 74+    + TamperMonkey 4.18+   — полная поддержка               ║
// ║  ✅ Vivaldi 3.6+ + TamperMonkey 4.18+   — полная поддержка               ║
// ║  ✅ Llama [Puter.js]: Chrome/Edge 88+, Firefox 78+ (TM 4.18+)           ║
// ║       Firefox: исправлено через cloneInto() без правки настроек          ║
// ║  ⚠️  Любой браузер + ViolentMonkey 2.12+ — работает, не тестировалось    ║
// ║  ⚠️  Via Browser (Android) — частично: ИИ работает, ключ может           ║
// ║       не сохраняться между сессиями                                      ║
// ║  ❌  Safari (любой) + Userscripts — GM XHR API несовместим               ║
// ║  ❌  Firefox + GreasyMonkey 3.x — устаревший API, не поддерживается      ║
// ╠═══════════════════════════════════════════════════════════════════════════╣
// ║  ИСТОРИЯ ИЗМЕНЕНИЙ                                                        ║
// ║  v1.2.0  Добавлен чекбокс «Расскажи о скрипте»:                          ║
// ║           • При активации ИИ дополнительно запрашивает краткое описание  ║
// ║             скрипта и его алгоритма работы                               ║
// ║           • Добавляет юмористический вывод о безопасности использования  ║
// ║             с точки зрения конфиденциальности данных пользователя        ║
// ║             на основе локально найденных параметров                      ║
// ║           • Состояние чекбокса НЕ сохраняется между страницами           ║
// ║  v1.1.0  Оптимизация производительности (по анализу Nemotron):           ║
// ║           • MutationObserver вместо setInterval в init() — убирает       ║
// ║             тикающий таймер, меньше пробуждений CPU                      ║
// ║           • pre-compile regex в RULES[i].re — new RegExp только 1 раз   ║
// ║           • URL_RE.lastIndex=0 при каждом вызове extractUrls             ║
// ║           • aiInProgress флаг — предотвращает параллельные XHR          ║
// ║           • GM-тип определяется один раз при старте (hasNewGM)          ║
// ║           • parts[]+join вместо h+= в buildWidget                       ║
// ║           • requestIdleCallback для запуска скана (деградирует к setTimeout)║
// ║           • cleanup() при Turbolinks — явная отвязка observer            ║
// ║           • Именованные константы: RETRY_MS, MAX_RETRIES, MAX_CODE_LEN  ║
// ║  v1.0.8  Firefox fix для Puter.js: cloneInto(); подсказка о ключе;      ║
// ║           nVidia→OpenRouter; BrowserAI отключён; комментарии на RU      ║
// ║  v1.0.7  Флаг CLEAR_ALL_API_KEYS; Qwen через Groq; Puter.js; OpenRouter  ║
// ║  v1.0.6  Точные версии @compatible; изолированные CSS для ответа ИИ      ║
// ║  v1.0.5  Исправление window.prompt() в Firefox (TDZ)                     ║
// ║  v1.0.4  Универсальный GM API; выбор языка ответа; i18n UI              ║
// ║  v1.0.3  Первый публичный релиз                                           ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  НАСТРОЙКИ ПОЛЬЗОВАТЕЛЯ — редактировать только этот раздел              ║
// ╠═══════════════════════════════════════════════════════════════════════════╣

// Принудительная очистка всех сохранённых API-ключей.
// Установить в true → перезагрузить страницу → вернуть в false.
const CLEAR_ALL_API_KEYS = false;

// Диагностика: вывести в консоль (F12) список доступных моделей провайдеров.
// Нужны сохранённые ключи. Установить true → открыть консоль → вернуть false.
const TEST_MODELS_AVAILABLE = false;

// ╚═══════════════════════════════════════════════════════════════════════════╝

(async function () {
    'use strict';
    console.log('[GF-Scanner-v1.2.0] Start');

    // ══════════════════════════════════════════════════════════════════════════
    //  ИМЕНОВАННЫЕ КОНСТАНТЫ (вместо «магических чисел» в коде)
    // ══════════════════════════════════════════════════════════════════════════
    const RETRY_MS      = 400;    // интервал ожидания <pre> в MutationObserver-таймауте
    const MAX_RETRIES   = 20;     // максимум попыток найти <pre> (fallback для старых браузеров)
    const MAX_CODE_LEN  = 15000;  // лимит символов кода в промпте для ИИ
    const MAX_FIND_LINES = 5;     // максимум строк кода для каждой найденной угрозы

    // ══════════════════════════════════════════════════════════════════════════
    //  CSS ДЛЯ ОТОБРАЖЕНИЯ ОТВЕТА ИИ (markdown)
    //  Инжектируется один раз. Все правила изолированы на #gfr.
    // ══════════════════════════════════════════════════════════════════════════
    if (!document.getElementById('gfs-md-style')) {
        const style = document.createElement('style');
        style.id = 'gfs-md-style';
        style.textContent = `
#gfr { background:#0d1117 !important; color:#c9d1d9 !important; }
#gfr p   { margin:4px 0; }
#gfr ul, #gfr ol { padding-left:20px; margin:4px 0; }
#gfr li  { margin:2px 0; }
#gfr code {
    background:rgba(110,118,129,0.2); color:#c9d1d9;
    border:1px solid rgba(110,118,129,0.3); border-radius:3px;
    padding:1px 5px; font-family:'Consolas','Courier New',monospace;
    font-size:11px; word-break:break-word; }
#gfr pre { background:rgba(0,0,0,0.4); border:1px solid rgba(110,118,129,0.2);
    border-radius:4px; padding:8px 10px; overflow-x:auto; margin:6px 0; }
#gfr pre code { background:none !important; border:none !important; padding:0 !important; }
#gfr h1,#gfr h2 { color:#79b8ff; font-size:14px; margin:10px 0 4px;
    border-bottom:1px solid #30363d; padding-bottom:3px; }
#gfr h3,#gfr h4 { color:#79b8ff; font-size:13px; margin:8px 0 3px; }
#gfr strong,#gfr b { color:#f0f0f0; font-weight:600; }
#gfr em,#gfr i    { color:#d0d0d0; font-style:italic; }
#gfr a { color:#58a6ff; text-decoration:none; }
#gfr a:hover { text-decoration:underline; }
#gfr blockquote { border-left:3px solid #444; padding:4px 8px; color:#8b949e;
    margin:4px 0; background:rgba(255,255,255,0.03); border-radius:0 4px 4px 0; }
#gfr hr { border:none; border-top:1px solid #30363d; margin:8px 0; }
#gfr table { border-collapse:collapse; width:100%; margin:6px 0; font-size:12px; }
#gfr th { background:rgba(110,118,129,0.15); color:#c9d1d9;
    padding:4px 8px; border:1px solid #30363d; }
#gfr td { padding:4px 8px; border:1px solid #30363d; color:#c9d1d9; }
#gfr tr:nth-child(even) td { background:rgba(110,118,129,0.05); }`;
        document.head.appendChild(style);
    }

    // ══════════════════════════════════════════════════════════════════════════
    //  GM API — ОПРЕДЕЛЕНИЕ ТИПА ОДИН РАЗ ПРИ СТАРТЕ
    //
    //  TamperMonkey  → GM_getValue / GM_setValue / GM_xmlhttpRequest  (синхр.)
    //  GreasyMonkey4 → GM.getValue  / GM.setValue  / GM.xmlHttpRequest (Promise)
    // ══════════════════════════════════════════════════════════════════════════
    const hasNewGM = typeof GM !== 'undefined' && typeof GM.getValue === 'function';

    const gmGet = async (key, def) => {
        try {
            if (hasNewGM) return await GM.getValue(key, def);
            if (typeof GM_getValue === 'function') {
                const v = GM_getValue(key, def);
                return (v && typeof v.then === 'function') ? await v : v;
            }
        } catch (e) { console.warn('[GF-Scanner] gmGet:', e); }
        return def;
    };

    const gmSet = async (key, val) => {
        try {
            if (hasNewGM) return await GM.setValue(key, val);
            if (typeof GM_setValue === 'function') return GM_setValue(key, val);
        } catch (e) { console.warn('[GF-Scanner] gmSet:', e); }
    };

    const gmXhr = (opts) => {
        try {
            if (hasNewGM && typeof GM.xmlHttpRequest === 'function') return GM.xmlHttpRequest(opts);
            if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest(opts);
            throw new Error('GM XHR недоступен — проверьте @grant в заголовке скрипта');
        } catch (e) {
            console.error('[GF-Scanner] gmXhr:', e);
            opts.onerror && opts.onerror({ message: e.message });
        }
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  СПИСОК МОДЕЛЕЙ ИИ
    // ══════════════════════════════════════════════════════════════════════════
    const MODELS = {
        groq:        { n: 'Groq',
                       u: 'https://api.groq.com/openai/v1/chat/completions',
                       m: 'llama-3.3-70b-versatile' },

        deepseek:    { n: 'DeepSeek 💰',
                       u: 'https://api.deepseek.com/chat/completions',
                       m: 'deepseek-chat' },

        gemini:      { n: 'Gemini',
                       u: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent',
                       m: 'gemini-2.5-flash',
                       g: true },

        grok:        { n: 'Grok 💰',
                       u: 'https://api.x.ai/v1/chat/completions',
                       m: 'grok-beta' },

        qwen:        { n: 'Qwen 🡒 Groq',
                       u: 'https://api.groq.com/openai/v1/chat/completions',
                       m: 'qwen/qwen3-32b',
                       sharedKey: 'groq' },

        openai:      { n: 'ChatGPT 💰',
                       u: 'https://api.openai.com/v1/chat/completions',
                       m: 'gpt-3.5-turbo' },

        mistral_or:  { n: 'nVidia 🡒 OpenRouter',
                       u: 'https://openrouter.ai/api/v1/chat/completions',
                       m: 'nvidia/nemotron-3-super-120b-a12b:free',
                       keyLabel: 'OpenRouter (openrouter.ai/keys)',
                       orModel: true },

        llama_puter: { n: 'Llama 🡒 Puter.js',
                       noKey: true,
                       puter: true },
    };

    const KEY_URLS = {
        groq:       'https://console.groq.com/keys',
        deepseek:   'https://platform.deepseek.com/api_keys',
        gemini:     'https://aistudio.google.com/app/apikey',
        grok:       'https://console.x.ai/',
        openai:     'https://platform.openai.com/api-keys',
        mistral_or: 'https://openrouter.ai/keys'
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  ЯЗЫКИ ОТВЕТА ИИ
    // ══════════════════════════════════════════════════════════════════════════
    const LANGS = {
        ru: { label: '🇷🇺 Русский',   name: 'Russian'    },
        en: { label: '🇬🇧 English',    name: 'English'    },
        de: { label: '🇩🇪 Deutsch',    name: 'German'     },
        fr: { label: '🇫🇷 Français',   name: 'French'     },
        es: { label: '🇪🇸 Español',    name: 'Spanish'    },
        zh: { label: '🇨🇳 中文',       name: 'Chinese'    },
        ja: { label: '🇯🇵 日本語',     name: 'Japanese'   },
        ko: { label: '🇰🇷 한국어',     name: 'Korean'     },
        pt: { label: '🇵🇹 Português',  name: 'Portuguese' }
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  ПЕРЕВОД ИНТЕРФЕЙСА (i18n)
    // ══════════════════════════════════════════════════════════════════════════
    const I18N = {
        ru: {
            report:'Отчёт', safe:'Безопасно', low:'Низкий риск', med:'Средний риск', danger:'ОПАСНО',
            issues:'Угроз', domains:'Домены', showUrls:'Показать URL', showLines:'Показать строки',
            noThreats:'Угроз не обнаружено.', retryLocal:'Пересканировать', checkAi:'Проверить ИИ',
            retryAi:'Снова ИИ', sending:'Отправка данных в', thinking:'Думает...',
            noKey:'Ключ не введён', enterKey:'Введите API-ключ для', parseErr:'Ошибка разбора ответа',
            netErr:'Ошибка сети. Проверьте консоль (F12).', emptyResp:'Пустой ответ от ИИ.',
            modelLabel:'Модель ИИ:', langLabel:'Язык ответа:',
            keyHint:'🔑 API-ключ вводится один раз и сохраняется в браузере',
            loadingPuter:'Загрузка Puter.js, подключение к Llama...',
            puterErr:'Ошибка Puter.js',
            keysCleared:'✅ Все API-ключи очищены (CLEAR_ALL_API_KEYS=true).',
            describeScript:'Расскажи о скрипте'
        },
        en: {
            report:'Report', safe:'Safe', low:'Low Risk', med:'Medium Risk', danger:'DANGER',
            issues:'Issues', domains:'Domains', showUrls:'Show URLs', showLines:'Show Lines',
            noThreats:'No threats found.', retryLocal:'Retry Local', checkAi:'Check AI',
            retryAi:'Retry AI', sending:'Sending data to', thinking:'Thinking...',
            noKey:'No key provided', enterKey:'Enter API key for', parseErr:'Parse error',
            netErr:'Network error. Check console (F12).', emptyResp:'Empty response from AI.',
            modelLabel:'AI Model:', langLabel:'Response language:',
            keyHint:'🔑 API key is entered once and saved in your browser',
            loadingPuter:'Loading Puter.js, connecting to Llama...',
            puterErr:'Puter.js error',
            keysCleared:'✅ All API keys cleared (CLEAR_ALL_API_KEYS=true).',
            describeScript:'Describe script'
        }
    };
    const t = (k) => (I18N[L] || I18N.en)[k] ?? I18N.en[k] ?? k;

    // ══════════════════════════════════════════════════════════════════════════
    //  ПРАВИЛА ЛОКАЛЬНОГО СКАНИРОВАНИЯ — PRE-COMPILED REGEX
    // ══════════════════════════════════════════════════════════════════════════
    const RULES = [
        { p:/\beval\s*\(/g,                            v:4, en:'eval()',          ru:'eval()',           den:'Code Execution',        dru:'Выполнение кода'     },
        { p:/\bnew\s+Function\s*\(/g,                  v:4, en:'new Function()',  ru:'new Function()',   den:'Dynamic Code',          dru:'Динамика'            },
        { p:/coinhive|cryptonight|monero|minero\.js/i, v:4, en:'Miner',           ru:'Майнер',           den:'Crypto Mining',         dru:'Криптомайнинг'       },
        { p:/\batob\s*\(/g,                            v:3, en:'atob()',          ru:'atob()',           den:'Hidden Base64',         dru:'Скрытый Base64'      },
        { p:/_0x[a-f0-9]{4,}/i,                        v:3, en:'Obfuscation',     ru:'Обфускация',       den:'Obfuscated Code',       dru:'Запутанный код'      },
        { p:/String\.fromCharCode/i,                   v:3, en:'CharCode',        ru:'CharCode',         den:'Obfuscation Technique', dru:'Техника обфускации'  },
        { p:/\bnavigator\.geolocation\b/g,             v:3, en:'Geolocation',     ru:'Геолокация',       den:'GPS Tracking',          dru:'Отслеживание GPS'    },
        { p:/\bnavigator\.mediaDevices\b/g,            v:3, en:'Cam/Mic',         ru:'Кам/Мик',          den:'Camera/Microphone',     dru:'Камера/Микрофон'     },
        { p:/\bGM_xmlhttpRequest\b/g,                  v:3, en:'GM_XHR',          ru:'GM_XHR',           den:'Hidden XHR Requests',   dru:'Скрытые запросы'     },
        { p:/\bdocument\.cookie\b/g,                   v:2, en:'Cookies',         ru:'Cookies',          den:'Cookie Access',         dru:'Доступ к куки'       },
        { p:/\blocalStorage\b/g,                       v:2, en:'LocalStorage',    ru:'LocalStorage',     den:'Persistent Storage',    dru:'Локальное хранение'  },
        { p:/\bsessionStorage\b/g,                     v:2, en:'SessionStorage',  ru:'SessionStorage',   den:'Session Storage',       dru:'Сессионное хранение' },
        { p:/\bnavigator\.clipboard\b/g,               v:2, en:'Clipboard',       ru:'Буфер обмена',     den:'Clipboard Access',      dru:'Буфер обмена'        },
        { p:/\bGM_getValue\b|\bGM_setValue\b/g,        v:2, en:'GM_Store',        ru:'GM_Store',         den:'TM/GM Storage API',     dru:'Хранилище TM/GM'     },
        { p:/\bfetch\s*\(/g,                           v:1, en:'Fetch',           ru:'Fetch',            den:'Network Request',       dru:'Сетевой запрос'      },
        { p:/\bXMLHttpRequest\b/g,                     v:1, en:'XHR',             ru:'XHR',              den:'Network Request',       dru:'Сетевой запрос'      },
        { p:/\bdocument\.write\s*\(/g,                 v:2, en:'DocWrite',        ru:'DocWrite',         den:'Page Overwrite',        dru:'Перезапись страницы' }
    ].map(r => ({ ...r, re: new RegExp(r.p.source, r.p.flags) }));

    const URL_RE = /https?:\/\/[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)/gi;

    const esc = s => s ? String(s)
        .replace(/&/g,'&amp;').replace(/</g,'&lt;')
        .replace(/>/g,'&gt;').replace(/"/g,'&quot;') : '';

    const findLines = (code, re) => {
        const localRe = new RegExp(re.source, re.flags);
        const out = [];
        code.split('\n').forEach((l, i) => {
            if (localRe.test(l)) out.push(`L${i+1}: ${l.trim().substring(0, 70)}`);
            localRe.lastIndex = 0;
        });
        return out.slice(0, MAX_FIND_LINES);
    };

    const extractUrls = code => {
        URL_RE.lastIndex = 0;
        const u = [...new Set(code.match(URL_RE) || [])], d = new Set();
        u.forEach(x => { try { d.add(new URL(x).hostname); } catch(e) {} });
        return { u, d: [...d] };
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  ЗАГРУЗКА СОХРАНЁННЫХ НАСТРОЕК
    // ══════════════════════════════════════════════════════════════════════════
    let K = await gmGet('k', {});
    if (typeof K !== 'object' || K === null || Array.isArray(K)) K = {};
    let S = await gmGet('s', 'groq'); if (!MODELS[S]) S = 'groq';
    let L = await gmGet('l', 'ru');   if (!LANGS[L])  L = 'ru';
    // v1.2.0: чекбокс «Расскажи о скрипте» — локально, не сохраняется между страницами
    let D = false;

    // ══════════════════════════════════════════════════════════════════════════
    //  ДИАГНОСТИКА
    // ══════════════════════════════════════════════════════════════════════════
    if (TEST_MODELS_AVAILABLE) {
        console.log('[GF-Scanner] === ДИАГНОСТИКА: проверка доступных моделей ===');
        const fetchModels = (url, apiKey, name) => new Promise(resolve => {
            if (!apiKey) { console.warn(`[${name}] Нет ключа`); return resolve(null); }
            gmXhr({ method:'GET', url, headers:{'Authorization':`Bearer ${apiKey}`,'Content-Type':'application/json'},
                onload: r => {
                    try {
                        const d = JSON.parse(r.responseText);
                        const ids = (d.data||d.models||[]).map(m => m.id);
                        console.log(`[${name}] Доступно (${ids.length}):`, ids); resolve(ids);
                    } catch(e) { console.error(`[${name}] Ошибка:`, e.message); resolve(null); }
                },
                onerror: e => { console.error(`[${name}] Сеть:`, e); resolve(null); }
            });
        });
        if (K.groq)     await fetchModels('https://api.groq.com/openai/v1/models',   K.groq,     'Groq');
        if (K.deepseek) await fetchModels('https://api.deepseek.com/v1/models',       K.deepseek, 'DeepSeek');
        gmXhr({ method:'GET', url:'https://openrouter.ai/api/v1/models',
            onload: r => { try { const d=JSON.parse(r.responseText);
                const free=(d.data||[]).filter(m=>m.id.includes(':free')).map(m=>m.id);
                console.log(`[OpenRouter] Бесплатных (${free.length}):`, free);
            } catch(e) { console.error('[OpenRouter]', e.message); }},
            onerror: e => console.error('[OpenRouter]', e)
        });
        console.log('[GF-Scanner] === Диагностика завершена. Верните TEST_MODELS_AVAILABLE=false ===');
    }

    if (CLEAR_ALL_API_KEYS) {
        K = {};
        await gmSet('k', {});
        console.warn('[GF-Scanner] Все API-ключи очищены. Верните CLEAR_ALL_API_KEYS=false.');
    }

    // ══════════════════════════════════════════════════════════════════════════
    //  ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
    // ══════════════════════════════════════════════════════════════════════════
    const renderAnswer = (el, ans) => {
        try {
            el.innerHTML = (typeof marked !== 'undefined' && marked.parse)
                ? marked.parse(ans)
                : ans.replace(/\n/g, '<br>');
        } catch(e) { el.textContent = ans; }
    };

    const buildPrompt = (findings, net, code, langName, describe) => {
        const localSummary = findings.length
            ? 'LOCAL SCAN FOUND:\n' + findings.map(x =>
                `- ${x.en}: ${x.den} (×${x.count}${x.lines.length ? '; Lines: ' + x.lines.join('; ') : ''})`
              ).join('\n')
            : 'Local scan found no obvious threats.';
        const domainSummary = net.d.length
            ? `DOMAINS FOUND: ${net.d.join(', ')}`
            : 'No external domains found.';
        let prompt = `YOU ARE A JAVASCRIPT SECURITY EXPERT.\n`
            + `ANSWER STRICTLY IN ${langName.toUpperCase()} — NO OTHER LANGUAGE.\n\n`
            + `=== LOCAL SCAN RESULTS ===\n${localSummary}\n\n`
            + `=== NETWORK ===\n${domainSummary}\n\n`
            + `=== FULL SCRIPT CODE (truncated to ${MAX_CODE_LEN} chars) ===\n${code.substring(0, MAX_CODE_LEN)}\n\n`
            + `=== YOUR TASK ===\n`
            + `1. Confirm or dismiss each locally-found threat (Confirmed / False Positive).\n`
            + `2. Identify any hidden or missed threats.\n`
            + `3. Analyse all external domains for malicious patterns.\n`
            + `4. Final verdict: SAFE or DANGEROUS with justification.\n`
            + `5. ALL RESPONSES MUST BE IN ${langName.toUpperCase()} ONLY.`;
        if (describe) {
            prompt += `\n\n=== ADDITIONAL REQUEST ===\n`
                + `6. Provide a brief, clear description of what this script does and its main algorithm/workflow.\n`
                + `7. Based on the locally found parameters and your analysis, give a humorous conclusion about the script's safety from a user data privacy perspective. `
                + `Make it funny and entertaining, but keep it informative and grounded in the actual findings.`;
        }
        return prompt;
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  PUTER.JS — LLAMA БЕЗ API-КЛЮЧА
    // ══════════════════════════════════════════════════════════════════════════
    const RW = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
    const PUTER_OPTS = { model: 'meta-llama/llama-4-scout' };

    const loadPuter = () => new Promise((resolve, reject) => {
        if (RW.puter && RW.puter.ai) return resolve();
        const s = document.createElement('script');
        s.src = 'https://js.puter.com/v2/';
        s.onload = () => {
            const puterReady = new Promise(res => {
                let tries = 0;
                const poll = setInterval(() => {
                    if (RW.puter && RW.puter.ai) { clearInterval(poll); res(true); }
                    else if (++tries >= 40)       { clearInterval(poll); res(false); }
                }, 200);
            });
            puterReady.then(resolve);
        };
        s.onerror = () => reject(new Error('Не удалось загрузить Puter.js с js.puter.com'));
        (document.head || document.documentElement).appendChild(s);
    });

    const callPuter = async (promptText) => {
        await loadPuter();
        if (!RW.puter?.ai?.chat) throw new Error('puter.ai.chat недоступен после загрузки Puter.js.');
        const safeOpts = typeof cloneInto !== 'undefined' ? cloneInto(PUTER_OPTS, RW) : PUTER_OPTS;
        const response = await RW.puter.ai.chat(promptText, safeOpts);
        return response?.message?.content
            || response?.content
            || (typeof response === 'string' ? response : '')
            || 'Empty response from Puter.js / Llama.';
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  ФЛАГ ЗАЩИТЫ ОТ ПАРАЛЛЕЛЬНЫХ XHR
    // ══════════════════════════════════════════════════════════════════════════
    let aiInProgress = false;

    // ══════════════════════════════════════════════════════════════════════════
    //  ОСНОВНАЯ ФУНКЦИЯ: СКАН + РЕНДЕР ВИДЖЕТА
    // ══════════════════════════════════════════════════════════════════════════
    const scan = () => {
        document.getElementById('gfs')?.remove();

        const blk = document.querySelector('pre code') || document.querySelector('pre');
        if (!blk) return false;

        const code = blk.innerText || blk.textContent || '';

        // ── ЛОКАЛЬНЫЙ СКАН ───────────────────────────────────────────────────
        const findings = []; let maxV = 0;
        RULES.forEach(r => {
            r.re.lastIndex = 0;
            const m = code.match(r.re);
            if (m) {
                findings.push({ ...r, count: m.length, lines: findLines(code, r.re) });
                if (r.v > maxV) maxV = r.v;
            }
        });
        const net = extractUrls(code);

        // ── ОЦЕНКА УРОВНЯ РИСКА ──────────────────────────────────────────────
        let col = '#4caf50', status = t('safe');
        if (maxV === 1) { col = '#ffd700'; status = t('low');    }
        if (maxV === 2) { col = '#ff9800'; status = t('med');    }
        if (maxV >= 3)  { col = '#f44336'; status = t('danger'); }

        // ══════════════════════════════════════════════════════════════════════
        //  ПОСТРОЕНИЕ HTML
        // ══════════════════════════════════════════════════════════════════════
        const p = [];

        p.push(`<h3 style="margin:0 0 10px;color:${col};font-size:15px">🔍 ${t('report')}: <span style="font-size:16px">${status}</span></h3>`);
        p.push(`<p style="margin:0 0 10px;color:#9e9e9e;font-size:12px">${t('issues')}: <b style="color:${col}">${findings.length}</b> &nbsp;|&nbsp; ${t('domains')}: <b style="color:#4fc3f7">${net.d.length}</b></p>`);

        if (net.d.length) {
            p.push(`<div style="background:#16213e;padding:8px 10px;margin:6px 0;border-radius:4px;border-left:3px solid #4fc3f7">`);
            p.push(`<b style="color:#4fc3f7">🌐 ${t('domains')}:</b> <span style="word-break:break-all">${net.d.map(esc).join(', ')}</span></div>`);
            if (net.u.length <= 20) {
                p.push(`<details style="margin:4px 0 8px"><summary style="cursor:pointer;color:#666;font-size:11px;user-select:none">${t('showUrls')} (${net.u.length})</summary>`);
                p.push(`<div style="background:#0d1117;padding:6px;border-radius:4px;word-break:break-all;font-size:10px;color:#888;margin-top:4px">${net.u.map(esc).join('<br>')}</div></details>`);
            }
        }

        if (findings.length) {
            findings.forEach(x => {
                const fc  = x.v >= 4 ? '#f44336' : x.v === 3 ? '#ff9800' : '#ffd700';
                const nm = L === 'ru' ? x.ru  : x.en;
                const ds = L === 'ru' ? x.dru : x.den;
                p.push(`<div style="border-left:3px solid ${fc};padding:6px 8px;margin:5px 0;background:#16213e;border-radius:0 4px 4px 0">`);
                p.push(`<b style="color:${fc}">⚠ ${esc(nm)}</b>: <span style="color:#ccc">${esc(ds)}</span> <span style="color:#666;font-size:11px">(×${x.count})</span>`);
                if (x.lines.length) {
                    p.push(`<details><summary style="cursor:pointer;font-size:10px;color:#666;margin-top:3px;user-select:none">${t('showLines')}</summary>`);
                    p.push(`<div style="background:#0d1117;padding:4px 6px;font-family:monospace;font-size:10px;color:#888;border-radius:4px;margin-top:3px">${x.lines.map(esc).join('<br>')}</div></details>`);
                }
                p.push(`</div>`);
            });
        } else if (!net.d.length) {
            p.push(`<p style="color:#4caf50;margin:8px 0">✅ ${t('noThreats')}</p>`);
        }

        if (CLEAR_ALL_API_KEYS) {
            p.push(`<p style="color:#ffd700;font-size:11px;margin:6px 0;background:#2a1e00;padding:6px 8px;border-radius:4px;border-left:3px solid #ffd700">⚠ ${t('keysCleared')}</p>`);
        }

        // Кнопки действий
        p.push(`<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">`);
        p.push(`<button id="gfb-local" style="flex:1;min-width:130px;padding:8px 12px;background:#1565c0;color:#fff;border:none;border-radius:4px;cursor:pointer;font-weight:bold;font-size:13px">🔄 ${t('retryLocal')}</button>`);
        p.push(`<button id="gfb-ai" style="flex:1;min-width:130px;padding:8px 12px;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border:none;border-radius:4px;cursor:pointer;font-weight:bold;font-size:13px">🤖 ${t('checkAi')}</button>`);
        p.push(`</div>`);

        // Чекбокс «Расскажи о скрипте» (v1.2.0) — локально, без сохранения
        p.push(`<div style="margin-top:8px;display:flex;align-items:center;gap:6px">`);
        p.push(`<input type="checkbox" id="gfs-describe" style="cursor:pointer"${D ? ' checked' : ''}>`);
        p.push(`<label for="gfs-describe" style="color:#aaa;font-size:12px;cursor:pointer;user-select:none">${t('describeScript')}</label>`);
        p.push(`</div>`);

        // Строка выборов: модель + язык + подсказка о ключе
        p.push(`<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap;align-items:center">`);

        let mopts = ''; Object.keys(MODELS).forEach(k => { mopts += `<option value="${k}"${k === S ? ' selected' : ''}>${esc(MODELS[k].n)}</option>`; });
        p.push(`<div style="display:flex;align-items:center;gap:6px">`);
        p.push(`<label for="gfs-sel-model" style="color:#888;font-size:12px;white-space:nowrap">${t('modelLabel')}</label>`);
        p.push(`<select id="gfs-sel-model" style="background:#2a2a3e;color:#fff;border:1px solid #444;padding:4px 8px;border-radius:4px;font-size:12px">${mopts}</select>`);
        p.push(`</div>`);

        let lopts = ''; Object.keys(LANGS).forEach(k => { lopts += `<option value="${k}"${k === L ? ' selected' : ''}>${esc(LANGS[k].label)}</option>`; });
        p.push(`<div style="display:flex;align-items:center;gap:6px">`);
        p.push(`<label for="gfs-sel-lang" style="color:#888;font-size:12px;white-space:nowrap">${t('langLabel')}</label>`);
        p.push(`<select id="gfs-sel-lang" style="background:#2a2a3e;color:#fff;border:1px solid #444;padding:4px 8px;border-radius:4px;font-size:12px">${lopts}</select>`);
        p.push(`</div>`);

        p.push(`<div id="gfs-key-hint" style="font-size:11px;color:#8b949e;font-style:italic;display:${MODELS[S].noKey ? 'none' : 'block'}">${t('keyHint')}</div>`);
        p.push(`</div>`);

        // Область отображения ответа ИИ
        p.push(`<div id="gfr" style="margin-top:10px;display:none;padding:12px;border:1px solid #333;border-radius:4px;max-height:500px;overflow-y:auto;font-size:12px;line-height:1.7;"></div>`);

        // ── Вставка виджета ──────────────────────────────────────────────────
        const box = document.createElement('div');
        box.id = 'gfs';
        box.style.cssText = `background:#1a1a2e;color:#e0e0e0;border:2px solid ${col};padding:16px;margin:12px 0;font-family:system-ui,Arial,Helvetica,sans-serif;font-size:13px;border-radius:8px;line-height:1.5;box-sizing:border-box;`;
        box.innerHTML = p.join('');
        const anchor = document.querySelector('pre');
        if (anchor) anchor.parentNode.insertBefore(box, anchor);
        else document.body.prepend(box);

        // ── ОБРАБОТЧИКИ СОБЫТИЙ ──────────────────────────────────────────────
        document.getElementById('gfs-sel-model').addEventListener('change', async e => {
            S = e.target.value;
            await gmSet('s', S);
            const hint = document.getElementById('gfs-key-hint');
            if (hint) { hint.textContent = t('keyHint'); hint.style.display = MODELS[S].noKey ? 'none' : 'block'; }
        });

        document.getElementById('gfs-sel-lang').addEventListener('change', async e => {
            L = e.target.value; await gmSet('l', L); scan();
        });

        // Чекбокс «Расскажи о скрипте» — только локальная переменная (v1.2.0)
        document.getElementById('gfs-describe').addEventListener('change', e => {
            D = e.target.checked;
        });

        document.getElementById('gfb-local').addEventListener('click', () => scan());

        document.getElementById('gfb-ai').addEventListener('click', async () => {
            if (aiInProgress) return;
            aiInProgress = true;

            const btn = document.getElementById('gfb-ai');
            const res = document.getElementById('gfr');
            const cfg = MODELS[S];
            const langName = LANGS[L].name;
            // Состояние чекбокса берём прямо из DOM (на случай если scan() перерисовался)
            const describeScript = document.getElementById('gfs-describe')?.checked ?? false;
            const promptText = buildPrompt(findings, net, code, langName, describeScript);

            btn.disabled = true;
            res.style.display = 'block';

            // ── ВЕТКА A: Puter.js ───────────────────────────────────────────
            if (cfg.puter) {
                btn.textContent = '⏳ Puter.js…';
                res.textContent = t('loadingPuter');
                try {
                    const ans = await callPuter(promptText);
                    renderAnswer(res, ans);
                } catch(e) {
                    res.textContent = `${t('puterErr')}: ${e.message}`;
                }
                btn.disabled = false;
                btn.textContent = `🤖 ${t('retryAi')}`;
                aiInProgress = false;
                return;
            }

            // ── ВЕТКА B: модели с API-ключом (GM XHR) ───────────────────────
            const keySlot  = cfg.sharedKey || S;
            const keyOwner = cfg.keyLabel || (cfg.sharedKey ? MODELS[cfg.sharedKey]?.n || cfg.sharedKey : cfg.n);
            const keyUrl   = KEY_URLS[keySlot] || '';

            let key = K[keySlot];
            if (!key) {
                key = window.prompt(`${t('enterKey')} ${keyOwner}\n(${keyUrl}):`, '');
                if (key && key.length > 5) { K[keySlot] = key; await gmSet('k', K); }
                else {
                    window.alert(t('noKey'));
                    btn.disabled = false; btn.textContent = `🤖 ${t('checkAi')}`;
                    aiInProgress = false; return;
                }
            }

            btn.textContent = `⏳ ${t('thinking')}`;
            res.textContent = `${t('sending')} ${cfg.n}…`;

            let body, headers = { 'Content-Type': 'application/json' }, url = cfg.u;

            if (cfg.g) {
                const gemUrl = new URL(cfg.u);
                gemUrl.searchParams.set('key', key);
                url = gemUrl.toString();
                body = JSON.stringify({ contents: [{ parts: [{ text: promptText }] }] });
            } else {
                const reqBody = {
                    model: cfg.m,
                    messages: [
                        { role: 'system', content: `You are a security expert. Answer strictly in ${langName}.` },
                        { role: 'user',   content: promptText }
                    ],
                    temperature: 0.2
                };
                if (cfg.orModel) reqBody.tool_choice = 'none';
                body = JSON.stringify(reqBody);
                headers['Authorization'] = 'Bearer ' + key;
            }

            gmXhr({
                method: 'POST', url, headers, data: body,
                onload: r => {
                    try {
                        const j = JSON.parse(r.responseText);
                        let ans = cfg.g
                            ? j.candidates?.[0]?.content?.parts?.[0]?.text || (j.error ? `API Error: ${j.error.message}` : '')
                            : j.choices?.[0]?.message?.content             || (j.error ? `API Error: ${j.error.message}` : '');
                        if (!ans) ans = t('emptyResp');
                        renderAnswer(res, ans);
                    } catch(e) {
                        res.textContent = `${t('parseErr')}: ${e.message}\n${r.responseText.substring(0, 300)}`;
                    }
                    btn.disabled = false; btn.textContent = `🤖 ${t('retryAi')}`;
                    aiInProgress = false;
                },
                onerror: () => {
                    res.textContent = t('netErr');
                    btn.disabled = false; btn.textContent = `🤖 ${t('checkAi')}`;
                    aiInProgress = false;
                }
            });
        });

        return true;
    };

    // ══════════════════════════════════════════════════════════════════════════
    //  ИНИЦИАЛИЗАЦИЯ С MutationObserver
    // ══════════════════════════════════════════════════════════════════════════
    let _observer = null;

    const cleanup = () => {
        if (_observer) { _observer.disconnect(); _observer = null; }
        document.getElementById('gfs')?.remove();
    };

    const init = () => {
        cleanup();
        if (scan()) return;

        const scheduleIdle = (fn) => {
            if (typeof requestIdleCallback !== 'undefined') {
                requestIdleCallback(fn, { timeout: 2000 });
            } else {
                setTimeout(fn, RETRY_MS);
            }
        };

        if (typeof MutationObserver !== 'undefined') {
            let tries = 0;
            _observer = new MutationObserver(() => {
                scheduleIdle(() => {
                    if (scan()) {
                        _observer?.disconnect(); _observer = null;
                    } else if (++tries >= MAX_RETRIES) {
                        _observer?.disconnect(); _observer = null;
                        console.warn('[GF-Scanner] <pre> не найден после', MAX_RETRIES, 'попыток');
                    }
                });
            });
            _observer.observe(document.body, { childList: true, subtree: true });

            setTimeout(() => {
                if (_observer) { _observer.disconnect(); _observer = null; }
            }, MAX_RETRIES * RETRY_MS);
        } else {
            let tries = 0;
            const id = setInterval(() => { if (scan() || ++tries >= MAX_RETRIES) clearInterval(id); }, RETRY_MS);
        }
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => init());
    } else {
        init();
    }

    document.addEventListener('turbolinks:load', () => init());
    document.addEventListener('turbo:load',       () => init());

})();