GF Code Scanner [Local + AI]

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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://greasyfork.org/*/scripts/*/code*
// @match        https://greasyfork.org/*/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.greasyfork.org/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());

})();